Skip to content
Closed
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
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
`firstdraft` is the command-line client for [First Draft](https://github.com/firstdraft/firstdraft). It is being
built for agents that author and review Foundation Plans with their users.

The package is not released yet. This repository contains the auditable command shell and local Foundation Plan
initialization; API and release behavior will arrive in reviewed increments.
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.

## Requirements

Expand All @@ -31,10 +31,33 @@ 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.

## Push a Foundation Plan

From the initialized project:

```sh
firstdraft plan push
```

The command sends the exact bytes in `.firstdraft/foundation-plan.json`. The first push conditionally creates the
Project; later pushes replay the complete ETag saved in `.firstdraft/state.json` so a stale writer cannot replace a
newer Plan. Successful responses and server diagnostics are printed as JSON for an agent to inspect.

The initial API origin defaults to `https://firstdraft.com`. Set `FIRSTDRAFT_API_URL` to use another HTTPS origin
or a loopback HTTP development server. The first successful push pins the normalized origin in local state, and a
later override must match it.

If a failure happens after sending the request, the CLI reports that the outcome may be ambiguous and leaves local
state unchanged. It never constructs an ETag from the Plan digest or trusts an ETag from a response it could not
fully verify. Until First Draft has a read or reconciliation endpoint, an accepted request whose response cannot be
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.

## Trust model

- The published CLI will run the reviewed JavaScript source directly, without generated or bundled code.
- The CLI has no runtime dependencies, install scripts, telemetry, update checks, or implicit network activity.
- The CLI has no runtime dependencies, install scripts, telemetry, update checks, or network activity except an
explicitly invoked API command.
- Package contents are allowlisted and checked before release.
- CI exercises the exact minimum Node.js version separately from current development tooling.
- Public releases will use npm provenance after the first useful version bootstraps trusted publishing.
Expand Down
2 changes: 1 addition & 1 deletion bin/firstdraft.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { run } from "../src/cli.js";
process.stdout.on("error", handleStreamError);
process.stderr.on("error", handleStreamError);

process.exitCode = run({
process.exitCode = await run({
argv: process.argv.slice(2),
stdout: process.stdout,
stderr: process.stderr,
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ if (result.status !== 0) {
"package.json",
"src/cli.js",
"src/commands/plan-init.js",
"src/commands/plan-push.js",
"src/file-system.js",
"src/uuid-v7.js",
"src/version.js",
]);
Expand Down
189 changes: 186 additions & 3 deletions src/cli.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { parseArgs } from "node:util";

import { initializePlan, isFileSystemError } from "./commands/plan-init.js";
import { initializePlan } from "./commands/plan-init.js";
import {
PlanPushConfigurationError,
PlanPushLocalError,
PlanPushNetworkError,
PlanPushProtocolError,
PlanPushStateWriteError,
pushPlan,
} from "./commands/plan-push.js";
import { isFileSystemError } from "./file-system.js";
import { generateUuidV7 } from "./uuid-v7.js";
import { VERSION } from "./version.js";

Expand All @@ -25,11 +34,27 @@ Usage:

Commands:
init Create a local empty Foundation Plan
push Send the local Foundation Plan to First Draft

Options:
-h, --help Show help
`;

const PLAN_PUSH_HELP = `First Draft CLI

Usage:
firstdraft plan push

Options:
-h, --help Show help

Environment:
FIRSTDRAFT_API_URL Override the initial API origin

The first successful push saves its API origin in .firstdraft/state.json.
Later pushes reject a different origin.
`;

const PLAN_INIT_HELP = `First Draft CLI

Usage:
Expand All @@ -54,6 +79,16 @@ const PLAN_INIT_USAGE_ERROR =
const PLAN_INIT_ERROR =
"Could not initialize .firstdraft. The directory may be incomplete; no existing files were overwritten.\n";
const PLAN_INIT_SUCCESS = "Initialized .firstdraft/foundation-plan.json.\n";
const PLAN_PUSH_USAGE_ERROR =
"Invalid arguments.\nRun 'firstdraft plan push --help' for usage.\n";
const PLAN_PUSH_CONFIGURATION_ERROR =
"Invalid First Draft API configuration.\nRun 'firstdraft plan push --help' for usage.\n";
const PLAN_PUSH_LOCAL_ERROR =
"Could not read the local First Draft Plan or state. No network request was made.\n";
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";

/**
* @typedef {object} Writer
Expand All @@ -68,6 +103,11 @@ const PLAN_INIT_SUCCESS = "Initialized .firstdraft/foundation-plan.json.\n";
* @property {string} [cwd]
* @property {() => string} [createProjectId]
* @property {import("./commands/plan-init.js").FileSystem} [fileSystem]
* @property {typeof globalThis.fetch} [fetchFunction]
* @property {import("./commands/plan-push.js").PlanPushFileSystem} [planPushFileSystem]
* @property {() => string} [createTemporaryId]
* @property {() => AbortSignal} [createRequestSignal]
* @property {string} [apiUrl]
*/

/**
Expand All @@ -78,16 +118,26 @@ const PLAN_INIT_SUCCESS = "Initialized .firstdraft/foundation-plan.json.\n";
* @property {string} cwd
* @property {() => string} createProjectId
* @property {import("./commands/plan-init.js").FileSystem} [fileSystem]
* @property {typeof globalThis.fetch} [fetchFunction]
* @property {import("./commands/plan-push.js").PlanPushFileSystem} [planPushFileSystem]
* @property {() => string} [createTemporaryId]
* @property {() => AbortSignal} [createRequestSignal]
* @property {string} [apiUrl]
*/

/** @param {RunOptions} options */
export function run({
export async function run({
argv,
stdout,
stderr,
cwd = process.cwd(),
createProjectId = generateUuidV7,
fileSystem,
fetchFunction,
planPushFileSystem,
createTemporaryId,
createRequestSignal,
apiUrl = process.env.FIRSTDRAFT_API_URL,
}) {
if (argv[0] === "plan") {
return runPlan({
Expand All @@ -97,6 +147,11 @@ export function run({
cwd,
createProjectId,
fileSystem,
fetchFunction,
planPushFileSystem,
createTemporaryId,
createRequestSignal,
apiUrl,
});
}

Expand Down Expand Up @@ -147,7 +202,19 @@ function runRoot({ argv, stdout, stderr }) {
}

/** @param {CommandOptions} options */
function runPlan({ argv, stdout, stderr, cwd, createProjectId, fileSystem }) {
async function runPlan({
argv,
stdout,
stderr,
cwd,
createProjectId,
fileSystem,
fetchFunction,
planPushFileSystem,
createTemporaryId,
createRequestSignal,
apiUrl,
}) {
if (argv[0] === "init") {
return runPlanInit({
argv: argv.slice(1),
Expand All @@ -159,6 +226,20 @@ function runPlan({ argv, stdout, stderr, cwd, createProjectId, fileSystem }) {
});
}

if (argv[0] === "push") {
return runPlanPush({
argv: argv.slice(1),
stdout,
stderr,
cwd,
fetchFunction,
planPushFileSystem,
createTemporaryId,
createRequestSignal,
apiUrl,
});
}

const parsed = parseArguments(() =>
parseArgs({
args: [...argv],
Expand Down Expand Up @@ -187,6 +268,103 @@ function runPlan({ argv, stdout, stderr, cwd, createProjectId, fileSystem }) {
return 0;
}

/**
* @param {Pick<CommandOptions, "argv" | "stdout" | "stderr" | "cwd" | "fetchFunction" | "planPushFileSystem" | "createTemporaryId" | "createRequestSignal" | "apiUrl">} options
*/
async function runPlanPush({
argv,
stdout,
stderr,
cwd,
fetchFunction,
planPushFileSystem,
createTemporaryId,
createRequestSignal,
apiUrl,
}) {
const parsed = parseArguments(() =>
parseArgs({
args: [...argv],
options: { help: { type: "boolean", short: "h" } },
allowPositionals: false,
strict: true,
tokens: true,
}),
);

if (!parsed || repeatedValueOption(parsed.tokens)) {
stderr.write(PLAN_PUSH_USAGE_ERROR);
return 2;
}

if (parsed.values.help) {
stdout.write(PLAN_PUSH_HELP);
return 0;
}

let result;
try {
result = await pushPlan({
cwd,
apiUrl,
fetchFunction,
fileSystem: planPushFileSystem,
createTemporaryId,
createRequestSignal,
});
} catch (error) {
if (error instanceof PlanPushConfigurationError) {
stderr.write(PLAN_PUSH_CONFIGURATION_ERROR);
return 2;
}

if (error instanceof PlanPushLocalError) {
stderr.write(PLAN_PUSH_LOCAL_ERROR);
return 1;
}

if (error instanceof PlanPushNetworkError) {
stderr.write(PLAN_PUSH_NETWORK_ERROR);
return 1;
}

if (error instanceof PlanPushProtocolError) {
stderr.write(PLAN_PUSH_PROTOCOL_ERROR);
return 1;
}

if (error instanceof PlanPushStateWriteError) {
writeJson(stderr, {
error: "local_state_not_saved",
detail:
"The Plan was accepted, but its ETag could not be saved. Do not push again until local state is repaired.",
recovery_state: error.recoveryState,
});
return 1;
}

throw error;
}

if (!("etag" in result)) {
if (result.body === null) {
stderr.write(`First Draft rejected the Plan (HTTP ${result.status}).\n`);
} else {
writeJson(stderr, result.body);
}
return 1;
}

writeJson(stdout, {
outcome: result.outcome,
etag: result.etag,
project: result.body.project,
foundation_plan: result.body.foundation_plan,
diagnostics: result.body.diagnostics,
});
return 0;
}

/** @param {CommandOptions} options */
function runPlanInit({
argv,
Expand Down Expand Up @@ -329,3 +507,8 @@ function isParseArgsError(error) {
error.code.startsWith("ERR_PARSE_ARGS_")
);
}

/** @param {Writer} writer @param {unknown} value */
function writeJson(writer, value) {
writer.write(`${JSON.stringify(value, null, 2)}\n`);
}
10 changes: 0 additions & 10 deletions src/commands/plan-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,6 @@ export function initializePlan({
);
}

/** @param {unknown} error */
export function isFileSystemError(error) {
return (
error instanceof Error &&
"code" in error &&
typeof error.code === "string" &&
!error.code.startsWith("ERR_")
);
}

/** @param {string} applicationKey @param {string} name */
function emptyPlan(applicationKey, name) {
return {
Expand Down
Loading