diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 738b388..630cd99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ jobs: run: git diff --exit-code -- packages/tree-sitter-lore/src test/fixtures - run: bun run build - run: bunx tsc --noEmit + - run: sudo apt-get install -y --no-install-recommends age - run: bun test - uses: actions/setup-node@v4 with: diff --git a/.gitignore b/.gitignore index 452a14e..f262119 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ packages/zed/extension.toml packages/zed/extension.wasm packages/zed/grammars/ packages/zed/target/ +private/dev/ +private/dev.tar.age.partial diff --git a/bunfig.toml b/bunfig.toml index 4e99371..1b6d7b1 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,3 +1,4 @@ [test] -# Zed clones the whole repository here when the dev extension is installed. -pathIgnorePatterns = ["packages/zed/grammars/**"] +# Zed clones the whole repository into packages/zed/grammars when the dev extension is installed. +# private/dev holds unlocked material whose tests need their own setup. +pathIgnorePatterns = ["packages/zed/grammars/**", "private/**"] diff --git a/private/README.md b/private/README.md new file mode 100644 index 0000000..cff6343 --- /dev/null +++ b/private/README.md @@ -0,0 +1,12 @@ +# private + +Development material that is not public. It lives here as one encrypted archive, `dev.tar.age`. + +```sh +bun scripts/private.ts unlock # decrypt dev.tar.age into private/dev/ +bun scripts/private.ts lock # encrypt private/dev/ back into dev.tar.age +``` + +`unlock` refuses to write into a non-empty `private/dev/`; pass `--force` to overwrite it. + +`private/dev/` is ignored by git, so only the archive is committed. Run `lock` and commit `dev.tar.age` after changing anything under `private/dev/`. diff --git a/private/dev.tar.age b/private/dev.tar.age new file mode 100644 index 0000000..192a488 Binary files /dev/null and b/private/dev.tar.age differ diff --git a/scripts/private.test.ts b/scripts/private.test.ts new file mode 100644 index 0000000..5e2ede4 --- /dev/null +++ b/scripts/private.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; + +const script = join(import.meta.dir, "private.ts"); +let scratch: string; +let identityFile: string; + +beforeEach(() => { + scratch = mkdtempSync(join(tmpdir(), "lore-private-test-")); + identityFile = join(scratch, "identity.txt"); + const keygen = spawnSync("age-keygen", ["-o", identityFile], { encoding: "utf8" }); + if (keygen.status !== 0) throw new Error(`age-keygen failed: ${keygen.stderr}`); +}); + +afterEach(() => { + rmSync(scratch, { recursive: true, force: true }); +}); + +function privateCli(root: string, ...args: string[]) { + return spawnSync("bun", [script, ...args], { + encoding: "utf8", + env: { ...process.env, LORE_PRIVATE_ROOT: root, LORE_PRIVATE_IDENTITY_FILE: identityFile }, + }); +} + +function makeRoot(name: string): string { + const root = join(scratch, name); + mkdirSync(join(root, "private"), { recursive: true }); + return root; +} + +function snapshot(dir: string): Record { + const files: Record = {}; + const walk = (current: string) => { + for (const entry of readdirSync(current)) { + const path = join(current, entry); + if (statSync(path).isDirectory()) walk(path); + else files[relative(dir, path)] = readFileSync(path, "utf8"); + } + }; + walk(dir); + return files; +} + +test("lock then unlock into a fresh root restores the tree and skips .DS_Store and node_modules", () => { + const source = makeRoot("source"); + const dev = join(source, "private", "dev"); + mkdirSync(join(dev, "notes", "deep"), { recursive: true }); + mkdirSync(join(dev, "node_modules", "pkg"), { recursive: true }); + writeFileSync(join(dev, "top.md"), "top\n"); + writeFileSync(join(dev, "notes", "deep", "a.json"), "{\"a\":1}\n"); + writeFileSync(join(dev, ".DS_Store"), "junk"); + writeFileSync(join(dev, "node_modules", "pkg", "index.js"), "junk"); + + const locked = privateCli(source, "lock"); + expect(locked.stderr).toBe(""); + expect(locked.status).toBe(0); + expect(locked.stdout).toContain("KiB"); + + const target = makeRoot("target"); + writeFileSync(join(target, "private", "dev.tar.age"), readFileSync(join(source, "private", "dev.tar.age"))); + const unlocked = privateCli(target, "unlock"); + expect(unlocked.stderr).toBe(""); + expect(unlocked.status).toBe(0); + + expect(snapshot(join(target, "private", "dev"))).toEqual({ + "top.md": "top\n", + "notes/deep/a.json": "{\"a\":1}\n", + }); +}); + +test("lock refuses an empty dev folder", () => { + const root = makeRoot("empty"); + mkdirSync(join(root, "private", "dev")); + const result = privateCli(root, "lock"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("nothing to lock"); +}); + +test("lock refuses a dev folder that holds only excluded entries and keeps the old archive", () => { + const root = makeRoot("junk"); + const dev = join(root, "private", "dev"); + mkdirSync(dev); + writeFileSync(join(dev, "real.md"), "real\n"); + expect(privateCli(root, "lock").status).toBe(0); + const before = readFileSync(join(root, "private", "dev.tar.age")); + + rmSync(join(dev, "real.md")); + mkdirSync(join(dev, "node_modules", "pkg"), { recursive: true }); + writeFileSync(join(dev, ".DS_Store"), "junk"); + writeFileSync(join(dev, "node_modules", "pkg", "index.js"), "junk"); + + const result = privateCli(root, "lock"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("nothing to lock"); + expect(readFileSync(join(root, "private", "dev.tar.age")).equals(before)).toBe(true); + expect(readdirSync(join(root, "private")).sort()).toEqual(["dev", "dev.tar.age"]); +}); + +test("unlock refuses a non-empty dev folder unless --force", () => { + const root = makeRoot("busy"); + const dev = join(root, "private", "dev"); + mkdirSync(dev); + writeFileSync(join(dev, "kept.md"), "original\n"); + expect(privateCli(root, "lock").status).toBe(0); + writeFileSync(join(dev, "kept.md"), "edited\n"); + + const refused = privateCli(root, "unlock"); + expect(refused.status).toBe(1); + expect(refused.stderr).toContain("pass --force"); + expect(readFileSync(join(dev, "kept.md"), "utf8")).toBe("edited\n"); + + const forced = privateCli(root, "unlock", "--force"); + expect(forced.status).toBe(0); + expect(readFileSync(join(dev, "kept.md"), "utf8")).toBe("original\n"); +}); + +test("unlock fails with one line when there is no archive", () => { + const root = makeRoot("bare"); + const result = privateCli(root, "unlock"); + expect(result.status).toBe(1); + expect(result.stderr.trim().split("\n")).toHaveLength(1); + expect(result.stderr).toContain("no archive"); +}); diff --git a/scripts/private.ts b/scripts/private.ts new file mode 100644 index 0000000..0d1d324 --- /dev/null +++ b/scripts/private.ts @@ -0,0 +1,102 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = process.env.LORE_PRIVATE_ROOT ?? join(import.meta.dir, ".."); +const devDir = join(root, "private", "dev"); +const archive = join(root, "private", "dev.tar.age"); + +class Failure extends Error {} + +function run(command: string, args: string[], options: { input?: string; env?: NodeJS.ProcessEnv } = {}): string { + const result = spawnSync(command, args, { + input: options.input, + env: options.env ?? process.env, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + if (result.error) { + if ((result.error as NodeJS.ErrnoException).code === "ENOENT") throw new Failure(`${command} is not installed or not on PATH`); + throw new Failure(`${command} failed: ${result.error.message}`); + } + if (result.status !== 0) { + const reason = result.stderr.trim().split("\n")[0] || `exit ${result.status}`; + throw new Failure(`${command} failed: ${reason}`); + } + return result.stdout; +} + +function readIdentity(): string { + const file = process.env.LORE_PRIVATE_IDENTITY_FILE; + if (file) { + if (!existsSync(file)) throw new Failure(`identity file not found: ${file}`); + return readFileSync(file, "utf8"); + } + const result = spawnSync("security", ["find-generic-password", "-a", "mattstack", "-s", "mattstack-age-key", "-w"], { encoding: "utf8" }); + if (result.error || result.status !== 0 || result.stdout.trim() === "") { + throw new Failure("keychain item mattstack-age-key (account mattstack) not found"); + } + return result.stdout.trim() + "\n"; +} + +function isEmptyDir(path: string): boolean { + return !existsSync(path) || (statSync(path).isDirectory() && readdirSync(path).length === 0); +} + +function withScratch(body: (scratch: string) => T): T { + const scratch = mkdtempSync(join(tmpdir(), "lore-private-")); + try { + return body(scratch); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +function lock(): void { + if (isEmptyDir(devDir)) throw new Failure(`nothing to lock: ${devDir} is missing or empty`); + const recipient = run("age-keygen", ["-y"], { input: readIdentity() }).trim(); + withScratch((scratch) => { + const tarball = join(scratch, "dev.tar.gz"); + run("tar", ["-czf", tarball, "--exclude", ".DS_Store", "--exclude", "node_modules", "-C", devDir, "."], { + env: { ...process.env, COPYFILE_DISABLE: "1" }, + }); + const entries = run("tar", ["-tzf", tarball]).split("\n"); + if (!entries.some((entry) => entry !== "" && !entry.endsWith("/"))) { + throw new Failure(`nothing to lock: ${devDir} holds only excluded entries`); + } + const pending = `${archive}.partial`; + run("age", ["-r", recipient, "-o", pending, tarball]); + renameSync(pending, archive); + }); + const kib = (statSync(archive).size / 1024).toFixed(1); + console.log(`Locked ${devDir} into ${archive} (${kib} KiB).`); +} + +function unlock(force: boolean): void { + if (!existsSync(archive)) throw new Failure(`no archive at ${archive}`); + if (!isEmptyDir(devDir) && !force) throw new Failure(`${devDir} is not empty; pass --force to overwrite it`); + const identity = readIdentity(); + withScratch((scratch) => { + const identityFile = join(scratch, "identity"); + writeFileSync(identityFile, identity, { mode: 0o600 }); + chmodSync(identityFile, 0o600); + const tarball = join(scratch, "dev.tar.gz"); + run("age", ["-d", "-i", identityFile, "-o", tarball, archive]); + rmSync(identityFile, { force: true }); + mkdirSync(devDir, { recursive: true }); + run("tar", ["-xzf", tarball, "-C", devDir]); + }); + console.log(`Unlocked ${archive} into ${devDir}.`); +} + +const [command, ...flags] = process.argv.slice(2); +try { + if (command === "lock") lock(); + else if (command === "unlock") unlock(flags.includes("--force")); + else throw new Failure("usage: bun scripts/private.ts lock|unlock [--force]"); +} catch (error) { + const reason = error instanceof Failure ? error.message : String(error); + console.error(`private: ${reason}`); + process.exit(1); +}