diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9877012..3305484 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/README.md b/README.md index 458683d..01b0c08 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ `firstdraft` is the command-line client for [First Draft](https://github.com/firstdraft/firstdraft). It is being built for agents that author and review Foundation Plans with their users. -The package is not released yet. This repository currently contains only the auditable command shell; Plan and -network behavior will arrive in reviewed increments. +The package is not released yet. This repository contains the auditable command shell and local Foundation Plan +initialization; API and release behavior will arrive in reviewed increments. ## Requirements @@ -19,11 +19,22 @@ npm run check npm run pack:check ``` +## Start a Foundation Plan + +From the project that the Plan describes: + +```sh +firstdraft plan init --application-key oscar_party --name "Oscar Party" +``` + +This creates an empty `sketch/0.19` Plan and client-generated Project ID under `.firstdraft/`. A nested ignore file +keeps that local scratch area out of Git without changing the project's own `.gitignore`. Initialization makes no +network request and refuses to replace an existing `.firstdraft` path. + ## Trust model - The published CLI will run the reviewed JavaScript source directly, without generated or bundled code. -- The command shell has no runtime dependencies, install scripts, telemetry, update checks, or implicit network - activity. +- The CLI has no runtime dependencies, install scripts, telemetry, update checks, or implicit network activity. - Package contents are allowlisted and checked before release. - CI exercises the exact minimum Node.js version separately from current development tooling. - Public releases will use npm provenance after the first useful version bootstraps trusted publishing. diff --git a/scripts/check-pack.js b/scripts/check-pack.js index 3b06232..9f2dbc9 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -26,6 +26,8 @@ if (result.status !== 0) { "bin/firstdraft.js", "package.json", "src/cli.js", + "src/commands/plan-init.js", + "src/uuid-v7.js", "src/version.js", ]); } diff --git a/src/cli.js b/src/cli.js index e7ce7fe..dd16ac6 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,20 +1,59 @@ import { parseArgs } from "node:util"; +import { initializePlan, isFileSystemError } from "./commands/plan-init.js"; +import { generateUuidV7 } from "./uuid-v7.js"; import { VERSION } from "./version.js"; -const HELP = `First Draft CLI +const ROOT_HELP = `First Draft CLI Usage: + firstdraft [options] firstdraft [options] +Commands: + plan Work with Foundation Plans + Options: -h, --help Show help -V, --version Show version `; -const USAGE_ERROR = "Invalid arguments.\nRun 'firstdraft --help' for usage.\n"; -const UNKNOWN_COMMAND = +const PLAN_HELP = `First Draft CLI + +Usage: + firstdraft plan [options] + +Commands: + init Create a local empty Foundation Plan + +Options: + -h, --help Show help +`; + +const PLAN_INIT_HELP = `First Draft CLI + +Usage: + firstdraft plan init --application-key --name + +Options: + --application-key Lower-snake-case application key + --name Application display name + -h, --help Show help +`; + +const ROOT_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft --help' for usage.\n"; +const ROOT_UNKNOWN_COMMAND = "Unknown command.\nRun 'firstdraft --help' for usage.\n"; +const PLAN_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan --help' for usage.\n"; +const PLAN_UNKNOWN_COMMAND = + "Unknown command.\nRun 'firstdraft plan --help' for usage.\n"; +const PLAN_INIT_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan init --help' for usage.\n"; +const PLAN_INIT_ERROR = + "Could not initialize .firstdraft. The directory may be incomplete; no existing files were overwritten.\n"; +const PLAN_INIT_SUCCESS = "Initialized .firstdraft/foundation-plan.json.\n"; /** * @typedef {object} Writer @@ -26,14 +65,48 @@ const UNKNOWN_COMMAND = * @property {readonly string[]} argv * @property {Writer} stdout * @property {Writer} stderr + * @property {string} [cwd] + * @property {() => string} [createProjectId] + * @property {import("./commands/plan-init.js").FileSystem} [fileSystem] + */ + +/** + * @typedef {object} CommandOptions + * @property {readonly string[]} argv + * @property {Writer} stdout + * @property {Writer} stderr + * @property {string} cwd + * @property {() => string} createProjectId + * @property {import("./commands/plan-init.js").FileSystem} [fileSystem] */ /** @param {RunOptions} options */ -export function run({ argv, stdout, stderr }) { - let parsed; +export function run({ + argv, + stdout, + stderr, + cwd = process.cwd(), + createProjectId = generateUuidV7, + fileSystem, +}) { + if (argv[0] === "plan") { + return runPlan({ + argv: argv.slice(1), + stdout, + stderr, + cwd, + createProjectId, + fileSystem, + }); + } - try { - parsed = parseArgs({ + return runRoot({ argv, stdout, stderr }); +} + +/** @param {Pick} options */ +function runRoot({ argv, stdout, stderr }) { + const parsed = parseArguments(() => + parseArgs({ args: [...argv], options: { help: { type: "boolean", short: "h" }, @@ -41,26 +114,26 @@ export function run({ argv, stdout, stderr }) { }, allowPositionals: true, strict: true, - }); - } catch (error) { - if (!isParseArgsError(error)) throw error; + }), + ); - stderr.write(USAGE_ERROR); + if (!parsed) { + stderr.write(ROOT_USAGE_ERROR); return 2; } if (argv.length === 0) { - stdout.write(HELP); + stdout.write(ROOT_HELP); return 0; } if (parsed.positionals.length > 0) { - stderr.write(UNKNOWN_COMMAND); + stderr.write(ROOT_UNKNOWN_COMMAND); return 2; } if (parsed.values.help) { - stdout.write(HELP); + stdout.write(ROOT_HELP); return 0; } @@ -69,10 +142,184 @@ export function run({ argv, stdout, stderr }) { return 0; } - stdout.write(HELP); + stdout.write(ROOT_HELP); + return 0; +} + +/** @param {CommandOptions} options */ +function runPlan({ argv, stdout, stderr, cwd, createProjectId, fileSystem }) { + if (argv[0] === "init") { + return runPlanInit({ + argv: argv.slice(1), + stdout, + stderr, + cwd, + createProjectId, + fileSystem, + }); + } + + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { help: { type: "boolean", short: "h" } }, + allowPositionals: true, + strict: true, + }), + ); + + if (!parsed) { + stderr.write(PLAN_USAGE_ERROR); + return 2; + } + + if (parsed.positionals.length > 0) { + stderr.write(PLAN_UNKNOWN_COMMAND); + return 2; + } + + if (argv.length === 0 || parsed.values.help) { + stdout.write(PLAN_HELP); + return 0; + } + + stdout.write(PLAN_HELP); return 0; } +/** @param {CommandOptions} options */ +function runPlanInit({ + argv, + stdout, + stderr, + cwd, + createProjectId, + fileSystem, +}) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { + "application-key": { type: "string" }, + name: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: false, + strict: true, + tokens: true, + }), + ); + + if (!parsed || repeatedValueOption(parsed.tokens)) { + stderr.write(PLAN_INIT_USAGE_ERROR); + return 2; + } + + if (parsed.values.help) { + stdout.write(PLAN_INIT_HELP); + return 0; + } + + const applicationKey = parsed.values["application-key"]; + const name = parsed.values.name; + if ( + typeof applicationKey !== "string" || + !/^[a-z][a-z0-9_]*$/.test(applicationKey) || + typeof name !== "string" || + !isValidApplicationName(name) + ) { + stderr.write(PLAN_INIT_USAGE_ERROR); + return 2; + } + + const projectId = createProjectId(); + + try { + initializePlan({ + applicationKey, + name, + projectId, + cwd, + fileSystem, + }); + } catch (error) { + if (!isFileSystemError(error)) throw error; + + stderr.write(PLAN_INIT_ERROR); + return 1; + } + + stdout.write(PLAN_INIT_SUCCESS); + return 0; +} + +/** @param {string} name */ +function isValidApplicationName(name) { + let hasNonWhitespace = false; + + for (const character of name) { + const codePoint = character.codePointAt(0) ?? 0; + if ( + codePoint === 0 || + (codePoint >= 0xd800 && codePoint <= 0xdfff) || + (codePoint >= 0xfdd0 && codePoint <= 0xfdef) || + (codePoint & 0xfffe) === 0xfffe + ) { + return false; + } + + if (!isUnicodeWhitespace(codePoint)) hasNonWhitespace = true; + } + + return hasNonWhitespace; +} + +/** @param {number} codePoint */ +function isUnicodeWhitespace(codePoint) { + return ( + (codePoint >= 0x0009 && codePoint <= 0x000d) || + codePoint === 0x0020 || + codePoint === 0x0085 || + codePoint === 0x00a0 || + codePoint === 0x1680 || + (codePoint >= 0x2000 && codePoint <= 0x200a) || + codePoint === 0x2028 || + codePoint === 0x2029 || + codePoint === 0x202f || + codePoint === 0x205f || + codePoint === 0x3000 + ); +} + +/** + * @template T + * @param {() => T} callback + * @returns {T | null} + */ +function parseArguments(callback) { + try { + return callback(); + } catch (error) { + if (!isParseArgsError(error)) throw error; + + return null; + } +} + +/** @param {readonly {kind: string, name?: string}[]} tokens */ +function repeatedValueOption(tokens) { + const names = tokens + .filter( + (token) => + token.kind === "option" && + typeof token.name === "string" && + token.name !== "help", + ) + .map((token) => token.name); + + return new Set(names).size !== names.length; +} + /** @param {unknown} error */ function isParseArgsError(error) { return ( diff --git a/src/commands/plan-init.js b/src/commands/plan-init.js new file mode 100644 index 0000000..8b27ed9 --- /dev/null +++ b/src/commands/plan-init.js @@ -0,0 +1,87 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +/** + * @typedef {object} FileSystem + * @property {typeof mkdirSync} mkdirSync + * @property {typeof writeFileSync} writeFileSync + */ + +/** @type {FileSystem} */ +const DEFAULT_FILE_SYSTEM = { mkdirSync, writeFileSync }; + +/** + * @typedef {object} InitializePlanOptions + * @property {string} applicationKey + * @property {string} name + * @property {string} projectId + * @property {string} cwd + * @property {FileSystem} [fileSystem] + */ + +/** @param {InitializePlanOptions} options */ +export function initializePlan({ + applicationKey, + name, + projectId, + cwd, + fileSystem = DEFAULT_FILE_SYSTEM, +}) { + const plan = `${JSON.stringify(emptyPlan(applicationKey, name), null, 2)}\n`; + const state = `${JSON.stringify(projectState(projectId), null, 2)}\n`; + const directory = path.join(cwd, ".firstdraft"); + const writeOptions = { flag: "wx", mode: 0o600, flush: true }; + + fileSystem.mkdirSync(directory, { recursive: false, mode: 0o700 }); + fileSystem.writeFileSync( + path.join(directory, ".gitignore"), + "*\n", + writeOptions, + ); + fileSystem.writeFileSync( + path.join(directory, "foundation-plan.json"), + plan, + writeOptions, + ); + fileSystem.writeFileSync( + path.join(directory, "state.json"), + state, + writeOptions, + ); +} + +/** @param {unknown} error */ +export function isFileSystemError(error) { + return ( + error instanceof Error && + "code" in error && + typeof error.code === "string" && + !error.code.startsWith("ERR_") + ); +} + +/** @param {string} applicationKey @param {string} name */ +function emptyPlan(applicationKey, name) { + return { + format: "firstdraft.foundation-plan.sketch/0.19", + target: { + id: "rails", + profile: "rails-sketch/2026-07", + }, + application: { + key: applicationKey, + name, + native: {}, + delivery: {}, + entities: [], + }, + }; +} + +/** @param {string} projectId */ +function projectState(projectId) { + return { + format: "firstdraft.cli-state/1", + project_id: projectId, + }; +} diff --git a/src/uuid-v7.js b/src/uuid-v7.js new file mode 100644 index 0000000..e1511e9 --- /dev/null +++ b/src/uuid-v7.js @@ -0,0 +1,55 @@ +import { randomBytes as secureRandomBytes } from "node:crypto"; + +const MAX_TIMESTAMP = 2 ** 48 - 1; +const RANDOM_BYTE_LENGTH = 16; + +/** + * @typedef {object} UuidV7Options + * @property {() => number} [now] + * @property {(size: number) => Uint8Array} [randomBytes] + */ + +/** @param {UuidV7Options} [options] */ +export function generateUuidV7({ + now = Date.now, + randomBytes = secureRandomBytes, +} = {}) { + const timestamp = now(); + + if ( + !Number.isInteger(timestamp) || + timestamp < 0 || + timestamp > MAX_TIMESTAMP + ) { + throw new RangeError( + `UUIDv7 timestamp must be an integer between 0 and ${MAX_TIMESTAMP}`, + ); + } + + const suppliedRandomness = randomBytes(RANDOM_BYTE_LENGTH); + + if ( + !(suppliedRandomness instanceof Uint8Array) || + suppliedRandomness.byteLength !== RANDOM_BYTE_LENGTH + ) { + throw new TypeError("UUIDv7 randomBytes must return exactly 16 bytes"); + } + + const bytes = Uint8Array.from(suppliedRandomness); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let remainingTimestamp = timestamp; + + for (let index = 5; index >= 0; index -= 1) { + bytes[index] = remainingTimestamp % 256; + remainingTimestamp = Math.floor(remainingTimestamp / 256); + } + + view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x70); + view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80); + + const hex = Array.from(bytes, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/test/cli.test.js b/test/cli.test.js index 40049df..69c92da 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -11,8 +11,12 @@ import { VERSION } from "../src/version.js"; const HELP = `First Draft CLI Usage: + firstdraft [options] firstdraft [options] +Commands: + plan Work with Foundation Plans + Options: -h, --help Show help -V, --version Show version diff --git a/test/plan-init.test.js b/test/plan-init.test.js new file mode 100644 index 0000000..4fa3f08 --- /dev/null +++ b/test/plan-init.test.js @@ -0,0 +1,590 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { run } from "../src/cli.js"; + +const PROJECT_ID = "01900000-0000-7000-8000-000000000301"; +const PLAN_HELP = `First Draft CLI + +Usage: + firstdraft plan [options] + +Commands: + init Create a local empty Foundation Plan + +Options: + -h, --help Show help +`; +const PLAN_INIT_HELP = `First Draft CLI + +Usage: + firstdraft plan init --application-key --name + +Options: + --application-key Lower-snake-case application key + --name Application display name + -h, --help Show help +`; +const PLAN_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan --help' for usage.\n"; +const PLAN_UNKNOWN_COMMAND = + "Unknown command.\nRun 'firstdraft plan --help' for usage.\n"; +const PLAN_INIT_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan init --help' for usage.\n"; +const PLAN_INIT_ERROR = + "Could not initialize .firstdraft. The directory may be incomplete; no existing files were overwritten.\n"; + +const EXPECTED_PLAN = `{ + "format": "firstdraft.foundation-plan.sketch/0.19", + "target": { + "id": "rails", + "profile": "rails-sketch/2026-07" + }, + "application": { + "key": "oscar_party", + "name": "Oscar Party", + "native": {}, + "delivery": {}, + "entities": [] + } +} +`; +const EXPECTED_STATE = `{ + "format": "firstdraft.cli-state/1", + "project_id": "01900000-0000-7000-8000-000000000301" +} +`; + +test("plan help describes the available command", () => { + assert.deepEqual(invoke(["plan"]), { + status: 0, + stdout: PLAN_HELP, + stderr: "", + }); + assert.deepEqual(invoke(["plan", "--help"]), { + status: 0, + stdout: PLAN_HELP, + stderr: "", + }); + assert.deepEqual(invoke(["plan", "-h"]), { + status: 0, + stdout: PLAN_HELP, + stderr: "", + }); +}); + +test("plan init help does not require creation options", () => { + for (const argv of [ + ["plan", "init", "--help"], + ["plan", "init", "-h"], + ["plan", "init", "--help", "--help"], + ["plan", "init", "-h", "-h"], + ]) { + assert.deepEqual(invoke(argv), { + status: 0, + stdout: PLAN_INIT_HELP, + stderr: "", + }); + } +}); + +test("plan commands return non-echoing usage errors", () => { + const canary = "canary-secret-command"; + + for (const argv of [ + ["plan", canary], + ["plan", canary, "--help"], + ["plan", "--help", canary], + ]) { + const result = invoke(argv); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: PLAN_UNKNOWN_COMMAND, + }); + refuteCanary(result); + } + + for (const argv of [ + ["plan", "--canary-secret-option"], + ["plan", "--canary-secret-option", "--help"], + ]) { + const result = invoke(argv); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: PLAN_USAGE_ERROR, + }); + refuteCanary(result); + } +}); + +test("plan init creates exact deterministic local files", (context) => { + const cwd = temporaryDirectory(context, "firstdraft init ünicode "); + const result = invoke( + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + ], + { cwd, createProjectId: () => PROJECT_ID }, + ); + const directory = path.join(cwd, ".firstdraft"); + + assert.deepEqual(result, { + status: 0, + stdout: "Initialized .firstdraft/foundation-plan.json.\n", + stderr: "", + }); + assert.equal(readFileSync(path.join(directory, ".gitignore"), "utf8"), "*\n"); + assert.equal( + readFileSync(path.join(directory, "foundation-plan.json"), "utf8"), + EXPECTED_PLAN, + ); + assert.equal( + readFileSync(path.join(directory, "state.json"), "utf8"), + EXPECTED_STATE, + ); + assert.equal(existsSync(path.join(cwd, ".gitignore")), false); + + if (process.platform !== "win32") { + assert.equal(statSync(directory).mode & 0o777, 0o700); + for (const file of [".gitignore", "foundation-plan.json", "state.json"]) { + assert.equal(statSync(path.join(directory, file)).mode & 0o777, 0o600); + } + } +}); + +test("the executable initializes with a production UUIDv7", (context) => { + const cwd = temporaryDirectory(context); + const executable = fileURLToPath( + new URL("../bin/firstdraft.js", import.meta.url), + ); + const result = spawnSync( + process.execPath, + [ + executable, + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + ], + { cwd, encoding: "utf8" }, + ); + const state = JSON.parse( + readFileSync(path.join(cwd, ".firstdraft", "state.json"), "utf8"), + ); + + assert.deepEqual( + { status: result.status, stdout: result.stdout, stderr: result.stderr }, + { + status: 0, + stdout: "Initialized .firstdraft/foundation-plan.json.\n", + stderr: "", + }, + ); + assert.match( + state.project_id, + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); +}); + +test("the nested ignore file hides the complete local directory from Git", (context) => { + const cwd = temporaryDirectory(context); + runGit(cwd, ["init", "--quiet"]); + + const result = invoke( + ["plan", "init", "--application-key=oscar_party", "--name=Oscar Party"], + { cwd, createProjectId: () => PROJECT_ID }, + ); + assert.equal(result.status, 0); + + const status = runGit(cwd, [ + "status", + "--porcelain", + "--untracked-files=all", + ]); + assert.equal(status.stdout, ""); + + const ignored = runGit(cwd, [ + "check-ignore", + "-v", + ".firstdraft/.gitignore", + ".firstdraft/foundation-plan.json", + ".firstdraft/state.json", + ]); + assert.equal(ignored.stdout.trim().split("\n").length, 3); + assert.match(ignored.stdout, /\.firstdraft\/\.gitignore:1:\*/); +}); + +test("an existing root gitignore remains byte-for-byte unchanged", (context) => { + const cwd = temporaryDirectory(context); + const gitignore = path.join(cwd, ".gitignore"); + const original = Buffer.from([0x61, 0x0d, 0x0a, 0x62, 0x0a, 0xff]); + writeFileSync(gitignore, original); + + const result = invoke( + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + ], + { cwd, createProjectId: () => PROJECT_ID }, + ); + + assert.equal(result.status, 0); + assert.deepEqual(readFileSync(gitignore), original); +}); + +test("plan init validates every argument before randomness or filesystem access", () => { + const invalidArguments = [ + ["plan", "init"], + ["plan", "init", "--application-key", "oscar_party"], + ["plan", "init", "--name", "Oscar Party"], + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--application-key", + "other", + "--name", + "Oscar Party", + ], + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + "--name", + "Other", + ], + [ + "plan", + "init", + "--application-key", + "Invalid-Key", + "--name", + "Oscar Party", + ], + ["plan", "init", "--application-key", "oscar_party", "--name", "\u00a0\t"], + ["plan", "init", "--application-key", "oscar_party", "--name", "\u0085"], + ["plan", "init", "--application-key", "oscar_party", "--name", "\u0000"], + ["plan", "init", "--application-key", "oscar_party", "--name", "\ud800"], + ["plan", "init", "--application-key", "oscar_party", "--name", "\udc00"], + ["plan", "init", "--application-key", "oscar_party", "--name", "\ufdd0"], + ["plan", "init", "--application-key", "oscar_party", "--name", "\ufffe"], + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + String.fromCodePoint(0x1fffe), + ], + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + "--canary-secret-option", + ], + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + "canary-secret-positional", + ], + ]; + + for (const argv of invalidArguments) { + const result = invoke(argv, { + createProjectId: () => { + throw new Error("randomness must not run"); + }, + fileSystem: inaccessibleFileSystem(), + }); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: PLAN_INIT_USAGE_ERROR, + }); + refuteCanary(result); + } +}); + +test("plan init accepts ordinary astral Unicode in the application name", (context) => { + const cwd = temporaryDirectory(context); + const name = "Oscar Party \ud83c\udf89"; + + const result = invoke( + ["plan", "init", "--application-key", "oscar_party", "--name", name], + { cwd, createProjectId: () => PROJECT_ID }, + ); + const plan = JSON.parse( + readFileSync(path.join(cwd, ".firstdraft", "foundation-plan.json"), "utf8"), + ); + + assert.equal(result.status, 0); + assert.equal(plan.application.name, name); +}); + +test("plan init never overwrites an existing local path", (context) => { + const directoryCwd = temporaryDirectory(context); + const directory = path.join(directoryCwd, ".firstdraft"); + mkdirSync(directory); + writeFileSync(path.join(directory, "canary.txt"), "canary-secret-directory"); + + const directoryResult = invokeValidInit(directoryCwd); + assertInitializationFailure(directoryResult); + assert.equal( + readFileSync(path.join(directory, "canary.txt"), "utf8"), + "canary-secret-directory", + ); + + const fileCwd = temporaryDirectory(context); + const file = path.join(fileCwd, ".firstdraft"); + writeFileSync(file, "canary-secret-file"); + + const fileResult = invokeValidInit(fileCwd); + assertInitializationFailure(fileResult); + assert.equal(readFileSync(file, "utf8"), "canary-secret-file"); +}); + +test( + "plan init refuses an existing symlink without following it", + { skip: process.platform === "win32" }, + (context) => { + const cwd = temporaryDirectory(context); + const target = temporaryDirectory(context); + symlinkSync(target, path.join(cwd, ".firstdraft"), "dir"); + + const result = invokeValidInit(cwd); + + assertInitializationFailure(result); + assert.equal( + lstatSync(path.join(cwd, ".firstdraft")).isSymbolicLink(), + true, + ); + assert.deepEqual(readFileNames(target), []); + }, +); + +test("a second initialization preserves the first Project", (context) => { + const cwd = temporaryDirectory(context); + const first = invokeValidInit(cwd); + const directory = path.join(cwd, ".firstdraft"); + const originalPlan = readFileSync( + path.join(directory, "foundation-plan.json"), + ); + const originalState = readFileSync(path.join(directory, "state.json")); + + const second = invoke( + [ + "plan", + "init", + "--application-key", + "other_application", + "--name", + "Other Application", + ], + { cwd, createProjectId: () => "01900000-0000-7000-8000-000000000399" }, + ); + + assert.equal(first.status, 0); + assertInitializationFailure(second); + assert.deepEqual( + readFileSync(path.join(directory, "foundation-plan.json")), + originalPlan, + ); + assert.deepEqual( + readFileSync(path.join(directory, "state.json")), + originalState, + ); +}); + +test("partial filesystem failures stop immediately without cleanup", () => { + const operations = [ + "mkdir", + "write:.gitignore", + "write:foundation-plan.json", + "write:state.json", + ]; + + for ( + let failureIndex = 0; + failureIndex < operations.length; + failureIndex += 1 + ) { + /** @type {string[]} */ + const calls = []; + const fileSystem = recordingFileSystem(calls, failureIndex); + const result = invokeValidInit("/unused", { fileSystem }); + + assertInitializationFailure(result); + assert.deepEqual(calls, operations.slice(0, failureIndex + 1)); + } +}); + +test("unexpected programming errors remain loud", () => { + /** @type {import("../src/commands/plan-init.js").FileSystem} */ + const fileSystem = { + mkdirSync() { + throw new TypeError("programming error"); + }, + writeFileSync() { + throw new Error("not reached"); + }, + }; + + assert.throws( + () => invokeValidInit("/unused", { fileSystem }), + /programming error/, + ); +}); + +/** + * @param {readonly string[]} argv + * @param {Partial} [overrides] + */ +function invoke(argv, overrides = {}) { + let stdout = ""; + let stderr = ""; + + const status = run({ + argv, + stdout: { write: (text) => (stdout += text) }, + stderr: { write: (text) => (stderr += text) }, + ...overrides, + }); + + return { status, stdout, stderr }; +} + +/** + * @param {string} cwd + * @param {Partial} [overrides] + */ +function invokeValidInit(cwd, overrides = {}) { + return invoke( + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + ], + { cwd, createProjectId: () => PROJECT_ID, ...overrides }, + ); +} + +/** @param {{stdout: string, stderr: string}} result */ +function refuteCanary(result) { + assert.doesNotMatch(`${result.stdout}${result.stderr}`, /canary-secret/); +} + +/** @param {{status: number, stdout: string, stderr: string}} result */ +function assertInitializationFailure(result) { + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_INIT_ERROR, + }); + refuteCanary(result); +} + +/** @param {import("node:test").TestContext} context */ +function temporaryDirectory(context, prefix = "firstdraft-plan-init-") { + const directory = mkdtempSync(path.join(tmpdir(), prefix)); + context.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; +} + +/** @param {string} cwd */ +function readFileNames(cwd) { + return readdirSync(cwd); +} + +/** @param {string} cwd @param {string[]} arguments_ */ +function runGit(cwd, arguments_) { + const result = spawnSync("git", arguments_, { cwd, encoding: "utf8" }); + assert.equal( + result.status, + 0, + `git ${arguments_.join(" ")} failed\n${result.stdout}${result.stderr}`, + ); + return result; +} + +function inaccessibleFileSystem() { + /** @type {import("../src/commands/plan-init.js").FileSystem} */ + return { + mkdirSync() { + throw new Error("filesystem must not run"); + }, + writeFileSync() { + throw new Error("filesystem must not run"); + }, + }; +} + +/** + * @param {string[]} calls + * @param {number} failureIndex + * @returns {import("../src/commands/plan-init.js").FileSystem} + */ +function recordingFileSystem(calls, failureIndex) { + /** @param {string} operation */ + function record(operation) { + calls.push(operation); + if (calls.length - 1 === failureIndex) { + const error = new Error("canary-secret-filesystem"); + Object.assign(error, { code: "EIO" }); + throw error; + } + } + + return { + mkdirSync() { + record("mkdir"); + }, + writeFileSync(file) { + record(`write:${path.basename(file.toString())}`); + }, + }; +} diff --git a/test/uuid-v7.test.js b/test/uuid-v7.test.js new file mode 100644 index 0000000..09a83a4 --- /dev/null +++ b/test/uuid-v7.test.js @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { generateUuidV7 } from "../src/uuid-v7.js"; + +const MAX_TIMESTAMP = 2 ** 48 - 1; + +test("matches the RFC 9562 Appendix A.6 UUIDv7 vector", () => { + const randomness = Uint8Array.from([ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xcc, 0xc3, 0x98, 0xc4, 0xdc, 0x0c, + 0x0c, 0x07, 0x39, 0x8f, + ]); + + assert.equal( + generateUuidV7({ + now: () => 1_645_557_742_000, + randomBytes: () => randomness, + }), + "017f22e2-79b0-7cc3-98c4-dc0c0c07398f", + ); +}); + +test("encodes the inclusive timestamp bounds in network byte order", () => { + assert.equal( + generateUuidV7({ + now: () => 0, + randomBytes: () => new Uint8Array(16), + }), + "00000000-0000-7000-8000-000000000000", + ); + assert.equal( + generateUuidV7({ + now: () => MAX_TIMESTAMP, + randomBytes: () => new Uint8Array(16).fill(0xff), + }), + "ffffffff-ffff-7fff-bfff-ffffffffffff", + ); +}); + +test("sets the version and variant while preserving the other random bits", () => { + const zeros = generateUuidV7({ + now: () => 0, + randomBytes: () => new Uint8Array(16), + }); + const ones = generateUuidV7({ + now: () => 0, + randomBytes: () => new Uint8Array(16).fill(0xff), + }); + + assert.equal(zeros, "00000000-0000-7000-8000-000000000000"); + assert.equal(ones, "00000000-0000-7fff-bfff-ffffffffffff"); + assert.match( + zeros, + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-8[0-9a-f]{3}-[0-9a-f]{12}$/, + ); + assert.match( + ones, + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-b[0-9a-f]{3}-[0-9a-f]{12}$/, + ); +}); + +test("requests and copies exactly 16 random bytes", () => { + const randomness = new Uint8Array(16).fill(0xff); + const original = Uint8Array.from(randomness); + let requestedBytes; + + generateUuidV7({ + now: () => 0, + randomBytes: (size) => { + requestedBytes = size; + return randomness; + }, + }); + + assert.equal(requestedBytes, 16); + assert.deepEqual(randomness, original); +}); + +test("rejects timestamps outside the unsigned 48-bit integer range", () => { + for (const timestamp of [ + -1, + MAX_TIMESTAMP + 1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + ]) { + assert.throws( + () => + generateUuidV7({ + now: () => timestamp, + randomBytes: () => new Uint8Array(16), + }), + { + name: "RangeError", + message: `UUIDv7 timestamp must be an integer between 0 and ${MAX_TIMESTAMP}`, + }, + ); + } +}); + +test("rejects random input that is not exactly 16 bytes", () => { + for (const randomness of [ + new Uint8Array(15), + new Uint8Array(17), + Array(16).fill(0), + "0000000000000000", + ]) { + assert.throws( + () => + generateUuidV7({ + now: () => 0, + // @ts-expect-error Exercise runtime validation of untrusted input. + randomBytes: () => randomness, + }), + { + name: "TypeError", + message: "UUIDv7 randomBytes must return exactly 16 bytes", + }, + ); + } +});