diff --git a/README.md b/README.md index 9fb2b29..68aaef1 100644 --- a/README.md +++ b/README.md @@ -67,22 +67,27 @@ fully verify. Until First Draft has a read or reconciliation endpoint, an accept verified may require manual recovery. If a verified response cannot replace local state, preserve the printed recovery state; an adjacent `.tmp` file may contain the same private recovery copy. -Every handled `plan push` failure writes exactly one JSON object to standard error. Agents should branch on its -stable `error` value, not on the human-readable `detail`: - -| `error` | Exit | Meaning | -| ------------------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `invalid_arguments` | 2 | The command syntax is invalid; no request was made. | -| `invalid_configuration` | 2 | The configured API origin is invalid or conflicts with pinned local state; no request was made. | -| `local_input_unreadable` | 1 | The local Plan or state could not be read; no request was made. | -| `request_outcome_unknown` | 1 | A sent request or its response could not be verified. Stop and reconcile before pushing again. The object includes `status` when one was received. | -| `server_rejected` | 1 | First Draft returned a validated rejection. The object includes `status` and a whitelisted `response` containing validated problem details or diagnostics. | -| `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`. | - -Failure output never includes command arguments, local Plan bytes, raw network errors, or unvalidated response -bodies. Optional fields inside a validated 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. +Every handled failure from `plan init`, `plan subject-id`, or `plan push` 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` | `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` | `invalid_configuration` | 2 | The configured API origin is invalid or conflicts with pinned local state; no request was made. | +| `plan push` | `local_input_unreadable` | 1 | The local Plan or state could not be read; no request was made. | +| `plan push` | `request_outcome_unknown` | 1 | A sent request or its response could not be verified. Stop and reconcile before pushing again. The object includes `status` when one was received. | +| `plan push` | `server_rejected` | 1 | First Draft returned a validated rejection. The object includes `status` and a whitelisted `response` containing validated problem details or diagnostics. | +| `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`. | + +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 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. ## Trust model diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index fb6634b..7bde4ec 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -75,20 +75,79 @@ try { ); assert.equal(subjectId.stderr, ""); + const invalidSubjectId = spawnPackedCli( + ["plan", "subject-id", "--canary-secret-option"], + installationDirectory, + ); + assertHandledFailure(invalidSubjectId, 2, { + error: "invalid_arguments", + detail: + "Invalid arguments. Run 'firstdraft plan subject-id --help' for usage.", + }); + + const invalidInit = spawnPackedCli( + ["plan", "init", "--canary-secret-option"], + installationDirectory, + ); + assertHandledFailure(invalidInit, 2, { + error: "invalid_arguments", + detail: "Invalid arguments. Run 'firstdraft plan init --help' for usage.", + }); + + const projectDirectory = path.join(temporaryDirectory, "project"); + mkdirSync(projectDirectory); + const initialized = spawnPackedCli( + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + ], + projectDirectory, + ); + assert.deepEqual( + { + status: initialized.status, + stdout: initialized.stdout, + stderr: initialized.stderr, + }, + { + status: 0, + stdout: "Initialized .firstdraft/foundation-plan.json.\n", + stderr: "", + }, + ); + + const repeatedInit = spawnPackedCli( + [ + "plan", + "init", + "--application-key", + "other_application", + "--name", + "canary-secret-name", + ], + projectDirectory, + ); + assertHandledFailure(repeatedInit, 1, { + error: "local_initialization_failed", + detail: + "Could not initialize .firstdraft. The directory may be incomplete; no existing files were overwritten.", + }); + const invalidPush = spawnPackedCli( ["plan", "push", "--canary-secret-option"], installationDirectory, ); - assert.equal(invalidPush.status, 2); - assert.deepEqual(JSON.parse(invalidPush.stderr), { + assertHandledFailure(invalidPush, 2, { error: "invalid_arguments", detail: "Invalid arguments. Run 'firstdraft plan push --help' for usage.", }); - assert.doesNotMatch(invalidPush.stderr, /canary-secret/); const localPush = spawnPackedCli(["plan", "push"], installationDirectory); - assert.equal(localPush.status, 1); - assert.deepEqual(JSON.parse(localPush.stderr), { + assertHandledFailure(localPush, 1, { error: "local_input_unreadable", detail: "Could not read the local First Draft Plan or state. No network request was made. Preserve the local files for manual recovery.", @@ -135,6 +194,18 @@ function spawnPackedCli(arguments_, cwd = process.cwd()) { }); } +/** + * @param {ReturnType} execution + * @param {number} status + * @param {Record} error + */ +function assertHandledFailure(execution, status, error) { + assert.equal(execution.status, status); + assert.equal(execution.stdout, ""); + assert.equal(execution.stderr, `${JSON.stringify(error, null, 2)}\n`); + assert.doesNotMatch(execution.stderr, /canary-secret/); +} + /** @param {string} name */ function requiredEnvironmentVariable(name) { const value = process.env[name]; diff --git a/src/cli.js b/src/cli.js index 76bc4b7..ab00af8 100644 --- a/src/cli.js +++ b/src/cli.js @@ -87,10 +87,10 @@ 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_INVALID_ARGUMENTS_DETAIL = + "Invalid arguments. Run 'firstdraft plan init --help' for usage."; +const PLAN_INIT_FAILED_DETAIL = + "Could not initialize .firstdraft. The directory may be incomplete; no existing files were overwritten."; const PLAN_INIT_SUCCESS = "Initialized .firstdraft/foundation-plan.json.\n"; const PLAN_PUSH_INVALID_ARGUMENTS_DETAIL = "Invalid arguments. Run 'firstdraft plan push --help' for usage."; @@ -101,8 +101,8 @@ const PLAN_PUSH_LOCAL_INPUT_UNREADABLE_DETAIL = const PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL = "The Plan may have been accepted, but the response could not be verified. Stop and reconcile before pushing again; local state was not changed."; const PLAN_PUSH_SERVER_REJECTED_DETAIL = "First Draft rejected the Plan."; -const PLAN_SUBJECT_ID_USAGE_ERROR = - "Invalid arguments.\nRun 'firstdraft plan subject-id --help' for usage.\n"; +const PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL = + "Invalid arguments. Run 'firstdraft plan subject-id --help' for usage."; /** * @typedef {object} Writer @@ -318,7 +318,10 @@ function runPlanSubjectId({ argv, stdout, stderr, createSubjectId }) { ); if (!parsed) { - stderr.write(PLAN_SUBJECT_ID_USAGE_ERROR); + writeJson(stderr, { + error: "invalid_arguments", + detail: PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL, + }); return 2; } @@ -476,7 +479,10 @@ function runPlanInit({ ); if (!parsed || repeatedValueOption(parsed.tokens)) { - stderr.write(PLAN_INIT_USAGE_ERROR); + writeJson(stderr, { + error: "invalid_arguments", + detail: PLAN_INIT_INVALID_ARGUMENTS_DETAIL, + }); return 2; } @@ -493,7 +499,10 @@ function runPlanInit({ typeof name !== "string" || !isValidApplicationName(name) ) { - stderr.write(PLAN_INIT_USAGE_ERROR); + writeJson(stderr, { + error: "invalid_arguments", + detail: PLAN_INIT_INVALID_ARGUMENTS_DETAIL, + }); return 2; } @@ -510,7 +519,10 @@ function runPlanInit({ } catch (error) { if (!isFileSystemError(error)) throw error; - stderr.write(PLAN_INIT_ERROR); + writeJson(stderr, { + error: "local_initialization_failed", + detail: PLAN_INIT_FAILED_DETAIL, + }); return 1; } diff --git a/test/plan-init.test.js b/test/plan-init.test.js index 5a01e4c..9ed1306 100644 --- a/test/plan-init.test.js +++ b/test/plan-init.test.js @@ -47,10 +47,15 @@ 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_USAGE_ERROR = jsonOutput({ + error: "invalid_arguments", + detail: "Invalid arguments. Run 'firstdraft plan init --help' for usage.", +}); +const PLAN_INIT_ERROR = jsonOutput({ + error: "local_initialization_failed", + detail: + "Could not initialize .firstdraft. The directory may be incomplete; no existing files were overwritten.", +}); const EXPECTED_PLAN = `{ "format": "firstdraft.foundation-plan.sketch/0.19", @@ -530,6 +535,11 @@ function assertInitializationFailure(result) { refuteCanary(result); } +/** @param {unknown} value */ +function jsonOutput(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + /** @param {import("node:test").TestContext} context */ function temporaryDirectory(context, prefix = "firstdraft-plan-init-") { const directory = mkdtempSync(path.join(tmpdir(), prefix)); diff --git a/test/plan-subject-id.test.js b/test/plan-subject-id.test.js index 45dca68..6d90605 100644 --- a/test/plan-subject-id.test.js +++ b/test/plan-subject-id.test.js @@ -15,8 +15,15 @@ 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 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} */ @@ -134,6 +141,19 @@ test("plan subject-id validates arguments before generating an ID", async () => } }); +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]