diff --git a/README.md b/README.md index ec52c4a..94db167 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ 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, local application-key and UUID generation, conditional whole-document push, whole-graph -analysis status polling, explicit compilation, verified local artifact materialization, and conditional GitHub publication. +analysis status polling, compile-and-publish orchestration, and read-only retained-Compilation download. Interfaces may change between prereleases, and publishing the CLI does not make the wider First Draft service generally available. @@ -26,7 +26,7 @@ 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, compilation, and publication commands require a +repeatable installation matters. Remote Plan and Compilation commands require a compatible First Draft service and are currently intended for coordinated trials. ## Authenticate API commands @@ -38,9 +38,9 @@ export FIRSTDRAFT_API_TOKEN="your-token" firstdraft plan push ``` -`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 `generate`. Revoke the token in First Draft if it is exposed. A missing token, or First Draft's validated +`plan push`, `plan status`, `plan compile`, and `compilation` subcommands 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 `generate`. 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. ## Development @@ -138,138 +138,112 @@ to retry a bounded number of times because the command sends only `GET` requests inspect the API origin pinned in `.firstdraft/state.json`; an invalid server response instead requires reconciling the CLI and server contract. -## Compile a valid Plan +## Compile and publish the current Plan -After the current analysis is `valid`, choose an absent destination: +When the candidate is ready, run: ```sh -firstdraft plan compile --output ../oscar-party +firstdraft plan compile ``` -The command conditionally starts one Compilation using the complete Foundation Plan ETag saved by the last -successful push. It never reads an API origin from the current environment, retries an ambiguous `POST`, follows a -replacement Compilation, or overwrites an output path. It polls the accepted Compilation sequentially once per -second for at most ten minutes. A failed or cancelled Compilation is returned as a handled domain failure. +`plan compile` is the single terminal action. It first pushes the exact current bytes in +`.firstdraft/foundation-plan.json`, even when those bytes are unchanged, and saves the accepted ETag using the same +contract as `plan push`. It then waits up to two minutes for an analysis whose graph version exactly matches that +accepted push, polling past a terminal result retained for an older Head. Invalid JSON, schema diagnostics, +semantic diagnostics, a failed analysis, a superseded analysis, or a recurring diagnostic stop the command with +structured output; no Compilation or Publication is requested. -On success, the CLI downloads the Compilation's deterministic artifact, verifies its media type, declared and actual -byte sizes, strong digest ETag, exact-byte SHA-256, canonical UTF-8 JSON envelope, provenance, metadata-only manifest -digest, portable paths, strict Base64 contents, file digests, modes, owners, and source-subject UUIDs. It writes -exclusively into a uniquely created sibling directory, verifies the complete tree, and atomically renames that -directory into the still-absent destination. On POSIX, generated directories, including the output root, are created -and verified at mode `0755` independent of the caller's umask; files use their artifact-declared `0644` or `0755` -mode. Windows cannot represent those POSIX permission distinctions, so the CLI still verifies the complete structure, -contents, and digests there without claiming exact mode bits. The success JSON names the pinned Project and -Compilation and reports the local output path, file count, and manifest digest; it never prints generated file -contents or private local state. +Only a `valid` analysis proceeds to the internal GitHub Publication lifecycle. Invoking `plan compile` is the +authorization to request that lifecycle. Immediately before its conditional mutation, the CLI re-reads the local +Plan and requires its exact bytes to match the accepted Head, so bytes changed after analysis cannot be published. +It extracts the accepted source SHA-256 from the saved ETag, hashes the current local bytes, and then sends that +complete ETag in `If-Match`. +The command writes no progress output to stdout. Success is exactly one validated JSON object containing the +retained `project`, `compilation`, and `publication`, including the private GitHub repository URL. -## Publish a valid Plan to GitHub +The internal Publication is a Project singleton in this release. A repeat safely receives the same Publication +instead of creating another. If the first conditional `PUT` has an ambiguous result, the CLI reconciles it with +one read-only singleton `GET` and never automatically repeats the mutation. Rerunning `plan compile` safely +replays the singleton request. Publication polling is sequential, bounded to ten minutes, and pinned to the +retained Project Head, Compilation input, Publication identity, and repository identity. -To compile the accepted Plan and publish it as a new private repository in your connected personal GitHub account: +This release cannot repoint a Project's Publication to a later accepted Head. The public CLI therefore has no +`plan publish` command and no local-start `plan compile --output` mode. Use the standalone retained-Compilation +commands below when local generated source is useful. + +## Inspect a retained Compilation + +Read one Compilation ID returned by `plan compile`: + +```sh +firstdraft compilation status 01900000-0000-7000-8000-000000000001 +firstdraft compilation status 01900000-0000-7000-8000-000000000001 --wait +``` + +Without `--wait`, the command makes exactly one metadata-only `GET`. With `--wait`, it polls that same +Compilation sequentially for at most ten minutes and rejects changes to its identity, Head provenance, target, or +lifecycle progression. `failed` and `cancelled` are successfully read terminal states with exit 0; branch on +`compilation.status` and inspect its validated `failure`. + +## Download a retained Compilation + +Materialize an already successful Compilation into an absent path: ```sh -firstdraft plan publish +firstdraft compilation download 01900000-0000-7000-8000-000000000001 --output ../movie-catalog ``` -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 a domain precondition, not a cache validator for the possibly absent -Publication representation. A first creation compares it with the Project's current Foundation Plan Head. A -singleton replay compares it with the Publication's retained Head provenance, even if the live Project has since -changed. The server route must apply those semantics directly instead of delegating 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. A `408` or `5xx` start response is ambiguous even with a valid problem, so the CLI -uses the same read-only reconciliation; every other validated rejection is definitive and must not have created or -changed a Publication. - -When `request_outcome_unknown` includes `status` and `response`, a validated problem from the reconciliation `GET` -takes precedence; otherwise they describe the `PUT`. A `status` without `response` may be a success code when the -read returned a structurally invalid or foreign projection. Never infer from this error alone that no mutation -occurred. - -The response's `project` object is immutable Publication provenance, not a projection of the live mutable Project. -Every `201`, `200`, and read-only `GET` response must retain the graph version and Head digest selected when the -Publication was created. The CLI requires that retained Head digest to equal the local Plan bytes and saved ETag, -requires the associated Compilation to agree with the retained Project snapshot, and pins the snapshot for the -rest of polling. This lets a command whose local state still names the original Head safely replay after the live -Project changes elsewhere. A local file/state pair advanced to a newer Head cannot adopt the older Publication. -This release supports one Publication bound to one retained Head for the lifetime of each Project. It cannot -re-point that Publication or publish a later accepted Head, and no same-Project republish path exists yet. - -The CLI polls a validated Publication sequentially once per second for at most ten minutes and rejects changes to -its retained Project provenance, Compilation input, Publication identity, or allocated repository identity. -`repository_unknown` and `publication_unknown` are one-way server-side uncertainty states: polling remains -read-only, and the CLI rejects a regression from either state to the corresponding mutating phase. Either state may -still advance to a later phase or a terminal state when the server has enough evidence. The CLI does not offer a -publication-cancel mutation; it only reports a server-authoritative `cancelled` terminal state and does not infer -that cancellation reversed any remote side effect. - -Any non-success outcome after publication processing began may leave a private GitHub repository. A `current` -projection identifies the last accepted repository when present; `publication_changed` also includes the rejected -next projection. A null or missing repository does not prove that none was created, especially after -`repository_unknown`. Use a safe command replay or direct GitHub inspection to find every repository. The CLI never -deletes one; inspect it before separately deciding whether destructive GitHub deletion is appropriate. - -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 `generate uuid`, `generate application-key`, `plan init`, `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 | -| -------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generate uuid`, `generate application-key`, `plan init`, `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 ambiguous PUT could not be reconciled. No mutation was retried; retry is safe, and a validated problem may be included. | -| `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; no Publication was created or changed, and a validated problem is included. | -| `plan publish` | `publication_status_unavailable` | 1 | The first failed singleton read stopped polling; rerun `plan publish` to replay the singleton and resume. 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, metadata, repository, or lifecycle changed. `current` is the pinned projection and `rejected` is the next response; rerun to resume. | -| `plan publish` | `publication_wait_timed_out` | 1 | The ten-minute deadline ended; `current` contains the last validated status. Rerun `plan publish` to resume. | -| `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 -absent or when the CLI cannot validate their complete shape. Exit status remains a broad shell-level class; the -`error` value is the machine-readable recovery contract. - -Root-level, `generate`, and `plan` command-group usage failures remain human-readable text on standard error with -exit 2. A failure before a subcommand can begin, such as an unavailable working directory, also remains uncaught, -as do unexpected programming defects. +The command validates the UUID and output path before network access, makes one status `GET`, requires +`succeeded`, and makes one artifact `GET`. It never starts work or polls. Historical artifact validation uses +the retained `compilation.head_source_sha256`, not the current local Plan or ETag; both artifact +`head_source_sha256` and `foundation_plan.sha256` must equal that retained Head. + +Before materialization, the CLI verifies the artifact media type, declared and actual byte sizes, strong digest +ETag, exact-byte SHA-256, canonical UTF-8 JSON envelope, provenance, metadata-only manifest digest, portable paths, +strict Base64 contents, file digests, modes, owners, and source-subject UUIDs. It writes only into a uniquely +created sibling directory, verifies the complete tree, and atomically renames it into the still-absent destination. +On POSIX, directories use mode `0755` and files use artifact-declared `0644` or `0755`; Windows verifies +structure, contents, and digests without claiming POSIX mode bits. + +## Handled failures + +Every handled subcommand failure writes exactly one JSON object to standard error. Branch on its stable `error` +value rather than the human-readable `detail`; `plan compile` also supplies `phase: "push" | "publication"` when +`request_outcome_unknown` requires phase-specific recovery: + +- `phase: "push"` means the Plan mutation may have been accepted; stop and reconcile local Head state. +- `phase: "publication"` means the singleton Publication mutation was not resolved; rerunning `plan compile` is a + safe replay. + +| Commands | `error` | Exit | Meaning | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---: | -------------------------------------------------------------------------------------------------------------- | +| Any leaf command | `invalid_arguments` | 2 | Syntax was invalid; no request was made. | +| `plan init` | `local_initialization_failed` | 1 | Initialization failed without overwriting an existing path. | +| `plan push`, `plan compile` | `invalid_configuration` | 2 | API origin or saved Head state is incompatible. | +| Network commands | `authentication_required` | 1 | The token is missing or First Draft returned a validated authentication problem. | +| Plan commands, `compilation *` | `local_input_unreadable` | 1 | Required local Plan or private state could not be read. | +| Status, Compile, Compilation commands | `project_not_pushed` | 1 | No API origin is pinned for the local Project. | +| `plan push`, `plan compile` | `request_outcome_unknown` | 1 | A mutation or its response could not be verified; `plan compile` identifies its `push` or `publication` phase. | +| `plan push`, `plan compile` | `local_state_not_saved` | 1 | The Plan was accepted but the private ETag state could not be replaced; includes `recovery_state`. | +| `plan push`, `plan compile` | `server_rejected` | 1 | First Draft returned validated Plan diagnostics or a validated problem. | +| `plan status` | `status_unavailable`, `invalid_server_response` | 1 | The analysis read failed or violated its protocol. | +| `plan compile` | `analysis_status_unavailable`, `invalid_analysis_status`, `analysis_status_rejected` | 1 | The bounded analysis read failed, was invalid, or was rejected. | +| Analysis waits | `analysis_changed`, `wait_timed_out`, `analysis_wait_timed_out` | 1 | The pinned analysis changed or remained processing at the deadline. | +| `plan compile` | `plan_not_valid` | 1 | Analysis completed without `valid`; `current` contains diagnostics and status. | +| `plan compile` | `local_plan_changed` | 1 | Local bytes changed after acceptance or analysis, before Publication mutation. | +| `plan compile` | `publication_start_rejected`, `publication_status_unavailable`, `invalid_publication_status` | 1 | Publication start or status failed its validated transport contract. | +| `plan compile` | `publication_changed`, `publication_wait_timed_out`, `publication_failed`, `publication_cancelled` | 1 | The pinned Publication changed, timed out, or reached a non-success terminal state. | +| `compilation status`, `compilation download` | `compilation_status_unavailable`, `invalid_compilation_status` | 1 | The retained status could not be read or violated its exact contract. | +| `compilation status --wait` | `compilation_changed`, `compilation_wait_timed_out` | 1 | Retained identity/provenance changed or the wait ended. | +| `compilation download` | `compilation_not_succeeded` | 1 | Status was not `succeeded`; no artifact request was made. | +| `compilation download` | `artifact_unavailable`, `invalid_artifact` | 1 | Artifact transport or integrity validation failed before materialization. | +| `compilation download` | `invalid_output_path` | 2 | The destination was not an absent path beneath an existing real directory. | +| `compilation download` | `materialization_failed` | 1 | The output raced or the verified tree could not be atomically installed. | + +Handled output never includes command arguments, local Plan bytes, raw artifact bytes, raw filesystem or network +errors, or unvalidated response bodies. `local_state_not_saved` is the sole exception to private-state redaction: +its `recovery_state` is required to repair the accepted ETag locally. Root-level and command-group usage failures +remain human-readable text on standard error with exit 2. Unexpected programming defects remain loud. ## Trust model diff --git a/scripts/check-pack.js b/scripts/check-pack.js index 799e98f..243d63e 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -29,6 +29,7 @@ if (result.status !== 0) { "src/api-response.js", "src/application-identity.js", "src/cli.js", + "src/commands/compilation.js", "src/commands/plan-compile.js", "src/commands/plan-init.js", "src/commands/plan-publish.js", diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index 9da2b61..fcde686 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -227,24 +227,25 @@ try { "Invalid arguments. Run 'firstdraft plan compile --help' for usage.", }); - const invalidPublish = spawnPackedCli( - ["plan", "publish", "--canary-secret-option"], + const removedPublish = spawnPackedCli( + ["plan", "publish"], installationDirectory, ); - assertHandledFailure(invalidPublish, 2, { - error: "invalid_arguments", - detail: - "Invalid arguments. Run 'firstdraft plan publish --help' for usage.", - }); + assert.equal(removedPublish.status, 2); + assert.equal(removedPublish.stdout, ""); + assert.equal( + removedPublish.stderr, + "Unknown command.\nRun 'firstdraft plan --help' for usage.\n", + ); - const localPublish = spawnPackedCli( - ["plan", "publish"], + const invalidCompilation = spawnPackedCli( + ["compilation", "status", "not-a-uuid"], installationDirectory, ); - assertHandledFailure(localPublish, 1, { - error: "local_input_unreadable", + assertHandledFailure(invalidCompilation, 2, { + error: "invalid_arguments", detail: - "Could not read valid local First Draft state or Plan bytes. No network request was made. Run 'firstdraft plan push' before publishing.", + "Invalid arguments. Run 'firstdraft compilation status --help' for usage.", }); await exercisePackedCompilation(projectDirectory); @@ -322,11 +323,10 @@ async function exercisePackedCompilation(projectDirectory) { const compilationId = "01900000-0000-7000-8000-000000000902"; const analysisId = "01900000-0000-7000-8000-000000000903"; const publicationId = "01900000-0000-7000-8000-000000000904"; - const headSha256 = sha256( - readFileSync( - path.join(projectDirectory, ".firstdraft", "foundation-plan.json"), - ), + const plan = readFileSync( + path.join(projectDirectory, ".firstdraft", "foundation-plan.json"), ); + const headSha256 = sha256(plan); const statusPath = `/v1/projects/${projectId}/compilations/${compilationId}`; const artifactPath = `${statusPath}/artifact`; const compilerRelease = "foundation-plan-rails/compiler-scalar-2026-08"; @@ -360,7 +360,7 @@ async function exercisePackedCompilation(projectDirectory) { head_source_sha256: headSha256, foundation_plan: { format: "firstdraft.foundation-plan.sketch/0.19", - sha256: "2".repeat(64), + sha256: headSha256, }, analysis: { id: analysisId, @@ -385,6 +385,7 @@ async function exercisePackedCompilation(projectDirectory) { id: compilationId, analysis_run_id: analysisId, graph_version: 1, + head_source_sha256: headSha256, status: "succeeded", compiler_release: compilerRelease, target, @@ -397,12 +398,23 @@ async function exercisePackedCompilation(projectDirectory) { byte_size: artifact.byteLength, }, 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", + created_at: "2026-07-30T12:00:00.000000Z", + started_at: "2026-07-30T12:00:01.000000Z", + completed_at: "2026-07-30T12:00:02.000000Z", + }, + }; + const analysis = { + project: { id: projectId, graph_version: 1 }, + analysis: { + id: analysisId, + graph_version: 1, + analyzer_release: "foundation-plan-analyzer/2026-08", + status: "valid", + diagnostics: [], + started_at: "2026-07-30T12:00:00.000Z", + completed_at: "2026-07-30T12:00:01.000Z", }, }; - const repositoryUrl = "https://github.com/octocat/oscar-party"; const publication = { project: { id: projectId, @@ -432,7 +444,7 @@ async function exercisePackedCompilation(projectDirectory) { owner: { id: 7_654_321, login: "octocat", type: "User" }, full_name: "octocat/oscar-party", default_branch: "main", - html_url: repositoryUrl, + html_url: "https://github.com/octocat/oscar-party", tree_sha: "5".repeat(40), commit_sha: "6".repeat(40), }, @@ -442,56 +454,84 @@ async function exercisePackedCompilation(projectDirectory) { completed_at: "2026-07-30T12:00:02.000Z", }, }; - let startRequestSeen = false; - let artifactRequestSeen = false; - let publicationRequestSeen = false; + const seen = { + plan: false, + analysis: false, + publication: false, + status: false, + artifact: false, + post: false, + }; const server = createServer(async (request, response) => { const chunks = []; for await (const chunk of request) chunks.push(Buffer.from(chunk)); const requestBody = Buffer.concat(chunks); + assert.equal(request.headers.authorization, `Bearer ${apiToken}`); + if (request.method === "POST") seen.post = true; if ( - request.method === "POST" && - request.url === `/v1/projects/${projectId}/compilations` + request.method === "PUT" && + request.url === `/v1/projects/${projectId}/foundation-plan` ) { - assert.equal(request.headers.authorization, `Bearer ${apiToken}`); + assert.deepEqual(requestBody, plan); assert.equal(request.headers["if-match"], `"sha256:${headSha256}"`); - assert.equal(requestBody.byteLength, 0); - startRequestSeen = true; - respondJson(response, 202, compilation, { Location: statusPath }); + seen.plan = true; + respondJson( + response, + 200, + { + project: { id: projectId, graph_version: 1 }, + foundation_plan: { + format: "firstdraft.foundation-plan.sketch/0.19", + source_sha256: headSha256, + }, + diagnostics: [], + }, + { ETag: `"sha256:${headSha256}"` }, + ); return; } - if (request.method === "GET" && request.url === artifactPath) { - assert.equal(request.headers.authorization, `Bearer ${apiToken}`); - artifactRequestSeen = true; - response.writeHead(200, { - "Content-Type": "application/vnd.firstdraft.compilation-artifact+json", - "Content-Length": artifact.byteLength, - ETag: `"sha256:${artifactSha256}"`, - }); - response.end(artifact); + if ( + request.method === "GET" && + request.url === `/v1/projects/${projectId}/analysis` + ) { + seen.analysis = true; + respondJson(response, 200, analysis); 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; + seen.publication = true; respondJson(response, 201, publication); return; } + if (request.method === "GET" && request.url === statusPath) { + seen.status = true; + respondJson(response, 200, compilation); + return; + } + if (request.method === "GET" && request.url === artifactPath) { + seen.artifact = true; + response.writeHead(200, { + "Content-Type": "application/vnd.firstdraft.compilation-artifact+json", + "Content-Length": artifact.byteLength, + "Cache-Control": "no-store, no-transform", + ETag: `"sha256:${artifactSha256}"`, + }); + response.end(artifact); + return; + } response.writeHead(404).end(); }); try { await new Promise((/** @type {(value?: void) => void} */ resolve) => { - server.listen(0, "127.0.0.1", () => { - resolve(); - }); + server.listen(0, "127.0.0.1", resolve); }); const address = server.address(); assert(address && typeof address === "object"); @@ -510,15 +550,35 @@ async function exercisePackedCompilation(projectDirectory) { )}\n`, { mode: 0o600 }, ); - const output = path.join(projectDirectory, "generated"); - const execution = await spawnPackedCliAsync( - ["plan", "compile", "--output", output], + + const compiled = await spawnPackedCliAsync( + ["plan", "compile"], projectDirectory, ); + assert.deepEqual(compiled, { + status: 0, + stdout: `${JSON.stringify(publication, null, 2)}\n`, + stderr: "", + }); + + const status = await spawnPackedCliAsync( + ["compilation", "status", compilationId], + projectDirectory, + ); + assert.deepEqual(status, { + status: 0, + stdout: `${JSON.stringify(compilation, null, 2)}\n`, + stderr: "", + }); - assert.equal(execution.status, 0); - assert.equal(execution.stderr, ""); - assert.equal(JSON.parse(execution.stdout).output.path, output); + const output = path.join(projectDirectory, "generated"); + const downloaded = await spawnPackedCliAsync( + ["compilation", "download", compilationId, "--output", output], + projectDirectory, + ); + assert.equal(downloaded.status, 0); + assert.equal(downloaded.stderr, ""); + assert.equal(JSON.parse(downloaded.stdout).output.path, output); assert.equal( readFileSync(path.join(output, "app/models/movie.rb"), "utf8"), contents.toString("utf8"), @@ -529,19 +589,14 @@ async function exercisePackedCompilation(projectDirectory) { 0o644, ); } - 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.deepEqual(seen, { + plan: true, + analysis: true, + publication: true, + status: true, + artifact: true, + post: false, }); - assert.equal(publicationRequestSeen, true); } finally { await new Promise( ( diff --git a/src/cli.js b/src/cli.js index 08f0864..7397b28 100644 --- a/src/cli.js +++ b/src/cli.js @@ -14,18 +14,23 @@ import { CompilationArtifactInvalidError, CompilationArtifactResponseInvalidError, CompilationArtifactUnavailableError, - CompilationCancelledError, CompilationChangedError, - CompilationFailedError, - CompilationLocalStateError, CompilationMaterializationError, + CompilationNotSucceededError, CompilationNotPushedError, CompilationOutputPathError, - CompilationRequestOutcomeUnknownError, - CompilationStartRejectedError, CompilationStatusInvalidError, CompilationStatusUnavailableError, CompilationTimeoutError, + downloadCompilation, + readCompilation, +} from "./commands/compilation.js"; +import { + PlanCompileAnalysisInvalidError, + PlanCompileAnalysisNotValidError, + PlanCompileAnalysisRejectedError, + PlanCompileAnalysisUnavailableError, + PlanCompilePushRejectedError, compilePlan, } from "./commands/plan-compile.js"; import { initializePlan } from "./commands/plan-init.js"; @@ -41,7 +46,6 @@ import { PublicationStatusInvalidError, PublicationStatusUnavailableError, PublicationTimeoutError, - publishPlan, } from "./commands/plan-publish.js"; import { PlanPushConfigurationError, @@ -58,6 +62,7 @@ import { readPlanStatus, } from "./commands/plan-status.js"; import { isFileSystemError } from "./file-system.js"; +import { isUuidV7 } from "./plan-state.js"; import { generateUuidV7 } from "./uuid-v7.js"; import { VERSION } from "./version.js"; @@ -68,8 +73,9 @@ Usage: firstdraft [options] Commands: - generate Generate local values - plan Work with Foundation Plans + compilation Inspect and download Compilations + generate Generate local values + plan Work with Foundation Plans Options: -h, --help Show help @@ -85,8 +91,7 @@ Commands: init Create a local empty Foundation Plan 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 + compile Compile and publish the current Foundation Plan Options: -h, --help Show help @@ -166,34 +171,66 @@ Without --wait, it makes exactly one status request. const PLAN_COMPILE_HELP = `First Draft CLI Usage: - firstdraft plan compile --output + firstdraft plan compile Options: - --output Materialize the generated application here - -h, --help Show help + -h, --help Show help Environment: FIRSTDRAFT_API_TOKEN Authenticate API requests + FIRSTDRAFT_API_URL Override the initial API origin + +The command submits the exact current whole-file Plan, waits for its analysis, +and proceeds only when that analysis is valid. It then conditionally creates +or replays the internal GitHub Publication lifecycle and prints its complete +validated Project, Compilation, and Publication projection. +`; + +const COMPILATION_HELP = `First Draft CLI -The command starts one compilation of the exact Plan ETag pinned by the -last successful push, waits up to ten minutes, validates the complete -artifact, and atomically renames it into an absent output path. +Usage: + firstdraft compilation [options] + +Commands: + status Read one retained Compilation + download Download one successful Compilation artifact + +Options: + -h, --help Show help `; -const PLAN_PUBLISH_HELP = `First Draft CLI +const COMPILATION_STATUS_HELP = `First Draft CLI Usage: - firstdraft plan publish + firstdraft compilation status [--wait] Options: + --wait Poll until the Compilation reaches a terminal status -h, --help Show help Environment: FIRSTDRAFT_API_TOKEN Authenticate API requests -The command conditionally creates or replays the Project's one Publication. -Each Project can publish one retained Plan Head in this release. The command -waits up to ten minutes and prints the private GitHub repository URL. +Without --wait, the command makes exactly one metadata-only GET. With --wait, +it polls the same retained Compilation for at most ten minutes. Failed and +cancelled terminal states are successful status reads. +`; + +const COMPILATION_DOWNLOAD_HELP = `First Draft CLI + +Usage: + firstdraft compilation download --output + +Options: + --output Materialize the generated application here + -h, --help Show help + +Environment: + FIRSTDRAFT_API_TOKEN Authenticate API requests + +The command reads the retained Compilation once, requires it to have +succeeded, downloads and verifies its exact artifact once, and atomically +renames the verified files into an absent output path. It never starts work. `; const PLAN_INIT_HELP = `First Draft CLI @@ -218,6 +255,10 @@ const GENERATE_USAGE_ERROR = "Invalid arguments.\nRun 'firstdraft generate --help' for usage.\n"; const GENERATE_UNKNOWN_COMMAND = "Unknown command.\nRun 'firstdraft generate --help' for usage.\n"; +const COMPILATION_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft compilation --help' for usage.\n"; +const COMPILATION_UNKNOWN_COMMAND = + "Unknown command.\nRun 'firstdraft compilation --help' for usage.\n"; const PLAN_USAGE_ERROR = "Invalid arguments.\nRun 'firstdraft plan --help' for usage.\n"; const PLAN_UNKNOWN_COMMAND = @@ -257,47 +298,27 @@ const PLAN_STATUS_TIMEOUT_DETAIL = const PLAN_COMPILE_INVALID_ARGUMENTS_DETAIL = "Invalid arguments. Run 'firstdraft plan compile --help' for usage."; const PLAN_COMPILE_LOCAL_INPUT_UNREADABLE_DETAIL = - "Could not read valid local First Draft state. No network request was made. Run 'firstdraft plan push' before compiling."; + "Could not read the local First Draft Plan or state. No network request was made. Preserve the local files for manual recovery."; const PLAN_COMPILE_INCOMPATIBLE_STATE_DETAIL = - "The saved Foundation Plan ETag is incompatible with compilation. No network request was made; reconcile the CLI and server contract."; + "The configured API origin or saved Foundation Plan state is incompatible with compilation. No network request was made."; const PLAN_COMPILE_NOT_PUSHED_DETAIL = - "The local Foundation Plan has not been pushed successfully. Run 'firstdraft plan push' before compiling."; + "The current Foundation Plan could not be associated with a pushed Project."; const PLAN_COMPILE_REQUEST_OUTCOME_UNKNOWN_DETAIL = - "The compilation may have started, but the response could not be verified. Do not start another compilation until the current Project is reconciled."; -const PLAN_COMPILE_START_REJECTED_DETAIL = - "First Draft rejected the compilation start request."; -const PLAN_COMPILE_STATUS_UNAVAILABLE_DETAIL = - "Could not read the pinned compilation status. The command stopped without following or starting another Compilation."; -const PLAN_COMPILE_STATUS_INVALID_DETAIL = - "First Draft returned an invalid compilation status response. Retrying unchanged will not repair this protocol mismatch."; -const PLAN_COMPILE_CHANGED_DETAIL = - "The pinned Compilation changed while being polled. The command stopped without downloading an artifact."; -const PLAN_COMPILE_TIMEOUT_DETAIL = - "The pinned Compilation is still running after the bounded ten-minute wait."; -const PLAN_COMPILE_FAILED_DETAIL = - "The pinned Compilation failed. No artifact was downloaded or materialized."; -const PLAN_COMPILE_CANCELLED_DETAIL = - "The pinned Compilation was cancelled. No artifact was downloaded or materialized."; -const PLAN_COMPILE_ARTIFACT_UNAVAILABLE_DETAIL = - "Could not download the pinned Compilation artifact. No files were materialized."; -const PLAN_COMPILE_ARTIFACT_INVALID_DETAIL = - "The downloaded Compilation artifact did not satisfy the integrity contract. No files were materialized."; -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."; + "The current Plan may have been accepted, but its response could not be verified. Stop and reconcile before compiling again."; +const PLAN_COMPILE_PLAN_REJECTED_DETAIL = + "First Draft rejected the current Foundation Plan."; +const PLAN_COMPILE_ANALYSIS_REJECTED_DETAIL = + "First Draft rejected the current analysis status request."; +const PLAN_COMPILE_ANALYSIS_NOT_VALID_DETAIL = + "The current Foundation Plan analysis is not valid. Inspect its status and diagnostics before compiling again."; 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."; + "The current Foundation Plan was not retained before the Publication request."; const PLAN_PUBLISH_LOCAL_PLAN_CHANGED_DETAIL = - "The local Foundation Plan has changed since its last successful push. Run 'firstdraft plan push' before publishing."; + "The local Foundation Plan changed after validation. Run 'firstdraft plan compile' again to submit the current bytes."; const PLAN_PUBLISH_REQUEST_OUTCOME_UNKNOWN_DETAIL = - "The publication may have started, but its singleton status could not be verified. No mutation was retried. Running 'firstdraft plan publish' again is safe; if this Project's Publication is retained for a different Plan Head, it cannot be repointed."; + "The Publication may have started, but its singleton status could not be verified. No mutation was retried. Running 'firstdraft plan compile' again is safe."; const PLAN_PUBLISH_START_REJECTED_DETAIL = "First Draft rejected the publication request."; const PLAN_PUBLISH_STATUS_UNAVAILABLE_DETAIL = @@ -311,6 +332,32 @@ const PLAN_PUBLISH_TIMEOUT_DETAIL = 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 COMPILATION_STATUS_INVALID_ARGUMENTS_DETAIL = + "Invalid arguments. Run 'firstdraft compilation status --help' for usage."; +const COMPILATION_DOWNLOAD_INVALID_ARGUMENTS_DETAIL = + "Invalid arguments. Run 'firstdraft compilation download --help' for usage."; +const COMPILATION_LOCAL_INPUT_UNREADABLE_DETAIL = + "Could not read valid local First Draft state. No network request was made."; +const COMPILATION_NOT_PUSHED_DETAIL = + "The local Project has no pinned API origin. Run 'firstdraft plan push' first."; +const COMPILATION_STATUS_UNAVAILABLE_DETAIL = + "Could not read the requested Compilation status. This read-only request is safe to retry."; +const COMPILATION_STATUS_INVALID_DETAIL = + "First Draft returned an invalid Compilation status response. Retrying unchanged will not repair this protocol mismatch."; +const COMPILATION_CHANGED_DETAIL = + "The retained Compilation identity, provenance, or lifecycle progression changed while waiting."; +const COMPILATION_TIMEOUT_DETAIL = + "The retained Compilation is still processing after the bounded ten-minute wait."; +const COMPILATION_NOT_SUCCEEDED_DETAIL = + "The requested Compilation has not succeeded, so no artifact was downloaded."; +const COMPILATION_ARTIFACT_UNAVAILABLE_DETAIL = + "Could not download the requested Compilation artifact. No files were materialized."; +const COMPILATION_ARTIFACT_INVALID_DETAIL = + "The downloaded Compilation artifact did not satisfy the integrity contract. No files were materialized."; +const COMPILATION_MATERIALIZATION_FAILED_DETAIL = + "The validated Compilation artifact could not be materialized at the requested absent output path."; +const COMPILATION_INVALID_OUTPUT_PATH_DETAIL = + "The compilation output path must be absent beneath an existing real directory. No network request was made."; const GENERATE_UUID_INVALID_ARGUMENTS_DETAIL = "Invalid arguments. Run 'firstdraft generate uuid --help' for usage."; const GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL = @@ -341,6 +388,11 @@ const GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL = * @property {() => number} [planCompileNow] * @property {(delayMs: number) => Promise} [planPublishSleep] * @property {() => number} [planPublishNow] + * @property {(delayMs: number) => Promise} [compilationSleep] + * @property {() => number} [compilationNow] + * @property {typeof pushPlan} [planCompilePush] + * @property {typeof readPlanStatus} [planCompileReadStatus] + * @property {typeof import("./commands/plan-publish.js").publishPlan} [planCompilePublish] * @property {string} [apiUrl] * @property {string} [apiToken] */ @@ -364,6 +416,11 @@ const GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL = * @property {() => number} [planCompileNow] * @property {(delayMs: number) => Promise} [planPublishSleep] * @property {() => number} [planPublishNow] + * @property {(delayMs: number) => Promise} [compilationSleep] + * @property {() => number} [compilationNow] + * @property {typeof pushPlan} [planCompilePush] + * @property {typeof readPlanStatus} [planCompileReadStatus] + * @property {typeof import("./commands/plan-publish.js").publishPlan} [planCompilePublish] * @property {string} [apiUrl] * @property {string} [apiToken] */ @@ -376,6 +433,10 @@ const GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL = * @typedef {Pick} GenerateCommandOptions */ +/** + * @typedef {Omit & {cwd?: string, getCwd: () => string}} CompilationCommandOptions + */ + /** @param {RunOptions} options */ export async function run({ argv, @@ -396,6 +457,11 @@ export async function run({ planCompileNow, planPublishSleep, planPublishNow, + compilationSleep, + compilationNow, + planCompilePush, + planCompileReadStatus, + planCompilePublish, apiUrl = process.env.FIRSTDRAFT_API_URL, apiToken = process.env.FIRSTDRAFT_API_TOKEN, }) { @@ -427,11 +493,30 @@ export async function run({ planCompileNow, planPublishSleep, planPublishNow, + planCompilePush, + planCompileReadStatus, + planCompilePublish, apiUrl, apiToken, }); } + if (argv[0] === "compilation") { + return runCompilation({ + argv: argv.slice(1), + stdout, + stderr, + cwd, + getCwd, + fetchFunction, + planPushFileSystem, + createRequestSignal, + compilationSleep, + compilationNow, + apiToken, + }); + } + return runRoot({ argv, stdout, stderr }); } @@ -497,6 +582,9 @@ async function runPlan({ planCompileNow, planPublishSleep, planPublishNow, + planCompilePush, + planCompileReadStatus, + planCompilePublish, apiUrl, apiToken, }) { @@ -549,24 +637,16 @@ async function runPlan({ cwd: cwd ?? getCwd(), fetchFunction, planPushFileSystem, + createTemporaryId, createRequestSignal, planCompileSleep, planCompileNow, - apiToken, - }); - } - - if (argv[0] === "publish") { - return runPlanPublish({ - argv: argv.slice(1), - stdout, - stderr, - cwd: cwd ?? getCwd(), - fetchFunction, - planPushFileSystem, - createRequestSignal, planPublishSleep, planPublishNow, + planCompilePush, + planCompileReadStatus, + planCompilePublish, + apiUrl, apiToken, }); } @@ -599,24 +679,45 @@ async function runPlan({ return 0; } -/** - * @param {GenerateCommandOptions} options - */ -function runGenerate({ argv, stdout, stderr, createUuid }) { - if (argv[0] === "uuid") { - return runGenerateUuid({ +/** @param {CompilationCommandOptions} options */ +async function runCompilation({ + argv, + stdout, + stderr, + cwd, + getCwd, + fetchFunction, + planPushFileSystem, + createRequestSignal, + compilationSleep, + compilationNow, + apiToken, +}) { + if (argv[0] === "status") { + return runCompilationStatus({ argv: argv.slice(1), stdout, stderr, - createUuid, + cwd: cwd ?? getCwd(), + fetchFunction, + planPushFileSystem, + createRequestSignal, + compilationSleep, + compilationNow, + apiToken, }); } - if (argv[0] === "application-key") { - return runGenerateApplicationKey({ + if (argv[0] === "download") { + return runCompilationDownload({ argv: argv.slice(1), stdout, stderr, + cwd: cwd ?? getCwd(), + fetchFunction, + planPushFileSystem, + createRequestSignal, + apiToken, }); } @@ -630,261 +731,465 @@ function runGenerate({ argv, stdout, stderr, createUuid }) { ); if (!parsed) { - stderr.write(GENERATE_USAGE_ERROR); + stderr.write(COMPILATION_USAGE_ERROR); return 2; } if (parsed.positionals.length > 0) { - stderr.write(GENERATE_UNKNOWN_COMMAND); + stderr.write(COMPILATION_UNKNOWN_COMMAND); return 2; } if (argv.length === 0 || parsed.values.help) { - stdout.write(GENERATE_HELP); + stdout.write(COMPILATION_HELP); return 0; } - stdout.write(GENERATE_HELP); + stdout.write(COMPILATION_HELP); return 0; } -/** @param {GenerateCommandOptions} options */ -function runGenerateUuid({ argv, stdout, stderr, createUuid }) { +/** + * @param {Pick} options + */ +async function runCompilationStatus({ + argv, + stdout, + stderr, + cwd, + fetchFunction, + planPushFileSystem, + createRequestSignal, + compilationSleep, + compilationNow, + apiToken, +}) { const parsed = parseArguments(() => parseArgs({ args: [...argv], options: { - count: { type: "string" }, + wait: { type: "boolean" }, help: { type: "boolean", short: "h" }, }, - allowPositionals: false, + allowPositionals: true, strict: true, tokens: true, }), ); if (!parsed || repeatedValueOption(parsed.tokens)) { - writeJson(stderr, { - error: "invalid_arguments", - detail: GENERATE_UUID_INVALID_ARGUMENTS_DETAIL, - }); + writeCompilationInvalidArguments( + stderr, + COMPILATION_STATUS_INVALID_ARGUMENTS_DETAIL, + ); return 2; } if (parsed.values.help) { - stdout.write(GENERATE_UUID_HELP); + stdout.write(COMPILATION_STATUS_HELP); return 0; } - const count = parseUuidCount(parsed.values.count); - if (count === null) { - writeJson(stderr, { - error: "invalid_arguments", - detail: GENERATE_UUID_INVALID_ARGUMENTS_DETAIL, - }); + const [compilationId] = parsed.positionals; + if (parsed.positionals.length !== 1 || !isUuidV7(compilationId)) { + writeCompilationInvalidArguments( + stderr, + COMPILATION_STATUS_INVALID_ARGUMENTS_DETAIL, + ); return 2; } - for (let index = 0; index < count; index += 1) { - stdout.write(`${createUuid()}\n`); + const authorizedFetch = authenticatedFetch(fetchFunction, apiToken); + if (authorizedFetch === null) { + writeAuthenticationRequired(stderr); + return 1; } - return 0; -} - -/** - * @param {Pick} options - */ -function runGenerateApplicationKey({ argv, stdout, stderr }) { - const parsed = parseArguments(() => - parseArgs({ - args: [...argv], - options: { - name: { type: "string" }, - help: { type: "boolean", short: "h" }, - }, - allowPositionals: false, - strict: true, - tokens: true, - }), - ); - if (!parsed || repeatedValueOption(parsed.tokens)) { - writeJson(stderr, { - error: "invalid_arguments", - detail: GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL, + try { + const result = await readCompilation({ + cwd, + compilationId, + wait: parsed.values.wait, + fetchFunction: authorizedFetch, + fileSystem: planPushFileSystem, + createRequestSignal, + sleep: compilationSleep, + now: compilationNow, }); - return 2; - } - - if (parsed.values.help) { - stdout.write(GENERATE_APPLICATION_KEY_HELP); + writeJson(stdout, result); return 0; + } catch (error) { + const status = writeCompilationReadError(stderr, error, false); + if (status !== null) return status; + throw error; } - - if (!isValidApplicationName(parsed.values.name)) { - writeJson(stderr, { - error: "invalid_arguments", - detail: GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL, - }); - return 2; - } - - stdout.write(`${deriveApplicationKey(parsed.values.name)}\n`); - return 0; } /** - * @param {Pick} options + * @param {Pick} options */ -async function runPlanPush({ +async function runCompilationDownload({ argv, stdout, stderr, cwd, fetchFunction, planPushFileSystem, - createTemporaryId, createRequestSignal, - apiUrl, apiToken, }) { const parsed = parseArguments(() => parseArgs({ args: [...argv], - options: { help: { type: "boolean", short: "h" } }, - allowPositionals: false, + options: { + output: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, strict: true, tokens: true, }), ); if (!parsed || repeatedValueOption(parsed.tokens)) { - writeJson(stderr, { - error: "invalid_arguments", - detail: PLAN_PUSH_INVALID_ARGUMENTS_DETAIL, - }); + writeCompilationInvalidArguments( + stderr, + COMPILATION_DOWNLOAD_INVALID_ARGUMENTS_DETAIL, + ); return 2; } if (parsed.values.help) { - stdout.write(PLAN_PUSH_HELP); + stdout.write(COMPILATION_DOWNLOAD_HELP); return 0; } + const [compilationId] = parsed.positionals; + const output = parsed.values.output; + if ( + parsed.positionals.length !== 1 || + !isUuidV7(compilationId) || + typeof output !== "string" || + output.length === 0 + ) { + writeCompilationInvalidArguments( + stderr, + COMPILATION_DOWNLOAD_INVALID_ARGUMENTS_DETAIL, + ); + return 2; + } + const authorizedFetch = authenticatedFetch(fetchFunction, apiToken); if (authorizedFetch === null) { writeAuthenticationRequired(stderr); return 1; } - let result; try { - result = await pushPlan({ + const result = await downloadCompilation({ cwd, - apiUrl, + compilationId, + output, fetchFunction: authorizedFetch, fileSystem: planPushFileSystem, - createTemporaryId, createRequestSignal, }); + writeJson(stdout, result); + return 0; } catch (error) { - if (error instanceof PlanPushConfigurationError) { + const readStatus = writeCompilationReadError(stderr, error, false); + if (readStatus !== null) return readStatus; + + if (error instanceof CompilationNotSucceededError) { writeJson(stderr, { - error: "invalid_configuration", - detail: PLAN_PUSH_INVALID_CONFIGURATION_DETAIL, + error: "compilation_not_succeeded", + detail: COMPILATION_NOT_SUCCEEDED_DETAIL, + current: error.current, }); - return 2; + return 1; } - if (error instanceof PlanPushLocalError) { + if (error instanceof CompilationArtifactUnavailableError) { + if (isAuthenticationProblem(error.status, error.response)) { + writeAuthenticationRequired( + stderr, + error.status, + /** @type {Record} */ (error.response), + ); + return 1; + } writeJson(stderr, { - error: "local_input_unreadable", - detail: PLAN_PUSH_LOCAL_INPUT_UNREADABLE_DETAIL, + error: "artifact_unavailable", + detail: COMPILATION_ARTIFACT_UNAVAILABLE_DETAIL, + ...(typeof error.status === "number" ? { status: error.status } : {}), + ...(error.response ? { response: error.response } : {}), }); return 1; } if ( - error instanceof PlanPushNetworkError || - error instanceof PlanPushProtocolError + error instanceof CompilationArtifactResponseInvalidError || + error instanceof CompilationArtifactInvalidError ) { writeJson(stderr, { - error: "request_outcome_unknown", - detail: PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL, - ...(typeof error.status === "number" ? { status: error.status } : {}), + error: "invalid_artifact", + detail: COMPILATION_ARTIFACT_INVALID_DETAIL, + ...(error instanceof CompilationArtifactResponseInvalidError + ? { status: error.status } + : {}), }); return 1; } - if (error instanceof PlanPushStateWriteError) { + if (error instanceof CompilationOutputPathError) { writeJson(stderr, { - error: "local_state_not_saved", - detail: - "The Plan was accepted, but its ETag could not be saved. Do not push again until local state is repaired.", - recovery_state: error.recoveryState, + error: "invalid_output_path", + detail: COMPILATION_INVALID_OUTPUT_PATH_DETAIL, }); - return 1; + return 2; } - throw error; - } - - if (!("etag" in result)) { - if (result.responseKind === null) { + if (error instanceof CompilationMaterializationError) { writeJson(stderr, { - error: "request_outcome_unknown", - detail: PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL, - status: result.status, + error: "materialization_failed", + detail: COMPILATION_MATERIALIZATION_FAILED_DETAIL, }); return 1; } - const response = safeRejectedResponse(result.responseKind, result.body); - if (isAuthenticationProblem(result.status, response)) { - writeAuthenticationRequired(stderr, result.status, response); - return 1; - } - writeJson(stderr, { - error: "server_rejected", - detail: PLAN_PUSH_SERVER_REJECTED_DETAIL, - status: result.status, - ...(response ? { response } : {}), + throw error; + } +} + +/** @param {Writer} writer @param {string} detail */ +function writeCompilationInvalidArguments(writer, detail) { + writeJson(writer, { error: "invalid_arguments", detail }); +} + +/** + * @param {Writer} writer + * @param {unknown} error + * @param {boolean} [throwUnknown] + */ +function writeCompilationReadError(writer, error, throwUnknown = true) { + if (error instanceof PlanPushLocalError) { + writeJson(writer, { + error: "local_input_unreadable", + detail: COMPILATION_LOCAL_INPUT_UNREADABLE_DETAIL, }); return 1; } - writeJson(stdout, { - outcome: result.outcome, - etag: result.etag, - project: result.body.project, - foundation_plan: result.body.foundation_plan, - diagnostics: result.body.diagnostics, - }); + if (error instanceof CompilationNotPushedError) { + writeJson(writer, { + error: "project_not_pushed", + detail: COMPILATION_NOT_PUSHED_DETAIL, + }); + return 1; + } + + if (error instanceof CompilationStatusUnavailableError) { + if (isAuthenticationProblem(error.status, error.response)) { + writeAuthenticationRequired( + writer, + error.status, + /** @type {Record} */ (error.response), + ); + return 1; + } + writeJson(writer, { + error: "compilation_status_unavailable", + detail: COMPILATION_STATUS_UNAVAILABLE_DETAIL, + ...(typeof error.status === "number" ? { status: error.status } : {}), + ...(error.response ? { response: error.response } : {}), + }); + return 1; + } + + if (error instanceof CompilationStatusInvalidError) { + writeJson(writer, { + error: "invalid_compilation_status", + detail: COMPILATION_STATUS_INVALID_DETAIL, + status: error.status, + }); + return 1; + } + + if (error instanceof CompilationChangedError) { + writeJson(writer, { + error: "compilation_changed", + detail: COMPILATION_CHANGED_DETAIL, + current: error.current, + }); + return 1; + } + + if (error instanceof CompilationTimeoutError) { + writeJson(writer, { + error: "compilation_wait_timed_out", + detail: COMPILATION_TIMEOUT_DETAIL, + current: error.current, + }); + return 1; + } + + if (throwUnknown) throw error; + return null; +} + +/** + * @param {GenerateCommandOptions} options + */ +function runGenerate({ argv, stdout, stderr, createUuid }) { + if (argv[0] === "uuid") { + return runGenerateUuid({ + argv: argv.slice(1), + stdout, + stderr, + createUuid, + }); + } + + if (argv[0] === "application-key") { + return runGenerateApplicationKey({ + argv: argv.slice(1), + stdout, + stderr, + }); + } + + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { help: { type: "boolean", short: "h" } }, + allowPositionals: true, + strict: true, + }), + ); + + if (!parsed) { + stderr.write(GENERATE_USAGE_ERROR); + return 2; + } + + if (parsed.positionals.length > 0) { + stderr.write(GENERATE_UNKNOWN_COMMAND); + return 2; + } + + if (argv.length === 0 || parsed.values.help) { + stdout.write(GENERATE_HELP); + return 0; + } + + stdout.write(GENERATE_HELP); + return 0; +} + +/** @param {GenerateCommandOptions} options */ +function runGenerateUuid({ argv, stdout, stderr, createUuid }) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { + count: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: false, + strict: true, + tokens: true, + }), + ); + + if (!parsed || repeatedValueOption(parsed.tokens)) { + writeJson(stderr, { + error: "invalid_arguments", + detail: GENERATE_UUID_INVALID_ARGUMENTS_DETAIL, + }); + return 2; + } + + if (parsed.values.help) { + stdout.write(GENERATE_UUID_HELP); + return 0; + } + + const count = parseUuidCount(parsed.values.count); + if (count === null) { + writeJson(stderr, { + error: "invalid_arguments", + detail: GENERATE_UUID_INVALID_ARGUMENTS_DETAIL, + }); + return 2; + } + + for (let index = 0; index < count; index += 1) { + stdout.write(`${createUuid()}\n`); + } return 0; } /** - * @param {Pick} options + * @param {Pick} options */ -async function runPlanStatus({ +function runGenerateApplicationKey({ argv, stdout, stderr }) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { + name: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: false, + strict: true, + tokens: true, + }), + ); + + if (!parsed || repeatedValueOption(parsed.tokens)) { + writeJson(stderr, { + error: "invalid_arguments", + detail: GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL, + }); + return 2; + } + + if (parsed.values.help) { + stdout.write(GENERATE_APPLICATION_KEY_HELP); + return 0; + } + + if (!isValidApplicationName(parsed.values.name)) { + writeJson(stderr, { + error: "invalid_arguments", + detail: GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL, + }); + return 2; + } + + stdout.write(`${deriveApplicationKey(parsed.values.name)}\n`); + return 0; +} + +/** + * @param {Pick} options + */ +async function runPlanPush({ argv, stdout, stderr, cwd, fetchFunction, planPushFileSystem, + createTemporaryId, createRequestSignal, - planStatusSleep, - planStatusNow, + apiUrl, apiToken, }) { const parsed = parseArguments(() => parseArgs({ args: [...argv], - options: { - help: { type: "boolean", short: "h" }, - wait: { type: "boolean" }, - }, + options: { help: { type: "boolean", short: "h" } }, allowPositionals: false, strict: true, tokens: true, @@ -894,13 +1199,13 @@ async function runPlanStatus({ if (!parsed || repeatedValueOption(parsed.tokens)) { writeJson(stderr, { error: "invalid_arguments", - detail: PLAN_STATUS_INVALID_ARGUMENTS_DETAIL, + detail: PLAN_PUSH_INVALID_ARGUMENTS_DETAIL, }); return 2; } if (parsed.values.help) { - stdout.write(PLAN_STATUS_HELP); + stdout.write(PLAN_PUSH_HELP); return 0; } @@ -912,64 +1217,49 @@ async function runPlanStatus({ let result; try { - result = await readPlanStatus({ + result = await pushPlan({ cwd, - wait: parsed.values.wait, + apiUrl, fetchFunction: authorizedFetch, fileSystem: planPushFileSystem, + createTemporaryId, createRequestSignal, - sleep: planStatusSleep, - now: planStatusNow, }); } catch (error) { - if (error instanceof PlanPushLocalError) { - writeJson(stderr, { - error: "local_input_unreadable", - detail: PLAN_STATUS_LOCAL_INPUT_UNREADABLE_DETAIL, - }); - return 1; - } - - if (error instanceof PlanStatusNotPushedError) { - writeJson(stderr, { - error: "project_not_pushed", - detail: PLAN_STATUS_NOT_PUSHED_DETAIL, - }); - return 1; - } - - if (error instanceof PlanStatusChangedError) { + if (error instanceof PlanPushConfigurationError) { writeJson(stderr, { - error: "analysis_changed", - detail: PLAN_STATUS_CHANGED_DETAIL, - current: error.current, + error: "invalid_configuration", + detail: PLAN_PUSH_INVALID_CONFIGURATION_DETAIL, }); - return 1; + return 2; } - if (error instanceof PlanStatusTimeoutError) { + if (error instanceof PlanPushLocalError) { writeJson(stderr, { - error: "wait_timed_out", - detail: PLAN_STATUS_TIMEOUT_DETAIL, - current: error.current, + error: "local_input_unreadable", + detail: PLAN_PUSH_LOCAL_INPUT_UNREADABLE_DETAIL, }); return 1; } - if (error instanceof PlanPushNetworkError) { + if ( + error instanceof PlanPushNetworkError || + error instanceof PlanPushProtocolError + ) { writeJson(stderr, { - error: "status_unavailable", - detail: PLAN_STATUS_UNAVAILABLE_DETAIL, + error: "request_outcome_unknown", + detail: PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL, ...(typeof error.status === "number" ? { status: error.status } : {}), }); return 1; } - if (error instanceof PlanPushProtocolError) { + if (error instanceof PlanPushStateWriteError) { writeJson(stderr, { - error: "invalid_server_response", - detail: PLAN_STATUS_INVALID_RESPONSE_DETAIL, - status: error.status, + error: "local_state_not_saved", + detail: + "The Plan was accepted, but its ETag could not be saved. Do not push again until local state is repaired.", + recovery_state: error.recoveryState, }); return 1; } @@ -977,11 +1267,11 @@ async function runPlanStatus({ throw error; } - if ("responseKind" in result) { + if (!("etag" in result)) { if (result.responseKind === null) { writeJson(stderr, { - error: "invalid_server_response", - detail: PLAN_STATUS_INVALID_RESPONSE_DETAIL, + error: "request_outcome_unknown", + detail: PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL, status: result.status, }); return 1; @@ -994,21 +1284,27 @@ async function runPlanStatus({ } writeJson(stderr, { error: "server_rejected", - detail: PLAN_STATUS_SERVER_REJECTED_DETAIL, + detail: PLAN_PUSH_SERVER_REJECTED_DETAIL, status: result.status, ...(response ? { response } : {}), }); return 1; } - writeJson(stdout, result.body); + writeJson(stdout, { + outcome: result.outcome, + etag: result.etag, + project: result.body.project, + foundation_plan: result.body.foundation_plan, + diagnostics: result.body.diagnostics, + }); return 0; } /** - * @param {Pick} options + * @param {Pick} options */ -async function runPlanCompile({ +async function runPlanStatus({ argv, stdout, stderr, @@ -1016,16 +1312,16 @@ async function runPlanCompile({ fetchFunction, planPushFileSystem, createRequestSignal, - planCompileSleep, - planCompileNow, + planStatusSleep, + planStatusNow, apiToken, }) { const parsed = parseArguments(() => parseArgs({ args: [...argv], options: { - output: { type: "string" }, help: { type: "boolean", short: "h" }, + wait: { type: "boolean" }, }, allowPositionals: false, strict: true, @@ -1036,218 +1332,137 @@ async function runPlanCompile({ if (!parsed || repeatedValueOption(parsed.tokens)) { writeJson(stderr, { error: "invalid_arguments", - detail: PLAN_COMPILE_INVALID_ARGUMENTS_DETAIL, + detail: PLAN_STATUS_INVALID_ARGUMENTS_DETAIL, }); return 2; } if (parsed.values.help) { - stdout.write(PLAN_COMPILE_HELP); + stdout.write(PLAN_STATUS_HELP); return 0; } - if ( - typeof parsed.values.output !== "string" || - parsed.values.output.length === 0 || - parsed.values.output.includes("\0") - ) { - writeJson(stderr, { - error: "invalid_arguments", - detail: PLAN_COMPILE_INVALID_ARGUMENTS_DETAIL, - }); - return 2; - } - const authorizedFetch = authenticatedFetch(fetchFunction, apiToken); - if (authorizedFetch === null) { - writeAuthenticationRequired(stderr); - return 1; - } - - let result; - try { - result = await compilePlan({ - cwd, - output: parsed.values.output, - fetchFunction: authorizedFetch, - fileSystem: planPushFileSystem, - createRequestSignal, - sleep: planCompileSleep, - now: planCompileNow, - }); - } catch (error) { - if ( - (error instanceof CompilationStartRejectedError || - error instanceof CompilationStatusUnavailableError || - error instanceof CompilationArtifactUnavailableError) && - 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_COMPILE_LOCAL_INPUT_UNREADABLE_DETAIL, - }); - return 1; - } - - if (error instanceof CompilationLocalStateError) { - writeJson(stderr, { - error: "invalid_configuration", - detail: PLAN_COMPILE_INCOMPATIBLE_STATE_DETAIL, - }); - return 2; - } - - if (error instanceof CompilationNotPushedError) { - writeJson(stderr, { - error: "project_not_pushed", - detail: PLAN_COMPILE_NOT_PUSHED_DETAIL, - }); - return 1; - } - - if (error instanceof CompilationRequestOutcomeUnknownError) { - writeJson(stderr, { - error: "request_outcome_unknown", - detail: PLAN_COMPILE_REQUEST_OUTCOME_UNKNOWN_DETAIL, - ...(typeof error.status === "number" ? { status: error.status } : {}), - }); - return 1; - } - - if (error instanceof CompilationStartRejectedError) { - writeJson(stderr, { - error: "compilation_start_rejected", - detail: PLAN_COMPILE_START_REJECTED_DETAIL, - status: error.status, - ...(error.response ? { response: error.response } : {}), - }); - return 1; - } - - if (error instanceof CompilationStatusUnavailableError) { - writeJson(stderr, { - error: "compilation_status_unavailable", - detail: PLAN_COMPILE_STATUS_UNAVAILABLE_DETAIL, - ...(typeof error.status === "number" ? { status: error.status } : {}), - ...(error.response ? { response: error.response } : {}), - }); - return 1; - } - - if (error instanceof CompilationStatusInvalidError) { - writeJson(stderr, { - error: "invalid_compilation_status", - detail: PLAN_COMPILE_STATUS_INVALID_DETAIL, - status: error.status, - }); - return 1; - } + if (authorizedFetch === null) { + writeAuthenticationRequired(stderr); + return 1; + } - if (error instanceof CompilationChangedError) { + let result; + try { + result = await readPlanStatus({ + cwd, + wait: parsed.values.wait, + fetchFunction: authorizedFetch, + fileSystem: planPushFileSystem, + createRequestSignal, + sleep: planStatusSleep, + now: planStatusNow, + }); + } catch (error) { + if (error instanceof PlanPushLocalError) { writeJson(stderr, { - error: "compilation_changed", - detail: PLAN_COMPILE_CHANGED_DETAIL, - current: error.current, + error: "local_input_unreadable", + detail: PLAN_STATUS_LOCAL_INPUT_UNREADABLE_DETAIL, }); return 1; } - if (error instanceof CompilationTimeoutError) { + if (error instanceof PlanStatusNotPushedError) { writeJson(stderr, { - error: "compilation_wait_timed_out", - detail: PLAN_COMPILE_TIMEOUT_DETAIL, - current: error.current, + error: "project_not_pushed", + detail: PLAN_STATUS_NOT_PUSHED_DETAIL, }); return 1; } - if (error instanceof CompilationFailedError) { + if (error instanceof PlanStatusChangedError) { writeJson(stderr, { - error: "compilation_failed", - detail: PLAN_COMPILE_FAILED_DETAIL, + error: "analysis_changed", + detail: PLAN_STATUS_CHANGED_DETAIL, current: error.current, }); return 1; } - if (error instanceof CompilationCancelledError) { + if (error instanceof PlanStatusTimeoutError) { writeJson(stderr, { - error: "compilation_cancelled", - detail: PLAN_COMPILE_CANCELLED_DETAIL, + error: "wait_timed_out", + detail: PLAN_STATUS_TIMEOUT_DETAIL, current: error.current, }); return 1; } - if (error instanceof CompilationArtifactUnavailableError) { + if (error instanceof PlanPushNetworkError) { writeJson(stderr, { - error: "artifact_unavailable", - detail: PLAN_COMPILE_ARTIFACT_UNAVAILABLE_DETAIL, + error: "status_unavailable", + detail: PLAN_STATUS_UNAVAILABLE_DETAIL, ...(typeof error.status === "number" ? { status: error.status } : {}), - ...(error.response ? { response: error.response } : {}), }); return 1; } - if ( - error instanceof CompilationArtifactResponseInvalidError || - error instanceof CompilationArtifactInvalidError - ) { + if (error instanceof PlanPushProtocolError) { writeJson(stderr, { - error: "invalid_artifact", - detail: PLAN_COMPILE_ARTIFACT_INVALID_DETAIL, - ...(error instanceof CompilationArtifactResponseInvalidError - ? { status: error.status } - : {}), + error: "invalid_server_response", + detail: PLAN_STATUS_INVALID_RESPONSE_DETAIL, + status: error.status, }); return 1; } - if (error instanceof CompilationOutputPathError) { - writeJson(stderr, { - error: "invalid_output_path", - detail: PLAN_COMPILE_INVALID_OUTPUT_PATH_DETAIL, - }); - return 2; - } + throw error; + } - if (error instanceof CompilationMaterializationError) { + if ("responseKind" in result) { + if (result.responseKind === null) { writeJson(stderr, { - error: "materialization_failed", - detail: PLAN_COMPILE_MATERIALIZATION_FAILED_DETAIL, + error: "invalid_server_response", + detail: PLAN_STATUS_INVALID_RESPONSE_DETAIL, + status: result.status, }); return 1; } - throw error; + const response = safeRejectedResponse(result.responseKind, result.body); + if (isAuthenticationProblem(result.status, response)) { + writeAuthenticationRequired(stderr, result.status, response); + return 1; + } + writeJson(stderr, { + error: "server_rejected", + detail: PLAN_STATUS_SERVER_REJECTED_DETAIL, + status: result.status, + ...(response ? { response } : {}), + }); + return 1; } - writeJson(stdout, result); + writeJson(stdout, result.body); return 0; } /** - * @param {Pick} options + * @param {Pick} options */ -async function runPlanPublish({ +async function runPlanCompile({ argv, stdout, stderr, cwd, fetchFunction, planPushFileSystem, + createTemporaryId, createRequestSignal, + planCompileSleep, + planCompileNow, planPublishSleep, planPublishNow, + planCompilePush, + planCompileReadStatus, + planCompilePublish, + apiUrl, apiToken, }) { const parsed = parseArguments(() => @@ -1263,13 +1478,13 @@ async function runPlanPublish({ if (!parsed || repeatedValueOption(parsed.tokens)) { writeJson(stderr, { error: "invalid_arguments", - detail: PLAN_PUBLISH_INVALID_ARGUMENTS_DETAIL, + detail: PLAN_COMPILE_INVALID_ARGUMENTS_DETAIL, }); return 2; } if (parsed.values.help) { - stdout.write(PLAN_PUBLISH_HELP); + stdout.write(PLAN_COMPILE_HELP); return 0; } @@ -1281,145 +1496,289 @@ async function runPlanPublish({ let result; try { - result = await publishPlan({ + result = await compilePlan({ cwd, + apiUrl, fetchFunction: authorizedFetch, fileSystem: planPushFileSystem, + createTemporaryId, createRequestSignal, - sleep: planPublishSleep, - now: planPublishNow, + analysisSleep: planCompileSleep, + analysisNow: planCompileNow, + publicationSleep: planPublishSleep, + publicationNow: planPublishNow, + push: planCompilePush, + readStatus: planCompileReadStatus, + publish: planCompilePublish, }); } catch (error) { - if ( - (error instanceof PublicationStartRejectedError || - error instanceof PublicationStatusUnavailableError) && - isAuthenticationProblem(error.status, error.response) - ) { - writeAuthenticationRequired( - stderr, - error.status, - /** @type {Record} */ (error.response), - ); - return 1; - } + return writePlanCompileError(stderr, error); + } - if (error instanceof PlanPushLocalError) { - writeJson(stderr, { - error: "local_input_unreadable", - detail: PLAN_PUBLISH_LOCAL_INPUT_UNREADABLE_DETAIL, - }); - return 1; - } + writeJson(stdout, result); + return 0; +} - if (error instanceof PublicationLocalStateError) { - writeJson(stderr, { - error: "invalid_configuration", - detail: PLAN_PUBLISH_INCOMPATIBLE_STATE_DETAIL, - }); - return 2; - } +/** @param {Writer} writer @param {unknown} error */ +function writePlanCompileError(writer, error) { + if (error instanceof PlanPushConfigurationError) { + writeJson(writer, { + error: "invalid_configuration", + detail: PLAN_COMPILE_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 PlanPushLocalError) { + writeJson(writer, { + error: "local_input_unreadable", + detail: PLAN_COMPILE_LOCAL_INPUT_UNREADABLE_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 PlanPushNetworkError || + error instanceof PlanPushProtocolError + ) { + writeJson(writer, { + error: "request_outcome_unknown", + phase: "push", + detail: PLAN_COMPILE_REQUEST_OUTCOME_UNKNOWN_DETAIL, + ...(typeof error.status === "number" ? { status: error.status } : {}), + }); + return 1; + } - if (error instanceof PublicationRequestOutcomeUnknownError) { - writeJson(stderr, { + if (error instanceof PlanPushStateWriteError) { + writeJson(writer, { + error: "local_state_not_saved", + detail: + "The Plan was accepted, but its ETag could not be saved. Do not compile again until local state is repaired.", + recovery_state: error.recoveryState, + }); + return 1; + } + + if (error instanceof PlanCompilePushRejectedError) { + const { result } = error; + if (result.responseKind === null) { + writeJson(writer, { error: "request_outcome_unknown", - detail: PLAN_PUBLISH_REQUEST_OUTCOME_UNKNOWN_DETAIL, - ...(typeof error.status === "number" ? { status: error.status } : {}), - ...(error.response ? { response: error.response } : {}), + phase: "push", + detail: PLAN_COMPILE_REQUEST_OUTCOME_UNKNOWN_DETAIL, + status: result.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, - }); + const response = safeRejectedResponse(result.responseKind, result.body); + if (isAuthenticationProblem(result.status, response)) { + writeAuthenticationRequired(writer, result.status, response); return 1; } + writeJson(writer, { + error: "server_rejected", + detail: PLAN_COMPILE_PLAN_REJECTED_DETAIL, + status: result.status, + ...(response ? { 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 PlanStatusNotPushedError) { + writeJson(writer, { + error: "project_not_pushed", + detail: PLAN_COMPILE_NOT_PUSHED_DETAIL, + }); + 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 PlanStatusChangedError) { + writeJson(writer, { + error: "analysis_changed", + detail: PLAN_STATUS_CHANGED_DETAIL, + current: error.current, + }); + return 1; + } - if (error instanceof PublicationChangedError) { - writeJson(stderr, { - error: "publication_changed", - detail: PLAN_PUBLISH_CHANGED_DETAIL, - current: error.current, - rejected: error.rejected, - }); - return 1; - } + if (error instanceof PlanStatusTimeoutError) { + writeJson(writer, { + error: "analysis_wait_timed_out", + detail: PLAN_STATUS_TIMEOUT_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 PlanCompileAnalysisUnavailableError) { + writeJson(writer, { + error: "analysis_status_unavailable", + detail: PLAN_STATUS_UNAVAILABLE_DETAIL, + ...(typeof error.status === "number" ? { status: error.status } : {}), + }); + return 1; + } - if (error instanceof PublicationFailedError) { - writeJson(stderr, { - error: "publication_failed", - detail: PLAN_PUBLISH_FAILED_DETAIL, - current: error.current, + if (error instanceof PlanCompileAnalysisInvalidError) { + writeJson(writer, { + error: "invalid_analysis_status", + detail: PLAN_STATUS_INVALID_RESPONSE_DETAIL, + status: error.status, + }); + return 1; + } + + if (error instanceof PlanCompileAnalysisRejectedError) { + const { result } = error; + if (result.responseKind === null) { + writeJson(writer, { + error: "invalid_analysis_status", + detail: PLAN_STATUS_INVALID_RESPONSE_DETAIL, + status: result.status, }); return 1; } - if (error instanceof PublicationCancelledError) { - writeJson(stderr, { - error: "publication_cancelled", - detail: PLAN_PUBLISH_CANCELLED_DETAIL, - current: error.current, - }); + const response = safeRejectedResponse(result.responseKind, result.body); + if (isAuthenticationProblem(result.status, response)) { + writeAuthenticationRequired(writer, result.status, response); return 1; } + writeJson(writer, { + error: "analysis_status_rejected", + detail: PLAN_COMPILE_ANALYSIS_REJECTED_DETAIL, + status: result.status, + ...(response ? { response } : {}), + }); + return 1; + } - throw error; + if (error instanceof PlanCompileAnalysisNotValidError) { + writeJson(writer, { + error: "plan_not_valid", + detail: PLAN_COMPILE_ANALYSIS_NOT_VALID_DETAIL, + current: error.current, + }); + return 1; + } + + if (error instanceof PublicationLocalStateError) { + writeJson(writer, { + error: "invalid_configuration", + detail: PLAN_PUBLISH_INCOMPATIBLE_STATE_DETAIL, + }); + return 2; } - const repository = result.publication.repository; - if (repository === null) { - throw new Error("A successful Publication must identify its repository."); + if (error instanceof PublicationNotPushedError) { + writeJson(writer, { + error: "project_not_pushed", + detail: PLAN_PUBLISH_NOT_PUSHED_DETAIL, + }); + return 1; } - stdout.write(`${repository.html_url}\n`); - return 0; + + if (error instanceof PublicationLocalPlanChangedError) { + writeJson(writer, { + error: "local_plan_changed", + detail: PLAN_PUBLISH_LOCAL_PLAN_CHANGED_DETAIL, + }); + return 1; + } + + if ( + (error instanceof PublicationStartRejectedError || + error instanceof PublicationStatusUnavailableError) && + isAuthenticationProblem(error.status, error.response) + ) { + writeAuthenticationRequired( + writer, + error.status, + /** @type {Record} */ (error.response), + ); + return 1; + } + + if (error instanceof PublicationRequestOutcomeUnknownError) { + writeJson(writer, { + error: "request_outcome_unknown", + phase: "publication", + detail: PLAN_PUBLISH_REQUEST_OUTCOME_UNKNOWN_DETAIL, + ...(typeof error.status === "number" ? { status: error.status } : {}), + ...(error.response ? { response: error.response } : {}), + }); + return 1; + } + + if (error instanceof PublicationStartRejectedError) { + writeJson(writer, { + error: "publication_start_rejected", + detail: PLAN_PUBLISH_START_REJECTED_DETAIL, + status: error.status, + response: error.response, + }); + return 1; + } + + if (error instanceof PublicationStatusUnavailableError) { + writeJson(writer, { + 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(writer, { + error: "invalid_publication_status", + detail: PLAN_PUBLISH_STATUS_INVALID_DETAIL, + status: error.status, + }); + return 1; + } + + if (error instanceof PublicationChangedError) { + writeJson(writer, { + error: "publication_changed", + detail: PLAN_PUBLISH_CHANGED_DETAIL, + current: error.current, + rejected: error.rejected, + }); + return 1; + } + + if (error instanceof PublicationTimeoutError) { + writeJson(writer, { + error: "publication_wait_timed_out", + detail: PLAN_PUBLISH_TIMEOUT_DETAIL, + current: error.current, + }); + return 1; + } + + if (error instanceof PublicationFailedError) { + writeJson(writer, { + error: "publication_failed", + detail: PLAN_PUBLISH_FAILED_DETAIL, + current: error.current, + }); + return 1; + } + + if (error instanceof PublicationCancelledError) { + writeJson(writer, { + error: "publication_cancelled", + detail: PLAN_PUBLISH_CANCELLED_DETAIL, + current: error.current, + }); + return 1; + } + + throw error; } /** diff --git a/src/commands/compilation.js b/src/commands/compilation.js new file mode 100644 index 0000000..3f17acb --- /dev/null +++ b/src/commands/compilation.js @@ -0,0 +1,823 @@ +import { createHash } from "node:crypto"; + +import { + ARTIFACT_MEDIA_TYPE, + CompilationArtifactInvalidError, + CompilationMaterializationError, + CompilationOutputPathError, + MAX_ARTIFACT_BYTES, + materializeCompilationArtifact, + parseCompilationArtifact, + resolveOutputTarget, +} from "../compilation-artifact.js"; +import { + FirstDraftNetworkError, + FirstDraftProtocolError, + isProblemBody, + readResponseBody, + readResponseBytes, + responseMediaType, + sendRequest, +} from "../api-response.js"; +import { isUuidV7, readPlanState } from "../plan-state.js"; + +const REQUEST_TIMEOUT_MS = 30_000; +const WAIT_TIMEOUT_MS = 10 * 60_000; +const POLL_INTERVAL_MS = 1_000; +const ALL_STATUSES = new Set([ + "queued", + "running", + "succeeded", + "failed", + "cancelled", +]); +const TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled"]); +const RESPONSE_KEYS = ["project", "compilation"]; +const PROJECT_KEYS = ["id", "graph_version"]; +const COMPILATION_KEYS = [ + "id", + "analysis_run_id", + "graph_version", + "head_source_sha256", + "status", + "compiler_release", + "target", + "status_path", + "cancel_path", + "artifact", + "failure", + "created_at", + "started_at", + "completed_at", +]; +const TARGET_KEYS = ["id", "profile"]; +const ARTIFACT_KEYS = ["path", "sha256", "media_type", "byte_size"]; +const FAILURE_KEYS = ["phase", "code", "message"]; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const RELEASE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/; +const MAX_IDENTIFIER_BYTES = 256; +const MAX_FAILURE_MESSAGE_BYTES = 4_096; + +export class CompilationNotPushedError extends Error {} + +export class CompilationStatusUnavailableError extends Error { + /** + * @param {number | undefined} status + * @param {Record | null} [response] + * @param {{transport?: boolean}} [options] + */ + constructor(status, response = null, options = {}) { + super("The compilation status is unavailable."); + this.status = status; + this.response = response; + this.transport = options.transport ?? false; + } +} + +export class CompilationStatusInvalidError extends Error { + /** @param {number} status */ + constructor(status) { + super("The compilation status response is invalid."); + this.status = status; + } +} + +export class CompilationChangedError extends Error { + /** @param {CompilationResponse} current */ + constructor(current) { + super("The compilation changed while it was being polled."); + this.current = current; + } +} + +export class CompilationTimeoutError extends Error { + /** @param {CompilationResponse} current */ + constructor(current) { + super("The compilation did not finish before the wait deadline."); + this.current = current; + } +} + +export class CompilationNotSucceededError extends Error { + /** @param {CompilationResponse} current */ + constructor(current) { + super("The compilation has not succeeded."); + this.current = current; + } +} + +export class CompilationArtifactUnavailableError extends Error { + /** + * @param {number | undefined} status + * @param {Record | null} [response] + */ + constructor(status, response = null) { + super("The compilation artifact is unavailable."); + this.status = status; + this.response = response; + } +} + +export class CompilationArtifactResponseInvalidError extends Error { + /** @param {number} status */ + constructor(status) { + super("The compilation artifact response is invalid."); + this.status = status; + } +} + +/** + * @typedef {object} CompilationResponse + * @property {{id: string, graph_version: number}} 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}, + * status_path: string, + * cancel_path: string, + * artifact: null | {path: string, sha256: string, media_type: string, byte_size: number}, + * failure: null | {phase: string, code: string, message: string}, + * created_at: string, + * started_at: string | null, + * completed_at: string | null + * }} compilation + */ + +/** + * @typedef {object} CompilationStatusOptions + * @property {string} cwd + * @property {string} compilationId + * @property {boolean} [wait] + * @property {typeof globalThis.fetch} [fetchFunction] + * @property {import("../plan-state.js").PlanStateFileSystem} [fileSystem] + * @property {(timeoutMs: number) => AbortSignal} [createRequestSignal] + * @property {(delayMs: number) => Promise} [sleep] + * @property {() => number} [now] + */ + +/** + * @param {CompilationStatusOptions} options + * @returns {Promise} + */ +export async function readCompilation({ + cwd, + compilationId, + wait = false, + fetchFunction = globalThis.fetch, + fileSystem, + createRequestSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), + sleep = sleepFor, + now = Date.now, +}) { + const context = readContext({ cwd, compilationId, fileSystem }); + const deadline = wait ? now() + WAIT_TIMEOUT_MS : null; + /** @type {CompilationResponse | null} */ + let first = null; + /** @type {CompilationResponse | null} */ + let current = null; + + while (true) { + if (deadline !== null && current !== null && now() >= deadline) { + throw new CompilationTimeoutError(current); + } + + const requestTimeout = + deadline === null + ? REQUEST_TIMEOUT_MS + : Math.max(1, Math.min(REQUEST_TIMEOUT_MS, deadline - now())); + let next; + try { + next = await readCompilationStatus({ + ...context, + fetchFunction, + createRequestSignal, + requestTimeout, + }); + } catch (error) { + if ( + error instanceof CompilationStatusUnavailableError && + error.transport && + deadline !== null && + current !== null && + now() >= deadline + ) { + throw new CompilationTimeoutError(current); + } + throw error; + } + + first ??= next; + if (!sameCompilation(first, next)) { + throw new CompilationChangedError(next); + } + if (current !== null && !validTransition(current, next)) { + throw new CompilationChangedError(next); + } + current = next; + + if (!wait || TERMINAL_STATUSES.has(current.compilation.status)) { + return current; + } + + const remaining = deadline === null ? 0 : deadline - now(); + if (remaining <= 0) throw new CompilationTimeoutError(current); + await sleep(Math.min(POLL_INTERVAL_MS, remaining)); + } +} + +/** + * @typedef {object} CompilationDownloadOptions + * @property {string} cwd + * @property {string} compilationId + * @property {string} output + * @property {typeof globalThis.fetch} [fetchFunction] + * @property {import("../plan-state.js").PlanStateFileSystem} [fileSystem] + * @property {(timeoutMs: number) => AbortSignal} [createRequestSignal] + */ + +/** + * @param {CompilationDownloadOptions} options + */ +export async function downloadCompilation({ + cwd, + compilationId, + output, + fetchFunction = globalThis.fetch, + fileSystem, + createRequestSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), +}) { + const context = readContext({ cwd, compilationId, fileSystem }); + const outputTarget = resolveOutputTarget({ cwd, output }); + const current = await readCompilationStatus({ + ...context, + fetchFunction, + createRequestSignal, + requestTimeout: REQUEST_TIMEOUT_MS, + }); + if (current.compilation.status !== "succeeded") { + throw new CompilationNotSucceededError(current); + } + + const metadata = + /** @type {{path: string, sha256: string, media_type: string, byte_size: number}} */ ( + current.compilation.artifact + ); + const source = await downloadArtifact({ + apiUrl: context.apiUrl, + metadata, + fetchFunction, + createRequestSignal, + }); + const artifact = parseCompilationArtifact(source, { + projectId: context.projectId, + compilationId: current.compilation.id, + graphVersion: current.compilation.graph_version, + headSourceSha256: current.compilation.head_source_sha256, + analysisRunId: current.compilation.analysis_run_id, + compilerRelease: current.compilation.compiler_release, + target: current.compilation.target, + }); + const materialized = materializeCompilationArtifact(artifact, outputTarget); + + return { + project: current.project, + compilation: current.compilation, + output: materialized, + }; +} + +/** + * @param {object} options + * @param {string} options.cwd + * @param {string} options.compilationId + * @param {import("../plan-state.js").PlanStateFileSystem | undefined} options.fileSystem + */ +function readContext({ cwd, compilationId, fileSystem }) { + if (!isUuidV7(compilationId)) { + throw new TypeError("Compilation ID must be a canonical UUIDv7."); + } + + const state = readPlanState({ cwd, fileSystem }); + if (state.api_url === undefined) { + throw new CompilationNotPushedError( + "The local Foundation Plan has not been pushed.", + ); + } + + return { + apiUrl: state.api_url, + projectId: state.project_id, + compilationId, + }; +} + +/** + * @param {object} options + * @param {string} options.apiUrl + * @param {string} options.projectId + * @param {string} options.compilationId + * @param {typeof globalThis.fetch} options.fetchFunction + * @param {(timeoutMs: number) => AbortSignal} options.createRequestSignal + * @param {number} options.requestTimeout + */ +async function readCompilationStatus({ + apiUrl, + projectId, + compilationId, + fetchFunction, + createRequestSignal, + requestTimeout, +}) { + const endpoint = new URL( + `/v1/projects/${projectId}/compilations/${compilationId}`, + apiUrl, + ); + 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 CompilationStatusUnavailableError(error.status, null, { + transport: true, + }); + } + if (error instanceof FirstDraftProtocolError) { + throw new CompilationStatusInvalidError(error.status); + } + + throw error; + } + + if (response.status !== 200) { + if (!response.ok) { + throw new CompilationStatusUnavailableError( + response.status, + safeProblem(response, body), + ); + } + throw new CompilationStatusInvalidError(response.status); + } + + const parsed = parseCompilationResponse(body, projectId, compilationId); + if (responseMediaType(response) !== "application/json" || parsed === null) { + throw new CompilationStatusInvalidError(response.status); + } + + return parsed; +} + +/** + * @param {object} options + * @param {string} options.apiUrl + * @param {{path: string, sha256: string, media_type: string, byte_size: number}} options.metadata + * @param {typeof globalThis.fetch} options.fetchFunction + * @param {(timeoutMs: number) => AbortSignal} options.createRequestSignal + */ +async function downloadArtifact({ + apiUrl, + metadata, + fetchFunction, + createRequestSignal, +}) { + const endpoint = new URL(metadata.path, apiUrl); + let response; + let source; + try { + response = await sendRequest(fetchFunction, endpoint, { + method: "GET", + headers: { + Accept: `${ARTIFACT_MEDIA_TYPE}, application/problem+json`, + }, + redirect: "error", + signal: createRequestSignal(REQUEST_TIMEOUT_MS), + }); + source = await readResponseBytes(response, MAX_ARTIFACT_BYTES); + } catch (error) { + if (error instanceof FirstDraftNetworkError) { + throw new CompilationArtifactUnavailableError(error.status); + } + if (error instanceof FirstDraftProtocolError) { + throw new CompilationArtifactResponseInvalidError(error.status); + } + + throw error; + } + + if (response.status !== 200) { + let body = null; + try { + body = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(source), + ); + } catch (error) { + if (!(error instanceof SyntaxError || error instanceof TypeError)) { + throw error; + } + } + throw new CompilationArtifactUnavailableError( + response.status, + safeProblem(response, body), + ); + } + + const contentLength = response.headers.get("content-length"); + if ( + responseMediaType(response) !== ARTIFACT_MEDIA_TYPE || + response.headers.get("cache-control") !== "no-store, no-transform" || + response.headers.get("content-encoding") !== null || + contentLength === null || + contentLength !== String(metadata.byte_size) || + source.byteLength !== metadata.byte_size || + response.headers.get("etag") !== `"sha256:${metadata.sha256}"` || + sha256(source) !== metadata.sha256 + ) { + throw new CompilationArtifactResponseInvalidError(response.status); + } + + return source; +} + +/** + * @param {unknown} value + * @param {string} projectId + * @param {string} compilationId + * @returns {CompilationResponse | null} + */ +function parseCompilationResponse(value, projectId, compilationId) { + if ( + !hasExactKeySet(value, RESPONSE_KEYS) || + !hasExactKeySet(value.project, PROJECT_KEYS) || + !hasExactKeySet(value.compilation, COMPILATION_KEYS) + ) { + return null; + } + + const project = value.project; + const compilation = value.compilation; + if ( + project.id !== projectId || + !isGraphVersion(project.graph_version) || + compilation.id !== compilationId || + !isUuidV7(compilation.id) || + !isUuidV7(compilation.analysis_run_id) || + !isGraphVersion(compilation.graph_version) || + compilation.graph_version !== project.graph_version || + typeof compilation.head_source_sha256 !== "string" || + !SHA256_PATTERN.test(compilation.head_source_sha256) || + typeof compilation.status !== "string" || + !ALL_STATUSES.has(compilation.status) || + !isRelease(compilation.compiler_release) || + !hasExactKeySet(compilation.target, TARGET_KEYS) || + !isRelease(compilation.target.id) || + !isRelease(compilation.target.profile) || + !isCompilationPath(compilation.status_path, projectId, compilation.id) || + !isCompilationPath( + compilation.cancel_path, + projectId, + compilation.id, + "/cancel", + ) || + !isNullableArtifact(compilation.artifact, projectId, compilation.id) || + !isNullableFailure(compilation.failure) || + !isTimestamp(compilation.created_at) || + !isNullableTimestamp(compilation.started_at) || + !isNullableTimestamp(compilation.completed_at) || + !hasValidStatusFields(compilation) + ) { + return null; + } + + const target = /** @type {{id: string, profile: string}} */ ( + compilation.target + ); + const artifact = + /** @type {null | {path: string, sha256: string, media_type: string, byte_size: number}} */ ( + compilation.artifact + ); + const failure = + /** @type {null | {phase: string, code: string, message: string}} */ ( + compilation.failure + ); + return { + project: { id: project.id, graph_version: project.graph_version }, + compilation: { + id: compilation.id, + analysis_run_id: compilation.analysis_run_id, + graph_version: compilation.graph_version, + head_source_sha256: /** @type {string} */ ( + compilation.head_source_sha256 + ), + status: compilation.status, + compiler_release: /** @type {string} */ (compilation.compiler_release), + target: { + id: target.id, + profile: target.profile, + }, + status_path: /** @type {string} */ (compilation.status_path), + cancel_path: /** @type {string} */ (compilation.cancel_path), + artifact: + artifact === null + ? null + : { + path: artifact.path, + sha256: artifact.sha256, + media_type: artifact.media_type, + byte_size: artifact.byte_size, + }, + failure: + failure === null + ? null + : { + phase: failure.phase, + code: failure.code, + message: failure.message, + }, + created_at: /** @type {string} */ (compilation.created_at), + started_at: /** @type {string | null} */ (compilation.started_at), + completed_at: /** @type {string | null} */ (compilation.completed_at), + }, + }; +} + +/** @param {Record} compilation */ +function hasValidStatusFields(compilation) { + const status = compilation.status; + const terminal = TERMINAL_STATUSES.has(String(status)); + if ( + (status === "queued" && compilation.started_at !== null) || + (status === "running" && compilation.started_at === null) || + (terminal && compilation.completed_at === null) || + (!terminal && compilation.completed_at !== null) || + (status === "succeeded" && + (compilation.started_at === null || + compilation.artifact === null || + compilation.failure !== null)) || + (status === "failed" && + (compilation.started_at === null || + compilation.artifact !== null || + compilation.failure === null)) || + (status === "cancelled" && + (compilation.artifact !== null || compilation.failure !== null)) || + ((status === "queued" || status === "running") && + (compilation.artifact !== null || compilation.failure !== null)) + ) { + return false; + } + + const created = timestampMilliseconds(String(compilation.created_at)); + const started = + compilation.started_at === null + ? null + : timestampMilliseconds(String(compilation.started_at)); + const completed = + compilation.completed_at === null + ? null + : timestampMilliseconds(String(compilation.completed_at)); + return ( + (started === null || started >= created) && + (completed === null || completed >= created) && + (started === null || completed === null || completed >= started) + ); +} + +/** + * @param {unknown} value + * @param {string} projectId + * @param {string} compilationId + */ +function isNullableArtifact(value, projectId, compilationId) { + return ( + value === null || + (hasExactKeySet(value, ARTIFACT_KEYS) && + isCompilationPath(value.path, projectId, compilationId, "/artifact") && + typeof value.sha256 === "string" && + SHA256_PATTERN.test(value.sha256) && + value.media_type === ARTIFACT_MEDIA_TYPE && + Number.isSafeInteger(value.byte_size) && + Number(value.byte_size) >= 0 && + Number(value.byte_size) <= MAX_ARTIFACT_BYTES) + ); +} + +/** @param {unknown} value */ +function isNullableFailure(value) { + return ( + value === null || + (hasExactKeySet(value, FAILURE_KEYS) && + isRelease(value.phase) && + isRelease(value.code) && + typeof value.message === "string" && + Buffer.byteLength(value.message) > 0 && + Buffer.byteLength(value.message) <= MAX_FAILURE_MESSAGE_BYTES) + ); +} + +/** + * @param {unknown} value + * @param {string} projectId + * @param {string} compilationId + * @param {string} [suffix] + */ +function isCompilationPath(value, projectId, compilationId, suffix = "") { + if ( + typeof value !== "string" || + !value.startsWith("/") || + value.startsWith("//") || + value.includes("\\") + ) { + return false; + } + + const parsed = new URL(value, "https://firstdraft.invalid"); + const expected = `/v1/projects/${projectId}/compilations/${compilationId}${suffix}`; + return ( + parsed.origin === "https://firstdraft.invalid" && + parsed.pathname === value && + parsed.search === "" && + parsed.hash === "" && + value === expected + ); +} + +/** @param {CompilationResponse} first @param {CompilationResponse} current */ +function sameCompilation(first, current) { + return ( + current.project.id === first.project.id && + current.project.graph_version === first.project.graph_version && + 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.compilation.status_path === first.compilation.status_path && + current.compilation.cancel_path === first.compilation.cancel_path && + current.compilation.created_at === first.compilation.created_at + ); +} + +/** @param {CompilationResponse} previous @param {CompilationResponse} current */ +function validTransition(previous, current) { + const from = previous.compilation.status; + const to = current.compilation.status; + if (from === to) { + return ( + current.compilation.started_at === previous.compilation.started_at && + current.compilation.completed_at === previous.compilation.completed_at && + JSON.stringify(current.compilation.artifact) === + JSON.stringify(previous.compilation.artifact) && + JSON.stringify(current.compilation.failure) === + JSON.stringify(previous.compilation.failure) + ); + } + + if (from === "queued") { + return ( + to === "running" || + to === "succeeded" || + to === "failed" || + to === "cancelled" + ); + } + return ( + from === "running" && + (to === "succeeded" || to === "failed" || to === "cancelled") && + current.compilation.started_at === previous.compilation.started_at + ); +} + +/** @param {Response} response @param {unknown} body */ +function safeProblem(response, body) { + if (!isRecord(body) || !isProblemBody(response, body)) return null; + + return { + ...(body.type === "about:blank" ? { type: body.type } : {}), + title: body.title, + status: body.status, + code: body.code, + detail: body.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 @returns {value is string} */ +function isRelease(value) { + return ( + typeof value === "string" && + Buffer.byteLength(value) <= MAX_IDENTIFIER_BYTES && + RELEASE_PATTERN.test(value) + ); +} + +/** @param {unknown} value @returns {value is number} */ +function isGraphVersion(value) { + return Number.isSafeInteger(value) && Number(value) >= 1; +} + +/** @param {unknown} value @returns {value is string | null} */ +function isNullableTimestamp(value) { + return value === null || isTimestamp(value); +} + +/** @param {unknown} value @returns {value is string} */ +function isTimestamp(value) { + return timestampParts(value) !== null; +} + +/** @param {string} value */ +function timestampMilliseconds(value) { + return Date.parse(value); +} + +/** @param {unknown} value */ +function timestampParts(value) { + if (typeof value !== "string") return null; + + const match = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.\d{6}Z$/.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]); + if ( + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth(year, month) || + hour > 23 || + minute > 59 || + second > 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]) + ); +} + +export { + CompilationArtifactInvalidError, + CompilationMaterializationError, + CompilationOutputPathError, +}; diff --git a/src/commands/plan-compile.js b/src/commands/plan-compile.js index bf2d083..5de61b4 100644 --- a/src/commands/plan-compile.js +++ b/src/commands/plan-compile.js @@ -1,841 +1,150 @@ -import { createHash } from "node:crypto"; - -import { - ARTIFACT_MEDIA_TYPE, - CompilationArtifactInvalidError, - CompilationMaterializationError, - CompilationOutputPathError, - MAX_ARTIFACT_BYTES, - materializeCompilationArtifact, - parseCompilationArtifact, - resolveOutputTarget, -} from "../compilation-artifact.js"; +import { publishPlan } from "./plan-publish.js"; import { - FirstDraftNetworkError, - FirstDraftProtocolError, - isProblemBody, - readResponseBody, - readResponseBytes, - responseMediaType, - sendRequest, -} from "../api-response.js"; -import { isUuidV7, readPlanState } from "../plan-state.js"; - -const REQUEST_TIMEOUT_MS = 30_000; -const WAIT_TIMEOUT_MS = 10 * 60_000; -const POLL_INTERVAL_MS = 1_000; -const ALL_STATUSES = new Set([ - "queued", - "running", - "succeeded", - "failed", - "cancelled", -]); -const TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled"]); -const RESPONSE_KEYS = ["project", "compilation"]; -const PROJECT_KEYS = ["id", "graph_version"]; -const COMPILATION_KEYS = [ - "id", - "analysis_run_id", - "graph_version", - "status", - "compiler_release", - "target", - "status_path", - "cancel_path", - "artifact", - "failure", - "created_at", - "started_at", - "completed_at", -]; -const TARGET_KEYS = ["id", "profile"]; -const ARTIFACT_KEYS = ["path", "sha256", "media_type", "byte_size"]; -const FAILURE_KEYS = ["phase", "code", "message"]; -const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const HEAD_ETAG_PATTERN = /^"sha256:([0-9a-f]{64})"$/; -const RELEASE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/; -const MAX_IDENTIFIER_BYTES = 256; -const MAX_FAILURE_MESSAGE_BYTES = 4_096; - -export class CompilationNotPushedError extends Error {} -export class CompilationLocalStateError extends Error {} + PlanPushNetworkError, + PlanPushProtocolError, + pushPlan, +} from "./plan-push.js"; +import { PlanStatusChangedError, readPlanStatus } from "./plan-status.js"; -export class CompilationRequestOutcomeUnknownError extends Error { - /** @param {number | undefined} status */ - constructor(status) { - super("The compilation start request outcome is unknown."); - this.status = status; - } -} - -export class CompilationStartRejectedError extends Error { +export class PlanCompilePushRejectedError extends Error { /** - * @param {number} status - * @param {Record | null} response + * @param {{status: number, responseKind: "diagnostics" | "problem" | null, body: Record | null}} result */ - constructor(status, response) { - super("First Draft rejected the compilation start request."); - this.status = status; - this.response = response; + constructor(result) { + super("First Draft rejected the current Foundation Plan."); + this.result = result; } } -export class CompilationStatusUnavailableError extends Error { +export class PlanCompileAnalysisRejectedError extends Error { /** - * @param {number | undefined} status - * @param {Record | null} [response] + * @param {{status: number, responseKind: "problem" | null, body: Record | null}} result */ - constructor(status, response = null) { - super("The compilation status is unavailable."); - this.status = status; - this.response = response; - } -} - -export class CompilationStatusInvalidError extends Error { - /** @param {number} status */ - constructor(status) { - super("The compilation status response is invalid."); - this.status = status; - } -} - -export class CompilationChangedError extends Error { - /** @param {CompilationResponse} current */ - constructor(current) { - super("The pinned compilation changed while it was being polled."); - this.current = current; - } -} - -export class CompilationTimeoutError extends Error { - /** @param {CompilationResponse} current */ - constructor(current) { - super("The pinned compilation did not finish before the wait deadline."); - this.current = current; + constructor(result) { + super("First Draft rejected the analysis status request."); + this.result = result; } } -export class CompilationFailedError extends Error { - /** @param {CompilationResponse} current */ +export class PlanCompileAnalysisNotValidError extends Error { + /** @param {import("./plan-status.js").AnalysisResponse} current */ constructor(current) { - super("The compilation failed."); + super("The current Foundation Plan analysis is not valid."); this.current = current; } } -export class CompilationCancelledError extends Error { - /** @param {CompilationResponse} current */ - constructor(current) { - super("The compilation was cancelled."); - this.current = current; - } -} - -export class CompilationArtifactUnavailableError extends Error { - /** - * @param {number | undefined} status - * @param {Record | null} [response] - */ - constructor(status, response = null) { - super("The compilation artifact is unavailable."); +export class PlanCompileAnalysisUnavailableError extends Error { + /** @param {number | undefined} status */ + constructor(status) { + super("The current analysis status is unavailable."); this.status = status; - this.response = response; } } -export class CompilationArtifactResponseInvalidError extends Error { +export class PlanCompileAnalysisInvalidError extends Error { /** @param {number} status */ constructor(status) { - super("The compilation artifact response is invalid."); + super("The current analysis status response is invalid."); this.status = status; } } -/** - * @typedef {object} CompilationResponse - * @property {{id: string, graph_version: number}} project - * @property {{ - * id: string, - * analysis_run_id: string, - * graph_version: number, - * status: string, - * compiler_release: string, - * target: {id: string, profile: string}, - * status_path: string, - * cancel_path: string, - * artifact: null | {path: string, sha256: string, media_type: string, byte_size: number}, - * failure: null | {phase: string, code: string, message: string}, - * created_at: string, - * started_at: string | null, - * completed_at: string | null - * }} compilation - */ - /** * @typedef {object} CompilePlanOptions * @property {string} cwd - * @property {string} output + * @property {string} [apiUrl] * @property {typeof globalThis.fetch} [fetchFunction] - * @property {import("../plan-state.js").PlanStateFileSystem} [fileSystem] - * @property {(timeoutMs: number) => AbortSignal} [createRequestSignal] - * @property {(delayMs: number) => Promise} [sleep] - * @property {() => number} [now] + * @property {import("./plan-push.js").PlanPushFileSystem} [fileSystem] + * @property {() => string} [createTemporaryId] + * @property {(timeoutMs?: number) => AbortSignal} [createRequestSignal] + * @property {(delayMs: number) => Promise} [analysisSleep] + * @property {() => number} [analysisNow] + * @property {(delayMs: number) => Promise} [publicationSleep] + * @property {() => number} [publicationNow] + * @property {typeof pushPlan} [push] + * @property {typeof readPlanStatus} [readStatus] + * @property {typeof publishPlan} [publish] */ /** + * Submit and analyze the exact current local Plan before invoking the internal + * GitHub Publication lifecycle. The Publication function performs the final + * local-byte check immediately before its conditional mutation. + * * @param {CompilePlanOptions} options */ export async function compilePlan({ cwd, - output, - fetchFunction = globalThis.fetch, + apiUrl, + fetchFunction, fileSystem, - createRequestSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), - sleep = sleepFor, - now = Date.now, + createTemporaryId, + createRequestSignal, + analysisSleep, + analysisNow, + publicationSleep, + publicationNow, + push = pushPlan, + readStatus = readPlanStatus, + publish = publishPlan, }) { - const state = readPlanState({ cwd, fileSystem }); - if (state.api_url === undefined || state.foundation_plan_etag === undefined) { - throw new CompilationNotPushedError( - "The local Foundation Plan has not been pushed.", - ); - } - - const headEtagMatch = HEAD_ETAG_PATTERN.exec(state.foundation_plan_etag); - if (headEtagMatch === null) { - throw new CompilationLocalStateError( - "The saved Foundation Plan ETag cannot identify its accepted source.", - ); - } - const headSourceSha256 = headEtagMatch[1] ?? ""; - const outputTarget = resolveOutputTarget({ cwd, output }); - const endpoint = new URL( - `/v1/projects/${state.project_id}/compilations`, - state.api_url, - ); - - const initial = await startCompilation({ - endpoint, - projectId: state.project_id, - etag: state.foundation_plan_etag, - fetchFunction, - createRequestSignal, - }); - const deadline = now() + WAIT_TIMEOUT_MS; - let current = initial; - - while (!TERMINAL_STATUSES.has(current.compilation.status)) { - const remaining = deadline - now(); - if (remaining <= 0) throw new CompilationTimeoutError(current); - - await sleep(Math.min(POLL_INTERVAL_MS, remaining)); - if (now() >= deadline) throw new CompilationTimeoutError(current); - - const next = await readCompilationStatus({ - apiUrl: state.api_url, - path: initial.compilation.status_path, - projectId: state.project_id, - fetchFunction, - createRequestSignal, - requestTimeout: Math.max( - 1, - Math.min(REQUEST_TIMEOUT_MS, deadline - now()), - ), - }); - if (!sameCompilation(initial, next) || !validTransition(current, next)) { - throw new CompilationChangedError(next); - } - current = next; - } - - if (current.compilation.status === "failed") { - throw new CompilationFailedError(current); - } - if (current.compilation.status === "cancelled") { - throw new CompilationCancelledError(current); - } - - const artifactMetadata = - /** @type {{path: string, sha256: string, media_type: string, byte_size: number}} */ ( - current.compilation.artifact - ); - const source = await downloadArtifact({ - apiUrl: state.api_url, - metadata: artifactMetadata, + const pushed = await push({ + cwd, + apiUrl, fetchFunction, + fileSystem, + createTemporaryId, createRequestSignal, }); - const artifact = parseCompilationArtifact(source, { - projectId: state.project_id, - compilationId: current.compilation.id, - graphVersion: current.compilation.graph_version, - headSourceSha256, - analysisRunId: current.compilation.analysis_run_id, - compilerRelease: current.compilation.compiler_release, - target: current.compilation.target, - }); - const materialized = materializeCompilationArtifact(artifact, outputTarget); - - return { - project: current.project, - compilation: current.compilation, - output: materialized, - }; -} - -/** - * @param {object} options - * @param {URL} options.endpoint - * @param {string} options.projectId - * @param {string} options.etag - * @param {typeof globalThis.fetch} options.fetchFunction - * @param {(timeoutMs: number) => AbortSignal} options.createRequestSignal - */ -async function startCompilation({ - endpoint, - projectId, - etag, - fetchFunction, - createRequestSignal, -}) { - let response; - let body; - try { - response = await sendRequest(fetchFunction, endpoint, { - method: "POST", - 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 CompilationRequestOutcomeUnknownError(error.status); - } - - throw error; - } - - if (response.status !== 202) { - const problem = safeProblem(response, body); - if (response.ok || problem === null) { - throw new CompilationRequestOutcomeUnknownError(response.status); - } - - throw new CompilationStartRejectedError(response.status, problem); - } - - const parsed = parseCompilationResponse(body, projectId); - if ( - responseMediaType(response) !== "application/json" || - parsed === null || - response.headers.get("location") !== parsed.compilation.status_path - ) { - throw new CompilationRequestOutcomeUnknownError(response.status); - } - - return parsed; -} - -/** - * @param {object} options - * @param {string} options.apiUrl - * @param {string} options.path - * @param {string} options.projectId - * @param {typeof globalThis.fetch} options.fetchFunction - * @param {(timeoutMs: number) => AbortSignal} options.createRequestSignal - * @param {number} options.requestTimeout - */ -async function readCompilationStatus({ - apiUrl, - path: statusPath, - projectId, - fetchFunction, - createRequestSignal, - requestTimeout, -}) { - const endpoint = new URL(statusPath, apiUrl); - 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 CompilationStatusUnavailableError(error.status); - } - if (error instanceof FirstDraftProtocolError) { - throw new CompilationStatusInvalidError(error.status); - } - - throw error; - } - - if (response.status !== 200) { - if (!response.ok) { - throw new CompilationStatusUnavailableError( - response.status, - safeProblem(response, body), - ); - } - throw new CompilationStatusInvalidError(response.status); - } - - const parsed = parseCompilationResponse(body, projectId); - if (responseMediaType(response) !== "application/json" || parsed === null) { - throw new CompilationStatusInvalidError(response.status); + if (!("outcome" in pushed)) { + throw new PlanCompilePushRejectedError(pushed); } + const acceptedGraphVersion = /** @type {{graph_version: number}} */ ( + pushed.body.project + ).graph_version; - return parsed; -} - -/** - * @param {object} options - * @param {string} options.apiUrl - * @param {{path: string, sha256: string, media_type: string, byte_size: number}} options.metadata - * @param {typeof globalThis.fetch} options.fetchFunction - * @param {(timeoutMs: number) => AbortSignal} options.createRequestSignal - */ -async function downloadArtifact({ - apiUrl, - metadata, - fetchFunction, - createRequestSignal, -}) { - const endpoint = new URL(metadata.path, apiUrl); - let response; - let source; + let status; try { - response = await sendRequest(fetchFunction, endpoint, { - method: "GET", - headers: { - Accept: `${ARTIFACT_MEDIA_TYPE}, application/problem+json`, - }, - redirect: "error", - signal: createRequestSignal(REQUEST_TIMEOUT_MS), + status = await readStatus({ + cwd, + wait: true, + expectedGraphVersion: acceptedGraphVersion, + fetchFunction, + fileSystem, + createRequestSignal, + sleep: analysisSleep, + now: analysisNow, }); - source = await readResponseBytes(response, MAX_ARTIFACT_BYTES); } catch (error) { - if (error instanceof FirstDraftNetworkError) { - throw new CompilationArtifactUnavailableError(error.status); + if (error instanceof PlanPushNetworkError) { + throw new PlanCompileAnalysisUnavailableError(error.status); } - if (error instanceof FirstDraftProtocolError) { - throw new CompilationArtifactResponseInvalidError(error.status); + if (error instanceof PlanPushProtocolError) { + throw new PlanCompileAnalysisInvalidError(error.status); } - throw error; } - - if (response.status !== 200) { - let body = null; - try { - body = JSON.parse( - new TextDecoder("utf-8", { fatal: true }).decode(source), - ); - } catch (error) { - if (!(error instanceof SyntaxError || error instanceof TypeError)) { - throw error; - } - } - throw new CompilationArtifactUnavailableError( - response.status, - safeProblem(response, body), - ); - } - - const contentLength = response.headers.get("content-length"); - if ( - responseMediaType(response) !== ARTIFACT_MEDIA_TYPE || - contentLength === null || - contentLength !== String(metadata.byte_size) || - source.byteLength !== metadata.byte_size || - response.headers.get("etag") !== `"sha256:${metadata.sha256}"` || - sha256(source) !== metadata.sha256 - ) { - throw new CompilationArtifactResponseInvalidError(response.status); - } - - return source; -} - -/** - * @param {unknown} value - * @param {string} projectId - * @returns {CompilationResponse | null} - */ -function parseCompilationResponse(value, projectId) { - if ( - !hasExactKeySet(value, RESPONSE_KEYS) || - !hasExactKeySet(value.project, PROJECT_KEYS) || - !hasExactKeySet(value.compilation, COMPILATION_KEYS) - ) { - return null; + if ("responseKind" in status) { + throw new PlanCompileAnalysisRejectedError(status); } - - const project = value.project; - const compilation = value.compilation; - if ( - project.id !== projectId || - !isGraphVersion(project.graph_version) || - !isUuidV7(compilation.id) || - !isUuidV7(compilation.analysis_run_id) || - !isGraphVersion(compilation.graph_version) || - compilation.graph_version !== project.graph_version || - typeof compilation.status !== "string" || - !ALL_STATUSES.has(compilation.status) || - !isRelease(compilation.compiler_release) || - !hasExactKeySet(compilation.target, TARGET_KEYS) || - !isRelease(compilation.target.id) || - !isRelease(compilation.target.profile) || - !isCompilationPath(compilation.status_path, projectId, compilation.id) || - !isCompilationPath(compilation.cancel_path, projectId, compilation.id) || - !isNullableArtifact(compilation.artifact, projectId, compilation.id) || - !isNullableFailure(compilation.failure) || - !isTimestamp(compilation.created_at) || - !isNullableTimestamp(compilation.started_at) || - !isNullableTimestamp(compilation.completed_at) || - !hasValidStatusFields(compilation) - ) { - return null; - } - - const target = /** @type {{id: string, profile: string}} */ ( - compilation.target - ); - const artifact = - /** @type {null | {path: string, sha256: string, media_type: string, byte_size: number}} */ ( - compilation.artifact - ); - const failure = - /** @type {null | {phase: string, code: string, message: string}} */ ( - compilation.failure - ); - return { - project: { id: project.id, graph_version: project.graph_version }, - compilation: { - id: compilation.id, - analysis_run_id: compilation.analysis_run_id, - graph_version: compilation.graph_version, - status: compilation.status, - compiler_release: /** @type {string} */ (compilation.compiler_release), - target: { - id: target.id, - profile: target.profile, - }, - status_path: /** @type {string} */ (compilation.status_path), - cancel_path: /** @type {string} */ (compilation.cancel_path), - artifact: - artifact === null - ? null - : { - path: artifact.path, - sha256: artifact.sha256, - media_type: artifact.media_type, - byte_size: artifact.byte_size, - }, - failure: - failure === null - ? null - : { - phase: failure.phase, - code: failure.code, - message: failure.message, - }, - created_at: /** @type {string} */ (compilation.created_at), - started_at: /** @type {string | null} */ (compilation.started_at), - completed_at: /** @type {string | null} */ (compilation.completed_at), - }, - }; -} - -/** @param {Record} compilation */ -function hasValidStatusFields(compilation) { - const status = compilation.status; - const terminal = TERMINAL_STATUSES.has(String(status)); - if ( - (status === "queued" && compilation.started_at !== null) || - (status === "running" && compilation.started_at === null) || - (terminal && compilation.completed_at === null) || - (!terminal && compilation.completed_at !== null) || - (status === "succeeded" && - (compilation.started_at === null || - compilation.artifact === null || - compilation.failure !== null)) || - (status === "failed" && - (compilation.started_at === null || - compilation.artifact !== null || - compilation.failure === null)) || - (status === "cancelled" && - (compilation.artifact !== null || compilation.failure !== null)) || - ((status === "queued" || status === "running") && - (compilation.artifact !== null || compilation.failure !== null)) - ) { - return false; - } - - const created = timestampMilliseconds(String(compilation.created_at)); - const started = - compilation.started_at === null - ? null - : timestampMilliseconds(String(compilation.started_at)); - const completed = - compilation.completed_at === null - ? null - : timestampMilliseconds(String(compilation.completed_at)); - return ( - (started === null || started >= created) && - (completed === null || completed >= created) && - (started === null || completed === null || completed >= started) - ); -} - -/** - * @param {unknown} value - * @param {string} projectId - * @param {string} compilationId - */ -function isNullableArtifact(value, projectId, compilationId) { - return ( - value === null || - (hasExactKeySet(value, ARTIFACT_KEYS) && - isCompilationPath(value.path, projectId, compilationId) && - typeof value.sha256 === "string" && - SHA256_PATTERN.test(value.sha256) && - value.media_type === ARTIFACT_MEDIA_TYPE && - Number.isSafeInteger(value.byte_size) && - Number(value.byte_size) >= 0 && - Number(value.byte_size) <= MAX_ARTIFACT_BYTES) - ); -} - -/** @param {unknown} value */ -function isNullableFailure(value) { - return ( - value === null || - (hasExactKeySet(value, FAILURE_KEYS) && - isRelease(value.phase) && - isRelease(value.code) && - typeof value.message === "string" && - Buffer.byteLength(value.message) > 0 && - Buffer.byteLength(value.message) <= MAX_FAILURE_MESSAGE_BYTES) - ); -} - -/** - * @param {unknown} value - * @param {string} projectId - * @param {string} compilationId - */ -function isCompilationPath(value, projectId, compilationId) { if ( - typeof value !== "string" || - !value.startsWith("/") || - value.startsWith("//") || - value.includes("\\") + status.body.project.graph_version !== acceptedGraphVersion || + status.body.analysis.graph_version !== acceptedGraphVersion ) { - return false; + throw new PlanStatusChangedError(status.body); } - - const parsed = new URL(value, "https://firstdraft.invalid"); - const scope = `/v1/projects/${projectId}/compilations/${compilationId}`; - return ( - parsed.origin === "https://firstdraft.invalid" && - parsed.pathname === value && - parsed.search === "" && - parsed.hash === "" && - (value === scope || value.startsWith(`${scope}/`)) - ); -} - -/** @param {CompilationResponse} first @param {CompilationResponse} current */ -function sameCompilation(first, current) { - return ( - current.project.id === first.project.id && - current.project.graph_version === first.project.graph_version && - 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.compiler_release === - first.compilation.compiler_release && - current.compilation.target.id === first.compilation.target.id && - current.compilation.target.profile === first.compilation.target.profile && - current.compilation.status_path === first.compilation.status_path && - current.compilation.cancel_path === first.compilation.cancel_path && - current.compilation.created_at === first.compilation.created_at - ); -} - -/** @param {CompilationResponse} previous @param {CompilationResponse} current */ -function validTransition(previous, current) { - const from = previous.compilation.status; - const to = current.compilation.status; - if (from === to) { - return ( - current.compilation.started_at === previous.compilation.started_at && - current.compilation.completed_at === previous.compilation.completed_at && - JSON.stringify(current.compilation.artifact) === - JSON.stringify(previous.compilation.artifact) && - JSON.stringify(current.compilation.failure) === - JSON.stringify(previous.compilation.failure) - ); - } - - if (from === "queued") { - return ( - to === "running" || - to === "succeeded" || - to === "failed" || - to === "cancelled" - ); + if (status.body.analysis.status !== "valid") { + throw new PlanCompileAnalysisNotValidError(status.body); } - return ( - from === "running" && - (to === "succeeded" || to === "failed" || to === "cancelled") && - current.compilation.started_at === previous.compilation.started_at - ); -} - -/** @param {Response} response @param {unknown} body */ -function safeProblem(response, body) { - if (!isRecord(body) || !isProblemBody(response, body)) return null; - return { - ...(body.type === "about:blank" ? { type: body.type } : {}), - title: body.title, - status: body.status, - code: body.code, - detail: body.detail, - }; -} - -/** @param {number} delayMs */ -function sleepFor(delayMs) { - return new Promise((resolve) => { - setTimeout(resolve, delayMs); + return publish({ + cwd, + fetchFunction, + fileSystem, + createRequestSignal, + sleep: publicationSleep, + now: publicationNow, + expectedEtag: pushed.etag, }); } - -/** @param {Buffer} value */ -function sha256(value) { - return createHash("sha256").update(value).digest("hex"); -} - -/** @param {unknown} value @returns {value is string} */ -function isRelease(value) { - return ( - typeof value === "string" && - Buffer.byteLength(value) <= MAX_IDENTIFIER_BYTES && - RELEASE_PATTERN.test(value) - ); -} - -/** @param {unknown} value @returns {value is number} */ -function isGraphVersion(value) { - return Number.isSafeInteger(value) && Number(value) >= 1; -} - -/** @param {unknown} value @returns {value is string | null} */ -function isNullableTimestamp(value) { - return value === null || isTimestamp(value); -} - -/** @param {unknown} value @returns {value is string} */ -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]) - ); -} - -export { - CompilationArtifactInvalidError, - CompilationMaterializationError, - CompilationOutputPathError, -}; diff --git a/src/commands/plan-publish.js b/src/commands/plan-publish.js index df7f542..39cdc49 100644 --- a/src/commands/plan-publish.js +++ b/src/commands/plan-publish.js @@ -189,6 +189,7 @@ export class PublicationCancelledError extends Error { /** * @typedef {object} PublishPlanOptions * @property {string} cwd + * @property {string} [expectedEtag] * @property {typeof globalThis.fetch} [fetchFunction] * @property {PlanPublishFileSystem} [fileSystem] * @property {(timeoutMs: number) => AbortSignal} [createRequestSignal] @@ -202,6 +203,7 @@ export class PublicationCancelledError extends Error { */ export async function publishPlan({ cwd, + expectedEtag, fetchFunction = globalThis.fetch, fileSystem = DEFAULT_FILE_SYSTEM, createRequestSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), @@ -215,7 +217,17 @@ export async function publishPlan({ ); } - const match = HEAD_ETAG_PATTERN.exec(state.foundation_plan_etag); + if ( + expectedEtag !== undefined && + state.foundation_plan_etag !== expectedEtag + ) { + throw new PublicationLocalPlanChangedError( + "Local Plan state changed after this command accepted its Plan.", + ); + } + + const publicationEtag = expectedEtag ?? state.foundation_plan_etag; + const match = HEAD_ETAG_PATTERN.exec(publicationEtag); if (match === null) { throw new PublicationLocalStateError( "The saved Foundation Plan ETag cannot identify its accepted source.", @@ -245,7 +257,7 @@ export async function publishPlan({ endpoint, projectId: state.project_id, headSourceSha256, - etag: state.foundation_plan_etag, + etag: publicationEtag, fetchFunction, createRequestSignal, }); diff --git a/src/commands/plan-status.js b/src/commands/plan-status.js index 4a23633..6dc0985 100644 --- a/src/commands/plan-status.js +++ b/src/commands/plan-status.js @@ -72,6 +72,7 @@ export class PlanStatusTimeoutError extends Error { * @typedef {object} PlanStatusOptions * @property {string} cwd * @property {boolean} [wait] + * @property {number} [expectedGraphVersion] * @property {typeof globalThis.fetch} [fetchFunction] * @property {PlanStatusFileSystem} [fileSystem] * @property {(timeoutMs: number) => AbortSignal} [createRequestSignal] @@ -109,6 +110,7 @@ export class PlanStatusTimeoutError extends Error { export async function readPlanStatus({ cwd, wait = false, + expectedGraphVersion, fetchFunction = globalThis.fetch, fileSystem = DEFAULT_FILE_SYSTEM, createRequestSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), @@ -196,6 +198,20 @@ export async function readPlanStatus({ } current = parsed; + if ( + expectedGraphVersion !== undefined && + current.analysis.graph_version !== expectedGraphVersion + ) { + if (!wait || current.analysis.graph_version > expectedGraphVersion) { + throw new PlanStatusChangedError(current); + } + + const remaining = deadline === null ? 0 : deadline - now(); + if (remaining <= 0) throw new PlanStatusTimeoutError(current); + await sleep(Math.min(POLL_INTERVAL_MS, remaining)); + continue; + } + first ??= parsed; if ( current.analysis.id !== first.analysis.id || diff --git a/src/compilation-artifact.js b/src/compilation-artifact.js index 9b279ae..2e4d77f 100644 --- a/src/compilation-artifact.js +++ b/src/compilation-artifact.js @@ -246,8 +246,7 @@ function parseProvenance(value, expected) { value.head_source_sha256 !== expected.headSourceSha256 || !hasExactKeys(value.foundation_plan, FOUNDATION_PLAN_KEYS) || value.foundation_plan.format !== FOUNDATION_PLAN_FORMAT || - typeof value.foundation_plan.sha256 !== "string" || - !SHA256_PATTERN.test(value.foundation_plan.sha256) || + value.foundation_plan.sha256 !== expected.headSourceSha256 || !hasExactKeys(value.analysis, ANALYSIS_KEYS) || value.analysis.id !== expected.analysisRunId || !isRelease(value.analysis.release) || diff --git a/test/cli.test.js b/test/cli.test.js index fc2cfe1..e73e731 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -15,8 +15,9 @@ Usage: firstdraft [options] Commands: - generate Generate local values - plan Work with Foundation Plans + compilation Inspect and download Compilations + generate Generate local values + plan Work with Foundation Plans Options: -h, --help Show help diff --git a/test/compilation-artifact.test.js b/test/compilation-artifact.test.js index 6c76e75..8aa645a 100644 --- a/test/compilation-artifact.test.js +++ b/test/compilation-artifact.test.js @@ -159,6 +159,14 @@ test("pins every available provenance identity", () => { provenance: { foundation_plan: { format: "firstdraft.foundation-plan.sketch/0.18", + sha256: HEAD_SHA256, + }, + }, + }), + artifactFixture({ + provenance: { + foundation_plan: { + format: FOUNDATION_PLAN_FORMAT, sha256: "2".repeat(64), }, }, @@ -356,7 +364,7 @@ function artifactFixture(changes = {}) { head_source_sha256: HEAD_SHA256, foundation_plan: { format: FOUNDATION_PLAN_FORMAT, - sha256: "2".repeat(64), + sha256: HEAD_SHA256, }, analysis: { id: ANALYSIS_ID, diff --git a/test/compilation.test.js b/test/compilation.test.js new file mode 100644 index 0000000..ef7d8dd --- /dev/null +++ b/test/compilation.test.js @@ -0,0 +1,531 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { run } from "../src/cli.js"; +import { + ARTIFACT_MEDIA_TYPE, + FOUNDATION_PLAN_FORMAT, +} from "../src/compilation-artifact.js"; + +const PROJECT_ID = "01900000-0000-7000-8000-000000003001"; +const COMPILATION_ID = "01900000-0000-7000-8000-000000003002"; +const ANALYSIS_ID = "01900000-0000-7000-8000-000000003004"; +const API_TOKEN = `fd_${"b".repeat(43)}`; +const RETAINED_HEAD = "1".repeat(64); +const LOCAL_HEAD = "9".repeat(64); +const CREATED_AT = "2026-08-04T12:00:00.000000Z"; +const STARTED_AT = "2026-08-04T12:00:01.000000Z"; +const COMPLETED_AT = "2026-08-04T12:00:02.000000Z"; +const COMPILER_RELEASE = "foundation-plan-rails/compiler-2026-08"; +const TARGET = { id: "rails", profile: "rails-sketch/2026-08" }; +const STATUS_PATH = `/v1/projects/${PROJECT_ID}/compilations/${COMPILATION_ID}`; +const ARTIFACT_PATH = `${STATUS_PATH}/artifact`; + +test("compilation status makes one canonical GET and returns terminal failures successfully", async (context) => { + for (const status of ["failed", "cancelled"]) { + const cwd = remoteDirectory(context); + /** @type {FetchCall[]} */ + const calls = []; + const body = compilationBody(status); + const result = await invoke(["compilation", "status", COMPILATION_ID], { + cwd, + fetchFunction: sequenceFetch([jsonResponse(body)], calls), + }); + + assert.equal(result.status, 0); + assert.deepEqual(JSON.parse(result.stdout), body); + assert.equal(result.stderr, ""); + assert.equal(calls.length, 1); + assert.equal( + String(calls[0]?.input), + `https://api.example.test${STATUS_PATH}`, + ); + assert.equal(calls[0]?.init?.method, "GET"); + assert.equal( + new Headers(calls[0]?.init?.headers).get("authorization"), + `Bearer ${API_TOKEN}`, + ); + } +}); + +test("compilation status wait pins provenance and follows valid transitions", async (context) => { + const cwd = remoteDirectory(context); + /** @type {FetchCall[]} */ + const calls = []; + const queued = compilationBody("queued"); + const running = compilationBody("running"); + const succeeded = compilationBody("succeeded", { + artifact: artifactFixture(), + }); + /** @type {number[]} */ + const delays = []; + const result = await invoke( + ["compilation", "status", COMPILATION_ID, "--wait"], + { + cwd, + fetchFunction: sequenceFetch( + [jsonResponse(queued), jsonResponse(running), jsonResponse(succeeded)], + calls, + ), + compilationSleep: async (/** @type {number} */ delay) => { + delays.push(delay); + }, + }, + ); + + assert.equal(result.status, 0); + assert.deepEqual(JSON.parse(result.stdout), succeeded); + assert.deepEqual(delays, [1_000, 1_000]); + assert.equal(calls.length, 3); + + const changed = await invoke( + ["compilation", "status", COMPILATION_ID, "--wait"], + { + cwd, + fetchFunction: sequenceFetch([ + jsonResponse(queued), + jsonResponse( + compilationBody("running", { + compilation: { head_source_sha256: "8".repeat(64) }, + }), + ), + ]), + compilationSleep: async () => {}, + }, + ); + assertHandledFailure(changed, "compilation_changed"); +}); + +test("compilation status has a bounded wait and validates exact response shapes", async (context) => { + const cwd = remoteDirectory(context); + let now = 0; + const timeout = await invoke( + ["compilation", "status", COMPILATION_ID, "--wait"], + { + cwd, + fetchFunction: sequenceFetch([jsonResponse(compilationBody("queued"))]), + compilationNow: () => now, + compilationSleep: async () => { + now = 600_000; + }, + }, + ); + assertHandledFailure(timeout, "compilation_wait_timed_out"); + assert.equal(JSON.parse(timeout.stderr).current.compilation.status, "queued"); + + for (const body of [ + { ...compilationBody("queued"), additive: true }, + compilationBody("queued", { + compilation: { status_path: `${STATUS_PATH}/status` }, + }), + compilationBody("queued", { + compilation: { created_at: "2026-08-04T12:00:00.000Z" }, + }), + ]) { + const invalid = await invoke(["compilation", "status", COMPILATION_ID], { + cwd, + fetchFunction: sequenceFetch([jsonResponse(body)]), + }); + assertHandledFailure(invalid, "invalid_compilation_status"); + assert.doesNotMatch(invalid.stderr, /additive|status_path|created_at/); + } +}); + +test("compilation download reads retained status and artifact once without starting work", async (context) => { + const cwd = remoteDirectory(context); + const fixture = artifactFixture(); + const status = compilationBody("succeeded", { artifact: fixture }); + /** @type {FetchCall[]} */ + const calls = []; + const output = path.join(cwd, "movie-catalog"); + const result = await invoke( + ["compilation", "download", COMPILATION_ID, "--output", output], + { + cwd, + fetchFunction: sequenceFetch( + [jsonResponse(status), artifactResponse(fixture)], + calls, + ), + }, + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.deepEqual( + calls.map(({ input, init }) => [init?.method, String(input)]), + [ + ["GET", `https://api.example.test${STATUS_PATH}`], + ["GET", `https://api.example.test${ARTIFACT_PATH}`], + ], + ); + assert.equal( + readFileSync(path.join(output, "README.md"), "utf8"), + "Movie Catalog\n", + ); + const body = JSON.parse(result.stdout); + assert.equal(body.compilation.head_source_sha256, RETAINED_HEAD); + assert.equal(body.output.path, output); + assert.equal(body.output.file_count, 1); +}); + +test("download requires succeeded status and validates historical Head provenance", async (context) => { + const cwd = remoteDirectory(context); + /** @type {FetchCall[]} */ + const queuedCalls = []; + const queuedOutput = path.join(cwd, "queued-output"); + const queued = await invoke( + ["compilation", "download", COMPILATION_ID, "--output", queuedOutput], + { + cwd, + fetchFunction: sequenceFetch( + [jsonResponse(compilationBody("queued"))], + queuedCalls, + ), + }, + ); + assertHandledFailure(queued, "compilation_not_succeeded"); + assert.equal(queuedCalls.length, 1); + assert.equal(existsSync(queuedOutput), false); + + const mismatched = artifactFixture({ foundationPlanSha256: LOCAL_HEAD }); + const mismatchOutput = path.join(cwd, "mismatch-output"); + const mismatch = await invoke( + ["compilation", "download", COMPILATION_ID, "--output", mismatchOutput], + { + cwd, + fetchFunction: sequenceFetch([ + jsonResponse(compilationBody("succeeded", { artifact: mismatched })), + artifactResponse(mismatched), + ]), + }, + ); + assertHandledFailure(mismatch, "invalid_artifact"); + assert.equal(existsSync(mismatchOutput), false); + + const transport = artifactFixture(); + const transportOutput = path.join(cwd, "transport-output"); + const invalidTransport = await invoke( + ["compilation", "download", COMPILATION_ID, "--output", transportOutput], + { + cwd, + fetchFunction: sequenceFetch([ + jsonResponse(compilationBody("succeeded", { artifact: transport })), + new Response(transport.source, { + status: 200, + headers: { + "Content-Type": ARTIFACT_MEDIA_TYPE, + "Content-Length": String(transport.source.byteLength), + ETag: `"sha256:${transport.sha256}"`, + }, + }), + ]), + }, + ); + assertHandledFailure(invalidTransport, "invalid_artifact"); + assert.equal(existsSync(transportOutput), false); +}); + +test("compilation syntax and output preflight fail before network access", async (context) => { + const inaccessible = async () => { + throw new Error("network must remain inaccessible"); + }; + for (const argv of [ + ["compilation", "status", "not-a-uuid"], + ["compilation", "status", COMPILATION_ID, COMPILATION_ID], + ["compilation", "download", COMPILATION_ID], + [ + "compilation", + "download", + COMPILATION_ID, + "--output", + "one", + "--output", + "two", + ], + ]) { + const result = await invoke(argv, { + cwd: process.cwd(), + fetchFunction: inaccessible, + }); + assertHandledFailure(result, "invalid_arguments", 2); + } + + const cwd = remoteDirectory(context); + const existing = path.join(cwd, "existing"); + mkdirSync(existing); + const invalidOutput = await invoke( + ["compilation", "download", COMPILATION_ID, "--output", existing], + { cwd, fetchFunction: inaccessible }, + ); + assertHandledFailure(invalidOutput, "invalid_output_path", 2); + + const help = await invoke(["compilation", "download", "--help"], { + cwd: process.cwd(), + apiToken: undefined, + fetchFunction: inaccessible, + }); + assert.equal(help.status, 0); + assert.match(help.stdout, /compilation download/); +}); + +test("status and artifact authentication problems are stable", async (context) => { + const cwd = remoteDirectory(context); + const missing = await invoke(["compilation", "status", COMPILATION_ID], { + cwd, + apiToken: undefined, + fetchFunction: async () => { + throw new Error("network must remain inaccessible"); + }, + }); + assertHandledFailure(missing, "authentication_required"); + + const statusRejected = await invoke( + ["compilation", "status", COMPILATION_ID], + { + cwd, + fetchFunction: sequenceFetch([authenticationProblem()]), + }, + ); + assertHandledFailure(statusRejected, "authentication_required"); + + let now = 0; + let reads = 0; + const finalPollRejected = await invoke( + ["compilation", "status", COMPILATION_ID, "--wait"], + { + cwd, + compilationNow: () => now, + compilationSleep: async () => { + now = 599_999; + }, + fetchFunction: async () => { + reads += 1; + if (reads === 1) return jsonResponse(compilationBody("queued")); + now = 600_000; + return authenticationProblem(); + }, + }, + ); + assertHandledFailure(finalPollRejected, "authentication_required"); + assert.equal(reads, 2); + + const fixture = artifactFixture(); + const artifactRejected = await invoke( + [ + "compilation", + "download", + COMPILATION_ID, + "--output", + path.join(cwd, "auth-output"), + ], + { + cwd, + fetchFunction: sequenceFetch([ + jsonResponse(compilationBody("succeeded", { artifact: fixture })), + authenticationProblem(), + ]), + }, + ); + assertHandledFailure(artifactRejected, "authentication_required"); +}); + +/** @param {string} status @param {{artifact?: ReturnType, compilation?: Record}} [changes] */ +function compilationBody(status, changes = {}) { + const terminal = ["succeeded", "failed", "cancelled"].includes(status); + const fixture = changes.artifact; + return { + project: { id: PROJECT_ID, graph_version: 7 }, + compilation: { + id: COMPILATION_ID, + analysis_run_id: ANALYSIS_ID, + graph_version: 7, + head_source_sha256: RETAINED_HEAD, + status, + compiler_release: COMPILER_RELEASE, + target: TARGET, + status_path: STATUS_PATH, + cancel_path: `${STATUS_PATH}/cancel`, + artifact: + status === "succeeded" && fixture + ? { + path: ARTIFACT_PATH, + sha256: fixture.sha256, + media_type: ARTIFACT_MEDIA_TYPE, + byte_size: fixture.source.byteLength, + } + : null, + failure: + status === "failed" + ? { + phase: "render", + code: "render_failed", + message: "Rendering failed.", + } + : null, + created_at: CREATED_AT, + started_at: status === "queued" ? null : STARTED_AT, + completed_at: terminal ? COMPLETED_AT : null, + ...changes.compilation, + }, + }; +} + +/** @param {{foundationPlanSha256?: string}} [changes] */ +function artifactFixture(changes = {}) { + const contents = Buffer.from("Movie Catalog\n"); + const file = { + path: "README.md", + sha256: sha256(contents), + mode: 0o644, + owner: "renderer:readme", + source_subject_uuids: [], + contents_base64: contents.toString("base64"), + }; + const metadata = { + files: [ + { + path: file.path, + sha256: file.sha256, + mode: file.mode, + owner: file.owner, + source_subject_uuids: file.source_subject_uuids, + }, + ], + }; + const body = { + format: "firstdraft.compilation-artifact/1", + provenance: { + compilation_id: COMPILATION_ID, + project_id: PROJECT_ID, + graph_version: 7, + head_source_sha256: RETAINED_HEAD, + foundation_plan: { + format: FOUNDATION_PLAN_FORMAT, + sha256: changes.foundationPlanSha256 ?? RETAINED_HEAD, + }, + analysis: { + id: ANALYSIS_ID, + release: "foundation-plan-rails/analysis-2026-08", + }, + compiler_release: COMPILER_RELEASE, + target: TARGET, + core: { + repository: "firstdraft/foundation-rails-core", + revision: "2".repeat(40), + sha256: "3".repeat(64), + }, + }, + manifest_sha256: sha256(Buffer.from(JSON.stringify(metadata))), + files: [file], + }; + const source = Buffer.from(JSON.stringify(body)); + return { source, sha256: sha256(source) }; +} + +/** @param {ReturnType} fixture */ +function artifactResponse(fixture) { + return new Response(fixture.source, { + status: 200, + headers: { + "Content-Type": ARTIFACT_MEDIA_TYPE, + "Content-Length": String(fixture.source.byteLength), + "Cache-Control": "no-store, no-transform", + ETag: `"sha256:${fixture.sha256}"`, + }, + }); +} + +function authenticationProblem() { + return new Response( + JSON.stringify({ + type: "about:blank", + title: "Unauthorized", + status: 401, + code: "authentication_required", + detail: "Provide a token.", + }), + { status: 401, headers: { "Content-Type": "application/problem+json" } }, + ); +} + +/** @param {import("node:test").TestContext} context */ +function remoteDirectory(context) { + const cwd = mkdtempSync(path.join(tmpdir(), "firstdraft-compilation-")); + context.after(() => rmSync(cwd, { recursive: true, force: true })); + mkdirSync(path.join(cwd, ".firstdraft")); + writeFileSync( + path.join(cwd, ".firstdraft", "state.json"), + `${JSON.stringify( + { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: "https://api.example.test", + foundation_plan_etag: `"sha256:${LOCAL_HEAD}"`, + }, + null, + 2, + )}\n`, + { mode: 0o600 }, + ); + return cwd; +} + +/** @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); +} + +/** @typedef {{input: string | URL | Request, init: RequestInit | undefined}} FetchCall */ + +/** @param {Response[]} responses @param {FetchCall[]} [calls] */ +function sequenceFetch(responses, calls = []) { + return async ( + /** @type {string | URL | Request} */ input, + /** @type {RequestInit | undefined} */ init, + ) => { + calls.push({ input, init }); + const response = responses.shift(); + assert(response, "unexpected request"); + return response; + }; +} + +/** @param {unknown} body */ +function jsonResponse(body) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +/** @param {Buffer} value */ +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/test/plan-compile.test.js b/test/plan-compile.test.js index 23822f5..90d9312 100644 --- a/test/plan-compile.test.js +++ b/test/plan-compile.test.js @@ -1,64 +1,31 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { - lstatSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; +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 { - ARTIFACT_MEDIA_TYPE, - FOUNDATION_PLAN_FORMAT, -} from "../src/compilation-artifact.js"; import { run } from "../src/cli.js"; -const PROJECT_ID = "01900000-0000-7000-8000-000000000701"; -const COMPILATION_ID = "01900000-0000-7000-8000-000000000703"; -const OTHER_COMPILATION_ID = "01900000-0000-7000-8000-000000000704"; -const ANALYSIS_ID = "01900000-0000-7000-8000-000000000705"; -const SUBJECT_ID = "01900000-0000-7000-8000-000000000706"; -const HEAD_SHA256 = "1".repeat(64); -const ETAG = `"sha256:${HEAD_SHA256}"`; +const PROJECT_ID = "01900000-0000-7000-8000-000000002001"; +const ANALYSIS_ID = "01900000-0000-7000-8000-000000002002"; +const STALE_ANALYSIS_ID = "01900000-0000-7000-8000-000000002005"; +const COMPILATION_ID = "01900000-0000-7000-8000-000000002003"; +const PUBLICATION_ID = "01900000-0000-7000-8000-000000002004"; const API_TOKEN = `fd_${"a".repeat(43)}`; -const CREATED_AT = "2026-07-30T12:00:00.000Z"; -const STARTED_AT = "2026-07-30T12:00:01.000Z"; -const COMPLETED_AT = "2026-07-30T12:00:02.000Z"; -const STATUS_PATH = `/v1/projects/${PROJECT_ID}/compilations/${COMPILATION_ID}`; -const CANCEL_PATH = `${STATUS_PATH}/cancel`; -const ARTIFACT_PATH = `${STATUS_PATH}/artifact`; -const COMPILER_RELEASE = "foundation-plan-rails/compiler-scalar-2026-08"; -const TARGET = { id: "rails", profile: "rails-sketch/2026-08" }; -const PLAN_COMPILE_HELP = `First Draft CLI - -Usage: - firstdraft plan compile --output - -Options: - --output Materialize the generated application here - -h, --help Show help - -Environment: - FIRSTDRAFT_API_TOKEN Authenticate API requests - -The command starts one compilation of the exact Plan ETag pinned by the -last successful push, waits up to ten minutes, validates the complete -artifact, and atomically renames it into an absent output path. -`; +const PLAN_SOURCE = Buffer.from( + '{"format":"firstdraft.foundation-plan.sketch/0.19","application":{"key":"movie_catalog","name":"Movie Catalog"}}\n', +); +const HEAD_SHA256 = sha256(PLAN_SOURCE); +const ETAG = `"sha256:${HEAD_SHA256}"`; +const CREATED_AT = "2026-08-04T12:00:00.000Z"; +const STARTED_AT = "2026-08-04T12:00:01.000Z"; +const COMPLETED_AT = "2026-08-04T12:00:02.000Z"; -test("plan compile uses one pinned POST, sequential polling, and one artifact GET", async (context) => { +test("plan compile submits exact bytes, waits for valid analysis, and publishes once", async (context) => { /** @type {{method: string | undefined, url: string | undefined, headers: import("node:http").IncomingHttpHeaders, body: Buffer}[]} */ const requests = []; - const artifact = artifactFixture(); - let statusReads = 0; const server = createServer(async (request, response) => { const body = await readRequestBody(request); requests.push({ @@ -68,949 +35,477 @@ test("plan compile uses one pinned POST, sequential polling, and one artifact GE body, }); - if (request.method === "POST" && request.url === compilationCollection()) { - respondJson(response, 202, compilationBody("queued"), { - Location: STATUS_PATH, - }); + if (request.method === "PUT" && request.url === planPath()) { + respondJson(response, 201, acceptedPlanBody(), { ETag: ETAG }); return; } - if (request.method === "GET" && request.url === STATUS_PATH) { - statusReads += 1; - respondJson( - response, - 200, - statusReads === 1 - ? compilationBody("running") - : compilationBody("succeeded", { artifact }), - ); + if (request.method === "GET" && request.url === analysisPath()) { + respondJson(response, 200, analysisBody("valid")); return; } - if (request.method === "GET" && request.url === ARTIFACT_PATH) { - response.writeHead(200, { - "Content-Type": ARTIFACT_MEDIA_TYPE, - "Content-Length": artifact.source.byteLength, - ETag: `"sha256:${artifact.sha256}"`, - }); - response.end(artifact.source); + if (request.method === "PUT" && request.url === publicationPath()) { + respondJson(response, 201, publicationBody()); return; } - response.writeHead(404).end(); }); const apiUrl = await listen(context, server); - const cwd = remoteDirectory(context, apiUrl); - const output = path.join(cwd, "generated-app"); - /** @type {string[]} */ - const events = []; - /** @type {number[]} */ - const timeouts = []; - const result = await invoke(["plan", "compile", "--output", output], { - cwd, - apiUrl: "https://canary-secret.example", - planCompileSleep: async (/** @type {number} */ delayMs) => { - events.push(`sleep:${delayMs}`); - }, - createRequestSignal: (/** @type {number} */ timeoutMs) => { - timeouts.push(timeoutMs); - return new AbortController().signal; - }, - }); + const cwd = localDirectory(context, PLAN_SOURCE); + const result = await invoke(["plan", "compile"], { cwd, apiUrl }); assert.equal(result.status, 0); assert.equal(result.stderr, ""); - const printed = JSON.parse(result.stdout); - assert.equal(printed.project.id, PROJECT_ID); - assert.equal(printed.compilation.id, COMPILATION_ID); - assert.equal(printed.compilation.status, "succeeded"); - assert.deepEqual(printed.output, { - path: output, - file_count: 2, - manifest_sha256: artifact.manifestSha256, - }); - assert.deepEqual(events, ["sleep:1000", "sleep:1000"]); - assert.deepEqual(timeouts, [30_000, 30_000, 30_000, 30_000]); + assert.deepEqual(result.stdoutWrites, [jsonLine(publicationBody())]); assert.deepEqual( requests.map(({ method, url }) => [method, url]), [ - ["POST", compilationCollection()], - ["GET", STATUS_PATH], - ["GET", STATUS_PATH], - ["GET", ARTIFACT_PATH], + ["PUT", planPath()], + ["GET", analysisPath()], + ["PUT", publicationPath()], ], ); - - const [start, firstStatus, secondStatus, artifactRequest] = 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); - for (const statusRequest of [firstStatus, secondStatus]) { - assert(statusRequest); - assert.equal( - statusRequest.headers.accept, - "application/json, application/problem+json", - ); - assert.equal(statusRequest.headers.authorization, `Bearer ${API_TOKEN}`); - } - assert(artifactRequest); - assert.equal( - artifactRequest.headers.accept, - `${ARTIFACT_MEDIA_TYPE}, application/problem+json`, - ); - assert.equal(artifactRequest.headers.authorization, `Bearer ${API_TOKEN}`); - assert.equal( - readFileSync(path.join(output, "app/models/movie.rb"), "utf8"), - "class Movie < ApplicationRecord\nend\n", - ); - assert.deepEqual( - readFileSync(path.join(output, "bin/setup")), - Buffer.from([0, 255]), + assert.deepEqual(requests[0]?.body, PLAN_SOURCE); + assert.equal(requests[0]?.headers["if-none-match"], "*"); + assert.equal(requests[2]?.headers["if-match"], ETAG); + assert( + requests.every( + ({ headers }) => headers.authorization === `Bearer ${API_TOKEN}`, + ), ); - if (process.platform !== "win32") { - assert.equal( - lstatSync(path.join(output, "app/models/movie.rb")).mode & 0o777, - 0o644, - ); - assert.equal(lstatSync(path.join(output, "bin/setup")).mode & 0o777, 0o755); - } - assert.doesNotMatch(result.stdout, /canary-secret|sha256:1111/); -}); - -test("plan compile help and invalid arguments do not touch local or network dependencies", async () => { - const inaccessible = () => { - throw new Error("the dependency must remain inaccessible"); - }; - - for (const argv of [ - ["plan", "compile", "--help"], - ["plan", "compile", "-h"], - ["plan", "compile", "--output", "canary-secret", "--help"], - ]) { - assert.deepEqual( - await invoke(argv, { - cwd: process.cwd(), - getCwd: inaccessible, - fetchFunction: inaccessible, - planPushFileSystem: inaccessibleFileSystem(), - }), - { status: 0, stdout: PLAN_COMPILE_HELP, stderr: "" }, - ); - } - - for (const argv of [ - ["plan", "compile"], - ["plan", "compile", "--output"], - ["plan", "compile", "--output", "one", "--output", "two"], - ["plan", "compile", "canary-secret"], - ["plan", "compile", "--canary-secret"], - ]) { - const result = await invoke(argv, { - getCwd: () => process.cwd(), - fetchFunction: inaccessible, - planPushFileSystem: inaccessibleFileSystem(), - }); - - assert.equal(result.status, 2); - assert.equal(result.stdout, ""); - assert.deepEqual(JSON.parse(result.stderr), { - error: "invalid_arguments", - detail: - "Invalid arguments. Run 'firstdraft plan compile --help' for usage.", - }); - assert.doesNotMatch(result.stderr, /canary-secret/); - } }); -test("local prerequisites fail before a compilation can start", async (context) => { - const inaccessible = inaccessibleFetch(); - const unpushed = localDirectory(context, { - format: "firstdraft.cli-state/1", - project_id: PROJECT_ID, - }); - assertHandledFailure( - await invoke(["plan", "compile", "--output", "generated"], { - cwd: unpushed, - fetchFunction: inaccessible, - }), - "project_not_pushed", - ); - - const opaque = localDirectory(context, { - format: "firstdraft.cli-state/1", - project_id: PROJECT_ID, +test("plan compile may push unchanged bytes before analysis and Publication", async (context) => { + const cwd = localDirectory(context, PLAN_SOURCE, { api_url: "https://api.example.test", - foundation_plan_etag: '"opaque"', + foundation_plan_etag: ETAG, }); - assertHandledFailure( - await invoke(["plan", "compile", "--output", "generated"], { - cwd: opaque, - fetchFunction: inaccessible, - }), - "invalid_configuration", - 2, - ); - - const existing = remoteDirectory(context, "https://api.example.test"); - mkdirSync(path.join(existing, "generated")); - assertHandledFailure( - await invoke(["plan", "compile", "--output", "generated"], { - cwd: existing, - fetchFunction: inaccessible, - }), - "invalid_output_path", - 2, - ); - - const symlinkedParent = path.join(existing, "linked-parent"); - symlinkSync(existing, symlinkedParent); - assertHandledFailure( - await invoke(["plan", "compile", "--output", "linked-parent/generated"], { - cwd: existing, - fetchFunction: inaccessible, - }), - "invalid_output_path", - 2, - ); -}); - -test("a sent start request is never retried when its outcome is ambiguous", async (context) => { - const cwd = remoteDirectory(context, "https://api.example.test"); - let requests = 0; - const result = await invoke(["plan", "compile", "--output", "generated"], { + /** @type {unknown[]} */ + const order = []; + const expected = publicationBody(); + const result = await invoke(["plan", "compile"], { cwd, - fetchFunction: async () => { - requests += 1; - throw new TypeError("canary-secret-network"); + planCompilePush: async (/** @type {{cwd: string}} */ options) => { + order.push(["push", options.cwd]); + return { + status: 200, + etag: ETAG, + outcome: "updated", + body: acceptedPlanBody(), + }; }, - }); - - assert.equal(requests, 1); - assertHandledFailure(result, "request_outcome_unknown"); - assert.doesNotMatch(result.stderr, /canary-secret|sha256:1111/); -}); - -test("a validated start rejection is safe and does not poll", async (context) => { - const cwd = remoteDirectory(context, "https://api.example.test"); - const result = await invoke(["plan", "compile", "--output", "generated"], { - cwd, - fetchFunction: async () => - problemResponse(409, "project_not_valid", "Compile is unavailable."), - }); - - assert.deepEqual(JSON.parse(result.stderr), { - error: "compilation_start_rejected", - detail: "First Draft rejected the compilation start request.", - status: 409, - response: { - type: "about:blank", - title: "Conflict", - status: 409, - code: "project_not_valid", - detail: "Compile is unavailable.", + planCompileReadStatus: async (/** @type {{wait?: boolean}} */ options) => { + order.push(["analysis", options.wait]); + return { status: 200, body: analysisBody("valid") }; }, - }); - assert.equal(result.status, 1); -}); - -test("missing credentials and validated 401 responses use one stable authentication error", async (context) => { - const missingDirectory = remoteDirectory(context, "https://api.example.test"); - let requests = 0; - const missing = await invoke( - ["plan", "compile", "--output", "missing-auth-output"], - { - cwd: missingDirectory, - apiToken: "", - fetchFunction: async () => { - requests += 1; - throw new Error("request must not be sent"); - }, + planCompilePublish: async () => { + order.push(["publication"]); + return expected; }, - ); - - assert.deepEqual(JSON.parse(missing.stderr), { - error: "authentication_required", - detail: - "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token.", }); - assert.equal(missing.status, 1); - assert.equal(requests, 0); - const stages = [ - [ - problemResponse( - 401, - "authentication_required", - "Provide a valid API token.", - ), - ], - [ - jsonResponse(compilationBody("queued"), 202, { - Location: STATUS_PATH, - }), - problemResponse( - 401, - "authentication_required", - "Provide a valid API token.", - ), - ], - [ - jsonResponse( - compilationBody("succeeded", { artifact: artifactFixture() }), - 202, - { Location: STATUS_PATH }, - ), - problemResponse( - 401, - "authentication_required", - "Provide a valid API token.", - ), - ], - ]; - - for (const [index, responses] of stages.entries()) { - const cwd = remoteDirectory(context, `https://api-${index}.example.test`); - const result = await invoke( - ["plan", "compile", "--output", `auth-output-${index}`], - { - cwd, - fetchFunction: sequenceFetch(responses), - planCompileSleep: async () => undefined, - }, - ); - - assert.deepEqual(JSON.parse(result.stderr), { - error: "authentication_required", - detail: - "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token.", - status: 401, - response: { - type: "about:blank", - title: "Unauthorized", - status: 401, - code: "authentication_required", - detail: "Provide a valid API token.", - }, - }); - assert.equal(result.status, 1); - assert.equal(result.stderr.includes(API_TOKEN), false); - } + assert.equal(result.status, 0); + assert.deepEqual(order, [["push", cwd], ["analysis", true], ["publication"]]); + assert.deepEqual(JSON.parse(result.stdout), expected); }); -test("an unvalidated non-success start response remains ambiguous", async (context) => { - const cwd = remoteDirectory(context, "https://api.example.test"); +test("plan compile waits past a terminal analysis for the prior graph version", async (context) => { + const cwd = localDirectory(context, PLAN_SOURCE, { + api_url: "https://api.example.test", + foundation_plan_etag: ETAG, + }); /** @type {unknown[]} */ const calls = []; - const result = await invoke(["plan", "compile", "--output", "generated"], { + let publications = 0; + const result = await invoke(["plan", "compile"], { cwd, + planCompilePush: async () => ({ + status: 200, + etag: ETAG, + outcome: "updated", + body: acceptedPlanBody(2), + }), fetchFunction: sequenceFetch( [ - new Response("bad gateway", { - status: 502, - headers: { "Content-Type": "text/html" }, - }), + jsonResponse(analysisBody("valid", 1, STALE_ANALYSIS_ID)), + jsonResponse(analysisBody("valid", 2, ANALYSIS_ID)), ], calls, ), + planCompileSleep: async () => {}, + planCompilePublish: async () => { + publications += 1; + return publicationBody(); + }, }); - assert.equal(calls.length, 1); - assertHandledFailure(result, "request_outcome_unknown"); - assert.equal(JSON.parse(result.stderr).status, 502); - assert.doesNotMatch(result.stderr, /html|gateway/); + assert.equal(result.status, 0); + assert.equal(calls.length, 2); + assert.equal(publications, 1); }); -test("invalid accepted start responses remain ambiguous", async (context) => { - const cwd = remoteDirectory(context, "https://api.example.test"); - for (const response of [ - jsonResponse(compilationBody("queued"), 200), - jsonResponse(compilationBody("queued"), 202), - jsonResponse(compilationBody("queued"), 202, { - Location: "/wrong", - }), - new Response("{}", { - status: 202, - headers: { "Content-Type": "text/plain", Location: STATUS_PATH }, - }), - ]) { - const result = await invoke(["plan", "compile", "--output", "generated"], { +test("invalid JSON and schema diagnostics stop before analysis or Publication", async (context) => { + for (const code of ["invalid_json", "schema_invalid"]) { + const source = Buffer.from( + code === "invalid_json" ? "{\n" : '{"format":"wrong"}\n', + ); + const cwd = localDirectory(context, source); + /** @type {unknown[]} */ + const calls = []; + const result = await invoke(["plan", "compile"], { cwd, - fetchFunction: sequenceFetch([response]), - }); - - assertHandledFailure(result, "request_outcome_unknown"); - } -}); - -test("server-returned request paths stay inside the pinned compilation", async (context) => { - const invalidStatusPaths = [ - `${STATUS_PATH}/../../../evil`, - `${STATUS_PATH}/%2e%2e/evil`, - "//evil.example/x", - `${STATUS_PATH}?x=1`, - `/v1/projects/${PROJECT_ID}/compilations/${OTHER_COMPILATION_ID}`, - ]; - - for (const [index, statusPath] of invalidStatusPaths.entries()) { - const cwd = remoteDirectory(context, `https://api-${index}.example.test`); - const result = await invoke( - ["plan", "compile", "--output", `generated-status-${index}`], - { - cwd, - fetchFunction: sequenceFetch([ + apiUrl: "https://api.example.test", + fetchFunction: sequenceFetch( + [ jsonResponse( - compilationBody("queued", { - compilation: { status_path: statusPath }, - }), - 202, - { Location: statusPath }, + { + source_sha256: sha256(source), + diagnostics: [diagnostic(code, `Rejected ${code}.`)], + }, + 422, ), - ]), - }, - ); + ], + calls, + ), + }); - assertHandledFailure(result, "request_outcome_unknown"); + assertHandledFailure(result, "server_rejected"); + assert.equal(calls.length, 1); + assert.equal(JSON.parse(result.stderr).response.diagnostics[0].code, code); } +}); - for (const [index, cancelPath] of [ - `${CANCEL_PATH}?x=1`, - `/v1/projects/${PROJECT_ID}/compilations/${OTHER_COMPILATION_ID}/cancel`, - ].entries()) { - const cwd = remoteDirectory( - context, - `https://api-cancel-${index}.example.test`, - ); - const result = await invoke( - ["plan", "compile", "--output", `generated-cancel-${index}`], - { - cwd, - fetchFunction: sequenceFetch([ - jsonResponse( - compilationBody("queued", { - compilation: { cancel_path: cancelPath }, - }), - 202, - { Location: STATUS_PATH }, - ), - ]), +test("semantic and failed analysis stop before Publication with structured status", async (context) => { + for (const status of ["issues_found", "analysis_failed"]) { + const cwd = localDirectory(context, PLAN_SOURCE, { + api_url: "https://api.example.test", + foundation_plan_etag: ETAG, + }); + let publications = 0; + const current = analysisBody(status); + const result = await invoke(["plan", "compile"], { + cwd, + planCompilePush: successfulPush, + planCompileReadStatus: async () => ({ status: 200, body: current }), + planCompilePublish: async () => { + publications += 1; + return publicationBody(); }, - ); + }); - assertHandledFailure(result, "request_outcome_unknown"); + assertHandledFailure(result, "plan_not_valid"); + assert.deepEqual(JSON.parse(result.stderr).current, current); + assert.equal(publications, 0); } - - const artifact = artifactFixture(); - const cwd = remoteDirectory(context, "https://api-artifact.example.test"); - /** @type {unknown[]} */ - const calls = []; - const result = await invoke(["plan", "compile", "--output", "generated"], { - cwd, - fetchFunction: sequenceFetch( - [ - jsonResponse( - compilationBody("succeeded", { - artifact, - compilation: { - artifact: { - path: `/v1/projects/${PROJECT_ID}/compilations/${OTHER_COMPILATION_ID}/artifact`, - sha256: artifact.sha256, - media_type: ARTIFACT_MEDIA_TYPE, - byte_size: artifact.source.byteLength, - }, - }, - }), - 202, - { Location: STATUS_PATH }, - ), - ], - calls, - ), - }); - - assert.equal(calls.length, 1); - assertHandledFailure(result, "request_outcome_unknown"); }); -test("returned same-origin paths may use scoped action suffixes", async (context) => { - const cwd = remoteDirectory(context, "https://api.example.test"); - const artifact = artifactFixture(); - const statusPath = `${STATUS_PATH}/status`; - const artifactPath = `${STATUS_PATH}/downloads/artifact`; - const body = compilationBody("succeeded", { - artifact, - compilation: { - status_path: statusPath, - cancel_path: `${STATUS_PATH}/actions/cancel`, - artifact: { - path: artifactPath, - sha256: artifact.sha256, - media_type: ARTIFACT_MEDIA_TYPE, - byte_size: artifact.source.byteLength, - }, - }, +test("recurring diagnostics remain repairable and never trigger Publication", async (context) => { + const cwd = localDirectory(context, PLAN_SOURCE, { + api_url: "https://api.example.test", + foundation_plan_etag: ETAG, }); - const output = path.join(cwd, "generated"); - const result = await invoke(["plan", "compile", "--output", output], { + let publications = 0; + const current = analysisBody("issues_found"); + const options = { cwd, - fetchFunction: sequenceFetch([ - jsonResponse(body, 202, { Location: statusPath }), - new Response(artifact.source, { - status: 200, - headers: { - "Content-Type": ARTIFACT_MEDIA_TYPE, - "Content-Length": String(artifact.source.byteLength), - ETag: `"sha256:${artifact.sha256}"`, - }, - }), - ]), - }); + planCompilePush: successfulPush, + planCompileReadStatus: async () => ({ status: 200, body: current }), + planCompilePublish: async () => { + publications += 1; + return publicationBody(); + }, + }; - assert.equal(result.status, 0); - assert.equal(JSON.parse(result.stdout).compilation.status_path, statusPath); + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = await invoke(["plan", "compile"], options); + assertHandledFailure(result, "plan_not_valid"); + assert.equal( + JSON.parse(result.stderr).current.analysis.diagnostics[0].code, + "reference_missing", + ); + } + assert.equal(publications, 0); }); -test("polling pins compilation identity and stops on its first failed read", async (context) => { - const cwd = remoteDirectory(context, "https://api.example.test"); - const changed = compilationBody("running", { - compilation: { - id: OTHER_COMPILATION_ID, - status_path: `/v1/projects/${PROJECT_ID}/compilations/${OTHER_COMPILATION_ID}`, - cancel_path: `/v1/projects/${PROJECT_ID}/compilations/${OTHER_COMPILATION_ID}/cancel`, - }, +test("the final local-byte check stops a stale analyzed Plan before Publication", async (context) => { + const cwd = localDirectory(context, PLAN_SOURCE, { + api_url: "https://api.example.test", + foundation_plan_etag: ETAG, }); - /** @type {unknown[]} */ - const calls = []; - const result = await invoke(["plan", "compile", "--output", "generated"], { + let networkRequests = 0; + const result = await invoke(["plan", "compile"], { cwd, - fetchFunction: sequenceFetch( - [ - jsonResponse(compilationBody("queued"), 202, { - Location: STATUS_PATH, - }), - jsonResponse(changed), - ], - calls, - ), - planCompileSleep: async () => undefined, - }); - - assert.equal(calls.length, 2); - assertHandledFailure(result, "compilation_changed"); - assert.equal( - JSON.parse(result.stderr).current.compilation.id, - OTHER_COMPILATION_ID, - ); - - const unavailable = await invoke( - ["plan", "compile", "--output", "another-generated"], - { - cwd, - fetchFunction: sequenceFetch([ - jsonResponse(compilationBody("queued"), 202, { - Location: STATUS_PATH, - }), - async () => { - throw new TypeError("canary-secret-network"); - }, - ]), - planCompileSleep: async () => undefined, + planCompilePush: successfulPush, + planCompileReadStatus: async () => { + const replacement = Buffer.concat([PLAN_SOURCE, Buffer.from(" ")]); + const replacementEtag = `"sha256:${sha256(replacement)}"`; + writeFileSync(planFilePath(cwd), replacement); + writeFileSync( + stateFilePath(cwd), + `${JSON.stringify( + { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: "https://api.example.test", + foundation_plan_etag: replacementEtag, + }, + null, + 2, + )}\n`, + { mode: 0o600 }, + ); + return { status: 200, body: analysisBody("valid") }; }, - ); - assertHandledFailure(unavailable, "compilation_status_unavailable"); - assert.doesNotMatch(unavailable.stderr, /canary-secret/); -}); - -test("polling distinguishes a validated unavailable status from a protocol mismatch", async (context) => { - const unavailableDirectory = remoteDirectory( - context, - "https://api.example.test", - ); - const unavailable = await invoke( - ["plan", "compile", "--output", "generated-unavailable"], - { - cwd: unavailableDirectory, - fetchFunction: sequenceFetch([ - jsonResponse(compilationBody("queued"), 202, { - Location: STATUS_PATH, - }), - problemResponse(503, "temporarily_unavailable", "Try later."), - ]), - planCompileSleep: async () => undefined, + fetchFunction: async () => { + networkRequests += 1; + throw new Error("Publication must not start"); }, - ); - assertHandledFailure(unavailable, "compilation_status_unavailable"); - assert.equal(JSON.parse(unavailable.stderr).status, 503); + }); - const invalidDirectory = remoteDirectory(context, "https://api.example.test"); - const invalid = await invoke( - ["plan", "compile", "--output", "generated-invalid"], - { - cwd: invalidDirectory, - fetchFunction: sequenceFetch([ - jsonResponse(compilationBody("queued"), 202, { - Location: STATUS_PATH, - }), - new Response('{"canary":"secret"}', { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ]), - planCompileSleep: async () => undefined, - }, - ); - assertHandledFailure(invalid, "invalid_compilation_status"); - assert.doesNotMatch(invalid.stderr, /canary|secret/); + assertHandledFailure(result, "local_plan_changed"); + assert.equal(networkRequests, 0); }); -test("polling enforces the bounded ten-minute deadline", async (context) => { - const cwd = remoteDirectory(context, "https://api.example.test"); - let currentTime = 0; - const result = await invoke(["plan", "compile", "--output", "generated"], { - cwd, - fetchFunction: sequenceFetch([ - jsonResponse(compilationBody("queued"), 202, { - Location: STATUS_PATH, - }), - ]), - planCompileNow: () => currentTime, - planCompileSleep: async (/** @type {number} */ delayMs) => { - assert.equal(delayMs, 1_000); - currentTime = 600_000; +test("push ambiguity, analysis failures, and rejected reads have distinct errors", async (context) => { + const pushCwd = localDirectory(context, PLAN_SOURCE); + const push = await invoke(["plan", "compile"], { + cwd: pushCwd, + apiUrl: "https://api.example.test", + fetchFunction: async () => { + throw new TypeError("canary network failure"); }, }); + assertHandledFailure(push, "request_outcome_unknown"); + assert.equal(JSON.parse(push.stderr).phase, "push"); + assert.doesNotMatch(push.stderr, /canary/); - assertHandledFailure(result, "compilation_wait_timed_out"); - assert.equal(JSON.parse(result.stderr).current.compilation.status, "queued"); -}); - -test("failed and cancelled compilations are domain failures without artifact reads", async (context) => { - for (const status of ["failed", "cancelled"]) { - const cwd = remoteDirectory(context, "https://api.example.test"); - /** @type {unknown[]} */ - const calls = []; - const body = - status === "failed" - ? compilationBody(status, { - compilation: { - failure: { - phase: "render", - code: "render_failed", - message: "The renderer failed safely.", - }, - }, - }) - : compilationBody(status); - const result = await invoke( - ["plan", "compile", "--output", `generated-${status}`], - { - cwd, - fetchFunction: sequenceFetch( - [jsonResponse(body, 202, { Location: STATUS_PATH })], - calls, - ), - }, - ); - - assert.equal(calls.length, 1); - assertHandledFailure(result, `compilation_${status}`); - assert.equal(JSON.parse(result.stderr).current.compilation.status, status); - } -}); - -test("artifact transport metadata and exact bytes are verified before parsing", async (context) => { - const validArtifact = artifactFixture(); - const cases = [ - new Response(validArtifact.source, { - status: 200, - headers: { - "Content-Type": "application/json", - "Content-Length": String(validArtifact.source.byteLength), - ETag: `"sha256:${validArtifact.sha256}"`, - }, - }), - new Response(validArtifact.source, { - status: 200, - headers: { - "Content-Type": ARTIFACT_MEDIA_TYPE, - "Content-Length": String(validArtifact.source.byteLength + 1), - ETag: `"sha256:${validArtifact.sha256}"`, - }, - }), - new Response(validArtifact.source, { - status: 200, - headers: { - "Content-Type": ARTIFACT_MEDIA_TYPE, - "Content-Length": `0${validArtifact.source.byteLength}`, - ETag: `"sha256:${validArtifact.sha256}"`, - }, - }), - new Response(validArtifact.source, { - status: 200, - headers: { - "Content-Type": ARTIFACT_MEDIA_TYPE, - "Content-Length": String(validArtifact.source.byteLength), - ETag: `"sha256:${"0".repeat(64)}"`, + /** @type {[Response | (() => Promise), string][]} */ + const analysisFailures = [ + [ + async () => { + throw new TypeError("analysis network failure"); }, - }), + "analysis_status_unavailable", + ], + [ + new Response('{"canary":"invalid"}', { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + "invalid_analysis_status", + ], + [ + problemResponse(503, "analysis_unavailable", "Try later."), + "analysis_status_rejected", + ], ]; - - for (const [index, artifactResponse] of cases.entries()) { - const cwd = remoteDirectory(context, "https://api.example.test"); - const result = await invoke( - ["plan", "compile", "--output", `generated-${index}`], - { - cwd, - fetchFunction: sequenceFetch([ - jsonResponse( - compilationBody("succeeded", { artifact: validArtifact }), - 202, - { Location: STATUS_PATH }, - ), - artifactResponse, - ]), - }, - ); - - assertHandledFailure(result, "invalid_artifact"); + for (const [response, error] of analysisFailures) { + const cwd = localDirectory(context, PLAN_SOURCE); + const result = await invoke(["plan", "compile"], { + cwd, + apiUrl: "https://api.example.test", + fetchFunction: sequenceFetch([ + jsonResponse(acceptedPlanBody(), 201, { ETag: ETAG }), + response, + ]), + }); + assertHandledFailure(result, error); + assert.doesNotMatch(result.stderr, /canary|network failure/); } }); -test("an unavailable artifact is distinct from an invalid artifact", async (context) => { - const artifact = artifactFixture(); - const cwd = remoteDirectory(context, "https://api.example.test"); - const result = await invoke(["plan", "compile", "--output", "generated"], { - cwd, - fetchFunction: sequenceFetch([ - jsonResponse(compilationBody("succeeded", { artifact }), 202, { - Location: STATUS_PATH, - }), - problemResponse(503, "artifact_not_ready", "Try later."), - ]), +test("help and removed local-output syntax have no prerequisites", async () => { + const inaccessible = () => { + throw new Error("dependency must remain inaccessible"); + }; + const help = await invoke(["plan", "compile", "--help"], { + cwd: process.cwd(), + apiToken: undefined, + getCwd: inaccessible, + fetchFunction: inaccessible, }); - - assertHandledFailure(result, "artifact_unavailable"); - assert.equal(JSON.parse(result.stderr).status, 503); -}); - -test("a target created while compiling is preserved and never replaced", async (context) => { - const artifact = artifactFixture(); - const cwd = remoteDirectory(context, "https://api.example.test"); - const output = path.join(cwd, "generated"); - const marker = path.join(output, "belongs-to-user"); - const result = await invoke(["plan", "compile", "--output", output], { - cwd, - fetchFunction: sequenceFetch([ - jsonResponse(compilationBody("queued"), 202, { - Location: STATUS_PATH, - }), - jsonResponse(compilationBody("succeeded", { artifact })), - new Response(artifact.source, { - status: 200, - headers: { - "Content-Type": ARTIFACT_MEDIA_TYPE, - "Content-Length": String(artifact.source.byteLength), - ETag: `"sha256:${artifact.sha256}"`, - }, - }), - ]), - planCompileSleep: async () => { - mkdirSync(output); - writeFileSync(marker, "preserve me"); - }, + assert.equal(help.status, 0); + assert.match(help.stdout, /firstdraft plan compile/); + + const removed = await invoke(["plan", "compile", "--output", "generated"], { + cwd: process.cwd(), + apiToken: undefined, + getCwd: inaccessible, + fetchFunction: inaccessible, }); - - assertHandledFailure(result, "materialization_failed"); - assert.equal(readFileSync(marker, "utf8"), "preserve me"); - assert.deepEqual( - readdirSync(cwd) - .filter((name) => name.startsWith(".firstdraft-generated-")) - .sort(), - [], - ); + assertHandledFailure(removed, "invalid_arguments", 2); }); -test("exact status shapes and lifecycle timestamps are required", async (context) => { - const invalidBodies = [ - { - ...compilationBody("queued"), - canary: "canary-secret", - }, - compilationBody("queued", { - compilation: { started_at: STARTED_AT }, - }), - compilationBody("running", { - compilation: { completed_at: COMPLETED_AT }, - }), - compilationBody("succeeded", { - compilation: { artifact: null }, - }), - compilationBody("failed", { - compilation: { failure: null }, - }), - ]; +async function successfulPush() { + return { + status: 200, + etag: ETAG, + outcome: "updated", + body: acceptedPlanBody(), + }; +} - for (const [index, body] of invalidBodies.entries()) { - const cwd = remoteDirectory(context, "https://api.example.test"); - const result = await invoke( - ["plan", "compile", "--output", `generated-${index}`], - { - cwd, - fetchFunction: sequenceFetch([ - jsonResponse(body, 202, { Location: STATUS_PATH }), - ]), - }, - ); +/** @param {number} [graphVersion] */ +function acceptedPlanBody(graphVersion = 1) { + return { + project: { id: PROJECT_ID, graph_version: graphVersion }, + foundation_plan: { + format: "firstdraft.foundation-plan.sketch/0.19", + source_sha256: HEAD_SHA256, + }, + diagnostics: [], + }; +} - assertHandledFailure(result, "request_outcome_unknown"); - assert.doesNotMatch(result.stderr, /canary-secret/); - } -}); +/** @param {string} status @param {number} [graphVersion] @param {string} [analysisId] */ +function analysisBody(status, graphVersion = 1, analysisId = ANALYSIS_ID) { + return { + project: { id: PROJECT_ID, graph_version: graphVersion }, + analysis: { + id: analysisId, + graph_version: graphVersion, + analyzer_release: "foundation-plan-analyzer/2026-08", + status, + diagnostics: + status === "issues_found" + ? [structuredDiagnostic("reference_missing")] + : status === "analysis_failed" + ? [structuredDiagnostic("analysis_failed")] + : [], + started_at: STARTED_AT, + completed_at: COMPLETED_AT, + }, + }; +} -/** @param {string} status @param {{artifact?: ReturnType, project?: Record, compilation?: Record}} [changes] */ -function compilationBody(status, changes = {}) { - const artifact = changes.artifact; - const terminal = ["succeeded", "failed", "cancelled"].includes(status); - const started = status === "queued" ? null : STARTED_AT; +function publicationBody() { return { project: { id: PROJECT_ID, - graph_version: 7, - ...changes.project, + graph_version: 1, + head_source_sha256: HEAD_SHA256, }, compilation: { id: COMPILATION_ID, analysis_run_id: ANALYSIS_ID, - graph_version: 7, - status, - compiler_release: COMPILER_RELEASE, - target: TARGET, - status_path: STATUS_PATH, - cancel_path: CANCEL_PATH, - artifact: - status === "succeeded" && artifact - ? { - path: ARTIFACT_PATH, - sha256: artifact.sha256, - media_type: ARTIFACT_MEDIA_TYPE, - byte_size: artifact.source.byteLength, - } - : null, - failure: - status === "failed" - ? { - phase: "compile", - code: "compilation_failed", - message: "The compilation failed.", - } - : null, - created_at: CREATED_AT, - started_at: started, - completed_at: terminal ? COMPLETED_AT : null, - ...changes.compilation, - }, - }; -} - -function artifactFixture() { - const files = [ - artifactFile( - "app/models/movie.rb", - "class Movie < ApplicationRecord\nend\n", - 0o644, - "renderer:model", - [SUBJECT_ID], - ), - artifactFile( - "bin/setup", - Buffer.from([0, 255]), - 0o755, - "core:foundation-rails-core", - [], - ), - ]; - const metadata = files.map( - ({ path: filePath, sha256, mode, owner, source_subject_uuids }) => ({ - path: filePath, - sha256, - mode, - owner, - source_subject_uuids, - }), - ); - const manifestSha256 = sha256( - Buffer.from(JSON.stringify({ files: metadata })), - ); - const body = { - format: "firstdraft.compilation-artifact/1", - provenance: { - compilation_id: COMPILATION_ID, - project_id: PROJECT_ID, - graph_version: 7, + graph_version: 1, head_source_sha256: HEAD_SHA256, - foundation_plan: { - format: FOUNDATION_PLAN_FORMAT, - sha256: "2".repeat(64), - }, - analysis: { - id: ANALYSIS_ID, - release: "foundation-plan-rails/scalar-2026-08", + status: "succeeded", + compiler_release: "foundation-plan-rails/compiler-2026-08", + target: { id: "rails", profile: "rails-sketch/2026-08" }, + artifact: { + sha256: "1".repeat(64), + manifest_sha256: "2".repeat(64), + file_count: 10, }, - compiler_release: COMPILER_RELEASE, - target: TARGET, - core: { - repository: "firstdraft/foundation-rails-core", - revision: "3".repeat(40), - sha256: "4".repeat(64), + }, + publication: { + id: PUBLICATION_ID, + status: "succeeded", + repository: { + id: 123, + private: true, + owner: { id: 456, login: "octocat", type: "User" }, + full_name: "octocat/movie-catalog", + default_branch: "main", + html_url: "https://github.com/octocat/movie-catalog", + tree_sha: "3".repeat(40), + commit_sha: "4".repeat(40), }, + failure: null, + created_at: CREATED_AT, + started_at: STARTED_AT, + completed_at: COMPLETED_AT, }, - manifest_sha256: manifestSha256, - files, - }; - const source = Buffer.from(JSON.stringify(body)); - return { - body, - files, - source, - sha256: sha256(source), - manifestSha256, }; } -/** @param {string} filePath @param {string | Buffer} contents @param {number} mode @param {string} owner @param {string[]} subjectIds */ -function artifactFile(filePath, contents, mode, owner, subjectIds) { - const bytes = Buffer.isBuffer(contents) ? contents : Buffer.from(contents); +/** @param {string} code @param {string} [message] */ +function diagnostic(code, message = code) { + return { code, severity: "error", message }; +} + +/** @param {string} code */ +function structuredDiagnostic(code) { return { - path: filePath, - sha256: sha256(bytes), - mode, - owner, - source_subject_uuids: subjectIds, - contents_base64: bytes.toString("base64"), + code, + severity: "error", + message: "Resolve the referenced subject.", + location: { source_pointer: "/entities/0" }, + subject: null, + related_locations: [], + suggestions: [], }; } -/** @param {Buffer} value */ -function sha256(value) { - return createHash("sha256").update(value).digest("hex"); +function planPath() { + return `/v1/projects/${PROJECT_ID}/foundation-plan`; } -function compilationCollection() { - return `/v1/projects/${PROJECT_ID}/compilations`; +function analysisPath() { + return `/v1/projects/${PROJECT_ID}/analysis`; } -/** @param {import("node:http").ServerResponse} response @param {number} status @param {unknown} body @param {Record} [headers] */ -function respondJson(response, status, body, headers = {}) { - const source = Buffer.from(JSON.stringify(body)); - response.writeHead(status, { - "Content-Type": "application/json", - "Content-Length": source.byteLength, - ...headers, +function publicationPath() { + return `/v1/projects/${PROJECT_ID}/github-publication`; +} + +/** @param {import("node:test").TestContext} context @param {Buffer} source @param {Record} [extraState] */ +function localDirectory(context, source, extraState = {}) { + const cwd = mkdtempSync(path.join(tmpdir(), "firstdraft-plan-compile-")); + context.after(() => rmSync(cwd, { recursive: true, force: true })); + mkdirSync(path.join(cwd, ".firstdraft")); + writeFileSync( + path.join(cwd, ".firstdraft", "state.json"), + `${JSON.stringify({ format: "firstdraft.cli-state/1", project_id: PROJECT_ID, ...extraState }, null, 2)}\n`, + { mode: 0o600 }, + ); + writeFileSync(planFilePath(cwd), source); + return cwd; +} + +/** @param {string} cwd */ +function planFilePath(cwd) { + return path.join(cwd, ".firstdraft", "foundation-plan.json"); +} + +/** @param {string} cwd */ +function stateFilePath(cwd) { + return path.join(cwd, ".firstdraft", "state.json"); +} + +/** @param {readonly string[]} argv @param {Record} [options] */ +async function invoke(argv, options = {}) { + /** @type {string[]} */ + const stdoutWrites = []; + let stderr = ""; + const status = await run({ + argv, + stdout: { write: (text) => stdoutWrites.push(text) }, + stderr: { write: (text) => (stderr += text) }, + apiToken: API_TOKEN, + ...options, }); - response.end(source); + return { status, stdout: stdoutWrites.join(""), stdoutWrites, 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); +} + +/** @param {(Response | (() => Promise))[]} responses @param {unknown[]} [calls] */ +function sequenceFetch(responses, calls = []) { + return async ( + /** @type {string | URL | Request} */ input, + /** @type {RequestInit} */ init, + ) => { + calls.push({ input, init }); + const response = responses.shift(); + assert(response, "unexpected request"); + return typeof response === "function" ? response() : response; + }; } /** @param {unknown} body @param {number} [status] @param {Record} [headers] */ @@ -1026,35 +521,24 @@ function problemResponse(status, code, detail) { return new Response( JSON.stringify({ type: "about:blank", - title: - status === 401 - ? "Unauthorized" - : status === 409 - ? "Conflict" - : "Service Unavailable", + title: "Service Unavailable", status, code, detail, }), - { - status, - headers: { "Content-Type": "application/problem+json" }, - }, + { status, headers: { "Content-Type": "application/problem+json" } }, ); } -/** - * @param {(Response | (() => Promise))[]} responses - * @param {unknown[]} [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 @param {Record} [headers] */ +function respondJson(response, status, body, headers = {}) { + const source = Buffer.from(JSON.stringify(body)); + response.writeHead(status, { + "Content-Type": "application/json", + "Content-Length": source.byteLength, + ...headers, + }); + response.end(source); } /** @param {import("node:http").IncomingMessage} request */ @@ -1067,96 +551,25 @@ async function readRequestBody(request) { /** @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(); - }); + 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(); - }); - }, - ), + new Promise((/** @type {(value?: void) => void} */ resolve) => { + server.close(() => 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-compile-")); - 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 }, - ); - return directory; -} - -/** @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"); - }; +/** @param {Buffer} value */ +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); } -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"); - }, - }; +/** @param {unknown} value */ +function jsonLine(value) { + return `${JSON.stringify(value, null, 2)}\n`; } diff --git a/test/plan-init.test.js b/test/plan-init.test.js index 9fa9291..a829634 100644 --- a/test/plan-init.test.js +++ b/test/plan-init.test.js @@ -29,8 +29,7 @@ Commands: init Create a local empty Foundation Plan 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 + compile Compile and publish the current Foundation Plan Options: -h, --help Show help diff --git a/test/plan-publish.test.js b/test/plan-publish.test.js index a533d18..59519ec 100644 --- a/test/plan-publish.test.js +++ b/test/plan-publish.test.js @@ -42,23 +42,25 @@ const REPOSITORY = { tree_sha: TREE_SHA, commit_sha: COMMIT_SHA, }; -const PLAN_PUBLISH_HELP = `First Draft CLI +const PLAN_COMPILE_HELP = `First Draft CLI Usage: - firstdraft plan publish + firstdraft plan compile Options: -h, --help Show help Environment: FIRSTDRAFT_API_TOKEN Authenticate API requests + FIRSTDRAFT_API_URL Override the initial API origin -The command conditionally creates or replays the Project's one Publication. -Each Project can publish one retained Plan Head in this release. The command -waits up to ten minutes and prints the private GitHub repository URL. +The command submits the exact current whole-file Plan, waits for its analysis, +and proceeds only when that analysis is valid. It then conditionally creates +or replays the internal GitHub Publication lifecycle and prints its complete +validated Project, Compilation, and Publication projection. `; -test("plan publish sends one conditional singleton PUT and polls sequentially", async (context) => { +test("plan compile invokes Publication and 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; @@ -105,7 +107,7 @@ test("plan publish sends one conditional singleton PUT and polls sequentially", const timeouts = []; /** @type {number[]} */ const delays = []; - const result = await invoke(["plan", "publish"], { + const result = await invoke(["plan", "compile"], { cwd, apiUrl: "https://canary-secret.example", createRequestSignal: (/** @type {number} */ timeoutMs) => { @@ -119,7 +121,7 @@ test("plan publish sends one conditional singleton PUT and polls sequentially", assert.deepEqual(result, { status: 0, - stdout: `${REPOSITORY.html_url}\n`, + stdout: jsonLine(publicationBody("succeeded")), stderr: "", }); assert.deepEqual(delays, [1000, 1000, 1000, 1000, 1000, 1000]); @@ -157,14 +159,14 @@ test("plan publish sends one conditional singleton PUT and polls sequentially", "application/json, application/problem+json", ); } - assert.doesNotMatch(result.stdout, /canary-secret|sha256/); + assert.doesNotMatch(result.stdout, /canary-secret/); }); test("a repeated singleton PUT accepts provenance matching local Plan state", async (context) => { const cwd = remoteDirectory(context, "https://api.example.test"); /** @type {FetchCall[]} */ const calls = []; - const result = await invoke(["plan", "publish"], { + const result = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch( [jsonResponse(publicationBody("succeeded"), 200)], @@ -173,7 +175,7 @@ test("a repeated singleton PUT accepts provenance matching local Plan state", as }); assert.equal(result.status, 0); - assert.equal(result.stdout, `${REPOSITORY.html_url}\n`); + assert.equal(result.stdout, jsonLine(publicationBody("succeeded"))); assert.equal(calls.length, 1); assert.equal(calls[0]?.init?.method, "PUT"); assert.equal(new Headers(calls[0]?.init?.headers).get("if-match"), ETAG); @@ -183,7 +185,7 @@ 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"], { + const result = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch( [ @@ -198,7 +200,7 @@ test("an ambiguous PUT is reconciled by one safe singleton GET", async (context) assert.deepEqual(result, { status: 0, - stdout: `${REPOSITORY.html_url}\n`, + stdout: jsonLine(publicationBody("succeeded")), stderr: "", }); assert.deepEqual( @@ -213,7 +215,7 @@ test("an ambiguous PUT does not adopt a singleton from a different Plan Head", a const retainedHead = "a".repeat(64); /** @type {FetchCall[]} */ const calls = []; - const result = await invoke(["plan", "publish"], { + const result = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch( [ @@ -241,7 +243,7 @@ test("an ambiguous PUT does not adopt a singleton from a different Plan Head", a assert.equal(JSON.parse(result.stderr).status, 200); assert.match( JSON.parse(result.stderr).detail, - /if this Project's Publication is retained for a different Plan Head, it cannot be repointed/, + /Running 'firstdraft plan compile' again is safe/, ); assert.deepEqual( calls.map(({ init }) => init?.method), @@ -253,7 +255,7 @@ test("an invalid successful PUT response can reconcile to the exact singleton", const cwd = remoteDirectory(context, "https://api.example.test"); /** @type {FetchCall[]} */ const calls = []; - const result = await invoke(["plan", "publish"], { + const result = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch( [ @@ -269,7 +271,7 @@ test("an invalid successful PUT response can reconcile to the exact singleton", assert.deepEqual(result, { status: 0, - stdout: `${REPOSITORY.html_url}\n`, + stdout: jsonLine(publicationBody("succeeded")), stderr: "", }); assert.deepEqual( @@ -283,7 +285,7 @@ test("an unresolved ambiguous PUT remains outcome unknown without replaying the const cwd = remoteDirectory(context, "https://api.example.test"); /** @type {FetchCall[]} */ const calls = []; - const result = await invoke(["plan", "publish"], { + const result = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch( [ @@ -315,9 +317,9 @@ test("help and invalid arguments have no local or network prerequisites", async }; for (const argv of [ - ["plan", "publish", "--help"], - ["plan", "publish", "-h"], - ["plan", "publish", "--help", "--help"], + ["plan", "compile", "--help"], + ["plan", "compile", "-h"], + ["plan", "compile", "--help", "--help"], ]) { assert.deepEqual( await invoke(argv, { @@ -327,13 +329,13 @@ test("help and invalid arguments have no local or network prerequisites", async planPushFileSystem: inaccessibleFileSystem(), apiToken: undefined, }), - { status: 0, stdout: PLAN_PUBLISH_HELP, stderr: "" }, + { status: 0, stdout: PLAN_COMPILE_HELP, stderr: "" }, ); } for (const argv of [ - ["plan", "publish", "canary-secret"], - ["plan", "publish", "--canary-secret"], + ["plan", "compile", "canary-secret"], + ["plan", "compile", "--canary-secret"], ]) { const result = await invoke(argv, { fetchFunction: inaccessible, @@ -342,6 +344,19 @@ test("help and invalid arguments have no local or network prerequisites", async assertHandledFailure(result, "invalid_arguments", 2); assert.doesNotMatch(result.stderr, /canary-secret/); } + + assert.deepEqual( + await invoke(["plan", "publish"], { + getCwd: inaccessible, + fetchFunction: inaccessible, + planPushFileSystem: inaccessibleFileSystem(), + }), + { + status: 2, + stdout: "", + stderr: "Unknown command.\nRun 'firstdraft plan --help' for usage.\n", + }, + ); }); test("local prerequisites reject before publication network access", async (context) => { @@ -351,7 +366,7 @@ test("local prerequisites reject before publication network access", async (cont project_id: PROJECT_ID, }); assertHandledFailure( - await invoke(["plan", "publish"], { + await invoke(["plan", "compile"], { cwd: unpushed, fetchFunction: inaccessible, }), @@ -365,9 +380,15 @@ test("local prerequisites reject before publication network access", async (cont foundation_plan_etag: '"opaque"', }); assertHandledFailure( - await invoke(["plan", "publish"], { + await invoke(["plan", "compile"], { cwd: opaque, fetchFunction: inaccessible, + planCompilePush: async () => ({ + status: 200, + etag: '"opaque"', + outcome: "updated", + body: { project: { graph_version: 11 } }, + }), }), "invalid_configuration", 2, @@ -379,7 +400,7 @@ test("local prerequisites reject before publication network access", async (cont Buffer.concat([PLAN_SOURCE, Buffer.from(" ")]), ); assertHandledFailure( - await invoke(["plan", "publish"], { + await invoke(["plan", "compile"], { cwd: changed, fetchFunction: inaccessible, }), @@ -389,7 +410,7 @@ test("local prerequisites reject before publication network access", async (cont const missingPlan = remoteDirectory(context, "https://api.example.test"); rmSync(planPath(missingPlan)); assertHandledFailure( - await invoke(["plan", "publish"], { + await invoke(["plan", "compile"], { cwd: missingPlan, fetchFunction: inaccessible, }), @@ -399,14 +420,14 @@ test("local prerequisites reject before publication network access", async (cont 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"], { + const missing = await invoke(["plan", "compile"], { cwd, apiToken: undefined, fetchFunction: inaccessibleFetch(), }); assertHandledFailure(missing, "authentication_required"); - const rejected = await invoke(["plan", "publish"], { + const rejected = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch([ problemResponse( @@ -419,7 +440,7 @@ test("missing and rejected credentials use the stable authentication error", asy assertHandledFailure(rejected, "authentication_required"); assert.equal(JSON.parse(rejected.stderr).status, 401); - const reconciliation = await invoke(["plan", "publish"], { + const reconciliation = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch([ async () => { @@ -438,7 +459,7 @@ test("missing and rejected credentials use the stable authentication error", asy 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"], { + const rejected = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch([ problemResponse(412, "precondition_failed", "The Plan changed."), @@ -458,7 +479,7 @@ test("validated start rejections are distinct from unknown mutation outcomes", a }, }); - const malformed = await invoke(["plan", "publish"], { + const malformed = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch([ new Response("canary-secret", { status: 500 }), @@ -474,7 +495,7 @@ test("validated timeout and server errors reconcile without replaying the PUT", const cwd = remoteDirectory(context, "https://api.example.test"); /** @type {FetchCall[]} */ const calls = []; - const result = await invoke(["plan", "publish"], { + const result = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch( [ @@ -487,7 +508,7 @@ test("validated timeout and server errors reconcile without replaying the PUT", assert.deepEqual(result, { status: 0, - stdout: `${REPOSITORY.html_url}\n`, + stdout: jsonLine(publicationBody("succeeded")), stderr: "", }); assert.deepEqual( @@ -497,7 +518,7 @@ test("validated timeout and server errors reconcile without replaying the PUT", } const cwd = remoteDirectory(context, "https://api.example.test"); - const unresolved = await invoke(["plan", "publish"], { + const unresolved = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch([ problemResponse(503, "publication_delayed", "Publication is delayed."), @@ -519,7 +540,7 @@ test("validated timeout and server errors reconcile without replaying the PUT", test("polling distinguishes unavailable and invalid status responses", async (context) => { const unavailableCwd = remoteDirectory(context, "https://api.example.test"); - const unavailable = await invoke(["plan", "publish"], { + const unavailable = await invoke(["plan", "compile"], { cwd: unavailableCwd, fetchFunction: sequenceFetch([ jsonResponse(publicationBody("compiling"), 201), @@ -539,7 +560,7 @@ test("polling distinguishes unavailable and invalid status responses", async (co ...publicationBody("provisioning_repository"), canary: "canary-secret", }; - const invalid = await invoke(["plan", "publish"], { + const invalid = await invoke(["plan", "compile"], { cwd: invalidCwd, fetchFunction: sequenceFetch([ jsonResponse(publicationBody("compiling"), 201), @@ -591,7 +612,7 @@ test("polling rejects replacement identities, regressions, and repository mutati for (const { initial, changed } of cases) { const cwd = remoteDirectory(context, "https://api.example.test"); - const result = await invoke(["plan", "publish"], { + const result = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch([ jsonResponse(initial, 201), @@ -610,7 +631,7 @@ test("polling rejects replacement identities, regressions, and repository mutati 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"], { + const result = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch([ jsonResponse(publicationBody("compiling"), 201), @@ -638,7 +659,7 @@ test("failed, conflicted, and cancelled publications preserve terminal status", for (const [status, expectedError] of cases) { const cwd = remoteDirectory(context, "https://api.example.test"); - const result = await invoke(["plan", "publish"], { + const result = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch([ jsonResponse(publicationBody(status), 201), @@ -651,7 +672,7 @@ test("failed, conflicted, and cancelled publications preserve terminal status", for (const status of ["failed", "cancelled"]) { const cwd = remoteDirectory(context, "https://api.example.test"); - const result = await invoke(["plan", "publish"], { + const result = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch([ jsonResponse( @@ -727,7 +748,7 @@ test("exact response shapes and coherent terminal projections are required", asy for (const [index, body] of invalidBodies.entries()) { const cwd = remoteDirectory(context, "https://api.example.test"); - const result = await invoke(["plan", "publish"], { + const result = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch([ jsonResponse(body, 201), @@ -944,11 +965,41 @@ async function invoke(argv, options = {}) { stdout: { write: (text) => (stdout += text) }, stderr: { write: (text) => (stderr += text) }, apiToken: API_TOKEN, + planCompilePush: async () => ({ + status: 200, + etag: ETAG, + outcome: "updated", + body: { project: { graph_version: 11 } }, + }), + planCompileReadStatus: async () => ({ + status: 200, + body: validAnalysis(), + }), ...options, }); return { status, stdout, stderr }; } +function validAnalysis() { + return { + project: { id: PROJECT_ID, graph_version: 11 }, + analysis: { + id: ANALYSIS_ID, + graph_version: 11, + analyzer_release: "foundation-plan-analyzer/2026-08", + status: "valid", + diagnostics: [], + started_at: STARTED_AT, + completed_at: COMPLETED_AT, + }, + }; +} + +/** @param {unknown} value */ +function jsonLine(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + /** * @param {{status: number, stdout: string, stderr: string}} result * @param {string} error @@ -958,6 +1009,9 @@ function assertHandledFailure(result, error, status = 1) { assert.equal(result.status, status); assert.equal(result.stdout, ""); assert.equal(JSON.parse(result.stderr).error, error); + if (error === "request_outcome_unknown") { + assert.equal(JSON.parse(result.stderr).phase, "publication"); + } } function inaccessibleFetch() {