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"); + }, + }; +}