diff --git a/README.md b/README.md index 8a035c9..e70790b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ built for agents that author and review Foundation Plans with their users. The package is not released yet. This repository contains the auditable command shell, local Foundation Plan -initialization, and conditional whole-document push; release behavior will arrive in reviewed increments. +initialization, subject identity generation, and conditional whole-document push; release behavior will arrive in +reviewed increments. ## Requirements @@ -31,6 +32,19 @@ This creates an empty `sketch/0.19` Plan and client-generated Project ID under ` 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. +## Add Foundation Plan subjects + +Generate an identity before adding each new independently mutable authored subject: + +```sh +firstdraft plan subject-id +``` + +The command prints one UUIDv7 for the subject's `subject_uuid`. It does not read or modify the Plan, reserve the +value, or make a network request. Preserve that UUID when renaming the subject or moving it to a different semantic +owner without changing its kind. Use a new UUID for a replacement concept. Readable keys and paths may change and +remain the document's links; the UUID preserves continuity between complete-document pushes. + ## Push a Foundation Plan From the initialized project: diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index 7184fcb..5d2d548 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -56,6 +56,17 @@ try { assert.equal(execution.stdout, `${packageMetadata.version}\n`); assert.equal(execution.stderr, ""); + + const subjectId = runNpm( + ["exec", "--offline", "--", "firstdraft", "plan", "subject-id"], + installationDirectory, + ); + + assert.match( + subjectId.stdout, + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\n$/, + ); + assert.equal(subjectId.stderr, ""); } finally { rmSync(temporaryDirectory, { recursive: true, force: true }); } diff --git a/src/cli.js b/src/cli.js index ce6685a..dc74945 100644 --- a/src/cli.js +++ b/src/cli.js @@ -33,8 +33,9 @@ Usage: firstdraft plan [options] Commands: - init Create a local empty Foundation Plan - push Send the local Foundation Plan to First Draft + init Create a local empty Foundation Plan + subject-id Generate a UUIDv7 for a new Plan subject + push Send the local Foundation Plan to First Draft Options: -h, --help Show help @@ -55,6 +56,18 @@ The first successful push saves its API origin in .firstdraft/state.json. Later pushes reject a different origin. `; +const PLAN_SUBJECT_ID_HELP = `First Draft CLI + +Usage: + firstdraft plan subject-id + +Prints one UUIDv7 for a new independently mutable Plan subject. +The command reads no files and makes no network request. + +Options: + -h, --help Show help +`; + const PLAN_INIT_HELP = `First Draft CLI Usage: @@ -89,6 +102,8 @@ const PLAN_PUSH_NETWORK_ERROR = "Could not complete the First Draft request. The Plan may have been accepted; local state was not changed.\n"; const PLAN_PUSH_PROTOCOL_ERROR = "First Draft returned an unexpected response. The Plan may have been accepted; local state was not changed.\n"; +const PLAN_SUBJECT_ID_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan subject-id --help' for usage.\n"; /** * @typedef {object} Writer @@ -101,7 +116,9 @@ const PLAN_PUSH_PROTOCOL_ERROR = * @property {Writer} stdout * @property {Writer} stderr * @property {string} [cwd] + * @property {() => string} [getCwd] * @property {() => string} [createProjectId] + * @property {() => string} [createSubjectId] * @property {import("./commands/plan-init.js").FileSystem} [fileSystem] * @property {typeof globalThis.fetch} [fetchFunction] * @property {import("./commands/plan-push.js").PlanPushFileSystem} [planPushFileSystem] @@ -117,6 +134,7 @@ const PLAN_PUSH_PROTOCOL_ERROR = * @property {Writer} stderr * @property {string} cwd * @property {() => string} createProjectId + * @property {() => string} createSubjectId * @property {import("./commands/plan-init.js").FileSystem} [fileSystem] * @property {typeof globalThis.fetch} [fetchFunction] * @property {import("./commands/plan-push.js").PlanPushFileSystem} [planPushFileSystem] @@ -125,13 +143,19 @@ const PLAN_PUSH_PROTOCOL_ERROR = * @property {string} [apiUrl] */ +/** + * @typedef {Omit & {cwd?: string, getCwd: () => string}} PlanCommandOptions + */ + /** @param {RunOptions} options */ export async function run({ argv, stdout, stderr, - cwd = process.cwd(), + cwd, + getCwd = process.cwd, createProjectId = generateUuidV7, + createSubjectId = generateUuidV7, fileSystem, fetchFunction, planPushFileSystem, @@ -145,7 +169,9 @@ export async function run({ stdout, stderr, cwd, + getCwd, createProjectId, + createSubjectId, fileSystem, fetchFunction, planPushFileSystem, @@ -201,13 +227,15 @@ function runRoot({ argv, stdout, stderr }) { return 0; } -/** @param {CommandOptions} options */ +/** @param {PlanCommandOptions} options */ async function runPlan({ argv, stdout, stderr, cwd, + getCwd, createProjectId, + createSubjectId, fileSystem, fetchFunction, planPushFileSystem, @@ -220,7 +248,7 @@ async function runPlan({ argv: argv.slice(1), stdout, stderr, - cwd, + cwd: cwd ?? getCwd(), createProjectId, fileSystem, }); @@ -231,7 +259,7 @@ async function runPlan({ argv: argv.slice(1), stdout, stderr, - cwd, + cwd: cwd ?? getCwd(), fetchFunction, planPushFileSystem, createTemporaryId, @@ -240,6 +268,15 @@ async function runPlan({ }); } + if (argv[0] === "subject-id") { + return runPlanSubjectId({ + argv: argv.slice(1), + stdout, + stderr, + createSubjectId, + }); + } + const parsed = parseArguments(() => parseArgs({ args: [...argv], @@ -268,6 +305,33 @@ async function runPlan({ return 0; } +/** + * @param {Pick} options + */ +function runPlanSubjectId({ argv, stdout, stderr, createSubjectId }) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { help: { type: "boolean", short: "h" } }, + allowPositionals: false, + strict: true, + }), + ); + + if (!parsed) { + stderr.write(PLAN_SUBJECT_ID_USAGE_ERROR); + return 2; + } + + if (parsed.values.help) { + stdout.write(PLAN_SUBJECT_ID_HELP); + return 0; + } + + stdout.write(`${createSubjectId()}\n`); + return 0; +} + /** * @param {Pick} options */ @@ -365,7 +429,9 @@ async function runPlanPush({ return 0; } -/** @param {CommandOptions} options */ +/** + * @param {Pick} options + */ function runPlanInit({ argv, stdout, diff --git a/test/plan-init.test.js b/test/plan-init.test.js index 025c716..5a01e4c 100644 --- a/test/plan-init.test.js +++ b/test/plan-init.test.js @@ -26,8 +26,9 @@ Usage: firstdraft plan [options] Commands: - init Create a local empty Foundation Plan - push Send the local Foundation Plan to First Draft + init Create a local empty Foundation Plan + subject-id Generate a UUIDv7 for a new Plan subject + push Send the local Foundation Plan to First Draft Options: -h, --help Show help @@ -72,7 +73,7 @@ const EXPECTED_STATE = `{ } `; -test("plan help describes the available command", async () => { +test("plan help describes the available commands", async () => { assert.deepEqual(await invoke(["plan"]), { status: 0, stdout: PLAN_HELP, diff --git a/test/plan-subject-id.test.js b/test/plan-subject-id.test.js new file mode 100644 index 0000000..45dca68 --- /dev/null +++ b/test/plan-subject-id.test.js @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { run } from "../src/cli.js"; + +const SUBJECT_ID = "01900000-0000-7000-8000-000000000302"; +const HELP = `First Draft CLI + +Usage: + firstdraft plan subject-id + +Prints one UUIDv7 for a new independently mutable Plan subject. +The command reads no files and makes no network request. + +Options: + -h, --help Show help +`; +const USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan subject-id --help' for usage.\n"; +const UUID_V7 = + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\n$/; +/** @satisfies {Partial} */ +const INACCESSIBLE_DEPENDENCIES = { + getCwd: () => { + throw new Error("Working directory must not be read"); + }, + fileSystem: { + mkdirSync() { + throw new Error("Filesystem must not run"); + }, + writeFileSync() { + throw new Error("Filesystem must not run"); + }, + }, + planPushFileSystem: { + lstatSync() { + throw new Error("Filesystem must not run"); + }, + readFileSync() { + throw new Error("Filesystem must not run"); + }, + renameSync() { + throw new Error("Filesystem must not run"); + }, + writeFileSync() { + throw new Error("Filesystem must not run"); + }, + }, + fetchFunction: async () => { + throw new Error("Network must not run"); + }, + createRequestSignal: () => { + throw new Error("Network setup must not run"); + }, +}; + +test("plan subject-id prints exactly one generated UUID", async () => { + let calls = 0; + const result = await invoke(["plan", "subject-id"], { + ...INACCESSIBLE_DEPENDENCIES, + createProjectId: () => { + throw new Error("Project ID generation must not run"); + }, + createSubjectId: () => { + calls += 1; + return SUBJECT_ID; + }, + }); + + assert.deepEqual(result, { + status: 0, + stdout: `${SUBJECT_ID}\n`, + stderr: "", + }); + assert.equal(calls, 1); +}); + +test("plan subject-id uses the production generator for fresh UUIDv7s", async () => { + const first = await invoke(["plan", "subject-id"], INACCESSIBLE_DEPENDENCIES); + const second = await invoke( + ["plan", "subject-id"], + INACCESSIBLE_DEPENDENCIES, + ); + + for (const result of [first, second]) { + assert.equal(result.status, 0); + assert.match(result.stdout, UUID_V7); + assert.equal(result.stderr, ""); + } + assert.notEqual(first.stdout, second.stdout); +}); + +test("plan subject-id help has no generation prerequisites", async () => { + for (const argv of [ + ["plan", "subject-id", "--help"], + ["plan", "subject-id", "-h"], + ["plan", "subject-id", "--help", "--help"], + ["plan", "subject-id", "-h", "-h"], + ]) { + assert.deepEqual( + await invoke(argv, { + ...INACCESSIBLE_DEPENDENCIES, + createSubjectId: () => { + throw new Error("Subject ID generation must not run"); + }, + }), + { status: 0, stdout: HELP, stderr: "" }, + ); + } +}); + +test("plan subject-id validates arguments before generating an ID", async () => { + const canary = "canary-secret-argument"; + + for (const argv of [ + ["plan", "subject-id", canary], + ["plan", "subject-id", `--${canary}`], + ["plan", "subject-id", "--help", canary], + ["plan", "subject-id", canary, "--help"], + ]) { + const result = await invoke(argv, { + ...INACCESSIBLE_DEPENDENCIES, + createSubjectId: () => { + throw new Error("Subject ID generation must not run"); + }, + }); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: USAGE_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +/** + * @param {readonly string[]} argv + * @param {Partial} [overrides] + */ +async function invoke(argv, overrides = {}) { + let stdout = ""; + let stderr = ""; + + const status = await run({ + argv, + stdout: { write: (text) => (stdout += text) }, + stderr: { write: (text) => (stderr += text) }, + ...overrides, + }); + + return { status, stdout, stderr }; +}