diff --git a/Dockerfile b/Dockerfile index 249e3f5..747bba4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,3 +8,4 @@ RUN script/bootstrap COPY . . RUN make check +RUN make build diff --git a/Makefile b/Makefile index be4ba00..80fb8c1 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ check: @script/check build: - @$(YARN) tsc + @script/build build-bin: nix-shell -p bun --run "bun build bin/quak.ts --compile --outfile bin/quak" diff --git a/README.md b/README.md index 34ee16e..bcdfe49 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,9 @@ alpine. We provide: `script/bootstrap`, then `script/install-precommit` - `script/projectname` — output the project name (our own extension); used by `script/docker` for the image tag +- `script/build` — compile the TypeScript sources into `dist/`, then verify that + the entrypoints `package.json` declares (`main`, `types`, `bin`) are among the + files the compiler wrote, and make the CLI executable (our own extension) - `script/test` — run the test suite (vitest, hard-capped at 30s where `timeout` is available, verbose rerun on failure) - `script/lint` — run eslint and a prettier check @@ -91,7 +94,7 @@ alpine. We provide: - `script/docker` — build the Docker image, tagged via `script/projectname` (byte-identical across repos) - `script/cibuild` — cd to the repo root and `docker build .` (what CI runs; the - image build runs `make check`) + image build runs `make check` and `make build`) - `script/precommit` — run by the git pre-commit hook (our own extension); runs `script/lint` and `script/fmt-check` but deliberately not the tests, so the TDD red-phase commit can land @@ -133,8 +136,8 @@ All work on quak is test-driven. No exceptions. 3. Subsequent commits add the implementation and any refactors needed to make the tests pass. 4. A feature branch can only be merged into `main` when `make check` is green. - `main` is always green. The Dockerfile runs `make check`, so a red branch - cannot pass CI. + `main` is always green. The Dockerfile runs `make check` and `make build`, so + neither a red branch nor one that does not compile can pass CI. 5. Tests are the canonical API documentation for this library. Every test file is commented thoroughly enough that a reader who has never seen quak can learn how to use it from the tests alone. Comments explain why a behavior @@ -183,6 +186,12 @@ quak/ tsconfig.json ``` +`make build` compiles that tree into `dist/`, preserving its shape: the library +lands in `dist/src/` and the CLI in `dist/bin/quak.js`, which is what +`package.json` points `main`, `types` and `bin` at. The compiler's `rootDir` is +the repository root rather than `src/`, because `bin/` is compiled too and +`rootDir` has to contain everything that is compiled. + ### Cryptography All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is diff --git a/TODO.md b/TODO.md index 2ae41c8..7d2f8b1 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,13 @@ Update the README API reference section to match the current implementation. # Completed Steps +- 2026-08-09: Fixed the TypeScript build. `rootDir` is the repo root, so `bin/` + compiles alongside `src/` instead of failing with TS6059; output is + `dist/src/` and `dist/bin/`, which is where `main`, `types` and `bin.quak` now + point. `script/build` verifies the declared entrypoints exist after the + compiler runs and makes the CLI executable, the Dockerfile runs `make build` + as well as `make check`, and a `quak` script makes the README's + `yarn quak ` examples work. - 2026-08-09: Retry policy: no retry on 4xx (except `408` and `429`), exponential backoff with full jitter on 5xx, transport failures and truncated transfers, under per-attempt deadlines that cover the response body as well as diff --git a/package.json b/package.json index e47a7ca..746ba14 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "url": "https://git.eeqj.de/sneak/quak.git" }, "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", "bin": { "quak": "./dist/bin/quak.js" }, @@ -21,7 +21,8 @@ "LICENSE" ], "scripts": { - "build": "tsc", + "build": "script/build", + "quak": "node ./dist/bin/quak.js", "test": "vitest run", "lint": "eslint .", "fmt": "prettier --write .", diff --git a/script/build b/script/build new file mode 100755 index 0000000..05d5180 --- /dev/null +++ b/script/build @@ -0,0 +1,54 @@ +#!/bin/sh +# script/build: compile the TypeScript sources into dist/, then verify that +# the artifacts package.json advertises are among the files the compiler +# actually wrote. tsc reports success by exit status alone and knows nothing +# about the manifest, so without this step a green build can still ship a +# package whose main, types or bin resolve to nothing. Our own extension to +# scripts-to-rule-them-all. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# Reads package.json, requires every declared entrypoint to exist, requires +# each bin entry to have kept its shebang, and makes the bin entries +# executable: tsc copies the shebang through but not the mode bits, and an +# installed CLI has to be runnable. +verify_entrypoints() { + node -e ' +const { readFileSync, statSync, chmodSync } = require("node:fs"); + +const pkg = JSON.parse(readFileSync("package.json", "utf-8")); +const bins = Object.values(pkg.bin ?? {}); +const fail = (message) => { + console.error("build: " + message); + process.exit(1); +}; + +for (const declared of [pkg.main, pkg.types, ...bins]) { + if (!declared) continue; + try { + statSync(declared); + } catch { + fail("package.json declares " + declared + ", which the build did not produce"); + } + console.log("build: verified " + declared); +} + +for (const bin of bins) { + const firstLine = readFileSync(bin, "utf-8").split("\n")[0]; + if (!firstLine.startsWith("#!")) { + fail(bin + " lost its shebang, so it cannot be executed directly"); + } + chmodSync(bin, 0o755); + console.log("build: " + bin + " is executable (" + firstLine + ")"); +} +' +} + +main() { + cd "$ROOT" + yarn run tsc + verify_entrypoints +} + +main "$@" diff --git a/src/crypto/stream.ts b/src/crypto/stream.ts index d2bde06..c2b47a3 100644 --- a/src/crypto/stream.ts +++ b/src/crypto/stream.ts @@ -1,4 +1,4 @@ -import sodium from "libsodium-wrappers-sumo"; +import sodium, { type StateAddress } from "libsodium-wrappers-sumo"; // Plaintext chunk size used by Ente for file content streams. Hard-coded by // the server; clients must match. @@ -47,8 +47,10 @@ export const encryptBlob = ( }; // Opaque handle to libsodium's secretstream pull state. Threaded through -// successive pullStreamChunk calls. -export type StreamPullState = sodium.StateAddress; +// successive pullStreamChunk calls. The type comes from the named export; +// the default import is the module's value side and has no type namespace +// under it. +export type StreamPullState = StateAddress; // Initialise a pull stream from the per-file decryption header and the // per-file key. @@ -84,9 +86,13 @@ export const pullStreamChunk = ( state: StreamPullState, ciphertext: Uint8Array, ): { plaintext: Uint8Array; tag: number } => { + // The additional-data argument is not optional in libsodium's signature. + // null is "no additional data", matching the null passed on the push side + // in encryptBlob; Ente's file streams carry none. const result = sodium.crypto_secretstream_xchacha20poly1305_pull( state, ciphertext, + null, ); if (result === false) { throw new Error("secretstream chunk authentication failed"); diff --git a/test/packaging/entrypoints.test.ts b/test/packaging/entrypoints.test.ts index 92478e4..3a09f64 100644 --- a/test/packaging/entrypoints.test.ts +++ b/test/packaging/entrypoints.test.ts @@ -49,18 +49,20 @@ const rootDir = clean(tsconfig.compilerOptions.rootDir); const globRoot = (pattern: string): string => { const segments = clean(pattern).split("/"); const wildcard = segments.findIndex((s) => s.includes("*")); - return segments.slice(0, wildcard === -1 ? segments.length : wildcard) - .join("/") || "."; + return ( + segments + .slice(0, wildcard === -1 ? segments.length : wildcard) + .join("/") || "." + ); }; // Where tsc will write the output for a source file: the path relative to // rootDir, re-rooted under outDir, with the extension swapped. const emitted = (source: string, extension: string): string => "./" + - posix.join(outDir, posix.relative(rootDir, clean(source))).replace( - /\.ts$/, - extension, - ); + posix + .join(outDir, posix.relative(rootDir, clean(source))) + .replace(/\.ts$/, extension); describe("tsconfig include and rootDir", () => { // TS6059 is not a style complaint: tsc refuses to emit anything at all diff --git a/tsconfig.json b/tsconfig.json index 64dfdce..1cab40d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,8 @@ "moduleResolution": "NodeNext", "lib": ["ES2022"], "outDir": "./dist", - "rootDir": "./src", + "rootDir": ".", + "noEmitOnError": true, "strict": true, "noImplicitOverride": true, "noUncheckedIndexedAccess": true,