Compile bin/ alongside src/ so the package can be built (closes #3) #26
@@ -8,3 +8,4 @@ RUN script/bootstrap
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN make check
|
RUN make check
|
||||||
|
RUN make build
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -24,7 +24,7 @@ check:
|
|||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@$(YARN) tsc
|
@script/build
|
||||||
|
|
||||||
build-bin:
|
build-bin:
|
||||||
nix-shell -p bun --run "bun build bin/quak.ts --compile --outfile bin/quak"
|
nix-shell -p bun --run "bun build bin/quak.ts --compile --outfile bin/quak"
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -81,6 +81,9 @@ alpine. We provide:
|
|||||||
`script/bootstrap`, then `script/install-precommit`
|
`script/bootstrap`, then `script/install-precommit`
|
||||||
- `script/projectname` — output the project name (our own extension); used by
|
- `script/projectname` — output the project name (our own extension); used by
|
||||||
`script/docker` for the image tag
|
`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`
|
- `script/test` — run the test suite (vitest, hard-capped at 30s where `timeout`
|
||||||
is available, verbose rerun on failure)
|
is available, verbose rerun on failure)
|
||||||
- `script/lint` — run eslint and a prettier check
|
- `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`
|
- `script/docker` — build the Docker image, tagged via `script/projectname`
|
||||||
(byte-identical across repos)
|
(byte-identical across repos)
|
||||||
- `script/cibuild` — cd to the repo root and `docker build .` (what CI runs; the
|
- `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/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
|
`script/lint` and `script/fmt-check` but deliberately not the tests, so the
|
||||||
TDD red-phase commit can land
|
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
|
3. Subsequent commits add the implementation and any refactors needed to make
|
||||||
the tests pass.
|
the tests pass.
|
||||||
4. A feature branch can only be merged into `main` when `make check` is green.
|
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
|
`main` is always green. The Dockerfile runs `make check` and `make build`, so
|
||||||
cannot pass CI.
|
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
|
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
|
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
|
learn how to use it from the tests alone. Comments explain why a behavior
|
||||||
@@ -183,6 +186,12 @@ quak/
|
|||||||
tsconfig.json
|
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
|
### Cryptography
|
||||||
|
|
||||||
All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is
|
All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is
|
||||||
|
|||||||
7
TODO.md
7
TODO.md
@@ -18,6 +18,13 @@ Update the README API reference section to match the current implementation.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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 <command>` examples work.
|
||||||
- 2026-08-09: Retry policy: no retry on 4xx (except `408` and `429`),
|
- 2026-08-09: Retry policy: no retry on 4xx (except `408` and `429`),
|
||||||
exponential backoff with full jitter on 5xx, transport failures and truncated
|
exponential backoff with full jitter on 5xx, transport failures and truncated
|
||||||
transfers, under per-attempt deadlines that cover the response body as well as
|
transfers, under per-attempt deadlines that cover the response body as well as
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
"url": "https://git.eeqj.de/sneak/quak.git"
|
"url": "https://git.eeqj.de/sneak/quak.git"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/src/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/src/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
"quak": "./dist/bin/quak.js"
|
"quak": "./dist/bin/quak.js"
|
||||||
},
|
},
|
||||||
@@ -21,7 +21,8 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "script/build",
|
||||||
|
"quak": "node ./dist/bin/quak.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"fmt": "prettier --write .",
|
"fmt": "prettier --write .",
|
||||||
|
|||||||
54
script/build
Executable file
54
script/build
Executable file
@@ -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 "$@"
|
||||||
@@ -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
|
// Plaintext chunk size used by Ente for file content streams. Hard-coded by
|
||||||
// the server; clients must match.
|
// the server; clients must match.
|
||||||
@@ -47,8 +47,10 @@ export const encryptBlob = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Opaque handle to libsodium's secretstream pull state. Threaded through
|
// Opaque handle to libsodium's secretstream pull state. Threaded through
|
||||||
// successive pullStreamChunk calls.
|
// successive pullStreamChunk calls. The type comes from the named export;
|
||||||
export type StreamPullState = sodium.StateAddress;
|
// 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
|
// Initialise a pull stream from the per-file decryption header and the
|
||||||
// per-file key.
|
// per-file key.
|
||||||
@@ -84,9 +86,13 @@ export const pullStreamChunk = (
|
|||||||
state: StreamPullState,
|
state: StreamPullState,
|
||||||
ciphertext: Uint8Array,
|
ciphertext: Uint8Array,
|
||||||
): { plaintext: Uint8Array; tag: number } => {
|
): { 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(
|
const result = sodium.crypto_secretstream_xchacha20poly1305_pull(
|
||||||
state,
|
state,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
|
null,
|
||||||
);
|
);
|
||||||
if (result === false) {
|
if (result === false) {
|
||||||
throw new Error("secretstream chunk authentication failed");
|
throw new Error("secretstream chunk authentication failed");
|
||||||
|
|||||||
@@ -49,18 +49,20 @@ const rootDir = clean(tsconfig.compilerOptions.rootDir);
|
|||||||
const globRoot = (pattern: string): string => {
|
const globRoot = (pattern: string): string => {
|
||||||
const segments = clean(pattern).split("/");
|
const segments = clean(pattern).split("/");
|
||||||
const wildcard = segments.findIndex((s) => s.includes("*"));
|
const wildcard = segments.findIndex((s) => s.includes("*"));
|
||||||
return segments.slice(0, wildcard === -1 ? segments.length : wildcard)
|
return (
|
||||||
.join("/") || ".";
|
segments
|
||||||
|
.slice(0, wildcard === -1 ? segments.length : wildcard)
|
||||||
|
.join("/") || "."
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Where tsc will write the output for a source file: the path relative to
|
// Where tsc will write the output for a source file: the path relative to
|
||||||
// rootDir, re-rooted under outDir, with the extension swapped.
|
// rootDir, re-rooted under outDir, with the extension swapped.
|
||||||
const emitted = (source: string, extension: string): string =>
|
const emitted = (source: string, extension: string): string =>
|
||||||
"./" +
|
"./" +
|
||||||
posix.join(outDir, posix.relative(rootDir, clean(source))).replace(
|
posix
|
||||||
/\.ts$/,
|
.join(outDir, posix.relative(rootDir, clean(source)))
|
||||||
extension,
|
.replace(/\.ts$/, extension);
|
||||||
);
|
|
||||||
|
|
||||||
describe("tsconfig include and rootDir", () => {
|
describe("tsconfig include and rootDir", () => {
|
||||||
// TS6059 is not a style complaint: tsc refuses to emit anything at all
|
// TS6059 is not a style complaint: tsc refuses to emit anything at all
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": ".",
|
||||||
|
"noEmitOnError": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user