Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 21 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
81 changes: 76 additions & 5 deletions scripts/smoke-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -135,6 +194,18 @@ function spawnPackedCli(arguments_, cwd = process.cwd()) {
});
}

/**
* @param {ReturnType<typeof spawnPackedCli>} execution
* @param {number} status
* @param {Record<string, unknown>} 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];
Expand Down
32 changes: 22 additions & 10 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand All @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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;
}

Expand Down
18 changes: 14 additions & 4 deletions test/plan-init.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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));
Expand Down
24 changes: 22 additions & 2 deletions test/plan-subject-id.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<import("../src/cli.js").RunOptions>} */
Expand Down Expand Up @@ -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<import("../src/cli.js").RunOptions>} [overrides]
Expand Down