From 527c7875dc83f2d391978b352d88c340db79fd59 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Fri, 31 Jul 2026 23:31:14 -0500 Subject: [PATCH] Add GitHub publication command Bind singleton publication to the exact saved Plan bytes and strong ETag. Reconcile ambiguous mutation outcomes without duplicating the request, and validate the complete publication identity before exposing its repository URL. --- README.md | 106 ++-- scripts/check-pack.js | 1 + scripts/smoke-package.js | 90 +++- src/cli.js | 265 ++++++++++ src/commands/plan-publish.js | 955 +++++++++++++++++++++++++++++++++++ test/plan-init.test.js | 1 + test/plan-publish.test.js | 871 ++++++++++++++++++++++++++++++++ 7 files changed, 2255 insertions(+), 34 deletions(-) create mode 100644 src/commands/plan-publish.js create mode 100644 test/plan-publish.test.js diff --git a/README.md b/README.md index 496fe4f..e574432 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ built for agents that author and review Foundation Plans with their users. Public alpha releases use npm's `next` tag. This release line contains the auditable command shell, local Foundation Plan initialization, subject identity generation, conditional whole-document push, whole-graph analysis status -polling, explicit compilation, and verified local artifact materialization. Interfaces may change between -prereleases, and publishing the CLI does not make the wider First Draft service generally available. +polling, explicit compilation, verified local artifact materialization, and conditional GitHub publication. +Interfaces may change between prereleases, and publishing the CLI does not make the wider First Draft service +generally available. ## Requirements @@ -25,8 +26,8 @@ firstdraft --version The npm package is `@firstdraft.com/cli`; it installs the `firstdraft` executable. There is intentionally no stable `latest` release yet. Pin an exact prerelease version instead of `next` when a -repeatable installation matters. Remote Plan push, status, and compilation commands require a compatible First -Draft service and are currently intended for coordinated trials. +repeatable installation matters. Remote Plan push, status, compilation, and publication commands require a +compatible First Draft service and are currently intended for coordinated trials. ## Authenticate API commands @@ -37,8 +38,8 @@ export FIRSTDRAFT_API_TOKEN="your-token" firstdraft plan push ``` -`plan push`, `plan status`, and `plan compile` send the token as a Bearer credential on every API request. The CLI -does not save it in `.firstdraft`, print it, or require it for local commands such as `plan init` and +`plan push`, `plan status`, `plan compile`, and `plan publish` send the token as a Bearer credential on every API +request. The CLI does not save it in `.firstdraft`, print it, or require it for local commands such as `plan init` and `plan subject-id`. Revoke the token in First Draft if it is exposed. A missing token, or First Draft's validated `401` problem response with the `authentication_required` code, produces that stable CLI error. @@ -144,36 +145,75 @@ contents, and digests there without claiming exact mode bits. The success JSON n Compilation and reports the local output path, file count, and manifest digest; it never prints generated file contents or private local state. -Every handled failure from `plan init`, `plan subject-id`, `plan push`, `plan status`, or `plan compile` writes +## Publish a valid Plan to GitHub + +To compile the accepted Plan and publish it as a new private repository in your connected personal GitHub account: + +```sh +firstdraft plan publish +``` + +The command first verifies that the exact local Plan bytes still match the strong ETag saved by the last successful +push. It then conditionally creates or reuses the Project's singleton GitHub Publication with one `PUT` and the full +saved ETag in `If-Match`. It sends no request when the Plan changed locally; run `plan push` to validate and accept +those bytes first. + +For this application route, `If-Match` is explicitly a precondition on the Project's current Foundation Plan head, +not a cache validator for the possibly absent Publication representation. The server route must consume that domain +precondition directly instead of delegating it to generic conditional-response middleware. + +A repeated command safely receives the same Publication instead of creating another. If the first mutation's +response is lost or invalid, the CLI makes one read-only singleton `GET` to reconcile it and never automatically +repeats the mutation. An unresolved request reports `request_outcome_unknown`; running the same command again safely +replays the singleton request. The CLI polls a validated Publication sequentially once per second for at most ten +minutes and rejects changes to its Project, Plan digest, Compilation input digest, Publication, or allocated +repository identity. + +On success, stdout contains only the validated `https://github.com/OWNER/REPOSITORY` URL. Repository conflicts and +other failed publication phases include the validated terminal state in a handled `publication_failed` error. This +client currently assumes the provisional `{ project, compilation, publication }` response described by its tests; +the server route is not yet implemented, and no live GitHub publication smoke has been completed. + +Every handled failure from `plan init`, `plan subject-id`, `plan push`, `plan status`, `plan compile`, or +`plan publish` writes exactly one JSON object to standard error. Agents should branch on its stable `error` value, not on the human-readable `detail`: -| Commands | `error` | Exit | Meaning | -| -------------------------------------------------------------------------- | -------------------------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `plan init`, `plan subject-id`, `plan push`, `plan status`, `plan compile` | `invalid_arguments` | 2 | The command syntax is invalid; nothing was written and no request was made. | -| `plan init` | `local_initialization_failed` | 1 | Local initialization failed. The directory may be incomplete; existing files were not overwritten. | -| `plan push`, `plan compile` | `invalid_configuration` | 2 | API configuration or the saved ETag is incompatible with the requested command; no request was made. | -| `plan push`, `plan status`, `plan compile` | `authentication_required` | 1 | `FIRSTDRAFT_API_TOKEN` is missing, or First Draft returned a validated `401` problem with the `authentication_required` code; create or replace the token. | -| `plan push`, `plan status`, `plan compile` | `local_input_unreadable` | 1 | The required local Plan or private state could not be read; no request was made. | -| `plan status`, `plan compile` | `project_not_pushed` | 1 | Local state is valid but has no pinned remote Project yet; run `plan push` first. | -| `plan push`, `plan compile` | `request_outcome_unknown` | 1 | A sent mutation or its response could not be verified. Stop and reconcile instead of retrying it automatically. | -| `plan status` | `status_unavailable` | 1 | The network request or response stream failed. The object includes `status` when headers were received; retry the GET a bounded number of times. | -| `plan status` | `invalid_server_response` | 1 | First Draft returned a response that does not satisfy the status contract. The object includes `status`; retrying unchanged will not repair the mismatch. | -| `plan push`, `plan status` | `server_rejected` | 1 | First Draft returned a validated rejection. The object includes `status` and a whitelisted `response` containing validated problem details or diagnostics. | -| `plan status --wait` | `analysis_changed` | 1 | A different current analysis appeared while polling. `current` contains its validated state; start a fresh wait to follow it explicitly. | -| `plan status --wait` | `wait_timed_out` | 1 | The two-minute wait ended while processing continued. `current` contains the last validated state; another wait is safe. | -| `plan push` | `local_state_not_saved` | 1 | The server accepted the Plan, but local state replacement failed. This is the only error that includes private `recovery_state`. | -| `plan compile` | `compilation_start_rejected` | 1 | First Draft rejected the conditional start; a validated problem may be included as `response`. | -| `plan compile` | `compilation_status_unavailable` | 1 | The first failed read stopped polling the pinned Compilation; a validated problem may be included. | -| `plan compile` | `invalid_compilation_status` | 1 | A status response violated the exact Compilation contract. | -| `plan compile` | `compilation_changed` | 1 | Compilation identity, immutable metadata, or lifecycle progression changed while polling. | -| `plan compile` | `compilation_wait_timed_out` | 1 | The ten-minute deadline ended; `current` contains the last validated status. | -| `plan compile` | `compilation_failed` | 1 | The pinned Compilation failed; `current` contains its validated failure. | -| `plan compile` | `compilation_cancelled` | 1 | The pinned Compilation was cancelled. | -| `plan compile` | `artifact_unavailable` | 1 | The artifact GET failed or was rejected before materialization. | -| `plan compile` | `invalid_artifact` | 1 | Artifact transport metadata, bytes, provenance, manifest, or files failed validation. | -| `plan compile` | `invalid_output_path` | 2 | The output is not absent beneath an existing real directory; no request was made. | -| `plan compile` | `materialization_failed` | 1 | After artifact validation, filesystem state changed or the verified tree could not be written and atomically published. | +| Commands | `error` | Exit | Meaning | +| ------------------------------------------------------------------------------------------ | -------------------------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `plan init`, `plan subject-id`, `plan push`, `plan status`, `plan compile`, `plan publish` | `invalid_arguments` | 2 | The command syntax is invalid; nothing was written and no request was made. | +| `plan init` | `local_initialization_failed` | 1 | Local initialization failed. The directory may be incomplete; existing files were not overwritten. | +| `plan push`, `plan compile`, `plan publish` | `invalid_configuration` | 2 | API configuration or the saved ETag is incompatible with the requested command; no request was made. | +| `plan push`, `plan status`, `plan compile`, `plan publish` | `authentication_required` | 1 | `FIRSTDRAFT_API_TOKEN` is missing, or First Draft returned a validated `401` problem with the `authentication_required` code; create or replace the token. | +| `plan push`, `plan status`, `plan compile`, `plan publish` | `local_input_unreadable` | 1 | The required local Plan or private state could not be read; no request was made. | +| `plan status`, `plan compile`, `plan publish` | `project_not_pushed` | 1 | Local state is valid but has no pinned remote Project yet; run `plan push` first. | +| `plan push`, `plan compile` | `request_outcome_unknown` | 1 | A sent mutation or its response could not be verified. Stop and reconcile instead of retrying it automatically. | +| `plan publish` | `request_outcome_unknown` | 1 | The singleton could not be reconciled after an ambiguous `PUT`; repeating `plan publish` safely replays the same conditional resource request. | +| `plan status` | `status_unavailable` | 1 | The network request or response stream failed. The object includes `status` when headers were received; retry the GET a bounded number of times. | +| `plan status` | `invalid_server_response` | 1 | First Draft returned a response that does not satisfy the status contract. The object includes `status`; retrying unchanged will not repair the mismatch. | +| `plan push`, `plan status` | `server_rejected` | 1 | First Draft returned a validated rejection. The object includes `status` and a whitelisted `response` containing validated problem details or diagnostics. | +| `plan status --wait` | `analysis_changed` | 1 | A different current analysis appeared while polling. `current` contains its validated state; start a fresh wait to follow it explicitly. | +| `plan status --wait` | `wait_timed_out` | 1 | The two-minute wait ended while processing continued. `current` contains the last validated state; another wait is safe. | +| `plan push` | `local_state_not_saved` | 1 | The server accepted the Plan, but local state replacement failed. This is the only error that includes private `recovery_state`. | +| `plan compile` | `compilation_start_rejected` | 1 | First Draft rejected the conditional start; a validated problem may be included as `response`. | +| `plan compile` | `compilation_status_unavailable` | 1 | The first failed read stopped polling the pinned Compilation; a validated problem may be included. | +| `plan compile` | `invalid_compilation_status` | 1 | A status response violated the exact Compilation contract. | +| `plan compile` | `compilation_changed` | 1 | Compilation identity, immutable metadata, or lifecycle progression changed while polling. | +| `plan compile` | `compilation_wait_timed_out` | 1 | The ten-minute deadline ended; `current` contains the last validated status. | +| `plan compile` | `compilation_failed` | 1 | The pinned Compilation failed; `current` contains its validated failure. | +| `plan compile` | `compilation_cancelled` | 1 | The pinned Compilation was cancelled. | +| `plan compile` | `artifact_unavailable` | 1 | The artifact GET failed or was rejected before materialization. | +| `plan compile` | `invalid_artifact` | 1 | Artifact transport metadata, bytes, provenance, manifest, or files failed validation. | +| `plan compile` | `invalid_output_path` | 2 | The output is not absent beneath an existing real directory; no request was made. | +| `plan compile` | `materialization_failed` | 1 | After artifact validation, filesystem state changed or the verified tree could not be written and atomically published. | +| `plan publish` | `local_plan_changed` | 1 | Local Plan bytes no longer match the last successful push; push the complete Plan before publishing. | +| `plan publish` | `publication_start_rejected` | 1 | First Draft definitively rejected the conditional singleton request; a validated problem is included. | +| `plan publish` | `publication_status_unavailable` | 1 | The first failed singleton read stopped polling; a validated problem may be included. | +| `plan publish` | `invalid_publication_status` | 1 | A status response violated the complete Project, Compilation, Publication, or repository projection contract. | +| `plan publish` | `publication_changed` | 1 | Publication identity, immutable metadata, repository identity, or lifecycle progression changed while polling. | +| `plan publish` | `publication_wait_timed_out` | 1 | The ten-minute deadline ended; `current` contains the last validated status. | +| `plan publish` | `publication_failed` | 1 | The pinned Publication failed or reached `repository_conflict`; `current` contains its validated failure. | +| `plan publish` | `publication_cancelled` | 1 | The pinned Publication was cancelled; `current` contains its validated terminal state. | Handled failure output never includes command arguments, local Plan bytes, runtime paths, raw filesystem or network errors, or unvalidated response bodies. Optional fields inside a validated rejection diagnostic are omitted when diff --git a/scripts/check-pack.js b/scripts/check-pack.js index f3bd6f8..bce9421 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -30,6 +30,7 @@ if (result.status !== 0) { "src/cli.js", "src/commands/plan-compile.js", "src/commands/plan-init.js", + "src/commands/plan-publish.js", "src/commands/plan-push.js", "src/commands/plan-status.js", "src/compilation-artifact.js", diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index b0450d1..27bd0c9 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -184,6 +184,26 @@ try { "Invalid arguments. Run 'firstdraft plan compile --help' for usage.", }); + const invalidPublish = spawnPackedCli( + ["plan", "publish", "--canary-secret-option"], + installationDirectory, + ); + assertHandledFailure(invalidPublish, 2, { + error: "invalid_arguments", + detail: + "Invalid arguments. Run 'firstdraft plan publish --help' for usage.", + }); + + const localPublish = spawnPackedCli( + ["plan", "publish"], + installationDirectory, + ); + assertHandledFailure(localPublish, 1, { + error: "local_input_unreadable", + detail: + "Could not read valid local First Draft state or Plan bytes. No network request was made. Run 'firstdraft plan push' before publishing.", + }); + await exercisePackedCompilation(projectDirectory); } finally { rmSync(temporaryDirectory, { recursive: true, force: true }); @@ -258,7 +278,12 @@ async function exercisePackedCompilation(projectDirectory) { const projectId = "01900000-0000-7000-8000-000000000901"; const compilationId = "01900000-0000-7000-8000-000000000902"; const analysisId = "01900000-0000-7000-8000-000000000903"; - const headSha256 = "1".repeat(64); + const publicationId = "01900000-0000-7000-8000-000000000904"; + const headSha256 = sha256( + readFileSync( + path.join(projectDirectory, ".firstdraft", "foundation-plan.json"), + ), + ); const statusPath = `/v1/projects/${projectId}/compilations/${compilationId}`; const artifactPath = `${statusPath}/artifact`; const compilerRelease = "foundation-plan-rails/compiler-scalar-2026-08"; @@ -334,8 +359,49 @@ async function exercisePackedCompilation(projectDirectory) { completed_at: "2026-07-30T12:00:02.000Z", }, }; + const repositoryUrl = "https://github.com/octocat/oscar-party"; + const publication = { + project: { + id: projectId, + graph_version: 1, + head_source_sha256: headSha256, + }, + compilation: { + id: compilationId, + analysis_run_id: analysisId, + graph_version: 1, + head_source_sha256: headSha256, + status: "succeeded", + compiler_release: compilerRelease, + target, + artifact: { + sha256: artifactSha256, + manifest_sha256: manifestSha256, + file_count: 1, + }, + }, + publication: { + id: publicationId, + status: "succeeded", + repository: { + id: 1_234_567, + private: true, + owner: { id: 7_654_321, login: "octocat", type: "User" }, + full_name: "octocat/oscar-party", + default_branch: "main", + html_url: repositoryUrl, + tree_sha: "5".repeat(40), + commit_sha: "6".repeat(40), + }, + failure: null, + created_at: "2026-07-30T12:00:00.000Z", + started_at: "2026-07-30T12:00:01.000Z", + completed_at: "2026-07-30T12:00:02.000Z", + }, + }; let startRequestSeen = false; let artifactRequestSeen = false; + let publicationRequestSeen = false; const server = createServer(async (request, response) => { const chunks = []; for await (const chunk of request) chunks.push(Buffer.from(chunk)); @@ -363,6 +429,17 @@ async function exercisePackedCompilation(projectDirectory) { response.end(artifact); return; } + if ( + request.method === "PUT" && + request.url === `/v1/projects/${projectId}/github-publication` + ) { + assert.equal(request.headers.authorization, `Bearer ${apiToken}`); + assert.equal(request.headers["if-match"], `"sha256:${headSha256}"`); + assert.equal(requestBody.byteLength, 0); + publicationRequestSeen = true; + respondJson(response, 201, publication); + return; + } response.writeHead(404).end(); }); @@ -411,6 +488,17 @@ async function exercisePackedCompilation(projectDirectory) { } assert.equal(startRequestSeen, true); assert.equal(artifactRequestSeen, true); + + const published = await spawnPackedCliAsync( + ["plan", "publish"], + projectDirectory, + ); + assert.deepEqual(published, { + status: 0, + stdout: `${repositoryUrl}\n`, + stderr: "", + }); + assert.equal(publicationRequestSeen, true); } finally { await new Promise( ( diff --git a/src/cli.js b/src/cli.js index 7466a1d..65e1e4b 100644 --- a/src/cli.js +++ b/src/cli.js @@ -23,6 +23,20 @@ import { compilePlan, } from "./commands/plan-compile.js"; import { initializePlan } from "./commands/plan-init.js"; +import { + PublicationCancelledError, + PublicationChangedError, + PublicationFailedError, + PublicationLocalPlanChangedError, + PublicationLocalStateError, + PublicationNotPushedError, + PublicationRequestOutcomeUnknownError, + PublicationStartRejectedError, + PublicationStatusInvalidError, + PublicationStatusUnavailableError, + PublicationTimeoutError, + publishPlan, +} from "./commands/plan-publish.js"; import { PlanPushConfigurationError, PlanPushLocalError, @@ -66,6 +80,7 @@ Commands: push Send the local Foundation Plan to First Draft status Read the current whole-graph analysis status compile Compile the accepted Plan into a new local directory + publish Compile and publish the accepted Plan to GitHub Options: -h, --help Show help @@ -120,6 +135,21 @@ last successful push, waits up to ten minutes, validates the complete artifact, and atomically renames it into an absent output path. `; +const PLAN_PUBLISH_HELP = `First Draft CLI + +Usage: + firstdraft plan publish + +Options: + -h, --help Show help + +Environment: + FIRSTDRAFT_API_TOKEN Authenticate API requests + +The command publishes the exact Plan ETag pinned by the last successful +push, waits up to ten minutes, and prints the private GitHub repository URL. +`; + const PLAN_SUBJECT_ID_HELP = `First Draft CLI Usage: @@ -215,6 +245,31 @@ const PLAN_COMPILE_MATERIALIZATION_FAILED_DETAIL = "The validated Compilation artifact could not be materialized at the requested absent output path."; const PLAN_COMPILE_INVALID_OUTPUT_PATH_DETAIL = "The compilation output path must be absent beneath an existing real directory. No network request was made."; +const PLAN_PUBLISH_INVALID_ARGUMENTS_DETAIL = + "Invalid arguments. Run 'firstdraft plan publish --help' for usage."; +const PLAN_PUBLISH_LOCAL_INPUT_UNREADABLE_DETAIL = + "Could not read valid local First Draft state or Plan bytes. No network request was made. Run 'firstdraft plan push' before publishing."; +const PLAN_PUBLISH_INCOMPATIBLE_STATE_DETAIL = + "The saved Foundation Plan ETag is incompatible with publication. No network request was made; reconcile the CLI and server contract."; +const PLAN_PUBLISH_NOT_PUSHED_DETAIL = + "The local Foundation Plan has not been pushed successfully. Run 'firstdraft plan push' before publishing."; +const PLAN_PUBLISH_LOCAL_PLAN_CHANGED_DETAIL = + "The local Foundation Plan has changed since its last successful push. Run 'firstdraft plan push' before publishing."; +const PLAN_PUBLISH_REQUEST_OUTCOME_UNKNOWN_DETAIL = + "The publication may have started, but its singleton status could not be reconciled. Run 'firstdraft plan publish' again to safely replay the same conditional request."; +const PLAN_PUBLISH_START_REJECTED_DETAIL = + "First Draft rejected the publication request."; +const PLAN_PUBLISH_STATUS_UNAVAILABLE_DETAIL = + "Could not read the pinned publication status. The command stopped without starting another Publication."; +const PLAN_PUBLISH_STATUS_INVALID_DETAIL = + "First Draft returned an invalid publication status response. Retrying unchanged will not repair this protocol mismatch."; +const PLAN_PUBLISH_CHANGED_DETAIL = + "The pinned Publication changed while being polled. The command stopped without following a replacement."; +const PLAN_PUBLISH_TIMEOUT_DETAIL = + "The pinned Publication is still processing after the bounded ten-minute wait."; +const PLAN_PUBLISH_FAILED_DETAIL = + "The pinned Publication failed. Its validated status identifies the failed phase."; +const PLAN_PUBLISH_CANCELLED_DETAIL = "The pinned Publication was cancelled."; const PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL = "Invalid arguments. Run 'firstdraft plan subject-id --help' for usage."; @@ -241,6 +296,8 @@ const PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL = * @property {() => number} [planStatusNow] * @property {(delayMs: number) => Promise} [planCompileSleep] * @property {() => number} [planCompileNow] + * @property {(delayMs: number) => Promise} [planPublishSleep] + * @property {() => number} [planPublishNow] * @property {string} [apiUrl] * @property {string} [apiToken] */ @@ -262,6 +319,8 @@ const PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL = * @property {() => number} [planStatusNow] * @property {(delayMs: number) => Promise} [planCompileSleep] * @property {() => number} [planCompileNow] + * @property {(delayMs: number) => Promise} [planPublishSleep] + * @property {() => number} [planPublishNow] * @property {string} [apiUrl] * @property {string} [apiToken] */ @@ -288,6 +347,8 @@ export async function run({ planStatusNow, planCompileSleep, planCompileNow, + planPublishSleep, + planPublishNow, apiUrl = process.env.FIRSTDRAFT_API_URL, apiToken = process.env.FIRSTDRAFT_API_TOKEN, }) { @@ -309,6 +370,8 @@ export async function run({ planStatusNow, planCompileSleep, planCompileNow, + planPublishSleep, + planPublishNow, apiUrl, apiToken, }); @@ -378,6 +441,8 @@ async function runPlan({ planStatusNow, planCompileSleep, planCompileNow, + planPublishSleep, + planPublishNow, apiUrl, apiToken, }) { @@ -437,6 +502,21 @@ async function runPlan({ }); } + if (argv[0] === "publish") { + return runPlanPublish({ + argv: argv.slice(1), + stdout, + stderr, + cwd: cwd ?? getCwd(), + fetchFunction, + planPushFileSystem, + createRequestSignal, + planPublishSleep, + planPublishNow, + apiToken, + }); + } + if (argv[0] === "subject-id") { return runPlanSubjectId({ argv: argv.slice(1), @@ -1006,6 +1086,191 @@ async function runPlanCompile({ return 0; } +/** + * @param {Pick} options + */ +async function runPlanPublish({ + argv, + stdout, + stderr, + cwd, + fetchFunction, + planPushFileSystem, + createRequestSignal, + planPublishSleep, + planPublishNow, + apiToken, +}) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { help: { type: "boolean", short: "h" } }, + allowPositionals: false, + strict: true, + tokens: true, + }), + ); + + if (!parsed || repeatedValueOption(parsed.tokens)) { + writeJson(stderr, { + error: "invalid_arguments", + detail: PLAN_PUBLISH_INVALID_ARGUMENTS_DETAIL, + }); + return 2; + } + + if (parsed.values.help) { + stdout.write(PLAN_PUBLISH_HELP); + return 0; + } + + const authorizedFetch = authenticatedFetch(fetchFunction, apiToken); + if (authorizedFetch === null) { + writeAuthenticationRequired(stderr); + return 1; + } + + let result; + try { + result = await publishPlan({ + cwd, + fetchFunction: authorizedFetch, + fileSystem: planPushFileSystem, + createRequestSignal, + sleep: planPublishSleep, + now: planPublishNow, + }); + } catch (error) { + if ( + (error instanceof PublicationStartRejectedError || + error instanceof PublicationStatusUnavailableError) && + isAuthenticationProblem(error.status, error.response) + ) { + writeAuthenticationRequired( + stderr, + error.status, + /** @type {Record} */ (error.response), + ); + return 1; + } + + if (error instanceof PlanPushLocalError) { + writeJson(stderr, { + error: "local_input_unreadable", + detail: PLAN_PUBLISH_LOCAL_INPUT_UNREADABLE_DETAIL, + }); + return 1; + } + + if (error instanceof PublicationLocalStateError) { + writeJson(stderr, { + error: "invalid_configuration", + detail: PLAN_PUBLISH_INCOMPATIBLE_STATE_DETAIL, + }); + return 2; + } + + if (error instanceof PublicationNotPushedError) { + writeJson(stderr, { + error: "project_not_pushed", + detail: PLAN_PUBLISH_NOT_PUSHED_DETAIL, + }); + return 1; + } + + if (error instanceof PublicationLocalPlanChangedError) { + writeJson(stderr, { + error: "local_plan_changed", + detail: PLAN_PUBLISH_LOCAL_PLAN_CHANGED_DETAIL, + }); + return 1; + } + + if (error instanceof PublicationRequestOutcomeUnknownError) { + writeJson(stderr, { + error: "request_outcome_unknown", + detail: PLAN_PUBLISH_REQUEST_OUTCOME_UNKNOWN_DETAIL, + ...(typeof error.status === "number" ? { status: error.status } : {}), + }); + return 1; + } + + if (error instanceof PublicationStartRejectedError) { + writeJson(stderr, { + error: "publication_start_rejected", + detail: PLAN_PUBLISH_START_REJECTED_DETAIL, + status: error.status, + response: error.response, + }); + return 1; + } + + if (error instanceof PublicationStatusUnavailableError) { + writeJson(stderr, { + error: "publication_status_unavailable", + detail: PLAN_PUBLISH_STATUS_UNAVAILABLE_DETAIL, + ...(typeof error.status === "number" ? { status: error.status } : {}), + ...(error.response ? { response: error.response } : {}), + }); + return 1; + } + + if (error instanceof PublicationStatusInvalidError) { + writeJson(stderr, { + error: "invalid_publication_status", + detail: PLAN_PUBLISH_STATUS_INVALID_DETAIL, + status: error.status, + }); + return 1; + } + + if (error instanceof PublicationChangedError) { + writeJson(stderr, { + error: "publication_changed", + detail: PLAN_PUBLISH_CHANGED_DETAIL, + current: error.current, + }); + return 1; + } + + if (error instanceof PublicationTimeoutError) { + writeJson(stderr, { + error: "publication_wait_timed_out", + detail: PLAN_PUBLISH_TIMEOUT_DETAIL, + current: error.current, + }); + return 1; + } + + if (error instanceof PublicationFailedError) { + writeJson(stderr, { + error: "publication_failed", + detail: PLAN_PUBLISH_FAILED_DETAIL, + current: error.current, + }); + return 1; + } + + if (error instanceof PublicationCancelledError) { + writeJson(stderr, { + error: "publication_cancelled", + detail: PLAN_PUBLISH_CANCELLED_DETAIL, + current: error.current, + }); + return 1; + } + + throw error; + } + + const repository = result.publication.repository; + if (repository === null) { + throw new Error("A successful Publication must identify its repository."); + } + stdout.write(`${repository.html_url}\n`); + return 0; +} + /** * @param {Pick} options */ diff --git a/src/commands/plan-publish.js b/src/commands/plan-publish.js new file mode 100644 index 0000000..a3cbd4f --- /dev/null +++ b/src/commands/plan-publish.js @@ -0,0 +1,955 @@ +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { isAuthenticationProblem } from "../api-authentication.js"; +import { + FirstDraftNetworkError, + FirstDraftProtocolError, + isProblemBody, + readResponseBody, + responseMediaType, + sendRequest, +} from "../api-response.js"; +import { isUuidV7, readLocalFile, readPlanState } from "../plan-state.js"; + +const MAX_PLAN_BYTES = 1024 * 1024; +const REQUEST_TIMEOUT_MS = 30_000; +const WAIT_TIMEOUT_MS = 10 * 60_000; +const POLL_INTERVAL_MS = 1_000; +const MAX_IDENTIFIER_BYTES = 256; +const MAX_REPOSITORY_NAME_BYTES = 100; +const MAX_LOGIN_BYTES = 39; +const MAX_FAILURE_CODE_BYTES = 256; +const HEAD_ETAG_PATTERN = /^"sha256:([0-9a-f]{64})"$/; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const GIT_SHA_PATTERN = /^[0-9a-f]{40}$/; +const RELEASE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/; +const GITHUB_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/; +const COMPILATION_STATUSES = new Set([ + "queued", + "running", + "succeeded", + "failed", + "cancelled", +]); +const PUBLICATION_STATUSES = new Set([ + "compiling", + "provisioning_repository", + "repository_unknown", + "publishing", + "publication_unknown", + "succeeded", + "repository_conflict", + "failed", + "cancelled", +]); +const TERMINAL_PUBLICATION_STATUSES = new Set([ + "succeeded", + "repository_conflict", + "failed", + "cancelled", +]); +const RESPONSE_KEYS = ["compilation", "project", "publication"]; +const PROJECT_KEYS = ["graph_version", "head_source_sha256", "id"]; +const COMPILATION_KEYS = [ + "analysis_run_id", + "artifact", + "compiler_release", + "graph_version", + "head_source_sha256", + "id", + "status", + "target", +]; +const TARGET_KEYS = ["id", "profile"]; +const ARTIFACT_KEYS = ["file_count", "manifest_sha256", "sha256"]; +const PUBLICATION_KEYS = [ + "completed_at", + "created_at", + "failure", + "id", + "repository", + "started_at", + "status", +]; +const FAILURE_KEYS = ["code", "phase"]; +const REPOSITORY_KEYS = [ + "commit_sha", + "default_branch", + "full_name", + "html_url", + "id", + "owner", + "private", + "tree_sha", +]; +const OWNER_KEYS = ["id", "login", "type"]; + +/** + * @typedef {object} PlanPublishFileSystem + * @property {typeof lstatSync} lstatSync + * @property {typeof readFileSync} readFileSync + */ + +/** @type {PlanPublishFileSystem} */ +const DEFAULT_FILE_SYSTEM = { lstatSync, readFileSync }; + +export class PublicationNotPushedError extends Error {} +export class PublicationLocalStateError extends Error {} +export class PublicationLocalPlanChangedError extends Error {} + +export class PublicationRequestOutcomeUnknownError extends Error { + /** @param {number | undefined} status */ + constructor(status) { + super("The publication request outcome is unknown."); + this.status = status; + } +} + +export class PublicationStartRejectedError extends Error { + /** + * @param {number} status + * @param {Record} response + */ + constructor(status, response) { + super("First Draft rejected the publication request."); + this.status = status; + this.response = response; + } +} + +export class PublicationStatusUnavailableError extends Error { + /** + * @param {number | undefined} status + * @param {Record | null} [response] + */ + constructor(status, response = null) { + super("The publication status is unavailable."); + this.status = status; + this.response = response; + } +} + +export class PublicationStatusInvalidError extends Error { + /** @param {number} status */ + constructor(status) { + super("The publication status response is invalid."); + this.status = status; + } +} + +export class PublicationChangedError extends Error { + /** @param {PublicationResponse} current */ + constructor(current) { + super("The pinned publication changed while it was being polled."); + this.current = current; + } +} + +export class PublicationTimeoutError extends Error { + /** @param {PublicationResponse} current */ + constructor(current) { + super("The publication did not finish before the wait deadline."); + this.current = current; + } +} + +export class PublicationFailedError extends Error { + /** @param {PublicationResponse} current */ + constructor(current) { + super("The publication failed."); + this.current = current; + } +} + +export class PublicationCancelledError extends Error { + /** @param {PublicationResponse} current */ + constructor(current) { + super("The publication was cancelled."); + this.current = current; + } +} + +/** + * @typedef {object} PublicationResponse + * @property {{id: string, graph_version: number, head_source_sha256: string}} project + * @property {{id: string, analysis_run_id: string, graph_version: number, head_source_sha256: string, status: string, compiler_release: string, target: {id: string, profile: string}, artifact: null | {sha256: string, manifest_sha256: string, file_count: number}}} compilation + * @property {{id: string, status: string, repository: null | {id: number, private: true, owner: {id: number, login: string, type: "User"}, full_name: string, default_branch: string, html_url: string, tree_sha: string | null, commit_sha: string | null}, failure: null | {phase: string, code: string}, created_at: string, started_at: string | null, completed_at: string | null}} publication + */ + +/** + * @typedef {object} PublishPlanOptions + * @property {string} cwd + * @property {typeof globalThis.fetch} [fetchFunction] + * @property {PlanPublishFileSystem} [fileSystem] + * @property {(timeoutMs: number) => AbortSignal} [createRequestSignal] + * @property {(delayMs: number) => Promise} [sleep] + * @property {() => number} [now] + */ + +/** + * @param {PublishPlanOptions} options + * @returns {Promise} + */ +export async function publishPlan({ + cwd, + fetchFunction = globalThis.fetch, + fileSystem = DEFAULT_FILE_SYSTEM, + createRequestSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), + sleep = sleepFor, + now = Date.now, +}) { + const state = readPlanState({ cwd, fileSystem }); + if (state.api_url === undefined || state.foundation_plan_etag === undefined) { + throw new PublicationNotPushedError( + "The local Foundation Plan has not been pushed.", + ); + } + + const match = HEAD_ETAG_PATTERN.exec(state.foundation_plan_etag); + if (match === null) { + throw new PublicationLocalStateError( + "The saved Foundation Plan ETag cannot identify its accepted source.", + ); + } + const headSourceSha256 = match[1] ?? ""; + const planSource = readLocalFile( + path.join(cwd, ".firstdraft", "foundation-plan.json"), + MAX_PLAN_BYTES, + fileSystem, + ); + if (sha256(planSource) !== headSourceSha256) { + throw new PublicationLocalPlanChangedError( + "The local Foundation Plan differs from the accepted Plan.", + ); + } + + const endpoint = new URL( + `/v1/projects/${state.project_id}/github-publication`, + state.api_url, + ); + const deadline = now() + WAIT_TIMEOUT_MS; + let initial; + + try { + initial = await startPublication({ + endpoint, + projectId: state.project_id, + headSourceSha256, + etag: state.foundation_plan_etag, + fetchFunction, + createRequestSignal, + }); + } catch (error) { + if (!(error instanceof PublicationRequestOutcomeUnknownError)) throw error; + + try { + initial = await readPublicationStatus({ + endpoint, + projectId: state.project_id, + headSourceSha256, + fetchFunction, + createRequestSignal, + requestTimeout: Math.max( + 1, + Math.min(REQUEST_TIMEOUT_MS, deadline - now()), + ), + }); + } catch (reconciliationError) { + if ( + reconciliationError instanceof PublicationStatusUnavailableError && + isAuthenticationProblem( + reconciliationError.status, + reconciliationError.response, + ) + ) { + throw reconciliationError; + } + if ( + reconciliationError instanceof PublicationStatusUnavailableError || + reconciliationError instanceof PublicationStatusInvalidError + ) { + throw new PublicationRequestOutcomeUnknownError( + reconciliationError.status ?? error.status, + ); + } + + throw reconciliationError; + } + } + + let current = initial; + while (!TERMINAL_PUBLICATION_STATUSES.has(current.publication.status)) { + const remaining = deadline - now(); + if (remaining <= 0) throw new PublicationTimeoutError(current); + + await sleep(Math.min(POLL_INTERVAL_MS, remaining)); + if (now() >= deadline) throw new PublicationTimeoutError(current); + + const next = await readPublicationStatus({ + endpoint, + projectId: state.project_id, + headSourceSha256, + fetchFunction, + createRequestSignal, + requestTimeout: Math.max( + 1, + Math.min(REQUEST_TIMEOUT_MS, deadline - now()), + ), + }); + if (!samePublication(initial, next) || !validTransition(current, next)) { + throw new PublicationChangedError(next); + } + current = next; + } + + if ( + current.publication.status === "failed" || + current.publication.status === "repository_conflict" + ) { + throw new PublicationFailedError(current); + } + if (current.publication.status === "cancelled") { + throw new PublicationCancelledError(current); + } + + return current; +} + +/** + * @param {object} options + * @param {URL} options.endpoint + * @param {string} options.projectId + * @param {string} options.headSourceSha256 + * @param {string} options.etag + * @param {typeof globalThis.fetch} options.fetchFunction + * @param {(timeoutMs: number) => AbortSignal} options.createRequestSignal + */ +async function startPublication({ + endpoint, + projectId, + headSourceSha256, + etag, + fetchFunction, + createRequestSignal, +}) { + let response; + let body; + try { + response = await sendRequest(fetchFunction, endpoint, { + method: "PUT", + headers: { + Accept: "application/json, application/problem+json", + "If-Match": etag, + }, + redirect: "error", + signal: createRequestSignal(REQUEST_TIMEOUT_MS), + }); + body = await readResponseBody(response); + } catch (error) { + if ( + error instanceof FirstDraftNetworkError || + error instanceof FirstDraftProtocolError + ) { + throw new PublicationRequestOutcomeUnknownError(error.status); + } + + throw error; + } + + if (response.status !== 200 && response.status !== 201) { + const problem = safeProblem(response, body); + if (response.ok || problem === null) { + throw new PublicationRequestOutcomeUnknownError(response.status); + } + + throw new PublicationStartRejectedError(response.status, problem); + } + + const parsed = parsePublicationResponse(body, projectId, headSourceSha256); + if (responseMediaType(response) !== "application/json" || parsed === null) { + throw new PublicationRequestOutcomeUnknownError(response.status); + } + + return parsed; +} + +/** + * @param {object} options + * @param {URL} options.endpoint + * @param {string} options.projectId + * @param {string} options.headSourceSha256 + * @param {typeof globalThis.fetch} options.fetchFunction + * @param {(timeoutMs: number) => AbortSignal} options.createRequestSignal + * @param {number} options.requestTimeout + */ +async function readPublicationStatus({ + endpoint, + projectId, + headSourceSha256, + fetchFunction, + createRequestSignal, + requestTimeout, +}) { + let response; + let body; + try { + response = await sendRequest(fetchFunction, endpoint, { + method: "GET", + headers: { Accept: "application/json, application/problem+json" }, + redirect: "error", + signal: createRequestSignal(requestTimeout), + }); + body = await readResponseBody(response); + } catch (error) { + if (error instanceof FirstDraftNetworkError) { + throw new PublicationStatusUnavailableError(error.status); + } + if (error instanceof FirstDraftProtocolError) { + throw new PublicationStatusInvalidError(error.status); + } + + throw error; + } + + if (response.status !== 200) { + if (!response.ok) { + throw new PublicationStatusUnavailableError( + response.status, + safeProblem(response, body), + ); + } + throw new PublicationStatusInvalidError(response.status); + } + + const parsed = parsePublicationResponse(body, projectId, headSourceSha256); + if (responseMediaType(response) !== "application/json" || parsed === null) { + throw new PublicationStatusInvalidError(response.status); + } + + return parsed; +} + +/** + * @param {unknown} value + * @param {string} projectId + * @param {string} headSourceSha256 + * @returns {PublicationResponse | null} + */ +function parsePublicationResponse(value, projectId, headSourceSha256) { + if ( + !hasExactKeySet(value, RESPONSE_KEYS) || + !hasExactKeySet(value.project, PROJECT_KEYS) || + !hasExactKeySet(value.compilation, COMPILATION_KEYS) || + !hasExactKeySet(value.publication, PUBLICATION_KEYS) + ) { + return null; + } + + const project = value.project; + const compilation = value.compilation; + const publication = value.publication; + if ( + project.id !== projectId || + !isGraphVersion(project.graph_version) || + project.head_source_sha256 !== headSourceSha256 || + !isUuidV7(compilation.id) || + !isUuidV7(compilation.analysis_run_id) || + compilation.graph_version !== project.graph_version || + compilation.head_source_sha256 !== headSourceSha256 || + typeof compilation.status !== "string" || + !COMPILATION_STATUSES.has(compilation.status) || + !isRelease(compilation.compiler_release) || + !hasExactKeySet(compilation.target, TARGET_KEYS) || + !isRelease(compilation.target.id) || + !isRelease(compilation.target.profile) || + !isNullableArtifact(compilation.artifact) || + !isUuidV7(publication.id) || + typeof publication.status !== "string" || + !PUBLICATION_STATUSES.has(publication.status) || + !isNullableRepository(publication.repository) || + !isNullableFailure(publication.failure) || + !isTimestamp(publication.created_at) || + !isNullableTimestamp(publication.started_at) || + !isNullableTimestamp(publication.completed_at) || + !hasValidCompilationShape(compilation) || + !hasValidPublicationShape(compilation, publication) + ) { + return null; + } + + const target = /** @type {{id: string, profile: string}} */ ( + compilation.target + ); + const artifact = + /** @type {null | {sha256: string, manifest_sha256: string, file_count: number}} */ ( + compilation.artifact + ); + const repository = + /** @type {null | {id: number, private: true, owner: {id: number, login: string, type: "User"}, full_name: string, default_branch: string, html_url: string, tree_sha: string | null, commit_sha: string | null}} */ ( + publication.repository + ); + const failure = /** @type {null | {phase: string, code: string}} */ ( + publication.failure + ); + + return { + project: { + id: project.id, + graph_version: /** @type {number} */ (project.graph_version), + head_source_sha256: project.head_source_sha256, + }, + compilation: { + id: compilation.id, + analysis_run_id: compilation.analysis_run_id, + graph_version: /** @type {number} */ (compilation.graph_version), + head_source_sha256: compilation.head_source_sha256, + status: compilation.status, + compiler_release: /** @type {string} */ (compilation.compiler_release), + target: { id: target.id, profile: target.profile }, + artifact: + artifact === null + ? null + : { + sha256: artifact.sha256, + manifest_sha256: artifact.manifest_sha256, + file_count: artifact.file_count, + }, + }, + publication: { + id: publication.id, + status: publication.status, + repository: + repository === null + ? null + : { + id: repository.id, + private: true, + owner: { + id: repository.owner.id, + login: repository.owner.login, + type: "User", + }, + full_name: repository.full_name, + default_branch: repository.default_branch, + html_url: repository.html_url, + tree_sha: repository.tree_sha, + commit_sha: repository.commit_sha, + }, + failure: + failure === null ? null : { phase: failure.phase, code: failure.code }, + created_at: /** @type {string} */ (publication.created_at), + started_at: /** @type {string | null} */ (publication.started_at), + completed_at: /** @type {string | null} */ (publication.completed_at), + }, + }; +} + +/** @param {Record} compilation */ +function hasValidCompilationShape(compilation) { + return compilation.status === "succeeded" + ? compilation.artifact !== null + : compilation.artifact === null; +} + +/** + * @param {Record} compilation + * @param {Record} publication + */ +function hasValidPublicationShape(compilation, publication) { + const status = String(publication.status); + const terminal = TERMINAL_PUBLICATION_STATUSES.has(status); + const failureExpected = + status === "failed" || status === "repository_conflict"; + const created = timestampMilliseconds(String(publication.created_at)); + const started = + publication.started_at === null + ? null + : timestampMilliseconds(String(publication.started_at)); + const completed = + publication.completed_at === null + ? null + : timestampMilliseconds(String(publication.completed_at)); + const repository = /** @type {Record | null} */ ( + publication.repository + ); + + if ( + (terminal && completed === null) || + (!terminal && completed !== null) || + (failureExpected && publication.failure === null) || + (!failureExpected && publication.failure !== null) || + (status !== "compiling" && + started === null && + !( + (status === "failed" && compilation.status === "failed") || + (status === "cancelled" && compilation.status === "cancelled") + )) || + (started !== null && started < created) || + (completed !== null && completed < created) || + (started !== null && completed !== null && completed < started) || + (status === "compiling" && + !["queued", "running", "succeeded"].includes( + String(compilation.status), + )) || + (status === "failed" && + !["failed", "succeeded"].includes(String(compilation.status))) || + (status === "cancelled" && + !["cancelled", "succeeded"].includes(String(compilation.status))) || + (!["compiling", "failed", "cancelled"].includes(status) && + compilation.status !== "succeeded") || + (["publishing", "publication_unknown", "succeeded"].includes(status) && + repository === null) + ) { + return false; + } + + if (status !== "succeeded") return true; + + return ( + repository !== null && + typeof repository.tree_sha === "string" && + typeof repository.commit_sha === "string" + ); +} + +/** @param {unknown} value */ +function isNullableArtifact(value) { + return ( + value === null || + (hasExactKeySet(value, ARTIFACT_KEYS) && + typeof value.sha256 === "string" && + SHA256_PATTERN.test(value.sha256) && + typeof value.manifest_sha256 === "string" && + SHA256_PATTERN.test(value.manifest_sha256) && + Number.isSafeInteger(value.file_count) && + Number(value.file_count) >= 0) + ); +} + +/** @param {unknown} value */ +function isNullableFailure(value) { + return ( + value === null || + (hasExactKeySet(value, FAILURE_KEYS) && + isRelease(value.phase) && + isFailureCode(value.code)) + ); +} + +/** @param {unknown} value */ +function isNullableRepository(value) { + if (value === null) return true; + if ( + !hasExactKeySet(value, REPOSITORY_KEYS) || + !isPositiveInteger(value.id) || + value.private !== true || + !hasExactKeySet(value.owner, OWNER_KEYS) || + !isPositiveInteger(value.owner.id) || + value.owner.type !== "User" || + !isGithubName(value.owner.login, MAX_LOGIN_BYTES) || + typeof value.full_name !== "string" || + typeof value.default_branch !== "string" || + typeof value.html_url !== "string" || + !isNullableGitSha(value.tree_sha) || + !isNullableGitSha(value.commit_sha) || + (value.tree_sha === null) !== (value.commit_sha === null) + ) { + return false; + } + + const prefix = `${value.owner.login}/`; + const repositoryName = value.full_name.startsWith(prefix) + ? value.full_name.slice(prefix.length) + : ""; + return ( + isGithubName(repositoryName, MAX_REPOSITORY_NAME_BYTES) && + isGitBranch(value.default_branch) && + value.html_url === `https://github.com/${value.full_name}` + ); +} + +/** @param {PublicationResponse} first @param {PublicationResponse} current */ +function samePublication(first, current) { + return ( + current.project.id === first.project.id && + current.project.graph_version === first.project.graph_version && + current.project.head_source_sha256 === first.project.head_source_sha256 && + current.compilation.id === first.compilation.id && + current.compilation.analysis_run_id === first.compilation.analysis_run_id && + current.compilation.graph_version === first.compilation.graph_version && + current.compilation.head_source_sha256 === + first.compilation.head_source_sha256 && + current.compilation.compiler_release === + first.compilation.compiler_release && + current.compilation.target.id === first.compilation.target.id && + current.compilation.target.profile === first.compilation.target.profile && + current.publication.id === first.publication.id && + current.publication.created_at === first.publication.created_at + ); +} + +/** @param {PublicationResponse} previous @param {PublicationResponse} current */ +function validTransition(previous, current) { + if (!validCompilationTransition(previous.compilation, current.compilation)) { + return false; + } + if (!validPublicationStatusTransition(previous, current)) return false; + if (!validRepositoryTransition(previous, current)) return false; + + return ( + (previous.publication.started_at === null || + current.publication.started_at === previous.publication.started_at) && + (previous.publication.completed_at === null || + current.publication.completed_at === previous.publication.completed_at) && + (previous.publication.failure === null || + JSON.stringify(current.publication.failure) === + JSON.stringify(previous.publication.failure)) + ); +} + +/** + * @param {PublicationResponse["compilation"]} previous + * @param {PublicationResponse["compilation"]} current + */ +function validCompilationTransition(previous, current) { + if (previous.status === current.status) { + return ( + JSON.stringify(previous.artifact) === JSON.stringify(current.artifact) + ); + } + + if (previous.status === "queued") { + return ["running", "succeeded", "failed", "cancelled"].includes( + current.status, + ); + } + return ( + previous.status === "running" && + ["succeeded", "failed", "cancelled"].includes(current.status) + ); +} + +/** @param {PublicationResponse} previous @param {PublicationResponse} current */ +function validPublicationStatusTransition(previous, current) { + const from = previous.publication.status; + const to = current.publication.status; + if (from === to) { + return ( + current.publication.started_at === previous.publication.started_at && + current.publication.completed_at === previous.publication.completed_at && + JSON.stringify(current.publication.failure) === + JSON.stringify(previous.publication.failure) + ); + } + + return publicationStage(to) >= publicationStage(from); +} + +/** @param {string} status */ +function publicationStage(status) { + if (status === "compiling") return 0; + if (status === "provisioning_repository" || status === "repository_unknown") { + return 1; + } + if (status === "publishing" || status === "publication_unknown") return 2; + return 3; +} + +/** @param {PublicationResponse} previous @param {PublicationResponse} current */ +function validRepositoryTransition(previous, current) { + const before = previous.publication.repository; + const after = current.publication.repository; + if (before === null) return true; + if (after === null) return false; + + return ( + after.id === before.id && + after.private === before.private && + after.owner.id === before.owner.id && + after.owner.login === before.owner.login && + after.owner.type === before.owner.type && + after.full_name === before.full_name && + after.default_branch === before.default_branch && + after.html_url === before.html_url && + (before.tree_sha === null || after.tree_sha === before.tree_sha) && + (before.commit_sha === null || after.commit_sha === before.commit_sha) + ); +} + +/** @param {Response} response @param {unknown} body */ +function safeProblem(response, body) { + if (!isProblemBody(response, body)) return null; + const problem = /** @type {Record} */ (body); + + return { + ...(problem.type === "about:blank" ? { type: problem.type } : {}), + title: problem.title, + status: problem.status, + code: problem.code, + detail: problem.detail, + }; +} + +/** @param {number} delayMs */ +function sleepFor(delayMs) { + return new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); +} + +/** @param {Buffer} value */ +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +/** @param {unknown} value */ +function isRelease(value) { + return ( + typeof value === "string" && + Buffer.byteLength(value) <= MAX_IDENTIFIER_BYTES && + RELEASE_PATTERN.test(value) + ); +} + +/** @param {unknown} value */ +function isFailureCode(value) { + return ( + typeof value === "string" && + Buffer.byteLength(value) <= MAX_FAILURE_CODE_BYTES && + RELEASE_PATTERN.test(value) + ); +} + +/** @param {unknown} value */ +function isGraphVersion(value) { + return Number.isSafeInteger(value) && Number(value) >= 1; +} + +/** @param {unknown} value */ +function isPositiveInteger(value) { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +/** @param {unknown} value */ +function isNullableGitSha(value) { + return ( + value === null || (typeof value === "string" && GIT_SHA_PATTERN.test(value)) + ); +} + +/** @param {unknown} value @param {number} maximumBytes */ +function isGithubName(value, maximumBytes) { + return ( + typeof value === "string" && + Buffer.byteLength(value) <= maximumBytes && + GITHUB_NAME_PATTERN.test(value) + ); +} + +/** @param {unknown} value */ +function isGitBranch(value) { + return ( + typeof value === "string" && + Buffer.byteLength(value) <= MAX_IDENTIFIER_BYTES && + value.length > 0 && + !value.startsWith(".") && + !value.endsWith(".") && + !value.endsWith("/") && + !value.endsWith(".lock") && + !value.includes("..") && + !value.includes("//") && + ![...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x20 || "~^:?*[\\".includes(character); + }) + ); +} + +/** @param {unknown} value */ +function isNullableTimestamp(value) { + return value === null || isTimestamp(value); +} + +/** @param {unknown} value */ +function isTimestamp(value) { + return timestampParts(value) !== null; +} + +/** @param {string} value */ +function timestampMilliseconds(value) { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + + return Date.parse(value.replace(/:60(?=[.,Zz+-])/, ":59")) + 1_000; +} + +/** @param {unknown} value */ +function timestampParts(value) { + if (typeof value !== "string") return null; + + const match = + /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/.exec( + value, + ); + if (!match) return null; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[8] === undefined ? 0 : Number(match[8]); + const offsetMinute = match[9] === undefined ? 0 : Number(match[9]); + if ( + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth(year, month) || + hour > 23 || + minute > 59 || + second > 60 || + (second === 60 && (hour !== 23 || minute !== 59)) || + offsetHour > 23 || + offsetMinute > 59 + ) { + return null; + } + + return true; +} + +/** @param {number} year @param {number} month */ +function daysInMonth(year, month) { + if (month === 2) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28; + } + + return [4, 6, 9, 11].includes(month) ? 30 : 31; +} + +/** + * @param {unknown} value + * @param {string[]} keys + * @returns {value is Record} + */ +function hasExactKeySet(value, keys) { + return ( + isRecord(value) && arraysEqual(Object.keys(value).sort(), [...keys].sort()) + ); +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** @param {unknown[]} left @param {unknown[]} right */ +function arraysEqual(left, right) { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} diff --git a/test/plan-init.test.js b/test/plan-init.test.js index ad55575..801bcc8 100644 --- a/test/plan-init.test.js +++ b/test/plan-init.test.js @@ -31,6 +31,7 @@ Commands: push Send the local Foundation Plan to First Draft status Read the current whole-graph analysis status compile Compile the accepted Plan into a new local directory + publish Compile and publish the accepted Plan to GitHub Options: -h, --help Show help diff --git a/test/plan-publish.test.js b/test/plan-publish.test.js new file mode 100644 index 0000000..38b3c28 --- /dev/null +++ b/test/plan-publish.test.js @@ -0,0 +1,871 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, mkdirSync, rmSync, 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 { run } from "../src/cli.js"; + +const PROJECT_ID = "01900000-0000-7000-8000-000000001001"; +const COMPILATION_ID = "01900000-0000-7000-8000-000000001002"; +const OTHER_COMPILATION_ID = "01900000-0000-7000-8000-000000001003"; +const ANALYSIS_ID = "01900000-0000-7000-8000-000000001004"; +const PUBLICATION_ID = "01900000-0000-7000-8000-000000001005"; +const OTHER_PUBLICATION_ID = "01900000-0000-7000-8000-000000001006"; +const PLAN_SOURCE = Buffer.from( + '{"format":"firstdraft.foundation-plan.sketch/0.19"}\n', +); +const HEAD_SHA256 = sha256(PLAN_SOURCE); +const ETAG = `"sha256:${HEAD_SHA256}"`; +const API_TOKEN = `fd_${"a".repeat(43)}`; +const CREATED_AT = "2026-08-01T12:00:00.000Z"; +const STARTED_AT = "2026-08-01T12:00:01.000Z"; +const COMPLETED_AT = "2026-08-01T12:00:02.000Z"; +const COMPILER_RELEASE = "foundation-plan-rails/compiler-2026-08"; +const TARGET = { id: "rails", profile: "rails-sketch/2026-08" }; +const ARTIFACT = { + sha256: "1".repeat(64), + manifest_sha256: "2".repeat(64), + file_count: 197, +}; +const TREE_SHA = "3".repeat(40); +const COMMIT_SHA = "4".repeat(40); +const REPOSITORY = { + id: 1_234_567, + private: true, + owner: { id: 7_654_321, login: "octocat", type: "User" }, + full_name: "octocat/oscar-party", + default_branch: "main", + html_url: "https://github.com/octocat/oscar-party", + tree_sha: TREE_SHA, + commit_sha: COMMIT_SHA, +}; +const PLAN_PUBLISH_HELP = `First Draft CLI + +Usage: + firstdraft plan publish + +Options: + -h, --help Show help + +Environment: + FIRSTDRAFT_API_TOKEN Authenticate API requests + +The command publishes the exact Plan ETag pinned by the last successful +push, waits up to ten minutes, and prints the private GitHub repository URL. +`; + +test("plan publish sends one conditional singleton PUT and polls sequentially", async (context) => { + /** @type {{method: string | undefined, url: string | undefined, headers: import("node:http").IncomingHttpHeaders, body: Buffer}[]} */ + const requests = []; + let reads = 0; + const server = createServer(async (request, response) => { + const body = await readRequestBody(request); + requests.push({ + method: request.method, + url: request.url, + headers: request.headers, + body, + }); + + if (request.method === "PUT" && request.url === publicationPath()) { + respondJson(response, 201, publicationBody("compiling")); + return; + } + if (request.method === "GET" && request.url === publicationPath()) { + reads += 1; + respondJson( + response, + 200, + reads === 1 + ? publicationBody("compiling", { + compilation: { status: "succeeded", artifact: ARTIFACT }, + }) + : reads === 2 + ? publicationBody("provisioning_repository") + : reads === 3 + ? publicationBody("repository_unknown") + : reads === 4 + ? publicationBody("publishing") + : reads === 5 + ? publicationBody("publication_unknown") + : publicationBody("succeeded"), + ); + return; + } + + response.writeHead(404).end(); + }); + const apiUrl = await listen(context, server); + const cwd = remoteDirectory(context, apiUrl); + /** @type {number[]} */ + const timeouts = []; + /** @type {number[]} */ + const delays = []; + const result = await invoke(["plan", "publish"], { + cwd, + apiUrl: "https://canary-secret.example", + createRequestSignal: (/** @type {number} */ timeoutMs) => { + timeouts.push(timeoutMs); + return new AbortController().signal; + }, + planPublishSleep: async (/** @type {number} */ delayMs) => { + delays.push(delayMs); + }, + }); + + assert.deepEqual(result, { + status: 0, + stdout: `${REPOSITORY.html_url}\n`, + stderr: "", + }); + assert.deepEqual(delays, [1000, 1000, 1000, 1000, 1000, 1000]); + assert.deepEqual( + timeouts, + [30_000, 30_000, 30_000, 30_000, 30_000, 30_000, 30_000], + ); + assert.deepEqual( + requests.map(({ method, url }) => [method, url]), + [ + ["PUT", publicationPath()], + ["GET", publicationPath()], + ["GET", publicationPath()], + ["GET", publicationPath()], + ["GET", publicationPath()], + ["GET", publicationPath()], + ["GET", publicationPath()], + ], + ); + const [start, ...polls] = requests; + assert(start); + assert.equal(start.body.byteLength, 0); + assert.equal(start.headers["if-match"], ETAG); + assert.equal(start.headers.authorization, `Bearer ${API_TOKEN}`); + assert.equal( + start.headers.accept, + "application/json, application/problem+json", + ); + assert.equal(start.headers["content-type"], undefined); + assert.equal(start.headers["idempotency-key"], undefined); + for (const poll of polls) { + assert.equal(poll.headers.authorization, `Bearer ${API_TOKEN}`); + assert.equal( + poll.headers.accept, + "application/json, application/problem+json", + ); + } + assert.doesNotMatch(result.stdout, /canary-secret|sha256/); +}); + +test("a repeated singleton PUT returns its existing successful publication", async (context) => { + const cwd = remoteDirectory(context, "https://api.example.test"); + /** @type {FetchCall[]} */ + const calls = []; + const result = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch( + [jsonResponse(publicationBody("succeeded"), 200)], + calls, + ), + }); + + assert.equal(result.status, 0); + assert.equal(result.stdout, `${REPOSITORY.html_url}\n`); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.init?.method, "PUT"); + assert.equal(new Headers(calls[0]?.init?.headers).get("if-match"), ETAG); +}); + +test("an ambiguous PUT is reconciled by one safe singleton GET", async (context) => { + const cwd = remoteDirectory(context, "https://api.example.test"); + /** @type {FetchCall[]} */ + const calls = []; + const result = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch( + [ + async () => { + throw new TypeError("canary-secret-network"); + }, + jsonResponse(publicationBody("succeeded")), + ], + calls, + ), + }); + + assert.deepEqual(result, { + status: 0, + stdout: `${REPOSITORY.html_url}\n`, + stderr: "", + }); + assert.deepEqual( + calls.map(({ init }) => init?.method), + ["PUT", "GET"], + ); + assert.doesNotMatch(result.stdout, /canary-secret/); +}); + +test("an invalid successful PUT response can reconcile to the exact singleton", async (context) => { + const cwd = remoteDirectory(context, "https://api.example.test"); + /** @type {FetchCall[]} */ + const calls = []; + const result = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch( + [ + jsonResponse( + { ...publicationBody("succeeded"), canary: "canary-secret" }, + 201, + ), + jsonResponse(publicationBody("succeeded")), + ], + calls, + ), + }); + + assert.deepEqual(result, { + status: 0, + stdout: `${REPOSITORY.html_url}\n`, + stderr: "", + }); + assert.deepEqual( + calls.map(({ init }) => init?.method), + ["PUT", "GET"], + ); + assert.doesNotMatch(result.stdout, /canary-secret/); +}); + +test("an unresolved ambiguous PUT remains outcome unknown without replaying the mutation", async (context) => { + const cwd = remoteDirectory(context, "https://api.example.test"); + /** @type {FetchCall[]} */ + const calls = []; + const result = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch( + [ + async () => { + throw new TypeError("canary-secret-network"); + }, + problemResponse(404, "publication_not_found", "Not found."), + ], + calls, + ), + }); + + assertHandledFailure(result, "request_outcome_unknown"); + assert.equal(JSON.parse(result.stderr).status, 404); + assert.deepEqual( + calls.map(({ init }) => init?.method), + ["PUT", "GET"], + ); + assert.doesNotMatch(result.stderr, /canary-secret/); +}); + +test("help and invalid arguments have no local or network prerequisites", async () => { + const inaccessible = () => { + throw new Error("dependency must remain inaccessible"); + }; + + for (const argv of [ + ["plan", "publish", "--help"], + ["plan", "publish", "-h"], + ["plan", "publish", "--help", "--help"], + ]) { + assert.deepEqual( + await invoke(argv, { + cwd: process.cwd(), + getCwd: inaccessible, + fetchFunction: inaccessible, + planPushFileSystem: inaccessibleFileSystem(), + apiToken: undefined, + }), + { status: 0, stdout: PLAN_PUBLISH_HELP, stderr: "" }, + ); + } + + for (const argv of [ + ["plan", "publish", "canary-secret"], + ["plan", "publish", "--canary-secret"], + ]) { + const result = await invoke(argv, { + fetchFunction: inaccessible, + planPushFileSystem: inaccessibleFileSystem(), + }); + assertHandledFailure(result, "invalid_arguments", 2); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +test("local prerequisites reject before publication network access", async (context) => { + const inaccessible = inaccessibleFetch(); + const unpushed = localDirectory(context, { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + }); + assertHandledFailure( + await invoke(["plan", "publish"], { + cwd: unpushed, + fetchFunction: inaccessible, + }), + "project_not_pushed", + ); + + const opaque = localDirectory(context, { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: "https://api.example.test", + foundation_plan_etag: '"opaque"', + }); + assertHandledFailure( + await invoke(["plan", "publish"], { + cwd: opaque, + fetchFunction: inaccessible, + }), + "invalid_configuration", + 2, + ); + + const changed = remoteDirectory(context, "https://api.example.test"); + writeFileSync( + planPath(changed), + Buffer.concat([PLAN_SOURCE, Buffer.from(" ")]), + ); + assertHandledFailure( + await invoke(["plan", "publish"], { + cwd: changed, + fetchFunction: inaccessible, + }), + "local_plan_changed", + ); + + const missingPlan = remoteDirectory(context, "https://api.example.test"); + rmSync(planPath(missingPlan)); + assertHandledFailure( + await invoke(["plan", "publish"], { + cwd: missingPlan, + fetchFunction: inaccessible, + }), + "local_input_unreadable", + ); +}); + +test("missing and rejected credentials use the stable authentication error", async (context) => { + const cwd = remoteDirectory(context, "https://api.example.test"); + const missing = await invoke(["plan", "publish"], { + cwd, + apiToken: undefined, + fetchFunction: inaccessibleFetch(), + }); + assertHandledFailure(missing, "authentication_required"); + + const rejected = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch([ + problemResponse( + 401, + "authentication_required", + "An API token is required.", + ), + ]), + }); + assertHandledFailure(rejected, "authentication_required"); + assert.equal(JSON.parse(rejected.stderr).status, 401); + + const reconciliation = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch([ + async () => { + throw new TypeError("ambiguous publication request"); + }, + problemResponse( + 401, + "authentication_required", + "An API token is required.", + ), + ]), + }); + assertHandledFailure(reconciliation, "authentication_required"); + assert.equal(JSON.parse(reconciliation.stderr).status, 401); +}); + +test("validated start rejections are distinct from unknown mutation outcomes", async (context) => { + const cwd = remoteDirectory(context, "https://api.example.test"); + const rejected = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch([ + problemResponse(412, "precondition_failed", "The Plan changed."), + ]), + }); + + assert.deepEqual(JSON.parse(rejected.stderr), { + error: "publication_start_rejected", + detail: "First Draft rejected the publication request.", + status: 412, + response: { + type: "about:blank", + title: "Precondition Failed", + status: 412, + code: "precondition_failed", + detail: "The Plan changed.", + }, + }); + + const malformed = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch([ + new Response("canary-secret", { status: 500 }), + problemResponse(404, "publication_not_found", "Not found."), + ]), + }); + assertHandledFailure(malformed, "request_outcome_unknown"); + assert.doesNotMatch(malformed.stderr, /canary-secret/); +}); + +test("polling distinguishes unavailable and invalid status responses", async (context) => { + const unavailableCwd = remoteDirectory(context, "https://api.example.test"); + const unavailable = await invoke(["plan", "publish"], { + cwd: unavailableCwd, + fetchFunction: sequenceFetch([ + jsonResponse(publicationBody("compiling"), 201), + problemResponse(503, "publication_unavailable", "Try later."), + ]), + planPublishSleep: async () => {}, + }); + assertHandledFailure(unavailable, "publication_status_unavailable"); + assert.equal(JSON.parse(unavailable.stderr).status, 503); + assert.equal( + JSON.parse(unavailable.stderr).response.code, + "publication_unavailable", + ); + + const invalidCwd = remoteDirectory(context, "https://api.example.test"); + const invalidBody = { + ...publicationBody("provisioning_repository"), + canary: "canary-secret", + }; + const invalid = await invoke(["plan", "publish"], { + cwd: invalidCwd, + fetchFunction: sequenceFetch([ + jsonResponse(publicationBody("compiling"), 201), + jsonResponse(invalidBody), + ]), + planPublishSleep: async () => {}, + }); + assertHandledFailure(invalid, "invalid_publication_status"); + assert.doesNotMatch(invalid.stderr, /canary-secret/); +}); + +test("polling rejects replacement identities, regressions, and repository mutation", async (context) => { + const cases = [ + { + initial: publicationBody("compiling"), + changed: publicationBody("provisioning_repository", { + publication: { id: OTHER_PUBLICATION_ID }, + }), + }, + { + initial: publicationBody("compiling"), + changed: publicationBody("provisioning_repository", { + compilation: { id: OTHER_COMPILATION_ID }, + }), + }, + { + initial: publicationBody("publishing"), + changed: publicationBody("publishing", { + repository: { id: REPOSITORY.id + 1 }, + }), + }, + { + initial: publicationBody("publishing"), + changed: publicationBody("provisioning_repository", { + publication: { + repository: { ...REPOSITORY, tree_sha: null, commit_sha: null }, + }, + }), + }, + ]; + + for (const { initial, changed } of cases) { + const cwd = remoteDirectory(context, "https://api.example.test"); + const result = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch([ + jsonResponse(initial, 201), + jsonResponse(changed), + ]), + planPublishSleep: async () => {}, + }); + + assertHandledFailure(result, "publication_changed"); + } +}); + +test("the bounded wait reports its last validated status", async (context) => { + const cwd = remoteDirectory(context, "https://api.example.test"); + let clock = 0; + const result = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch([ + jsonResponse(publicationBody("compiling"), 201), + ]), + planPublishNow: () => clock, + planPublishSleep: async () => { + clock = 10 * 60_000; + }, + }); + + assertHandledFailure(result, "publication_wait_timed_out"); + assert.equal( + JSON.parse(result.stderr).current.publication.status, + "compiling", + ); +}); + +test("failed, conflicted, and cancelled publications preserve terminal status", async (context) => { + /** @type {[string, string][]} */ + const cases = [ + ["failed", "publication_failed"], + ["repository_conflict", "publication_failed"], + ["cancelled", "publication_cancelled"], + ]; + + for (const [status, expectedError] of cases) { + const cwd = remoteDirectory(context, "https://api.example.test"); + const result = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch([ + jsonResponse(publicationBody(status), 201), + ]), + }); + + assertHandledFailure(result, expectedError); + assert.equal(JSON.parse(result.stderr).current.publication.status, status); + } + + for (const status of ["failed", "cancelled"]) { + const cwd = remoteDirectory(context, "https://api.example.test"); + const result = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch([ + jsonResponse( + publicationBody(status, { publication: { started_at: null } }), + 201, + ), + ]), + }); + + assertHandledFailure( + result, + status === "failed" ? "publication_failed" : "publication_cancelled", + ); + assert.equal( + JSON.parse(result.stderr).current.publication.started_at, + null, + ); + } +}); + +test("exact response shapes and coherent terminal projections are required", async (context) => { + const invalidBodies = [ + { ...publicationBody("succeeded"), canary: "canary-secret" }, + publicationBody("succeeded", { + project: { head_source_sha256: "f".repeat(64) }, + }), + publicationBody("succeeded", { + compilation: { artifact: null }, + }), + publicationBody("succeeded", { + compilation: { head_source_sha256: "f".repeat(64) }, + }), + publicationBody("succeeded", { + repository: { private: false }, + }), + publicationBody("succeeded", { + repository: { + html_url: "https://github.com/octocat/a-different-repository", + }, + }), + publicationBody("succeeded", { + repository: { + full_name: "someone-else/oscar-party", + html_url: "https://github.com/someone-else/oscar-party", + }, + }), + publicationBody("succeeded", { + repository: { + full_name: "octocat/team/oscar-party", + html_url: "https://github.com/octocat/team/oscar-party", + }, + }), + publicationBody("succeeded", { + repository: { commit_sha: null, tree_sha: null }, + }), + publicationBody("succeeded", { + publication: { started_at: null }, + }), + publicationBody("failed", { + publication: { failure: null }, + }), + publicationBody("publishing", { + publication: { completed_at: COMPLETED_AT }, + }), + ]; + + for (const [index, body] of invalidBodies.entries()) { + const cwd = remoteDirectory(context, "https://api.example.test"); + const result = await invoke(["plan", "publish"], { + cwd, + fetchFunction: sequenceFetch([ + jsonResponse(body, 201), + problemResponse(404, "publication_not_found", `Missing ${index}.`), + ]), + }); + + assertHandledFailure(result, "request_outcome_unknown"); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +/** @typedef {{input: string | URL | Request, init: RequestInit | undefined}} FetchCall */ + +/** + * @param {string} status + * @param {{project?: Record, compilation?: Record, publication?: Record, repository?: Record}} [changes] + */ +function publicationBody(status, changes = {}) { + const terminal = [ + "succeeded", + "repository_conflict", + "failed", + "cancelled", + ].includes(status); + const compilationStatus = + status === "compiling" + ? "queued" + : status === "cancelled" + ? "cancelled" + : status === "failed" + ? "failed" + : "succeeded"; + const repositoryRequired = [ + "publishing", + "publication_unknown", + "succeeded", + "repository_conflict", + ].includes(status); + const repository = repositoryRequired + ? { + ...REPOSITORY, + ...(status === "succeeded" ? {} : { tree_sha: null, commit_sha: null }), + ...changes.repository, + } + : null; + const failure = ["failed", "repository_conflict"].includes(status) + ? { phase: status === "failed" ? "compile" : "publish", code: status } + : null; + + return { + project: { + id: PROJECT_ID, + graph_version: 11, + head_source_sha256: HEAD_SHA256, + ...changes.project, + }, + compilation: { + id: COMPILATION_ID, + analysis_run_id: ANALYSIS_ID, + graph_version: 11, + head_source_sha256: HEAD_SHA256, + status: compilationStatus, + compiler_release: COMPILER_RELEASE, + target: TARGET, + artifact: compilationStatus === "succeeded" ? ARTIFACT : null, + ...changes.compilation, + }, + publication: { + id: PUBLICATION_ID, + status, + repository, + failure, + created_at: CREATED_AT, + started_at: status === "compiling" ? null : STARTED_AT, + completed_at: terminal ? COMPLETED_AT : null, + ...changes.publication, + }, + }; +} + +function publicationPath() { + return `/v1/projects/${PROJECT_ID}/github-publication`; +} + +/** @param {Buffer} value */ +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +/** @param {unknown} body @param {number} [status] */ +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** @param {number} status @param {string} code @param {string} detail */ +function problemResponse(status, code, detail) { + /** @type {Record} */ + const titles = { + 401: "Unauthorized", + 404: "Not Found", + 412: "Precondition Failed", + 503: "Service Unavailable", + }; + return new Response( + JSON.stringify({ + type: "about:blank", + title: titles[status] ?? "Error", + status, + code, + detail, + }), + { + status, + headers: { "Content-Type": "application/problem+json" }, + }, + ); +} + +/** + * @param {(Response | (() => Promise))[]} responses + * @param {FetchCall[]} [calls] + * @returns {typeof globalThis.fetch} + */ +function sequenceFetch(responses, calls = []) { + return async (input, init) => { + calls.push({ input, init }); + const response = responses.shift(); + assert(response, "unexpected request"); + return typeof response === "function" ? response() : response; + }; +} + +/** @param {import("node:http").ServerResponse} response @param {number} status @param {unknown} body */ +function respondJson(response, status, body) { + const source = Buffer.from(JSON.stringify(body)); + response.writeHead(status, { + "Content-Type": "application/json", + "Content-Length": source.byteLength, + }); + response.end(source); +} + +/** @param {import("node:http").IncomingMessage} request */ +async function readRequestBody(request) { + const chunks = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks); +} + +/** @param {import("node:test").TestContext} context @param {import("node:http").Server} server */ +async function listen(context, server) { + await new Promise((/** @type {(value?: void) => void} */ resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + context.after( + () => + new Promise( + ( + /** @type {(value?: void) => void} */ resolve, + /** @type {(error: Error) => void} */ reject, + ) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }, + ), + ); + const address = server.address(); + assert(address && typeof address === "object"); + return `http://127.0.0.1:${address.port}`; +} + +/** @param {import("node:test").TestContext} context @param {string} apiUrl */ +function remoteDirectory(context, apiUrl) { + return localDirectory(context, { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: apiUrl, + foundation_plan_etag: ETAG, + }); +} + +/** @param {import("node:test").TestContext} context @param {Record} state */ +function localDirectory(context, state) { + const directory = mkdtempSync(path.join(tmpdir(), "firstdraft-publish-")); + context.after(() => rmSync(directory, { recursive: true, force: true })); + mkdirSync(path.join(directory, ".firstdraft")); + writeFileSync( + path.join(directory, ".firstdraft", "state.json"), + `${JSON.stringify(state, null, 2)}\n`, + { mode: 0o600 }, + ); + writeFileSync(planPath(directory), PLAN_SOURCE); + return directory; +} + +/** @param {string} directory */ +function planPath(directory) { + return path.join(directory, ".firstdraft", "foundation-plan.json"); +} + +/** @param {readonly string[]} argv @param {Record} [options] */ +async function invoke(argv, options = {}) { + let stdout = ""; + let stderr = ""; + const status = await run({ + argv, + stdout: { write: (text) => (stdout += text) }, + stderr: { write: (text) => (stderr += text) }, + apiToken: API_TOKEN, + ...options, + }); + return { status, stdout, stderr }; +} + +/** + * @param {{status: number, stdout: string, stderr: string}} result + * @param {string} error + * @param {number} [status] + */ +function assertHandledFailure(result, error, status = 1) { + assert.equal(result.status, status); + assert.equal(result.stdout, ""); + assert.equal(JSON.parse(result.stderr).error, error); +} + +function inaccessibleFetch() { + return async () => { + throw new Error("network must remain inaccessible"); + }; +} + +function inaccessibleFileSystem() { + return { + lstatSync: () => { + throw new Error("filesystem must remain inaccessible"); + }, + readFileSync: () => { + throw new Error("filesystem must remain inaccessible"); + }, + renameSync: () => { + throw new Error("filesystem must remain inaccessible"); + }, + writeFileSync: () => { + throw new Error("filesystem must remain inaccessible"); + }, + }; +}