diff --git a/README.md b/README.md index 01b0c08..8a035c9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/bin/firstdraft.js b/bin/firstdraft.js index 75ca241..6aa514d 100755 --- a/bin/firstdraft.js +++ b/bin/firstdraft.js @@ -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, diff --git a/scripts/check-pack.js b/scripts/check-pack.js index 9f2dbc9..3e4da25 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -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", ]); diff --git a/src/cli.js b/src/cli.js index dd16ac6..ce6685a 100644 --- a/src/cli.js +++ b/src/cli.js @@ -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"; @@ -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: @@ -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 @@ -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] */ /** @@ -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({ @@ -97,6 +147,11 @@ export function run({ cwd, createProjectId, fileSystem, + fetchFunction, + planPushFileSystem, + createTemporaryId, + createRequestSignal, + apiUrl, }); } @@ -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), @@ -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], @@ -187,6 +268,103 @@ function runPlan({ argv, stdout, stderr, cwd, createProjectId, fileSystem }) { return 0; } +/** + * @param {Pick} 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, @@ -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`); +} diff --git a/src/commands/plan-init.js b/src/commands/plan-init.js index 8b27ed9..c70ff4e 100644 --- a/src/commands/plan-init.js +++ b/src/commands/plan-init.js @@ -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 { diff --git a/src/commands/plan-push.js b/src/commands/plan-push.js new file mode 100644 index 0000000..c02ffee --- /dev/null +++ b/src/commands/plan-push.js @@ -0,0 +1,568 @@ +import { createHash, randomUUID } from "node:crypto"; +import { lstatSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import { isFileSystemError } from "../file-system.js"; + +export const DEFAULT_API_URL = "https://firstdraft.com"; + +const FOUNDATION_PLAN_MEDIA_TYPE = + "application/vnd.firstdraft.foundation-plan+json"; +const STATE_FORMAT = "firstdraft.cli-state/1"; +const MAX_PLAN_BYTES = 1024 * 1024; +const MAX_STATE_BYTES = 4096; +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const MAX_ETAG_BYTES = 1024; +const REQUEST_TIMEOUT_MS = 30_000; +const PROJECT_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const STRONG_ETAG_PATTERN = /^"(?:[\x21\x23-\x7e\x80-\xff])*"$/; + +/** + * @typedef {object} PlanPushFileSystem + * @property {typeof lstatSync} lstatSync + * @property {typeof readFileSync} readFileSync + * @property {typeof renameSync} renameSync + * @property {typeof writeFileSync} writeFileSync + */ + +/** @type {PlanPushFileSystem} */ +const DEFAULT_FILE_SYSTEM = { + lstatSync, + readFileSync, + renameSync, + writeFileSync, +}; + +export class PlanPushConfigurationError extends Error {} +export class PlanPushLocalError extends Error {} +export class PlanPushNetworkError extends Error {} +export class PlanPushProtocolError extends Error {} + +export class PlanPushStateWriteError extends Error { + /** + * @param {{format: string, project_id: string, api_url: string, foundation_plan_etag: string}} recoveryState + * @param {{cause: Error}} options + */ + constructor(recoveryState, options) { + super("The Foundation Plan was accepted, but local state was not saved.", { + cause: options.cause, + }); + this.recoveryState = recoveryState; + } +} + +/** + * @typedef {object} PushPlanOptions + * @property {string} cwd + * @property {string} [apiUrl] + * @property {typeof globalThis.fetch} [fetchFunction] + * @property {PlanPushFileSystem} [fileSystem] + * @property {() => string} [createTemporaryId] + * @property {() => AbortSignal} [createRequestSignal] + */ + +/** + * @typedef {object} PushPlanResult + * @property {number} status + * @property {string} etag + * @property {"created" | "updated"} outcome + * @property {Record} body + */ + +/** + * @typedef {object} RejectedPushResult + * @property {number} status + * @property {unknown} body + */ + +/** + * @param {PushPlanOptions} options + * @returns {Promise} + */ +export async function pushPlan({ + cwd, + apiUrl, + fetchFunction = globalThis.fetch, + fileSystem = DEFAULT_FILE_SYSTEM, + createTemporaryId = randomUUID, + createRequestSignal = () => AbortSignal.timeout(REQUEST_TIMEOUT_MS), +}) { + const directory = path.join(cwd, ".firstdraft"); + assertLocalDirectory(directory, fileSystem); + const planPath = path.join(directory, "foundation-plan.json"); + const statePath = path.join(directory, "state.json"); + const planSource = readLocalFile(planPath, MAX_PLAN_BYTES, fileSystem); + const stateSource = readLocalFile(statePath, MAX_STATE_BYTES, fileSystem); + const state = loadState(stateSource); + const origin = resolveApiUrl(apiUrl, state.api_url); + const endpoint = new URL( + `/v1/projects/${state.project_id}/foundation-plan`, + origin, + ); + const headers = { + Accept: "application/json, application/problem+json", + "Content-Type": FOUNDATION_PLAN_MEDIA_TYPE, + ...(state.foundation_plan_etag + ? { "If-Match": state.foundation_plan_etag } + : { "If-None-Match": "*" }), + }; + + const response = await sendRequest(fetchFunction, endpoint, { + method: "PUT", + headers, + body: planSource, + redirect: "error", + signal: createRequestSignal(), + }); + const body = await readResponseBody(response); + const sourceSha256 = createHash("sha256").update(planSource).digest("hex"); + + if ( + response.status === 422 && + (responseMediaType(response) !== "application/json" || + !isDiagnosticBody(body, sourceSha256)) + ) { + throw new PlanPushProtocolError( + "First Draft returned invalid diagnostics.", + ); + } + + if (response.status !== 200 && response.status !== 201) { + if (response.ok) { + throw new PlanPushProtocolError( + "First Draft returned an unexpected success status.", + ); + } + return { + status: response.status, + body: + response.status === 422 || isProblemBody(response, body) ? body : null, + }; + } + + const etag = response.headers.get("etag"); + const expectedStatus = state.foundation_plan_etag ? 200 : 201; + if ( + response.status !== expectedStatus || + responseMediaType(response) !== "application/json" || + !isStrongEtag(etag) || + !isAcceptedBody(body, state.project_id, sourceSha256) + ) { + throw new PlanPushProtocolError( + "First Draft returned an invalid success response.", + ); + } + + saveState({ + statePath, + state, + apiUrl: origin, + etag, + fileSystem, + temporaryId: createTemporaryId(), + }); + + return { + status: response.status, + etag, + outcome: response.status === 201 ? "created" : "updated", + body, + }; +} + +/** @param {string | undefined} configured @param {string | undefined} stored */ +function resolveApiUrl(configured, stored) { + const configuredOrigin = + configured === undefined ? undefined : normalizeApiUrl(configured); + if ( + stored !== undefined && + configuredOrigin !== undefined && + stored !== configuredOrigin + ) { + throw new PlanPushConfigurationError( + "The configured API URL does not match local state.", + ); + } + + return stored ?? configuredOrigin ?? DEFAULT_API_URL; +} + +/** @param {string} value */ +export function normalizeApiUrl(value) { + let url; + + try { + url = new URL(value); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + + throw new PlanPushConfigurationError("The API URL is invalid.", { + cause: error, + }); + } + + const loopbackHttp = + url.protocol === "http:" && + (url.hostname === "127.0.0.1" || + url.hostname === "localhost" || + url.hostname === "[::1]"); + if ( + (url.protocol !== "https:" && !loopbackHttp) || + url.username !== "" || + url.password !== "" || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" + ) { + throw new PlanPushConfigurationError("The API URL is invalid."); + } + + return url.origin; +} + +/** @param {string} directory @param {PlanPushFileSystem} fileSystem */ +function assertLocalDirectory(directory, fileSystem) { + try { + if (!fileSystem.lstatSync(directory).isDirectory()) { + throw new PlanPushLocalError( + "The local First Draft directory is invalid.", + ); + } + } catch (error) { + if (error instanceof PlanPushLocalError) throw error; + if (!isFileSystemError(error)) throw error; + + throw new PlanPushLocalError( + "The local First Draft directory could not be read.", + { cause: error }, + ); + } +} + +/** + * @param {string} filePath + * @param {number} maximumBytes + * @param {PlanPushFileSystem} fileSystem + */ +function readLocalFile(filePath, maximumBytes, fileSystem) { + try { + const stat = fileSystem.lstatSync(filePath); + if (!stat.isFile() || stat.size > maximumBytes) { + throw new PlanPushLocalError("A local First Draft file is invalid."); + } + + const source = fileSystem.readFileSync(filePath); + if (!Buffer.isBuffer(source) || source.byteLength > maximumBytes) { + throw new PlanPushLocalError("A local First Draft file is invalid."); + } + + return source; + } catch (error) { + if (error instanceof PlanPushLocalError) throw error; + if (!isFileSystemError(error)) throw error; + + throw new PlanPushLocalError( + "A local First Draft file could not be read.", + { + cause: error, + }, + ); + } +} + +/** @param {Buffer} source */ +function loadState(source) { + let state; + + try { + state = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(source), + ); + } catch (error) { + if (!(error instanceof SyntaxError || error instanceof TypeError)) { + throw error; + } + + throw new PlanPushLocalError("Local First Draft state is invalid.", { + cause: error, + }); + } + + if (!isRecord(state)) { + throw new PlanPushLocalError("Local First Draft state is invalid."); + } + + const keys = Object.keys(state).sort(); + const hasRemoteState = + state.api_url !== undefined || state.foundation_plan_etag !== undefined; + const expectedKeys = hasRemoteState + ? ["api_url", "format", "foundation_plan_etag", "project_id"] + : ["format", "project_id"]; + let storedApiUrl; + + if (typeof state.api_url === "string") { + try { + storedApiUrl = normalizeApiUrl(state.api_url); + } catch (error) { + if (!(error instanceof PlanPushConfigurationError)) throw error; + + throw new PlanPushLocalError("Local First Draft state is invalid.", { + cause: error, + }); + } + } + + if ( + !arraysEqual(keys, expectedKeys) || + state.format !== STATE_FORMAT || + typeof state.project_id !== "string" || + !PROJECT_ID_PATTERN.test(state.project_id) || + (hasRemoteState && storedApiUrl !== state.api_url) || + (state.foundation_plan_etag !== undefined && + !isStrongEtag(state.foundation_plan_etag)) + ) { + throw new PlanPushLocalError("Local First Draft state is invalid."); + } + + return { + format: state.format, + project_id: state.project_id, + ...(storedApiUrl ? { api_url: storedApiUrl } : {}), + ...(typeof state.foundation_plan_etag === "string" + ? { foundation_plan_etag: state.foundation_plan_etag } + : {}), + }; +} + +/** + * @param {typeof globalThis.fetch} fetchFunction + * @param {URL} endpoint + * @param {RequestInit} request + */ +async function sendRequest(fetchFunction, endpoint, request) { + try { + return await fetchFunction(endpoint, request); + } catch (error) { + if (!(error instanceof Error)) throw error; + + throw new PlanPushNetworkError("The First Draft request failed.", { + cause: error, + }); + } +} + +/** @param {Response} response */ +async function readResponseBody(response) { + const bytes = await readResponseBytes(response); + + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + + return null; + } + + try { + return JSON.parse(text); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + + return null; + } +} + +/** @param {Response} response */ +async function readResponseBytes(response) { + const declaredLength = response.headers.get("content-length"); + if ( + declaredLength !== null && + /^\d+$/.test(declaredLength) && + Number(declaredLength) > MAX_RESPONSE_BYTES + ) { + if (response.body !== null) { + await response.body.cancel().catch(() => undefined); + } + throw new PlanPushProtocolError("The First Draft response is too large."); + } + + if (response.body === null) return Buffer.alloc(0); + + const reader = response.body.getReader(); + const chunks = []; + let byteLength = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + byteLength += value.byteLength; + if (byteLength > MAX_RESPONSE_BYTES) { + await reader.cancel().catch(() => undefined); + throw new PlanPushProtocolError( + "The First Draft response is too large.", + ); + } + chunks.push(Buffer.from(value)); + } + } catch (error) { + if (error instanceof PlanPushProtocolError) throw error; + if (!(error instanceof Error)) throw error; + + throw new PlanPushNetworkError("The First Draft response failed.", { + cause: error, + }); + } + + return Buffer.concat(chunks, byteLength); +} + +/** + * @param {object} options + * @param {string} options.statePath + * @param {{format: string, project_id: string, api_url?: string, foundation_plan_etag?: string}} options.state + * @param {string} options.apiUrl + * @param {string} options.etag + * @param {PlanPushFileSystem} options.fileSystem + * @param {string} options.temporaryId + */ +function saveState({ + statePath, + state, + apiUrl, + etag, + fileSystem, + temporaryId, +}) { + const recoveryState = { + format: state.format, + project_id: state.project_id, + api_url: apiUrl, + foundation_plan_etag: etag, + }; + const source = `${JSON.stringify(recoveryState, null, 2)}\n`; + const temporaryPath = `${statePath}.${temporaryId}.tmp`; + + if (Buffer.byteLength(source) > MAX_STATE_BYTES) { + throw new PlanPushStateWriteError(recoveryState, { + cause: new RangeError("Local First Draft state exceeds its size limit."), + }); + } + + try { + fileSystem.writeFileSync(temporaryPath, source, { + flag: "wx", + mode: 0o600, + flush: true, + }); + fileSystem.renameSync(temporaryPath, statePath); + } catch (error) { + if (!isFileSystemError(error)) throw error; + + throw new PlanPushStateWriteError(recoveryState, { cause: error }); + } +} + +/** @param {Response} response */ +function responseMediaType(response) { + return ( + response.headers + .get("content-type") + ?.split(";", 1)[0] + ?.trim() + .toLowerCase() ?? "" + ); +} + +/** + * @param {unknown} body + * @param {string} projectId + * @param {string} sourceSha256 + */ +function isAcceptedBody(body, projectId, sourceSha256) { + if (!isRecord(body)) return false; + + const project = body.project; + const foundationPlan = body.foundation_plan; + return ( + isRecord(project) && + project.id === projectId && + Number.isSafeInteger(project.graph_version) && + Number(project.graph_version) >= 1 && + isRecord(foundationPlan) && + typeof foundationPlan.format === "string" && + foundationPlan.source_sha256 === sourceSha256 && + Array.isArray(body.diagnostics) && + body.diagnostics.every(isWarningDiagnostic) + ); +} + +/** @param {unknown} body @param {string} sourceSha256 */ +function isDiagnosticBody(body, sourceSha256) { + return ( + isRecord(body) && + body.source_sha256 === sourceSha256 && + Array.isArray(body.diagnostics) && + body.diagnostics.some( + (diagnostic) => + isDiagnostic(diagnostic) && diagnostic.severity === "error", + ) && + body.diagnostics.every(isDiagnostic) + ); +} + +/** @param {Response} response @param {unknown} body */ +function isProblemBody(response, body) { + return ( + responseMediaType(response) === "application/problem+json" && + isRecord(body) && + (body.type === undefined || body.type === "about:blank") && + typeof body.title === "string" && + body.status === response.status && + typeof body.code === "string" && + typeof body.detail === "string" + ); +} + +/** @param {unknown} value */ +function isWarningDiagnostic(value) { + return isDiagnostic(value) && value.severity === "warning"; +} + +/** + * @param {unknown} value + * @returns {value is Record & {severity: "error" | "warning"}} + */ +function isDiagnostic(value) { + return ( + isRecord(value) && + typeof value.code === "string" && + (value.severity === "error" || value.severity === "warning") && + typeof value.message === "string" + ); +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** @param {unknown} value @returns {value is string} */ +function isStrongEtag(value) { + return ( + typeof value === "string" && + Buffer.byteLength(value) <= MAX_ETAG_BYTES && + STRONG_ETAG_PATTERN.test(value) + ); +} + +/** @param {string[]} left @param {string[]} right */ +function arraysEqual(left, right) { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} diff --git a/src/file-system.js b/src/file-system.js new file mode 100644 index 0000000..4082fde --- /dev/null +++ b/src/file-system.js @@ -0,0 +1,12 @@ +/** + * @param {unknown} error + * @returns {error is Error & {code: string}} + */ +export function isFileSystemError(error) { + return ( + error instanceof Error && + "code" in error && + typeof error.code === "string" && + (error.code === "ERR_ACCESS_DENIED" || !error.code.startsWith("ERR_")) + ); +} diff --git a/test/cli.test.js b/test/cli.test.js index 69c92da..0f127af 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -28,30 +28,30 @@ const packageMetadata = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), ); -test("no arguments show help", () => { - const result = invoke([]); +test("no arguments show help", async () => { + const result = await invoke([]); assert.deepEqual(result, { status: 0, stdout: HELP, stderr: "" }); }); -test("help uses long and short options", () => { - const long = invoke(["--help"]); - const short = invoke(["-h"]); +test("help uses long and short options", async () => { + const long = await invoke(["--help"]); + const short = await invoke(["-h"]); assert.deepEqual(long, { status: 0, stdout: HELP, stderr: "" }); assert.deepEqual(short, long); }); -test("version matches the package through long and short options", () => { - const long = invoke(["--version"]); - const short = invoke(["-V"]); +test("version matches the package through long and short options", async () => { + const long = await invoke(["--version"]); + const short = await invoke(["-V"]); assert.equal(VERSION, packageMetadata.version); assert.deepEqual(long, { status: 0, stdout: `${VERSION}\n`, stderr: "" }); assert.deepEqual(short, long); }); -test("an unknown command returns a non-echoing usage error", () => { +test("an unknown command returns a non-echoing usage error", async () => { const canary = "\u001b[31mcanary-secret-command\n"; for (const argv of [ @@ -61,7 +61,7 @@ test("an unknown command returns a non-echoing usage error", () => { [canary, "--version"], ["--version", canary], ]) { - const result = invoke(argv); + const result = await invoke(argv); assert.deepEqual(result, { status: 2, @@ -73,40 +73,44 @@ test("an unknown command returns a non-echoing usage error", () => { } }); -test("an unknown option returns a non-echoing usage error", () => { +test("an unknown option returns a non-echoing usage error", async () => { for (const argv of [ ["--canary-secret-option"], ["--canary-secret-option", "--help"], ["--help", "--canary-secret-option"], ]) { - const result = invoke(argv); + const result = await invoke(argv); assert.deepEqual(result, { status: 2, stdout: "", stderr: USAGE_ERROR }); assert.doesNotMatch(result.stderr, /canary-secret/); } }); -test("recognized root options have deterministic precedence", () => { +test("recognized root options have deterministic precedence", async () => { for (const argv of [ ["--help", "--version"], ["-hV"], ["--help", "--help"], ["--"], ]) { - assert.deepEqual(invoke(argv), { status: 0, stdout: HELP, stderr: "" }); + assert.deepEqual(await invoke(argv), { + status: 0, + stdout: HELP, + stderr: "", + }); } - assert.deepEqual(invoke(["--version", "--version"]), { + assert.deepEqual(await invoke(["--version", "--version"]), { status: 0, stdout: `${VERSION}\n`, stderr: "", }); }); -test("argument parsing does not mutate injected input", () => { +test("argument parsing does not mutate injected input", async () => { const argv = Object.freeze(["--version"]); - assert.equal(invoke(argv).status, 0); + assert.equal((await invoke(argv)).status, 0); assert.deepEqual(argv, ["--version"]); }); @@ -159,11 +163,11 @@ test("the executable preserves its status when an output pipe closes", async () }); /** @param {readonly string[]} argv */ -function invoke(argv) { +async function invoke(argv) { let stdout = ""; let stderr = ""; - const status = run({ + const status = await run({ argv, stdout: { write: (text) => (stdout += text) }, stderr: { write: (text) => (stderr += text) }, diff --git a/test/plan-init.test.js b/test/plan-init.test.js index 4fa3f08..025c716 100644 --- a/test/plan-init.test.js +++ b/test/plan-init.test.js @@ -27,6 +27,7 @@ Usage: Commands: init Create a local empty Foundation Plan + push Send the local Foundation Plan to First Draft Options: -h, --help Show help @@ -71,32 +72,32 @@ const EXPECTED_STATE = `{ } `; -test("plan help describes the available command", () => { - assert.deepEqual(invoke(["plan"]), { +test("plan help describes the available command", async () => { + assert.deepEqual(await invoke(["plan"]), { status: 0, stdout: PLAN_HELP, stderr: "", }); - assert.deepEqual(invoke(["plan", "--help"]), { + assert.deepEqual(await invoke(["plan", "--help"]), { status: 0, stdout: PLAN_HELP, stderr: "", }); - assert.deepEqual(invoke(["plan", "-h"]), { + assert.deepEqual(await invoke(["plan", "-h"]), { status: 0, stdout: PLAN_HELP, stderr: "", }); }); -test("plan init help does not require creation options", () => { +test("plan init help does not require creation options", async () => { for (const argv of [ ["plan", "init", "--help"], ["plan", "init", "-h"], ["plan", "init", "--help", "--help"], ["plan", "init", "-h", "-h"], ]) { - assert.deepEqual(invoke(argv), { + assert.deepEqual(await invoke(argv), { status: 0, stdout: PLAN_INIT_HELP, stderr: "", @@ -104,7 +105,7 @@ test("plan init help does not require creation options", () => { } }); -test("plan commands return non-echoing usage errors", () => { +test("plan commands return non-echoing usage errors", async () => { const canary = "canary-secret-command"; for (const argv of [ @@ -112,7 +113,7 @@ test("plan commands return non-echoing usage errors", () => { ["plan", canary, "--help"], ["plan", "--help", canary], ]) { - const result = invoke(argv); + const result = await invoke(argv); assert.deepEqual(result, { status: 2, @@ -126,7 +127,7 @@ test("plan commands return non-echoing usage errors", () => { ["plan", "--canary-secret-option"], ["plan", "--canary-secret-option", "--help"], ]) { - const result = invoke(argv); + const result = await invoke(argv); assert.deepEqual(result, { status: 2, @@ -137,9 +138,9 @@ test("plan commands return non-echoing usage errors", () => { } }); -test("plan init creates exact deterministic local files", (context) => { +test("plan init creates exact deterministic local files", async (context) => { const cwd = temporaryDirectory(context, "firstdraft init ünicode "); - const result = invoke( + const result = await invoke( [ "plan", "init", @@ -212,11 +213,11 @@ test("the executable initializes with a production UUIDv7", (context) => { ); }); -test("the nested ignore file hides the complete local directory from Git", (context) => { +test("the nested ignore file hides the complete local directory from Git", async (context) => { const cwd = temporaryDirectory(context); runGit(cwd, ["init", "--quiet"]); - const result = invoke( + const result = await invoke( ["plan", "init", "--application-key=oscar_party", "--name=Oscar Party"], { cwd, createProjectId: () => PROJECT_ID }, ); @@ -240,13 +241,13 @@ test("the nested ignore file hides the complete local directory from Git", (cont assert.match(ignored.stdout, /\.firstdraft\/\.gitignore:1:\*/); }); -test("an existing root gitignore remains byte-for-byte unchanged", (context) => { +test("an existing root gitignore remains byte-for-byte unchanged", async (context) => { const cwd = temporaryDirectory(context); const gitignore = path.join(cwd, ".gitignore"); const original = Buffer.from([0x61, 0x0d, 0x0a, 0x62, 0x0a, 0xff]); writeFileSync(gitignore, original); - const result = invoke( + const result = await invoke( [ "plan", "init", @@ -262,7 +263,7 @@ test("an existing root gitignore remains byte-for-byte unchanged", (context) => assert.deepEqual(readFileSync(gitignore), original); }); -test("plan init validates every argument before randomness or filesystem access", () => { +test("plan init validates every argument before randomness or filesystem access", async () => { const invalidArguments = [ ["plan", "init"], ["plan", "init", "--application-key", "oscar_party"], @@ -331,7 +332,7 @@ test("plan init validates every argument before randomness or filesystem access" ]; for (const argv of invalidArguments) { - const result = invoke(argv, { + const result = await invoke(argv, { createProjectId: () => { throw new Error("randomness must not run"); }, @@ -347,11 +348,11 @@ test("plan init validates every argument before randomness or filesystem access" } }); -test("plan init accepts ordinary astral Unicode in the application name", (context) => { +test("plan init accepts ordinary astral Unicode in the application name", async (context) => { const cwd = temporaryDirectory(context); const name = "Oscar Party \ud83c\udf89"; - const result = invoke( + const result = await invoke( ["plan", "init", "--application-key", "oscar_party", "--name", name], { cwd, createProjectId: () => PROJECT_ID }, ); @@ -363,13 +364,13 @@ test("plan init accepts ordinary astral Unicode in the application name", (conte assert.equal(plan.application.name, name); }); -test("plan init never overwrites an existing local path", (context) => { +test("plan init never overwrites an existing local path", async (context) => { const directoryCwd = temporaryDirectory(context); const directory = path.join(directoryCwd, ".firstdraft"); mkdirSync(directory); writeFileSync(path.join(directory, "canary.txt"), "canary-secret-directory"); - const directoryResult = invokeValidInit(directoryCwd); + const directoryResult = await invokeValidInit(directoryCwd); assertInitializationFailure(directoryResult); assert.equal( readFileSync(path.join(directory, "canary.txt"), "utf8"), @@ -380,7 +381,7 @@ test("plan init never overwrites an existing local path", (context) => { const file = path.join(fileCwd, ".firstdraft"); writeFileSync(file, "canary-secret-file"); - const fileResult = invokeValidInit(fileCwd); + const fileResult = await invokeValidInit(fileCwd); assertInitializationFailure(fileResult); assert.equal(readFileSync(file, "utf8"), "canary-secret-file"); }); @@ -388,12 +389,12 @@ test("plan init never overwrites an existing local path", (context) => { test( "plan init refuses an existing symlink without following it", { skip: process.platform === "win32" }, - (context) => { + async (context) => { const cwd = temporaryDirectory(context); const target = temporaryDirectory(context); symlinkSync(target, path.join(cwd, ".firstdraft"), "dir"); - const result = invokeValidInit(cwd); + const result = await invokeValidInit(cwd); assertInitializationFailure(result); assert.equal( @@ -404,16 +405,16 @@ test( }, ); -test("a second initialization preserves the first Project", (context) => { +test("a second initialization preserves the first Project", async (context) => { const cwd = temporaryDirectory(context); - const first = invokeValidInit(cwd); + const first = await invokeValidInit(cwd); const directory = path.join(cwd, ".firstdraft"); const originalPlan = readFileSync( path.join(directory, "foundation-plan.json"), ); const originalState = readFileSync(path.join(directory, "state.json")); - const second = invoke( + const second = await invoke( [ "plan", "init", @@ -437,7 +438,7 @@ test("a second initialization preserves the first Project", (context) => { ); }); -test("partial filesystem failures stop immediately without cleanup", () => { +test("partial filesystem failures stop immediately without cleanup", async () => { const operations = [ "mkdir", "write:.gitignore", @@ -453,14 +454,14 @@ test("partial filesystem failures stop immediately without cleanup", () => { /** @type {string[]} */ const calls = []; const fileSystem = recordingFileSystem(calls, failureIndex); - const result = invokeValidInit("/unused", { fileSystem }); + const result = await invokeValidInit("/unused", { fileSystem }); assertInitializationFailure(result); assert.deepEqual(calls, operations.slice(0, failureIndex + 1)); } }); -test("unexpected programming errors remain loud", () => { +test("unexpected programming errors remain loud", async () => { /** @type {import("../src/commands/plan-init.js").FileSystem} */ const fileSystem = { mkdirSync() { @@ -471,7 +472,7 @@ test("unexpected programming errors remain loud", () => { }, }; - assert.throws( + await assert.rejects( () => invokeValidInit("/unused", { fileSystem }), /programming error/, ); @@ -481,11 +482,11 @@ test("unexpected programming errors remain loud", () => { * @param {readonly string[]} argv * @param {Partial} [overrides] */ -function invoke(argv, overrides = {}) { +async function invoke(argv, overrides = {}) { let stdout = ""; let stderr = ""; - const status = run({ + const status = await run({ argv, stdout: { write: (text) => (stdout += text) }, stderr: { write: (text) => (stderr += text) }, @@ -499,7 +500,7 @@ function invoke(argv, overrides = {}) { * @param {string} cwd * @param {Partial} [overrides] */ -function invokeValidInit(cwd, overrides = {}) { +async function invokeValidInit(cwd, overrides = {}) { return invoke( [ "plan", diff --git a/test/plan-push.test.js b/test/plan-push.test.js new file mode 100644 index 0000000..beac60c --- /dev/null +++ b/test/plan-push.test.js @@ -0,0 +1,1139 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { run } from "../src/cli.js"; +import { + PlanPushStateWriteError, + pushPlan, +} from "../src/commands/plan-push.js"; + +/** @typedef {{input: string | URL | Request, init: RequestInit | undefined}} FetchCall */ + +const PROJECT_ID = "01900000-0000-7000-8000-000000000301"; +const API_URL = "https://api.example.test"; +const FIRST_ETAG = '"opaque:first-validator"'; +const SECOND_ETAG = '"opaque:second-validator"'; +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_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"; + +test("plan push help has no local or network prerequisites", async () => { + const inaccessible = () => { + throw new Error("help must not access dependencies"); + }; + + for (const argv of [ + ["plan", "push", "--help"], + ["plan", "push", "-h"], + ["plan", "push", "--help", "--help"], + ]) { + assert.deepEqual( + await invoke(argv, { + fetchFunction: inaccessible, + planPushFileSystem: inaccessiblePlanPushFileSystem(), + }), + { status: 0, stdout: PLAN_PUSH_HELP, stderr: "" }, + ); + } +}); + +test("the initial push sends exact bytes and saves its origin and ETag", async (context) => { + const cwd = await initializedDirectory(context); + const source = planSource(cwd); + const response = acceptedResponse(source, 201, FIRST_ETAG); + /** @type {FetchCall[]} */ + const calls = []; + const signal = new AbortController().signal; + const result = await invoke(["plan", "push"], { + cwd, + fetchFunction: recordingFetch(response, calls), + createRequestSignal: () => signal, + createTemporaryId: () => "initial-push", + apiUrl: `${API_URL}/`, + }); + + assert.equal(calls.length, 1); + const [call] = calls; + assert(call); + assert.equal( + String(call.input), + `${API_URL}/v1/projects/${PROJECT_ID}/foundation-plan`, + ); + assert.equal(call.init?.method, "PUT"); + assert.equal(call.init?.redirect, "error"); + assert.equal(call.init?.signal, signal); + const headers = new Headers(call.init?.headers); + assert.equal( + headers.get("content-type"), + "application/vnd.firstdraft.foundation-plan+json", + ); + assert.equal( + headers.get("accept"), + "application/json, application/problem+json", + ); + assert.equal(headers.get("if-none-match"), "*"); + assert.equal(headers.has("if-match"), false); + assert(Buffer.isBuffer(call.init?.body)); + assert.deepEqual(call.init.body, source); + + assert.deepEqual(result, { + status: 0, + stdout: `${JSON.stringify( + { + outcome: "created", + etag: FIRST_ETAG, + ...acceptedBody(source), + }, + null, + 2, + )}\n`, + stderr: "", + }); + assert.deepEqual(readState(cwd), { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: API_URL, + foundation_plan_etag: FIRST_ETAG, + }); + assert.deepEqual(readdirSync(path.join(cwd, ".firstdraft")).sort(), [ + ".gitignore", + "foundation-plan.json", + "state.json", + ]); + if (process.platform !== "win32") { + assert.equal( + statSync(path.join(cwd, ".firstdraft", "state.json")).mode & 0o777, + 0o600, + ); + } +}); + +test("the initial push defaults to the First Draft production origin", async (context) => { + const cwd = await initializedDirectory(context); + const source = planSource(cwd); + /** @type {FetchCall[]} */ + const calls = []; + const result = await pushPlan({ + cwd, + fetchFunction: recordingFetch( + acceptedResponse(source, 201, FIRST_ETAG), + calls, + ), + createTemporaryId: () => "default-origin", + }); + + assert("etag" in result); + assert.equal( + String(calls[0]?.input), + `https://firstdraft.com/v1/projects/${PROJECT_ID}/foundation-plan`, + ); + assert.equal(readState(cwd).api_url, "https://firstdraft.com"); +}); + +test("later pushes replay the saved ETag and rotate it opaquely", async (context) => { + const cwd = await initializedDirectory(context); + await successfulInitialPush(cwd); + const pathToPlan = path.join(cwd, ".firstdraft", "foundation-plan.json"); + const replacement = `${readFileSync(pathToPlan, "utf8")} `; + writeFileSync(pathToPlan, replacement); + /** @type {FetchCall[]} */ + const calls = []; + + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch( + acceptedResponse(Buffer.from(replacement), 200, SECOND_ETAG), + calls, + ), + createTemporaryId: () => "replacement", + }); + + const [call] = calls; + assert(call); + const headers = new Headers(call.init?.headers); + assert.equal(headers.get("if-match"), FIRST_ETAG); + assert.equal(headers.has("if-none-match"), false); + assert(Buffer.isBuffer(call.init?.body)); + assert.deepEqual(call.init.body, Buffer.from(replacement)); + assert.equal(result.status, 0); + assert.equal(JSON.parse(result.stdout).outcome, "updated"); + assert.equal(JSON.parse(result.stdout).etag, SECOND_ETAG); + assert.equal(readState(cwd).foundation_plan_etag, SECOND_ETAG); + assert.equal(readState(cwd).api_url, API_URL); +}); + +test("a saved origin may be repeated but never changed", async (context) => { + const cwd = await initializedDirectory(context); + await successfulInitialPush(cwd); + const before = stateSource(cwd); + + const same = await invoke(["plan", "push"], { + cwd, + fetchFunction: recordingFetch( + acceptedResponse(planSource(cwd), 200, SECOND_ETAG), + [], + ), + createTemporaryId: () => "same-origin", + apiUrl: `${API_URL}/`, + }); + assert.equal(same.status, 0); + + const different = await invoke(["plan", "push"], { + cwd, + apiUrl: "https://canary-secret.example", + fetchFunction: inaccessibleFetch(), + }); + assert.deepEqual(different, { + status: 2, + stdout: "", + stderr: PLAN_PUSH_CONFIGURATION_ERROR, + }); + assert.doesNotMatch(different.stderr, /canary-secret/); + assert.notDeepEqual(stateSource(cwd), before); + assert.equal(readState(cwd).api_url, API_URL); + assert.equal(readState(cwd).foundation_plan_etag, SECOND_ETAG); +}); + +test("an API override must be one valid secure or loopback origin", async (context) => { + const invalidApiUrls = [ + "canary-secret-not-a-url", + "ftp://canary-secret.example", + "http://canary-secret.example", + "https://user:pass@canary-secret.example", + "https://canary-secret.example/path", + "https://canary-secret.example?query", + "https://canary-secret.example#hash", + ]; + + for (const apiUrl of invalidApiUrls) { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl, + fetchFunction: inaccessibleFetch(), + }); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: PLAN_PUSH_CONFIGURATION_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + assert.deepEqual(stateSource(cwd), before); + } +}); + +test("usage errors happen before local or network access", async () => { + for (const argv of [ + ["plan", "push", "--canary-secret-option"], + ["plan", "push", "canary-secret-positional"], + ]) { + const result = await invoke(argv, { + fetchFunction: inaccessibleFetch(), + planPushFileSystem: inaccessiblePlanPushFileSystem(), + }); + + assert.equal(result.status, 2); + assert.equal(result.stdout, ""); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +test("HTTP diagnostics and problems leave local state byte-for-byte unchanged", async (context) => { + const cases = [ + { + status: 422, + body: { + source_sha256: "replace-with-request-digest", + diagnostics: [ + { + code: "foundation_plan.import.unsupported_bootstrap_content", + severity: "error", + message: "The Plan is not supported yet.", + }, + ], + }, + }, + { + status: 412, + body: { + type: "about:blank", + title: "Precondition Failed", + status: 412, + code: "precondition_failed", + detail: "The request precondition does not match.", + }, + }, + { + status: 428, + body: { + title: "Precondition Required", + status: 428, + code: "precondition_required", + detail: "A representation-specific precondition is required.", + }, + }, + ]; + + for (const { status, body } of cases) { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const responseBody = + status === 422 + ? { ...body, source_sha256: sha256(planSource(cwd)) } + : body; + const response = + status === 422 + ? jsonResponse(responseBody, status) + : problemResponse(responseBody, status); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(response, []), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: `${JSON.stringify(responseBody, null, 2)}\n`, + }); + assert.deepEqual(stateSource(cwd), before); + } +}); + +test("a stale update preserves the prior ETag and exact local state", async (context) => { + const cwd = await initializedDirectory(context); + await successfulInitialPush(cwd); + const before = stateSource(cwd); + const problem = { + type: "about:blank", + title: "Precondition Failed", + status: 412, + code: "precondition_failed", + detail: "The Foundation Plan has changed.", + }; + /** @type {FetchCall[]} */ + const calls = []; + + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(problemResponse(problem, 412), calls), + }); + + const [call] = calls; + assert(call); + const headers = new Headers(call.init?.headers); + assert.equal(headers.get("if-match"), FIRST_ETAG); + assert.equal(headers.has("if-none-match"), false); + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: `${JSON.stringify(problem, null, 2)}\n`, + }); + assert.deepEqual(stateSource(cwd), before); + assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); +}); + +test("an update rejects a create status before changing local state", async (context) => { + const cwd = await initializedDirectory(context); + await successfulInitialPush(cwd); + const before = stateSource(cwd); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch( + acceptedResponse(planSource(cwd), 201, SECOND_ETAG), + [], + ), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_PROTOCOL_ERROR, + }); + assert.deepEqual(stateSource(cwd), before); + assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); +}); + +test("422 diagnostics must identify the exact submitted bytes", async (context) => { + const invalidBodies = [ + { + source_sha256: "0".repeat(64), + diagnostics: [ + { code: "wrong", severity: "error", message: "Wrong source." }, + ], + }, + { + source_sha256: "replace-with-request-digest", + diagnostics: [ + { code: "warning", severity: "warning", message: "No error." }, + ], + }, + ]; + + for (const candidate of invalidBodies) { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const body = + candidate.source_sha256 === "replace-with-request-digest" + ? { ...candidate, source_sha256: sha256(planSource(cwd)) } + : candidate; + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(jsonResponse(body, 422), []), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_PROTOCOL_ERROR, + }); + assert.deepEqual(stateSource(cwd), before); + } + + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const diagnostic = { + source_sha256: sha256(planSource(cwd)), + diagnostics: [ + { code: "invalid", severity: "error", message: "Invalid Plan." }, + ], + }; + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch( + new Response(JSON.stringify(diagnostic), { + status: 422, + headers: { "Content-Type": "text/plain" }, + }), + [], + ), + }); + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_PROTOCOL_ERROR, + }); + assert.deepEqual(stateSource(cwd), before); +}); + +test("non-JSON HTTP failures are reported without echoing their body", async (context) => { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch( + new Response("canary-secret-upstream-body", { status: 503 }), + [], + ), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: "First Draft rejected the Plan (HTTP 503).\n", + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + assert.deepEqual(stateSource(cwd), before); +}); + +test("transport failures disclose the ambiguous outcome without leaking errors", async (context) => { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: async () => { + throw new TypeError("canary-secret-network-detail"); + }, + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_NETWORK_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + assert.deepEqual(stateSource(cwd), before); +}); + +test("success responses are bound to the request before state changes", async (context) => { + /** @type {((source: Buffer) => Response)[]} */ + const sourceMutators = [ + (source) => acceptedResponse(source, 200, FIRST_ETAG), + (source) => acceptedResponse(source, 201, 'W/"weak"'), + (source) => acceptedResponse(source, 201, `"${"x".repeat(1023)}"`), + () => new Response(null, { status: 204 }), + (source) => + new Response(JSON.stringify(acceptedBody(source)), { + status: 201, + headers: { "Content-Type": "application/json" }, + }), + (source) => + new Response(JSON.stringify(acceptedBody(source)), { + status: 201, + headers: { "Content-Type": "text/plain", ETag: FIRST_ETAG }, + }), + (source) => { + const body = acceptedBody(source); + body.project.id = "01900000-0000-7000-8000-000000000399"; + return jsonResponse(body, 201, FIRST_ETAG); + }, + (source) => { + const body = acceptedBody(source); + body.foundation_plan.source_sha256 = "0".repeat(64); + return jsonResponse(body, 201, FIRST_ETAG); + }, + (source) => { + const body = acceptedBody(source); + body.diagnostics = [ + { code: "canary", severity: "error", message: "canary-secret" }, + ]; + return jsonResponse(body, 201, FIRST_ETAG); + }, + ]; + + for (const makeResponse of sourceMutators) { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(makeResponse(planSource(cwd)), []), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_PROTOCOL_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + assert.deepEqual(stateSource(cwd), before); + } +}); + +test("warning diagnostics survive an accepted response", async (context) => { + const cwd = await initializedDirectory(context); + const warning = { + code: "foundation_plan.example_warning", + severity: "warning", + message: "This Plan can be improved.", + }; + const response = acceptedResponse(planSource(cwd), 201, FIRST_ETAG, [ + warning, + ]); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(response, []), + createTemporaryId: () => "warning", + }); + + assert.equal(result.status, 0); + assert.deepEqual(JSON.parse(result.stdout).diagnostics, [warning]); + assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); +}); + +test("success media types are compared case-insensitively", async (context) => { + const cwd = await initializedDirectory(context); + const source = planSource(cwd); + const response = new Response(JSON.stringify(acceptedBody(source)), { + status: 201, + headers: { + "Content-Type": "Application/JSON; Charset=UTF-8", + ETag: FIRST_ETAG, + }, + }); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(response, []), + createTemporaryId: () => "mixed-case-media-type", + }); + + assert.equal(result.status, 0); + assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); +}); + +test("a push from an uninitialized directory makes no request", async (context) => { + const cwd = temporaryDirectory(context); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: inaccessibleFetch(), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_LOCAL_ERROR, + }); +}); + +test("local paths are bounded regular files beneath a real directory", async (context) => { + /** @type {((cwd: string) => Promise)[]} */ + const cases = [ + async (cwd) => rmSync(path.join(cwd, ".firstdraft", "state.json")), + async (cwd) => { + const plan = path.join(cwd, ".firstdraft", "foundation-plan.json"); + rmSync(plan); + mkdirSync(plan); + }, + async (cwd) => + writeFileSync( + path.join(cwd, ".firstdraft", "foundation-plan.json"), + Buffer.alloc(1024 * 1024 + 1), + ), + ]; + + if (process.platform !== "win32") { + cases.push(async (cwd) => { + const plan = path.join(cwd, ".firstdraft", "foundation-plan.json"); + const target = path.join(cwd, "outside-plan.json"); + writeFileSync(target, "canary-secret-outside-plan"); + rmSync(plan); + symlinkSync(target, plan); + }); + cases.push(async (cwd) => { + const state = statePath(cwd); + const target = path.join(cwd, "outside-state.json"); + writeFileSync(target, stateSource(cwd)); + rmSync(state); + symlinkSync(target, state); + }); + cases.push(async (cwd) => { + const directory = path.join(cwd, ".firstdraft"); + const target = path.join(cwd, "real-firstdraft"); + renameSync(directory, target); + symlinkSync(target, directory, "dir"); + }); + } + + for (const mutate of cases) { + const cwd = await initializedDirectory(context); + await mutate(cwd); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: inaccessibleFetch(), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_LOCAL_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +test("invalid local state is rejected before exact Plan bytes leave the machine", async (context) => { + const invalidStates = [ + Buffer.from("{"), + Buffer.from([0xff]), + Buffer.from("{}\n"), + stateJson({ format: "other", project_id: PROJECT_ID }), + stateJson({ + format: "firstdraft.cli-state/1", + project_id: "01900000-0000-7000-8000-00000000030A", + }), + stateJson({ + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + extra: "canary-secret", + }), + stateJson({ + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: API_URL, + foundation_plan_etag: 'W/"weak"', + }), + stateJson({ + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: API_URL, + foundation_plan_etag: `"${"x".repeat(1023)}"`, + }), + stateJson({ + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: `${API_URL}/path`, + foundation_plan_etag: FIRST_ETAG, + }), + ]; + + for (const invalidState of invalidStates) { + const cwd = await initializedDirectory(context); + writeFileSync(statePath(cwd), invalidState); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: inaccessibleFetch(), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_LOCAL_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +test("the Plan is never parsed or reserialized locally", async (context) => { + const cwd = await initializedDirectory(context); + const invalidUtf8 = Buffer.from([0x7b, 0x22, 0x78, 0x22, 0x3a, 0xff, 0x7d]); + writeFileSync( + path.join(cwd, ".firstdraft", "foundation-plan.json"), + invalidUtf8, + ); + /** @type {FetchCall[]} */ + const calls = []; + const diagnostic = { + source_sha256: sha256(invalidUtf8), + diagnostics: [ + { + code: "foundation_plan.json.invalid", + severity: "error", + message: "The Foundation Plan is not valid UTF-8 JSON.", + }, + ], + }; + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(jsonResponse(diagnostic, 422), calls), + }); + + const [call] = calls; + assert(call); + assert(Buffer.isBuffer(call.init?.body)); + assert.deepEqual(call.init.body, invalidUtf8); + assert.equal(result.status, 1); + assert.deepEqual(JSON.parse(result.stderr), diagnostic); +}); + +test("failed atomic state replacements report the accepted ETag", async (context) => { + for (const code of ["EACCES", "ERR_ACCESS_DENIED"]) { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const fileSystem = { + lstatSync, + readFileSync, + writeFileSync, + renameSync() { + const error = new Error("canary-secret-rename-detail"); + Object.assign(error, { code }); + throw error; + }, + }; + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + planPushFileSystem: fileSystem, + fetchFunction: recordingFetch( + acceptedResponse(planSource(cwd), 201, FIRST_ETAG), + [], + ), + createTemporaryId: () => code, + }); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.deepEqual(JSON.parse(result.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: { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: API_URL, + foundation_plan_etag: FIRST_ETAG, + }, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + assert.deepEqual(stateSource(cwd), before); + assert.equal( + readFileSync(`${statePath(cwd)}.${code}.tmp`, "utf8"), + `${JSON.stringify( + { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: API_URL, + foundation_plan_etag: FIRST_ETAG, + }, + null, + 2, + )}\n`, + ); + } +}); + +test("state serialization cannot create a file the CLI refuses to read", async (context) => { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const oversizedApiUrl = `https://${"a".repeat(5000)}`; + + await assert.rejects( + pushPlan({ + cwd, + apiUrl: oversizedApiUrl, + fetchFunction: recordingFetch( + acceptedResponse(planSource(cwd), 201, FIRST_ETAG), + [], + ), + createTemporaryId: () => "oversized-state", + }), + (error) => { + assert(error instanceof PlanPushStateWriteError); + assert.equal(error.recoveryState.api_url, oversizedApiUrl); + assert.equal(error.recoveryState.foundation_plan_etag, FIRST_ETAG); + return true; + }, + ); + + assert.deepEqual(stateSource(cwd), before); + assert.deepEqual(readdirSync(path.join(cwd, ".firstdraft")).sort(), [ + ".gitignore", + "foundation-plan.json", + "state.json", + ]); +}); + +test("oversized success responses stop before local state changes", async (context) => { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + let cancelled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([0x78])); + }, + cancel() { + cancelled = true; + }, + }), + { + status: 201, + headers: { + "Content-Type": "application/json", + "Content-Length": String(2 * 1024 * 1024 + 1), + ETag: FIRST_ETAG, + }, + }, + ); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(response, []), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_PROTOCOL_ERROR, + }); + assert.deepEqual(stateSource(cwd), before); + assert.equal(cancelled, true); +}); + +test("streamed oversized responses are cancelled at the byte cap", async (context) => { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + let cancelled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(1024 * 1024)); + controller.enqueue(new Uint8Array(1024 * 1024)); + controller.enqueue(new Uint8Array([0x78])); + }, + cancel() { + cancelled = true; + }, + }), + { + status: 201, + headers: { "Content-Type": "application/json", ETag: FIRST_ETAG }, + }, + ); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(response, []), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_PROTOCOL_ERROR, + }); + assert.deepEqual(stateSource(cwd), before); + assert.equal(cancelled, true); +}); + +test("the packaged executable completes a real local HTTP push", async (context) => { + const cwd = await initializedDirectory(context); + const source = planSource(cwd); + let requestBody = Buffer.alloc(0); + /** @type {import("node:http").IncomingHttpHeaders | undefined} */ + let requestHeaders; + const server = createServer((request, response) => { + /** @type {Buffer[]} */ + const chunks = []; + request.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + request.on("end", () => { + requestBody = Buffer.concat(chunks); + requestHeaders = request.headers; + const body = JSON.stringify(acceptedBody(source)); + response.writeHead(201, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + ETag: FIRST_ETAG, + }); + response.end(body); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + context.after(() => server.close()); + const address = server.address(); + assert(address && typeof address !== "string"); + const apiUrl = `http://127.0.0.1:${address.port}`; + const executable = fileURLToPath( + new URL("../bin/firstdraft.js", import.meta.url), + ); + const child = spawn(process.execPath, [executable, "plan", "push"], { + cwd, + env: { ...process.env, FIRSTDRAFT_API_URL: apiUrl }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + const [status] = await once(child, "close"); + + assert.equal(status, 0); + assert.equal(stderr, ""); + assert.equal(JSON.parse(stdout).outcome, "created"); + assert.deepEqual(requestBody, source); + assert.equal(requestHeaders?.["if-none-match"], "*"); + assert.equal( + requestHeaders?.["content-type"], + "application/vnd.firstdraft.foundation-plan+json", + ); + assert.equal(readState(cwd).api_url, apiUrl); + assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); +}); + +/** + * @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 {import("node:test").TestContext} context */ +async function initializedDirectory(context) { + const cwd = temporaryDirectory(context); + const result = await invoke( + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + ], + { cwd, createProjectId: () => PROJECT_ID }, + ); + assert.equal(result.status, 0); + return cwd; +} + +/** @param {string} cwd */ +async function successfulInitialPush(cwd) { + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch( + acceptedResponse(planSource(cwd), 201, FIRST_ETAG), + [], + ), + createTemporaryId: () => "initial-setup", + }); + assert.equal(result.status, 0); +} + +/** + * @param {Response} response + * @param {FetchCall[]} calls + * @returns {typeof globalThis.fetch} + */ +function recordingFetch(response, calls) { + return async (input, init) => { + calls.push({ input, init }); + return response; + }; +} + +/** @returns {typeof globalThis.fetch} */ +function inaccessibleFetch() { + return async () => { + throw new Error("network must not run"); + }; +} + +/** @returns {import("../src/commands/plan-push.js").PlanPushFileSystem} */ +function inaccessiblePlanPushFileSystem() { + return { + 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"); + }, + }; +} + +/** + * @param {Buffer} source + * @param {number} status + * @param {string} etag + * @param {unknown[]} [diagnostics] + */ +function acceptedResponse(source, status, etag, diagnostics = []) { + return jsonResponse(acceptedBody(source, diagnostics), status, etag); +} + +/** @param {Buffer} source @param {unknown[]} [diagnostics] */ +function acceptedBody(source, diagnostics = []) { + return { + project: { id: PROJECT_ID, graph_version: 1 }, + foundation_plan: { + format: "firstdraft.foundation-plan.sketch/0.19", + source_sha256: sha256(source), + }, + diagnostics, + }; +} + +/** @param {unknown} body @param {number} status @param {string} [etag] */ +function jsonResponse(body, status, etag) { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json", + ...(etag ? { ETag: etag } : {}), + }, + }); +} + +/** @param {unknown} body @param {number} status */ +function problemResponse(body, status) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/problem+json" }, + }); +} + +/** @param {Buffer} source */ +function sha256(source) { + return createHash("sha256").update(source).digest("hex"); +} + +/** @param {string} cwd */ +function planSource(cwd) { + return readFileSync(path.join(cwd, ".firstdraft", "foundation-plan.json")); +} + +/** @param {string} cwd */ +function stateSource(cwd) { + return readFileSync(statePath(cwd)); +} + +/** @param {string} cwd */ +function readState(cwd) { + return JSON.parse(readFileSync(statePath(cwd), "utf8")); +} + +/** @param {string} cwd */ +function statePath(cwd) { + return path.join(cwd, ".firstdraft", "state.json"); +} + +/** @param {Record} state */ +function stateJson(state) { + return Buffer.from(`${JSON.stringify(state, null, 2)}\n`); +} + +/** @param {import("node:test").TestContext} context */ +function temporaryDirectory(context) { + const directory = mkdtempSync(path.join(tmpdir(), "firstdraft-plan-push-")); + context.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; +}