From a732fb2cd219c93def6b52464a8eaa7bf18adc54 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Tue, 28 Jul 2026 15:41:10 -0500 Subject: [PATCH] Initialize local Foundation Plan drafts Agents need a deterministic, private starting point before network behavior arrives. Add plan init to create the exact empty sketch/0.19 document and UUIDv7 Project state beneath a nested ignored .firstdraft directory. Validate keys and names at the schema and I-JSON boundary before invoking randomness or the filesystem. Refuse every existing .firstdraft path and use exclusive writes so initialization never overwrites existing data. Extend the package allowlist and tests for the new runtime files, and exercise the Node 22 compatibility job across Linux, Windows, and macOS. --- .github/workflows/ci.yml | 2 +- README.md | 19 +- scripts/check-pack.js | 2 + src/cli.js | 277 +++++++++++++++++- src/commands/plan-init.js | 87 ++++++ src/uuid-v7.js | 55 ++++ test/cli.test.js | 4 + test/plan-init.test.js | 590 ++++++++++++++++++++++++++++++++++++++ test/uuid-v7.test.js | 121 ++++++++ 9 files changed, 1137 insertions(+), 20 deletions(-) create mode 100644 src/commands/plan-init.js create mode 100644 src/uuid-v7.js create mode 100644 test/plan-init.test.js create mode 100644 test/uuid-v7.test.js 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", + }, + ); + } +});