diff --git a/README.md b/README.md index b01f0e6..ec52c4a 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ built for agents that author and review Foundation Plans with their users. Public alpha releases use npm's `next` tag. This release line contains the auditable command shell, local Foundation -Plan initialization, subject identity generation, conditional whole-document push, whole-graph analysis status -polling, explicit compilation, verified local artifact materialization, and conditional GitHub publication. +Plan initialization, local application-key and UUID generation, conditional whole-document push, whole-graph +analysis status polling, explicit compilation, verified local artifact materialization, and conditional GitHub publication. Interfaces may change between prereleases, and publishing the CLI does not make the wider First Draft service generally available. @@ -39,8 +39,8 @@ firstdraft plan push ``` `plan push`, `plan status`, `plan compile`, and `plan publish` send the token as a Bearer credential on every API -request. The CLI does not save it in `.firstdraft`, print it, or require it for local commands such as `plan init` and -`plan subject-id`. Revoke the token in First Draft if it is exposed. A missing token, or First Draft's validated +request. The CLI does not save it in `.firstdraft`, print it, or require it for local commands such as `plan init` +and `generate`. Revoke the token in First Draft if it is exposed. A missing token, or First Draft's validated `401` problem response with the `authentication_required` code, produces that stable CLI error. ## Development @@ -56,19 +56,34 @@ npm run pack:check From the project that the Plan describes: ```sh -firstdraft plan init --application-key oscar_party --name "Oscar Party" +firstdraft plan init --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. +Provide either `--name`, `--application-key`, or both. Name-only initialization derives a lower-snake key. Key-only +initialization derives a humanized display name. Supplying both preserves both values exactly after +validating them against the Foundation Plan schema. To inspect the name-to-key derivation without initializing a +project, run: + +```sh +firstdraft generate application-key --name "Oscar Party" +``` + +The generated key is deterministic, starts with a letter, contains only lowercase ASCII letters, digits, and +underscores, and is at most 63 bytes so it can lower to the current iOS application identifier component. Names +without a readable ASCII form receive a stable digest-based key. Longer readable names are shortened to a readable +prefix plus a stable digest suffix. Explicit application keys retain the Foundation Plan's broader +`^[a-z][a-z0-9_]*$` boundary and are left for target analysis rather than silently rewritten. + ## Add Foundation Plan subjects Generate an identity before adding each new independently mutable authored subject: ```sh -firstdraft plan subject-id +firstdraft generate uuid ``` The command prints one UUIDv7 for the subject's `subject_uuid`. It does not read or modify the Plan, reserve the @@ -76,6 +91,8 @@ value, or make a network request. Preserve that UUID when renaming the subject o 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. +Use `--count ` to print several independently generated UUIDv7 values, one per line. + ## Push a Foundation Plan From the initialized project: @@ -204,55 +221,55 @@ other failed publication phases include the validated terminal state in a handle client currently assumes the provisional `{ project, compilation, publication }` response described by its tests; the server route is not yet implemented, and no live GitHub publication smoke has been completed. -Every handled failure from `plan init`, `plan subject-id`, `plan push`, `plan status`, `plan compile`, or -`plan publish` writes +Every handled failure from `generate uuid`, `generate application-key`, `plan init`, `plan push`, `plan status`, +`plan compile`, or `plan publish` writes exactly one JSON object to standard error. Agents should branch on its stable `error` value, not on the human-readable `detail`: -| Commands | `error` | Exit | Meaning | -| ------------------------------------------------------------------------------------------ | -------------------------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `plan init`, `plan subject-id`, `plan push`, `plan status`, `plan compile`, `plan publish` | `invalid_arguments` | 2 | The command syntax is invalid; nothing was written and no request was made. | -| `plan init` | `local_initialization_failed` | 1 | Local initialization failed. The directory may be incomplete; existing files were not overwritten. | -| `plan push`, `plan compile`, `plan publish` | `invalid_configuration` | 2 | API configuration or the saved ETag is incompatible with the requested command; no request was made. | -| `plan push`, `plan status`, `plan compile`, `plan publish` | `authentication_required` | 1 | `FIRSTDRAFT_API_TOKEN` is missing, or First Draft returned a validated `401` problem with the `authentication_required` code; create or replace the token. | -| `plan push`, `plan status`, `plan compile`, `plan publish` | `local_input_unreadable` | 1 | The required local Plan or private state could not be read; no request was made. | -| `plan status`, `plan compile`, `plan publish` | `project_not_pushed` | 1 | Local state is valid but has no pinned remote Project yet; run `plan push` first. | -| `plan push`, `plan compile` | `request_outcome_unknown` | 1 | A sent mutation or its response could not be verified. Stop and reconcile instead of retrying it automatically. | -| `plan publish` | `request_outcome_unknown` | 1 | The ambiguous PUT could not be reconciled. No mutation was retried; retry is safe, and a validated problem may be included. | -| `plan status` | `status_unavailable` | 1 | The network request or response stream failed. The object includes `status` when headers were received; retry the GET a bounded number of times. | -| `plan status` | `invalid_server_response` | 1 | First Draft returned a response that does not satisfy the status contract. The object includes `status`; retrying unchanged will not repair the mismatch. | -| `plan push`, `plan status` | `server_rejected` | 1 | First Draft returned a validated rejection. The object includes `status` and a whitelisted `response` containing validated problem details or diagnostics. | -| `plan status --wait` | `analysis_changed` | 1 | A different current analysis appeared while polling. `current` contains its validated state; start a fresh wait to follow it explicitly. | -| `plan status --wait` | `wait_timed_out` | 1 | The two-minute wait ended while processing continued. `current` contains the last validated state; another wait is safe. | -| `plan push` | `local_state_not_saved` | 1 | The server accepted the Plan, but local state replacement failed. This is the only error that includes private `recovery_state`. | -| `plan compile` | `compilation_start_rejected` | 1 | First Draft rejected the conditional start; a validated problem may be included as `response`. | -| `plan compile` | `compilation_status_unavailable` | 1 | The first failed read stopped polling the pinned Compilation; a validated problem may be included. | -| `plan compile` | `invalid_compilation_status` | 1 | A status response violated the exact Compilation contract. | -| `plan compile` | `compilation_changed` | 1 | Compilation identity, immutable metadata, or lifecycle progression changed while polling. | -| `plan compile` | `compilation_wait_timed_out` | 1 | The ten-minute deadline ended; `current` contains the last validated status. | -| `plan compile` | `compilation_failed` | 1 | The pinned Compilation failed; `current` contains its validated failure. | -| `plan compile` | `compilation_cancelled` | 1 | The pinned Compilation was cancelled. | -| `plan compile` | `artifact_unavailable` | 1 | The artifact GET failed or was rejected before materialization. | -| `plan compile` | `invalid_artifact` | 1 | Artifact transport metadata, bytes, provenance, manifest, or files failed validation. | -| `plan compile` | `invalid_output_path` | 2 | The output is not absent beneath an existing real directory; no request was made. | -| `plan compile` | `materialization_failed` | 1 | After artifact validation, filesystem state changed or the verified tree could not be written and atomically published. | -| `plan publish` | `local_plan_changed` | 1 | Local Plan bytes no longer match the last successful push; push the complete Plan before publishing. | -| `plan publish` | `publication_start_rejected` | 1 | First Draft definitively rejected the conditional singleton request; no Publication was created or changed, and a validated problem is included. | -| `plan publish` | `publication_status_unavailable` | 1 | The first failed singleton read stopped polling; rerun `plan publish` to replay the singleton and resume. A validated problem may be included. | -| `plan publish` | `invalid_publication_status` | 1 | A status response violated the complete Project, Compilation, Publication, or repository projection contract. | -| `plan publish` | `publication_changed` | 1 | Publication identity, metadata, repository, or lifecycle changed. `current` is the pinned projection and `rejected` is the next response; rerun to resume. | -| `plan publish` | `publication_wait_timed_out` | 1 | The ten-minute deadline ended; `current` contains the last validated status. Rerun `plan publish` to resume. | -| `plan publish` | `publication_failed` | 1 | The pinned Publication failed or reached `repository_conflict`; `current` contains its validated failure. | -| `plan publish` | `publication_cancelled` | 1 | The pinned Publication was cancelled; `current` contains its validated terminal state. | +| Commands | `error` | Exit | Meaning | +| -------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `generate uuid`, `generate application-key`, `plan init`, `plan push`, `plan status`, `plan compile`, `plan publish` | `invalid_arguments` | 2 | The command syntax is invalid; nothing was written and no request was made. | +| `plan init` | `local_initialization_failed` | 1 | Local initialization failed. The directory may be incomplete; existing files were not overwritten. | +| `plan push`, `plan compile`, `plan publish` | `invalid_configuration` | 2 | API configuration or the saved ETag is incompatible with the requested command; no request was made. | +| `plan push`, `plan status`, `plan compile`, `plan publish` | `authentication_required` | 1 | `FIRSTDRAFT_API_TOKEN` is missing, or First Draft returned a validated `401` problem with the `authentication_required` code; create or replace the token. | +| `plan push`, `plan status`, `plan compile`, `plan publish` | `local_input_unreadable` | 1 | The required local Plan or private state could not be read; no request was made. | +| `plan status`, `plan compile`, `plan publish` | `project_not_pushed` | 1 | Local state is valid but has no pinned remote Project yet; run `plan push` first. | +| `plan push`, `plan compile` | `request_outcome_unknown` | 1 | A sent mutation or its response could not be verified. Stop and reconcile instead of retrying it automatically. | +| `plan publish` | `request_outcome_unknown` | 1 | The ambiguous PUT could not be reconciled. No mutation was retried; retry is safe, and a validated problem may be included. | +| `plan status` | `status_unavailable` | 1 | The network request or response stream failed. The object includes `status` when headers were received; retry the GET a bounded number of times. | +| `plan status` | `invalid_server_response` | 1 | First Draft returned a response that does not satisfy the status contract. The object includes `status`; retrying unchanged will not repair the mismatch. | +| `plan push`, `plan status` | `server_rejected` | 1 | First Draft returned a validated rejection. The object includes `status` and a whitelisted `response` containing validated problem details or diagnostics. | +| `plan status --wait` | `analysis_changed` | 1 | A different current analysis appeared while polling. `current` contains its validated state; start a fresh wait to follow it explicitly. | +| `plan status --wait` | `wait_timed_out` | 1 | The two-minute wait ended while processing continued. `current` contains the last validated state; another wait is safe. | +| `plan push` | `local_state_not_saved` | 1 | The server accepted the Plan, but local state replacement failed. This is the only error that includes private `recovery_state`. | +| `plan compile` | `compilation_start_rejected` | 1 | First Draft rejected the conditional start; a validated problem may be included as `response`. | +| `plan compile` | `compilation_status_unavailable` | 1 | The first failed read stopped polling the pinned Compilation; a validated problem may be included. | +| `plan compile` | `invalid_compilation_status` | 1 | A status response violated the exact Compilation contract. | +| `plan compile` | `compilation_changed` | 1 | Compilation identity, immutable metadata, or lifecycle progression changed while polling. | +| `plan compile` | `compilation_wait_timed_out` | 1 | The ten-minute deadline ended; `current` contains the last validated status. | +| `plan compile` | `compilation_failed` | 1 | The pinned Compilation failed; `current` contains its validated failure. | +| `plan compile` | `compilation_cancelled` | 1 | The pinned Compilation was cancelled. | +| `plan compile` | `artifact_unavailable` | 1 | The artifact GET failed or was rejected before materialization. | +| `plan compile` | `invalid_artifact` | 1 | Artifact transport metadata, bytes, provenance, manifest, or files failed validation. | +| `plan compile` | `invalid_output_path` | 2 | The output is not absent beneath an existing real directory; no request was made. | +| `plan compile` | `materialization_failed` | 1 | After artifact validation, filesystem state changed or the verified tree could not be written and atomically published. | +| `plan publish` | `local_plan_changed` | 1 | Local Plan bytes no longer match the last successful push; push the complete Plan before publishing. | +| `plan publish` | `publication_start_rejected` | 1 | First Draft definitively rejected the conditional singleton request; no Publication was created or changed, and a validated problem is included. | +| `plan publish` | `publication_status_unavailable` | 1 | The first failed singleton read stopped polling; rerun `plan publish` to replay the singleton and resume. A validated problem may be included. | +| `plan publish` | `invalid_publication_status` | 1 | A status response violated the complete Project, Compilation, Publication, or repository projection contract. | +| `plan publish` | `publication_changed` | 1 | Publication identity, metadata, repository, or lifecycle changed. `current` is the pinned projection and `rejected` is the next response; rerun to resume. | +| `plan publish` | `publication_wait_timed_out` | 1 | The ten-minute deadline ended; `current` contains the last validated status. Rerun `plan publish` to resume. | +| `plan publish` | `publication_failed` | 1 | The pinned Publication failed or reached `repository_conflict`; `current` contains its validated failure. | +| `plan publish` | `publication_cancelled` | 1 | The pinned Publication was cancelled; `current` contains its validated terminal state. | Handled failure output never includes command arguments, local Plan bytes, runtime paths, raw filesystem or network errors, or unvalidated response bodies. Optional fields inside a validated rejection diagnostic are omitted when absent or when the CLI cannot validate their complete shape. Exit status remains a broad shell-level class; the `error` value is the machine-readable recovery contract. -Root-level and `plan` command-group usage failures remain human-readable text on standard error with exit 2. A -failure before a subcommand can begin, such as an unavailable working directory, also remains uncaught, as do -unexpected programming defects. +Root-level, `generate`, and `plan` command-group usage failures remain human-readable text on standard error with +exit 2. A failure before a subcommand can begin, such as an unavailable working directory, also remains uncaught, +as do unexpected programming defects. ## Trust model diff --git a/scripts/check-pack.js b/scripts/check-pack.js index bce9421..799e98f 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -27,6 +27,7 @@ if (result.status !== 0) { "package.json", "src/api-authentication.js", "src/api-response.js", + "src/application-identity.js", "src/cli.js", "src/commands/plan-compile.js", "src/commands/plan-init.js", diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index 27bd0c9..9da2b61 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -69,25 +69,42 @@ try { assert.equal(execution.stdout, `${packageMetadata.version}\n`); assert.equal(execution.stderr, ""); - const subjectId = runNpm( - ["exec", "--offline", "--", "firstdraft", "plan", "subject-id"], + const generatedUuids = runNpm( + [ + "exec", + "--offline", + "--", + "firstdraft", + "generate", + "uuid", + "--count", + "2", + ], 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$/, + generatedUuids.stdout, + /^(?:[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\n){2}$/, ); - assert.equal(subjectId.stderr, ""); + assert.equal(generatedUuids.stderr, ""); + + const applicationKey = spawnPackedCli( + ["generate", "application-key", "--name", "Café Planner"], + installationDirectory, + ); + + assert.equal(applicationKey.stdout, "cafe_planner\n"); + assert.equal(applicationKey.stderr, ""); - const invalidSubjectId = spawnPackedCli( - ["plan", "subject-id", "--canary-secret-option"], + const invalidGeneratedUuid = spawnPackedCli( + ["generate", "uuid", "--count", "0"], installationDirectory, ); - assertHandledFailure(invalidSubjectId, 2, { + assertHandledFailure(invalidGeneratedUuid, 2, { error: "invalid_arguments", detail: - "Invalid arguments. Run 'firstdraft plan subject-id --help' for usage.", + "Invalid arguments. Run 'firstdraft generate uuid --help' for usage.", }); const invalidInit = spawnPackedCli( @@ -102,14 +119,7 @@ try { const projectDirectory = path.join(temporaryDirectory, "project"); mkdirSync(projectDirectory); const initialized = spawnPackedCli( - [ - "plan", - "init", - "--application-key", - "oscar_party", - "--name", - "Oscar Party", - ], + ["plan", "init", "--name", "Oscar Party"], projectDirectory, ); assert.deepEqual( @@ -124,6 +134,39 @@ try { stderr: "", }, ); + const initializedPlan = JSON.parse( + readFileSync( + path.join(projectDirectory, ".firstdraft", "foundation-plan.json"), + "utf8", + ), + ); + assert.deepEqual( + { + key: initializedPlan.application.key, + name: initializedPlan.application.name, + }, + { key: "oscar_party", name: "Oscar Party" }, + ); + + const keyOnlyProjectDirectory = path.join( + temporaryDirectory, + "key-only-project", + ); + mkdirSync(keyOnlyProjectDirectory); + const keyOnlyInitialization = spawnPackedCli( + ["plan", "init", "--application-key", "movie___catalog___"], + keyOnlyProjectDirectory, + ); + assert.equal(keyOnlyInitialization.status, 0); + assert.equal(keyOnlyInitialization.stderr, ""); + const keyOnlyPlan = JSON.parse( + readFileSync( + path.join(keyOnlyProjectDirectory, ".firstdraft", "foundation-plan.json"), + "utf8", + ), + ); + assert.equal(keyOnlyPlan.application.key, "movie___catalog___"); + assert.equal(keyOnlyPlan.application.name, "Movie Catalog"); const repeatedInit = spawnPackedCli( [ diff --git a/src/application-identity.js b/src/application-identity.js new file mode 100644 index 0000000..8907b3f --- /dev/null +++ b/src/application-identity.js @@ -0,0 +1,149 @@ +import { createHash } from "node:crypto"; + +export const MAX_DERIVED_APPLICATION_KEY_BYTES = 63; + +const DIGEST_HEX_LENGTH = 12; +const APPLICATION_KEY_PATTERN = /^[a-z][a-z0-9_]*$/; +const MARK_PATTERN = /\p{Mark}/u; +// NFKD does not decompose these Latin letters, so map them before ASCII filtering. +const LATIN_TRANSLITERATIONS = new Map([ + ["Æ", "ae"], + ["æ", "ae"], + ["Œ", "oe"], + ["œ", "oe"], + ["Ø", "o"], + ["ø", "o"], + ["Ł", "l"], + ["ł", "l"], + ["Đ", "d"], + ["đ", "d"], + ["Ð", "d"], + ["ð", "d"], + ["Þ", "th"], + ["þ", "th"], + ["ẞ", "ss"], + ["ß", "ss"], + ["ı", "i"], +]); + +/** @param {string} name */ +export function deriveApplicationKey(name) { + if (!isValidApplicationName(name)) { + throw new TypeError( + "Application name must be valid nonblank Unicode text.", + ); + } + + const normalizedName = name.normalize("NFKD"); + const readable = asciiWords(normalizedName); + const candidate = + readable.length === 0 + ? `app_${stableDigest(normalizedName)}` + : /^[0-9]/.test(readable) + ? `app_${readable}` + : readable; + + if (Buffer.byteLength(candidate) <= MAX_DERIVED_APPLICATION_KEY_BYTES) { + return candidate; + } + + const suffix = `_${stableDigest(normalizedName)}`; + const prefix = candidate + .slice(0, MAX_DERIVED_APPLICATION_KEY_BYTES - suffix.length) + .replace(/_+$/, ""); + + return `${prefix}${suffix}`; +} + +/** @param {string} applicationKey */ +export function deriveApplicationName(applicationKey) { + if (!isValidApplicationKey(applicationKey)) { + throw new TypeError( + "Application key must be a valid lower-snake identifier.", + ); + } + + return applicationKey + .split(/_+/) + .filter((word) => word.length > 0) + .map((word) => `${word[0]?.toUpperCase() ?? ""}${word.slice(1)}`) + .join(" "); +} + +/** @param {unknown} value @returns {value is string} */ +export function isValidApplicationKey(value) { + return typeof value === "string" && APPLICATION_KEY_PATTERN.test(value); +} + +/** @param {unknown} value @returns {value is string} */ +export function isValidApplicationName(value) { + if (typeof value !== "string") return false; + + let hasNonWhitespace = false; + for (const character of value) { + 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 {string} name */ +function asciiWords(name) { + let value = ""; + + for (const character of name) { + if (MARK_PATTERN.test(character)) continue; + + const expanded = LATIN_TRANSLITERATIONS.get(character) ?? character; + for (const asciiCharacter of expanded) { + const codePoint = asciiCharacter.codePointAt(0) ?? 0; + if (codePoint >= 0x41 && codePoint <= 0x5a) { + value += asciiCharacter.toLowerCase(); + } else if ( + (codePoint >= 0x61 && codePoint <= 0x7a) || + (codePoint >= 0x30 && codePoint <= 0x39) + ) { + value += asciiCharacter; + } else { + value += "_"; + } + } + } + + return value.replace(/_+/g, "_").replace(/^_+|_+$/g, ""); +} + +/** @param {string} value */ +function stableDigest(value) { + return createHash("sha256") + .update(Buffer.from(value, "utf8")) + .digest("hex") + .slice(0, DIGEST_HEX_LENGTH); +} + +/** @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 + ); +} diff --git a/src/cli.js b/src/cli.js index c7b70a3..08f0864 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,5 +1,11 @@ import { parseArgs } from "node:util"; +import { + deriveApplicationKey, + deriveApplicationName, + isValidApplicationKey, + isValidApplicationName, +} from "./application-identity.js"; import { authenticatedFetch, isAuthenticationProblem, @@ -62,7 +68,8 @@ Usage: firstdraft [options] Commands: - plan Work with Foundation Plans + generate Generate local values + plan Work with Foundation Plans Options: -h, --help Show help @@ -75,17 +82,55 @@ Usage: firstdraft plan [options] Commands: - 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 - status Read the current whole-graph analysis status - compile Compile the accepted Plan into a new local directory - publish Compile and publish the accepted Plan to GitHub + init Create a local empty Foundation Plan + push Send the local Foundation Plan to First Draft + status Read the current whole-graph analysis status + compile Compile the accepted Plan into a new local directory + publish Compile and publish the accepted Plan to GitHub + +Options: + -h, --help Show help +`; + +const GENERATE_HELP = `First Draft CLI + +Usage: + firstdraft generate [options] + +Commands: + uuid Generate one or more UUIDv7 values + application-key Derive a lower-snake-case application key Options: -h, --help Show help `; +const GENERATE_UUID_HELP = `First Draft CLI + +Usage: + firstdraft generate uuid [--count ] + +Options: + --count Number to generate (positive integer) + -h, --help Show help + +The command reads no files and makes no network request. Each UUID is printed +on its own line. +`; + +const GENERATE_APPLICATION_KEY_HELP = `First Draft CLI + +Usage: + firstdraft generate application-key --name + +Options: + --name Application display name + -h, --help Show help + +The command derives the same application key used by name-only plan init. +Generated keys are at most 63 ASCII bytes. +`; + const PLAN_PUSH_HELP = `First Draft CLI Usage: @@ -151,33 +196,28 @@ Each Project can publish one retained Plan Head in this release. The command waits up to ten minutes and prints the private GitHub repository URL. `; -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: - firstdraft plan init --application-key --name + firstdraft plan init [--application-key ] [--name ] Options: --application-key Lower-snake-case application key --name Application display name -h, --help Show help + +Provide at least one of --application-key or --name. The command derives a +missing key from the name or a missing display name from the key. `; 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 GENERATE_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft generate --help' for usage.\n"; +const GENERATE_UNKNOWN_COMMAND = + "Unknown command.\nRun 'firstdraft generate --help' for usage.\n"; const PLAN_USAGE_ERROR = "Invalid arguments.\nRun 'firstdraft plan --help' for usage.\n"; const PLAN_UNKNOWN_COMMAND = @@ -271,8 +311,10 @@ const PLAN_PUBLISH_TIMEOUT_DETAIL = const PLAN_PUBLISH_FAILED_DETAIL = "The pinned Publication failed. Its validated status identifies the failed phase."; const PLAN_PUBLISH_CANCELLED_DETAIL = "The pinned Publication was cancelled."; -const PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL = - "Invalid arguments. Run 'firstdraft plan subject-id --help' for usage."; +const GENERATE_UUID_INVALID_ARGUMENTS_DETAIL = + "Invalid arguments. Run 'firstdraft generate uuid --help' for usage."; +const GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL = + "Invalid arguments. Run 'firstdraft generate application-key --help' for usage."; /** * @typedef {object} Writer @@ -287,7 +329,7 @@ const PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL = * @property {string} [cwd] * @property {() => string} [getCwd] * @property {() => string} [createProjectId] - * @property {() => string} [createSubjectId] + * @property {() => string} [createUuid] * @property {import("./commands/plan-init.js").FileSystem} [fileSystem] * @property {typeof globalThis.fetch} [fetchFunction] * @property {import("./commands/plan-push.js").PlanPushFileSystem} [planPushFileSystem] @@ -310,7 +352,7 @@ const PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL = * @property {Writer} stderr * @property {string} cwd * @property {() => string} createProjectId - * @property {() => string} createSubjectId + * @property {() => string} createUuid * @property {import("./commands/plan-init.js").FileSystem} [fileSystem] * @property {typeof globalThis.fetch} [fetchFunction] * @property {import("./commands/plan-push.js").PlanPushFileSystem} [planPushFileSystem] @@ -327,7 +369,11 @@ const PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL = */ /** - * @typedef {Omit & {cwd?: string, getCwd: () => string}} PlanCommandOptions + * @typedef {Omit & {cwd?: string, getCwd: () => string}} PlanCommandOptions + */ + +/** + * @typedef {Pick} GenerateCommandOptions */ /** @param {RunOptions} options */ @@ -338,7 +384,7 @@ export async function run({ cwd, getCwd = process.cwd, createProjectId = generateUuidV7, - createSubjectId = generateUuidV7, + createUuid = generateUuidV7, fileSystem, fetchFunction, planPushFileSystem, @@ -353,6 +399,15 @@ export async function run({ apiUrl = process.env.FIRSTDRAFT_API_URL, apiToken = process.env.FIRSTDRAFT_API_TOKEN, }) { + if (argv[0] === "generate") { + return runGenerate({ + argv: argv.slice(1), + stdout, + stderr, + createUuid, + }); + } + if (argv[0] === "plan") { return runPlan({ argv: argv.slice(1), @@ -361,7 +416,6 @@ export async function run({ cwd, getCwd, createProjectId, - createSubjectId, fileSystem, fetchFunction, planPushFileSystem, @@ -432,7 +486,6 @@ async function runPlan({ cwd, getCwd, createProjectId, - createSubjectId, fileSystem, fetchFunction, planPushFileSystem, @@ -518,12 +571,52 @@ async function runPlan({ }); } - if (argv[0] === "subject-id") { - return runPlanSubjectId({ + 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 {GenerateCommandOptions} options + */ +function runGenerate({ argv, stdout, stderr, createUuid }) { + if (argv[0] === "uuid") { + return runGenerateUuid({ + argv: argv.slice(1), + stdout, + stderr, + createUuid, + }); + } + + if (argv[0] === "application-key") { + return runGenerateApplicationKey({ argv: argv.slice(1), stdout, stderr, - createSubjectId, }); } @@ -537,51 +630,106 @@ async function runPlan({ ); if (!parsed) { - stderr.write(PLAN_USAGE_ERROR); + stderr.write(GENERATE_USAGE_ERROR); return 2; } if (parsed.positionals.length > 0) { - stderr.write(PLAN_UNKNOWN_COMMAND); + stderr.write(GENERATE_UNKNOWN_COMMAND); return 2; } if (argv.length === 0 || parsed.values.help) { - stdout.write(PLAN_HELP); + stdout.write(GENERATE_HELP); return 0; } - stdout.write(PLAN_HELP); + stdout.write(GENERATE_HELP); + return 0; +} + +/** @param {GenerateCommandOptions} options */ +function runGenerateUuid({ argv, stdout, stderr, createUuid }) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { + count: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: false, + strict: true, + tokens: true, + }), + ); + + if (!parsed || repeatedValueOption(parsed.tokens)) { + writeJson(stderr, { + error: "invalid_arguments", + detail: GENERATE_UUID_INVALID_ARGUMENTS_DETAIL, + }); + return 2; + } + + if (parsed.values.help) { + stdout.write(GENERATE_UUID_HELP); + return 0; + } + + const count = parseUuidCount(parsed.values.count); + if (count === null) { + writeJson(stderr, { + error: "invalid_arguments", + detail: GENERATE_UUID_INVALID_ARGUMENTS_DETAIL, + }); + return 2; + } + + for (let index = 0; index < count; index += 1) { + stdout.write(`${createUuid()}\n`); + } return 0; } /** - * @param {Pick} options + * @param {Pick} options */ -function runPlanSubjectId({ argv, stdout, stderr, createSubjectId }) { +function runGenerateApplicationKey({ argv, stdout, stderr }) { const parsed = parseArguments(() => parseArgs({ args: [...argv], - options: { help: { type: "boolean", short: "h" } }, + options: { + name: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, allowPositionals: false, strict: true, + tokens: true, }), ); - if (!parsed) { + if (!parsed || repeatedValueOption(parsed.tokens)) { writeJson(stderr, { error: "invalid_arguments", - detail: PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL, + detail: GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL, }); return 2; } if (parsed.values.help) { - stdout.write(PLAN_SUBJECT_ID_HELP); + stdout.write(GENERATE_APPLICATION_KEY_HELP); return 0; } - stdout.write(`${createSubjectId()}\n`); + if (!isValidApplicationName(parsed.values.name)) { + writeJson(stderr, { + error: "invalid_arguments", + detail: GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL, + }); + return 2; + } + + stdout.write(`${deriveApplicationKey(parsed.values.name)}\n`); return 0; } @@ -1312,13 +1460,13 @@ function runPlanInit({ return 0; } - const applicationKey = parsed.values["application-key"]; - const name = parsed.values.name; + const providedApplicationKey = parsed.values["application-key"]; + const providedName = parsed.values.name; if ( - typeof applicationKey !== "string" || - !/^[a-z][a-z0-9_]*$/.test(applicationKey) || - typeof name !== "string" || - !isValidApplicationName(name) + (providedApplicationKey !== undefined && + !isValidApplicationKey(providedApplicationKey)) || + (providedName !== undefined && !isValidApplicationName(providedName)) || + (providedApplicationKey === undefined && providedName === undefined) ) { writeJson(stderr, { error: "invalid_arguments", @@ -1327,6 +1475,19 @@ function runPlanInit({ return 2; } + let applicationKey; + let name; + if (providedApplicationKey !== undefined) { + applicationKey = providedApplicationKey; + name = + providedName === undefined + ? deriveApplicationName(providedApplicationKey) + : providedName; + } else { + name = /** @type {string} */ (providedName); + applicationKey = deriveApplicationKey(name); + } + const projectId = createProjectId(); try { @@ -1351,42 +1512,13 @@ function runPlanInit({ 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 {unknown} value */ +function parseUuidCount(value) { + if (value === undefined) return 1; + if (typeof value !== "string" || !/^[1-9][0-9]*$/.test(value)) return null; -/** @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 - ); + const count = Number(value); + return Number.isSafeInteger(count) ? count : null; } /** diff --git a/test/application-identity.test.js b/test/application-identity.test.js new file mode 100644 index 0000000..e6bcc16 --- /dev/null +++ b/test/application-identity.test.js @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MAX_DERIVED_APPLICATION_KEY_BYTES, + deriveApplicationKey, + deriveApplicationName, + isValidApplicationKey, + isValidApplicationName, +} from "../src/application-identity.js"; + +test("derives readable lower-snake keys from Unicode names", () => { + assert.equal(deriveApplicationKey("Movie Catalog"), "movie_catalog"); + assert.equal(deriveApplicationKey("Café & Æther"), "cafe_aether"); + assert.equal(deriveApplicationKey("Smørrebrød & Œufs"), "smorrebrod_oeufs"); + assert.equal(deriveApplicationKey("2026 Inventory"), "app_2026_inventory"); + assert.equal(deriveApplicationKey("Home 🏠 Inventory"), "home_inventory"); +}); + +test("uses a stable digest when no readable ASCII remains", () => { + assert.equal(deriveApplicationKey("東京"), "app_130016b2599b"); + assert.equal(deriveApplicationKey("___"), "app_bda251550bf0"); + assert.equal(deriveApplicationKey("東京"), deriveApplicationKey("東京")); + assert.equal( + deriveApplicationKey("안녕"), + deriveApplicationKey("안녕".normalize("NFD")), + ); +}); + +test("bounds long derived keys with a readable prefix and stable digest", () => { + const key = deriveApplicationKey("A".repeat(100)); + + assert.equal(key, `${"a".repeat(50)}_d82c6aa133a0`); + assert.equal(Buffer.byteLength(key), MAX_DERIVED_APPLICATION_KEY_BYTES); + assert.match(key, /^[a-z][a-z0-9_]*$/); +}); + +test("every generated key lowers to the current iOS identifier component", () => { + for (const name of [ + "Movie Catalog", + "2026 Inventory", + "東京", + "Home 🏠 Inventory", + "_".repeat(100), + "A very long readable application name ".repeat(10), + ]) { + const key = deriveApplicationKey(name); + const component = key.replaceAll("_", "-"); + + assert.ok(Buffer.byteLength(key) <= MAX_DERIVED_APPLICATION_KEY_BYTES); + assert.match(component, /^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/); + } +}); + +test("explicit keys retain the complete Foundation Plan schema boundary", () => { + for (const key of [ + "a", + "movie_catalog", + "movie___catalog___", + "a".repeat(500), + ]) { + assert.equal(isValidApplicationKey(key), true, key); + } + + for (const key of [ + "", + "2026_inventory", + "Movie_Catalog", + "movie-catalog", + "café", + ]) { + assert.equal(isValidApplicationKey(key), false, key); + } +}); + +test("derives a clean display name from explicit keys", () => { + assert.equal(deriveApplicationName("movie_catalog"), "Movie Catalog"); + assert.equal(deriveApplicationName("movie___catalog___"), "Movie Catalog"); + assert.equal(deriveApplicationName("api_v2_client"), "Api V2 Client"); +}); + +test("accepts interoperable nonblank Unicode text", () => { + for (const name of ["Movie Catalog", "東京", "🏠"]) { + assert.equal(isValidApplicationName(name), true, JSON.stringify(name)); + } + + for (const name of [ + "", + "\u00a0\t", + "\u0085", + "\u3000", + "\u0000", + "\ufdd0", + "\ufffe", + String.fromCodePoint(0x1fffe), + "\ud800", + "\udc00", + ]) { + assert.equal(isValidApplicationName(name), false, JSON.stringify(name)); + } +}); + +test("derivation rejects invalid direct inputs", () => { + assert.throws(() => deriveApplicationKey("\u3000"), /valid nonblank/); + assert.throws( + () => deriveApplicationName("Invalid-Key"), + /valid lower-snake/, + ); +}); diff --git a/test/cli.test.js b/test/cli.test.js index 0f127af..fc2cfe1 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -15,7 +15,8 @@ Usage: firstdraft [options] Commands: - plan Work with Foundation Plans + generate Generate local values + plan Work with Foundation Plans Options: -h, --help Show help diff --git a/test/generate-application-key.test.js b/test/generate-application-key.test.js new file mode 100644 index 0000000..56ff2e7 --- /dev/null +++ b/test/generate-application-key.test.js @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { run } from "../src/cli.js"; + +const HELP = `First Draft CLI + +Usage: + firstdraft generate application-key --name + +Options: + --name Application display name + -h, --help Show help + +The command derives the same application key used by name-only plan init. +Generated keys are at most 63 ASCII bytes. +`; +const USAGE_ERROR = `${JSON.stringify( + { + error: "invalid_arguments", + detail: + "Invalid arguments. Run 'firstdraft generate application-key --help' for usage.", + }, + null, + 2, +)}\n`; + +test("generate application-key prints the deterministic derived key", async () => { + /** @type {readonly (readonly [string, string])[]} */ + const examples = [ + ["Movie Catalog", "movie_catalog"], + ["Café Planner", "cafe_planner"], + ["2026 Inventory", "app_2026_inventory"], + ["東京", "app_130016b2599b"], + ]; + + for (const [name, key] of examples) { + assert.deepEqual( + await invoke( + ["generate", "application-key", "--name", name], + inaccessibleDependencies(), + ), + { status: 0, stdout: `${key}\n`, stderr: "" }, + ); + } +}); + +test("generate application-key help has no local or network prerequisites", async () => { + for (const argv of [ + ["generate", "application-key", "--help"], + ["generate", "application-key", "-h"], + ["generate", "application-key", "--name", "canary-secret", "--help"], + ]) { + assert.deepEqual(await invoke(argv, inaccessibleDependencies()), { + status: 0, + stdout: HELP, + stderr: "", + }); + } +}); + +test("generate application-key validates names before producing output", async () => { + for (const argv of [ + ["generate", "application-key"], + ["generate", "application-key", "--name", "\u3000"], + ["generate", "application-key", "--name", "\ud800"], + [ + "generate", + "application-key", + "--name", + "Movie Catalog", + "--name", + "Other", + ], + ["generate", "application-key", "canary-secret"], + ["generate", "application-key", "--canary-secret-option"], + ]) { + const result = await invoke(argv, inaccessibleDependencies()); + assert.deepEqual(result, { status: 2, stdout: "", stderr: USAGE_ERROR }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +/** @returns {Partial} */ +function inaccessibleDependencies() { + return { + getCwd: () => { + throw new Error("Working directory must not be read"); + }, + createProjectId: () => { + throw new Error("Project ID generation must not run"); + }, + createUuid: () => { + throw new Error("UUID generation must not run"); + }, + fileSystem: { + mkdirSync() { + throw new Error("Filesystem must not run"); + }, + writeFileSync() { + throw new Error("Filesystem must not run"); + }, + }, + fetchFunction: async () => { + throw new Error("Network must not run"); + }, + }; +} + +/** + * @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 }; +} diff --git a/test/generate-uuid.test.js b/test/generate-uuid.test.js new file mode 100644 index 0000000..7401e61 --- /dev/null +++ b/test/generate-uuid.test.js @@ -0,0 +1,234 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { run } from "../src/cli.js"; + +const UUID_ONE = "01900000-0000-7000-8000-000000000301"; +const UUID_TWO = "01900000-0000-7000-8000-000000000302"; +const UUID_THREE = "01900000-0000-7000-8000-000000000303"; +const UUIDS = [UUID_ONE, UUID_TWO, UUID_THREE]; +const GENERATE_HELP = `First Draft CLI + +Usage: + firstdraft generate [options] + +Commands: + uuid Generate one or more UUIDv7 values + application-key Derive a lower-snake-case application key + +Options: + -h, --help Show help +`; +const UUID_HELP = `First Draft CLI + +Usage: + firstdraft generate uuid [--count ] + +Options: + --count Number to generate (positive integer) + -h, --help Show help + +The command reads no files and makes no network request. Each UUID is printed +on its own line. +`; +const USAGE_ERROR = jsonOutput({ + error: "invalid_arguments", + detail: "Invalid arguments. Run 'firstdraft generate uuid --help' for usage.", +}); +const GROUP_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft generate --help' for usage.\n"; +const GROUP_UNKNOWN_COMMAND = + "Unknown command.\nRun 'firstdraft generate --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}$/; + +test("generate help describes only its local commands", async () => { + for (const argv of [ + ["generate"], + ["generate", "--help"], + ["generate", "-h"], + ]) { + assert.deepEqual(await invoke(argv, inaccessibleDependencies()), { + status: 0, + stdout: GENERATE_HELP, + stderr: "", + }); + } +}); + +test("generate group returns non-echoing usage errors", async () => { + for (const argv of [ + ["generate", "canary-secret-command"], + ["generate", "canary-secret-command", "--help"], + ]) { + const result = await invoke(argv, inaccessibleDependencies()); + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: GROUP_UNKNOWN_COMMAND, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } + + const option = await invoke( + ["generate", "--canary-secret-option"], + inaccessibleDependencies(), + ); + assert.deepEqual(option, { + status: 2, + stdout: "", + stderr: GROUP_USAGE_ERROR, + }); + assert.doesNotMatch(option.stderr, /canary-secret/); +}); + +test("generate uuid prints one value by default and a requested count", async () => { + let calls = 0; + const createUuid = () => UUIDS[calls++] ?? UUID_ONE; + + assert.deepEqual( + await invoke(["generate", "uuid"], { + ...inaccessibleDependencies(), + createUuid, + }), + { status: 0, stdout: `${UUID_ONE}\n`, stderr: "" }, + ); + assert.deepEqual( + await invoke(["generate", "uuid", "--count", "2"], { + ...inaccessibleDependencies(), + createUuid, + }), + { status: 0, stdout: `${UUID_TWO}\n${UUID_THREE}\n`, stderr: "" }, + ); + assert.equal(calls, 3); +}); + +test("generate uuid accepts counts beyond an arbitrary product cap", async () => { + let calls = 0; + const result = await invoke(["generate", "uuid", "--count", "1001"], { + ...inaccessibleDependencies(), + createUuid: () => { + calls += 1; + return UUID_ONE; + }, + }); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.equal(result.stdout.trimEnd().split("\n").length, 1001); + assert.equal(calls, 1001); +}); + +test("generate uuid uses fresh production UUIDv7 values", async () => { + const first = await invoke(["generate", "uuid"], inaccessibleDependencies()); + const second = await invoke(["generate", "uuid"], inaccessibleDependencies()); + + assert.match(first.stdout.trim(), UUID_V7); + assert.match(second.stdout.trim(), UUID_V7); + assert.notEqual(first.stdout, second.stdout); +}); + +test("generate uuid help has no generation prerequisites", async () => { + for (const argv of [ + ["generate", "uuid", "--help"], + ["generate", "uuid", "-h"], + ["generate", "uuid", "--count", "canary-secret", "--help"], + ]) { + assert.deepEqual( + await invoke(argv, { + ...inaccessibleDependencies(), + createUuid: () => { + throw new Error("UUID generation must not run"); + }, + }), + { status: 0, stdout: UUID_HELP, stderr: "" }, + ); + } +}); + +test("generate uuid validates count before generating output", async () => { + const invalidArguments = [ + ["generate", "uuid", "--count", "0"], + ["generate", "uuid", "--count", "-1"], + ["generate", "uuid", "--count", "01"], + ["generate", "uuid", "--count", "1.5"], + ["generate", "uuid", "--count", "9007199254740992"], + ["generate", "uuid", "--count", "1", "--count", "2"], + ["generate", "uuid", "canary-secret"], + ["generate", "uuid", "--canary-secret-option"], + ]; + + for (const argv of invalidArguments) { + const result = await invoke(argv, { + ...inaccessibleDependencies(), + createUuid: () => { + throw new Error("UUID generation must not run"); + }, + }); + + assert.deepEqual(result, { status: 2, stdout: "", stderr: USAGE_ERROR }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +test("unexpected UUID generation errors remain loud", async () => { + let calls = 0; + await assert.rejects( + () => + invoke(["generate", "uuid", "--count", "2"], { + ...inaccessibleDependencies(), + createUuid: () => { + calls += 1; + if (calls === 2) throw new TypeError("programming error"); + return UUID_ONE; + }, + }), + /programming error/, + ); +}); + +/** @returns {Partial} */ +function inaccessibleDependencies() { + return { + getCwd: () => { + throw new Error("Working directory must not be read"); + }, + createProjectId: () => { + throw new Error("Project ID generation must not run"); + }, + fileSystem: { + mkdirSync() { + throw new Error("Filesystem must not run"); + }, + writeFileSync() { + throw new Error("Filesystem must not run"); + }, + }, + fetchFunction: async () => { + throw new Error("Network must not run"); + }, + }; +} + +/** + * @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 }; +} + +/** @param {unknown} value */ +function jsonOutput(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} diff --git a/test/plan-init.test.js b/test/plan-init.test.js index 801bcc8..9fa9291 100644 --- a/test/plan-init.test.js +++ b/test/plan-init.test.js @@ -26,12 +26,11 @@ Usage: firstdraft plan [options] Commands: - 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 - status Read the current whole-graph analysis status - compile Compile the accepted Plan into a new local directory - publish Compile and publish the accepted Plan to GitHub + init Create a local empty Foundation Plan + push Send the local Foundation Plan to First Draft + status Read the current whole-graph analysis status + compile Compile the accepted Plan into a new local directory + publish Compile and publish the accepted Plan to GitHub Options: -h, --help Show help @@ -39,12 +38,15 @@ Options: const PLAN_INIT_HELP = `First Draft CLI Usage: - firstdraft plan init --application-key --name + firstdraft plan init [--application-key ] [--name ] Options: --application-key Lower-snake-case application key --name Application display name -h, --help Show help + +Provide at least one of --application-key or --name. The command derives a +missing key from the name or a missing display name from the key. `; const PLAN_USAGE_ERROR = "Invalid arguments.\nRun 'firstdraft plan --help' for usage.\n"; @@ -186,6 +188,58 @@ test("plan init creates exact deterministic local files", async (context) => { } }); +test("plan init derives either missing application identity value", async (context) => { + const nameOnlyCwd = temporaryDirectory(context); + const nameOnly = await invoke(["plan", "init", "--name", "Café Planner"], { + cwd: nameOnlyCwd, + createProjectId: () => PROJECT_ID, + }); + const nameOnlyPlan = readPlan(nameOnlyCwd); + + assert.equal(nameOnly.status, 0); + assert.deepEqual(nameOnlyPlan.application, { + key: "cafe_planner", + name: "Café Planner", + native: {}, + delivery: {}, + entities: [], + }); + + const keyOnlyCwd = temporaryDirectory(context); + const keyOnly = await invoke( + ["plan", "init", "--application-key", "movie___catalog___"], + { cwd: keyOnlyCwd, createProjectId: () => PROJECT_ID }, + ); + const keyOnlyPlan = readPlan(keyOnlyCwd); + + assert.equal(keyOnly.status, 0); + assert.equal(keyOnlyPlan.application.key, "movie___catalog___"); + assert.equal(keyOnlyPlan.application.name, "Movie Catalog"); +}); + +test("plan init preserves both explicitly supplied identity values", async (context) => { + const cwd = temporaryDirectory(context); + const result = await invoke( + ["plan", "init", "--application-key", "movie_catalog", "--name", "MC"], + { cwd, createProjectId: () => PROJECT_ID }, + ); + + assert.equal(result.status, 0); + assert.equal(readPlan(cwd).application.name, "MC"); +}); + +test("plan init accepts explicit schema-valid keys beyond the derived limit", async (context) => { + const cwd = temporaryDirectory(context); + const applicationKey = `${"a".repeat(100)}_`; + const result = await invoke( + ["plan", "init", "--application-key", applicationKey], + { cwd, createProjectId: () => PROJECT_ID }, + ); + + assert.equal(result.status, 0); + assert.equal(readPlan(cwd).application.key, applicationKey); +}); + test("the executable initializes with a production UUIDv7", (context) => { const cwd = temporaryDirectory(context); const executable = fileURLToPath( @@ -275,8 +329,6 @@ test("an existing root gitignore remains byte-for-byte unchanged", async (contex test("plan init validates every argument before randomness or filesystem access", async () => { const invalidArguments = [ ["plan", "init"], - ["plan", "init", "--application-key", "oscar_party"], - ["plan", "init", "--name", "Oscar Party"], [ "plan", "init", @@ -523,6 +575,13 @@ async function invokeValidInit(cwd, overrides = {}) { ); } +/** @param {string} cwd */ +function readPlan(cwd) { + return JSON.parse( + readFileSync(path.join(cwd, ".firstdraft", "foundation-plan.json"), "utf8"), + ); +} + /** @param {{stdout: string, stderr: string}} result */ function refuteCanary(result) { assert.doesNotMatch(`${result.stdout}${result.stderr}`, /canary-secret/); diff --git a/test/plan-subject-id.test.js b/test/plan-subject-id.test.js deleted file mode 100644 index 6d90605..0000000 --- a/test/plan-subject-id.test.js +++ /dev/null @@ -1,173 +0,0 @@ -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 = `${JSON.stringify( - { - error: "invalid_arguments", - detail: - "Invalid arguments. Run 'firstdraft plan subject-id --help' for usage.", - }, - null, - 2, -)}\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/); - } -}); - -test("unexpected subject ID generation errors remain loud", async () => { - await assert.rejects( - () => - invoke(["plan", "subject-id"], { - ...INACCESSIBLE_DEPENDENCIES, - createSubjectId: () => { - throw new TypeError("programming error"); - }, - }), - /programming error/, - ); -}); - -/** - * @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 }; -}