diff --git a/README.md b/README.md index 7410ba9..531547d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # First Draft CLI firstdraft is the command-line client shared by First Draft agents and automation. It manages local Foundation Plan -files, calls the versioned Service API, exposes reviewed analysis and GapSets, and coordinates Compilation plus -private GitHub publication. +files, calls the versioned Service API, exposes reviewed analysis and GapSets, materializes verified Compilations, +and coordinates private GitHub publication. Trying First Draft as a tester? Start with the [Drawing Board guide](https://github.com/firstdraft/drawing-board#build-an-app-with-first-draft), which installs a @@ -13,7 +13,7 @@ compatible CLI and Skill together. - local Plan initialization, UUIDs, application keys, and source hashing; - conditional whole-document push and conflict reporting; - analysis polling and complete GapSet output; -- Compile-and-publish orchestration; +- direct Compile-and-materialize and private publish orchestration; - retained Compilation inspection and artifact download; - terminal output, exit status, and recovery contracts; - the dependency-free npm package; and diff --git a/docs/commands.md b/docs/commands.md index d90761f..b5332ff 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -4,10 +4,10 @@ This page owns the detailed public semantics of the current command surface. Run group's `--help` for concise executable syntax. See [Errors and recovery](errors.md) before retrying a failed mutation. The current `0.2.x` source 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, -compile-and-publish orchestration, and read-only retained-Compilation download. CLI `0.2.x` requires the service's -`0.3.x` API contract. See the [release policy](../RELEASING.md) for versioning and channel semantics and -[release history](release-history.md) for the transition from prereleases. +application-key and UUID generation, conditional whole-document push, whole-graph analysis status polling, direct +Compile-and-materialize and private publish orchestration, and retained-Compilation inspection. CLI `0.2.x` +requires the service's `0.3.x` API contract. See the [release policy](../RELEASING.md) for versioning and channel +semantics and [release history](release-history.md) for the transition from prereleases. ## Command map @@ -18,7 +18,7 @@ compile-and-publish orchestration, and read-only retained-Compilation download. | `firstdraft generate uuid` | No | Generate one or more Foundation Plan subject identities | | `firstdraft plan push` | Yes | Conditionally submit the exact whole Plan | | `firstdraft plan status` | Yes | Read or wait for the current whole-graph analysis | -| `firstdraft plan compile` | Yes | Push, analyze, compile, and publish the current Plan | +| `firstdraft plan compile` | Yes | Push and analyze, then materialize or publish | | `firstdraft compilation status` | Yes | Inspect a retained Compilation by ID | | `firstdraft compilation download` | Yes | Verify and materialize a successful retained Compilation | @@ -131,34 +131,62 @@ repeats only validated `processing` responses and stops on its first failed read a bounded number of times because the command sends only `GET` requests. See [read-only failures](errors.md#read-only-status-failures) if the problem persists. -## Compile and publish the current Plan +## Compile the current Plan -When the candidate is ready, run: +To compile into a local application directory, run: ```sh -firstdraft plan compile +firstdraft plan compile --output ./application ``` -`plan compile` is the single terminal action. It first pushes the exact current bytes in +Both `plan compile` modes first push 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 and `head_source_sha256` exactly match 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. -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 sends that complete -ETag in `If-Match`. +Only a `valid` analysis proceeds to the selected completion mode. Immediately before either conditional mutation, +the CLI re-reads the local Plan and requires its exact bytes and saved state to match the accepted Head. It extracts +the accepted source SHA-256 from the saved ETag, hashes the current local bytes, and sends that complete ETag in +`If-Match`. + +### Materialize a direct Compilation + +With `--output`, the CLI requires an explicit absent destination beneath an existing real directory. It validates +that destination before pushing the Plan, checks it again after analysis, and never overwrites, merges into, or +repairs an existing path. + +After valid analysis, the CLI requests one Compilation for that exact reviewed Head and never starts GitHub +Publication. It validates that the `202` response identifies the same Project, graph version, Head, Analysis, +Compiler release, and target; polls only that retained Compilation for up to ten minutes; downloads its exact +artifact; and applies the same integrity and atomic materialization contract as `compilation download`. An ambiguous +Compilation start is not retried automatically. After a validated `202`, later status, artifact, authentication, +and materialization failures retain the last validated Compilation projection so the caller can recover by ID +without starting duplicate work. Follow the [direct Compilation recovery procedure](errors.md#direct-compilation-recovery). + +Success writes one JSON object to stdout containing the validated Project, Compilation, and absolute output path. +The installed directory contains exactly the artifact files and modes; the CLI does not add a Git repository, run a +formatter, or repair generated source. When the output is nested inside another Git worktree, initialize the +application as its own repository before running generated checks that inspect Git; otherwise Git resolves to the +parent worktree. Progress on stderr reports analysis and Compilation only. + +### Publish through GitHub + +Without `--output`, the existing GitHub Publication journey remains unchanged: + +```sh +firstdraft plan compile +``` -The command writes stable human-readable progress to stderr, with every line prefixed by `First Draft:`. It reports -analysis, compilation completion or terminal failure or cancellation, the current GitHub phase, and an allowlisted -reason, retry count, and exact UTC retry time when a GitHub preflight check is delayed. A retained retry with no next -time is reported as paused and requiring operator recovery. Progress never includes IDs, hashes, repository names or -URLs, raw server projections, local paths, or environment values. Success writes exactly the validated private -GitHub repository URL plus a newline to stdout. If the command fails after progress has begun, its structured JSON -error envelope is the final stderr document after the progress lines. +Invoking this form authorizes the internal GitHub Publication lifecycle. The command writes stable human-readable +progress to stderr, with every line prefixed by `First Draft:`. It reports analysis, Compilation completion or +terminal failure or cancellation, the current GitHub phase, and an allowlisted reason, retry count, and exact UTC +retry time when a GitHub preflight check is delayed. A retained retry with no next time is reported as paused and +requiring operator recovery. Progress never includes IDs, hashes, repository names or URLs, raw server projections, +local paths, or environment values. Success writes exactly the validated private GitHub repository URL plus a +newline to stdout. If the command fails after progress has begun, its structured JSON error envelope is the final +stderr document after the progress lines. The closed API `0.3.x` progress-reason allowlist is `github.configuration_missing`, `github.oauth_unavailable`, `github.api_unavailable`, `github.reauthorization_required`, `github.account_mismatch`, @@ -177,14 +205,14 @@ identity, and repository identity. Do not run concurrent Compile commands; use t [publication recovery procedure](errors.md#publication-recovery) after an invocation exits. 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. It retains lower-level Compilation commands -for operational callers that acquire an ID separately, but they are intentionally not a continuation of the -URL-only `plan compile` journey. +`plan publish` command. Direct local Compilation and GitHub Publication are separate completion modes after the same +exact Plan push and valid Analysis. ## Inspect a retained Compilation These lower-level commands are for callers that already hold a retained Compilation ID from authenticated API -metadata or operational tooling; `plan compile` prints only the final repository URL: +metadata or operational tooling. The no-output `plan compile` form prints only the final repository URL, while +`plan compile --output` waits for and downloads its own direct Compilation: ```sh firstdraft compilation status 01900000-0000-7000-8000-000000000001 diff --git a/docs/errors.md b/docs/errors.md index 6883ae7..18b87aa 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -16,12 +16,15 @@ remain human-readable text on standard error with exit 2. Unexpected programming ## Ambiguous mutations -`plan compile` supplies `phase: "push" | "publication"` when `request_outcome_unknown` requires phase-specific -recovery: +`plan compile` supplies `phase: "push" | "compilation" | "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. Until First Draft has a Foundation Plan Head reconciliation endpoint, an accepted request whose response cannot be verified may require manual recovery. Do not construct an ETag from the Plan digest or trust an unverified response. +- `phase: "compilation"` means a direct `--output` Compilation may have started, but its retained identity is unknown. + The CLI never repeats that `POST`. Do not start another Compilation until the Project is reconciled through First + Draft or an operator can identify the retained work. - `phase: "publication"` means the singleton Publication mutation was not resolved. Do not run concurrent Compile commands. After the prior invocation exits, wait and rerun `plan compile` with unchanged Plan bytes to safely reconcile or resume the retained singleton. @@ -39,9 +42,39 @@ A network failure from `plan status` is safe to retry a bounded number of times `invalid_server_response` instead means the response violated the CLI/server contract; retrying the unchanged read will not repair it. -The lower-level `compilation status` command is also read-only. `compilation_status_unavailable` is safe to retry; +Only the lower-level `compilation status ` command is read-only. Its +`compilation_status_unavailable` result is safe to retry a bounded number of times; `invalid_compilation_status` requires contract reconciliation. A wait stops rather than following a changed -analysis or Compilation identity. +analysis or Compilation identity. This read-only retry guidance does not apply to `plan compile --output`, which +starts a new Compilation after analysis. + +## Direct Compilation recovery + +Do not blindly rerun `plan compile --output` after its Compilation start was accepted or may have been accepted. +That command creates new work; it is not a retained-Compilation reconciliation command. + +If `request_outcome_unknown` reports `phase: "compilation"`, the start request did not yield a validated retained +ID. The CLI sent exactly one `POST` and did not retry it. Stop until First Draft or an operator can reconcile the +Project and identify whether work was retained. + +Once the start response has yielded a validated retained Compilation, later status, artifact, authentication, and +materialization failure envelopes include that last validated projection as `current`. Preserve +`current.compilation.id` and recover without creating duplicate work: + +- after `compilation_status_unavailable`, use + `firstdraft compilation status `; this lower-level read is safe to retry boundedly; +- after `invalid_compilation_status` or `invalid_artifact`, preserve the retained ID and reconcile the CLI/Service + contract instead of retrying the unchanged invalid read; +- after `artifact_unavailable`, wait if appropriate and use + `firstdraft compilation download --output `; and +- after `materialization_failed`, repair the destination condition, then use the same lower-level download command + with a new absent path. + +After `compilation_wait_timed_out`, retained work may still continue. Use +`firstdraft compilation status ` for one read-only status check; do not rerun +`plan compile --output`. `compilation_failed`, `compilation_cancelled`, and `compilation_changed` already carry the +validated `current` projection appropriate to their stopping boundary. Authentication recovery may refresh the +credential, but it must continue from the retained ID rather than starting another Compilation. ## Publication recovery @@ -60,28 +93,30 @@ stopped without following the replacement. ## Error index -| 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 rejected the request with a validated problem. | -| `plan status` | `server_rejected` | 1 | First Draft rejected the analysis status request with a validated non-authentication 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. | +| 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 mutation 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 rejected the request with a validated problem. | +| `plan status` | `server_rejected` | 1 | First Draft rejected the analysis status request with a validated non-authentication 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 or saved state changed after acceptance, before the selected mutation. | +| `plan compile --output` | `compilation_start_rejected`, `compilation_status_unavailable`, `invalid_compilation_status` | 1 | Direct start was rejected or retained status failed; post-start errors include `current`. | +| `plan compile --output` | `compilation_changed`, `compilation_wait_timed_out`, `compilation_failed`, `compilation_cancelled` | 1 | The pinned direct Compilation changed, timed out, failed, or was cancelled. | +| `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. | +| Download commands | `artifact_unavailable`, `invalid_artifact` | 1 | Artifact transport or integrity validation failed; direct Compile post-start errors include `current`. | +| Download commands | `invalid_output_path` | 2 | The destination was not an absent path beneath an existing real directory. | +| Download commands | `materialization_failed` | 1 | The output raced or installation failed; direct Compile post-start errors include `current`. | diff --git a/docs/release-history.md b/docs/release-history.md index 1c9c9b8..fce0477 100644 --- a/docs/release-history.md +++ b/docs/release-history.md @@ -30,3 +30,11 @@ the living [release policy and runbook](../RELEASING.md). The alpha versions remain immutable registry history but, as of the August 12 observation, neither distribution channel selected them. Protected tag `v0.1.0` and package version `0.1.0` were consumed and immutable. Preparing source or documentation does not mutate either dist-tag. + +## 0.2.0 candidate publication + +- On August 27, 2026, protected tag `v0.2.0` published ordinary version `0.2.0` under `next`; `latest` remained + `0.1.0`. The candidate established compatibility with API contract `0.3.x` but did not displace the separately + promoted stable release. +- Package version `0.2.0` and protected tag `v0.2.0` are consumed and immutable. A backward-compatible addition to + the `0.2.x` line therefore requires a higher patch version rather than reusing those identities. diff --git a/package-lock.json b/package-lock.json index dc1d53a..6ba64af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@firstdraft.com/cli", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@firstdraft.com/cli", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "bin": { "firstdraft": "bin/firstdraft.js" diff --git a/package.json b/package.json index 4b15e5e..1dab0ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@firstdraft.com/cli", - "version": "0.2.0", + "version": "0.2.1", "description": "Command-line interface for First Draft", "license": "MIT", "type": "module", diff --git a/release/compatibility.json b/release/compatibility.json index e5b258b..b979685 100644 --- a/release/compatibility.json +++ b/release/compatibility.json @@ -1,7 +1,7 @@ { "format": "firstdraft.release-compatibility/1", "component": "cli", - "version": "0.2.0", + "version": "0.2.1", "requires": { "api_contract": [">= 0.3.0", "< 0.4.0"], "foundation_plan_formats": ["firstdraft.foundation-plan.sketch/0.19"] diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index d766529..ad364de 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -483,18 +483,16 @@ async function exercisePackedCompilation(projectDirectory) { const seen = { plan: false, analysis: false, - publication: false, + publication: 0, + compilationStarts: 0, 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 === "PUT" && request.url === `/v1/projects/${projectId}/foundation-plan` @@ -531,10 +529,20 @@ async function exercisePackedCompilation(projectDirectory) { ) { assert.equal(request.headers["if-match"], `"sha256:${headSha256}"`); assert.equal(requestBody.byteLength, 0); - seen.publication = true; + seen.publication += 1; respondJson(response, 201, publication); return; } + if ( + request.method === "POST" && + request.url === `/v1/projects/${projectId}/compilations` + ) { + assert.equal(request.headers["if-match"], `"sha256:${headSha256}"`); + assert.equal(requestBody.byteLength, 0); + seen.compilationStarts += 1; + respondJson(response, 202, compilation, { Location: statusPath }); + return; + } if (request.method === "GET" && request.url === statusPath) { seen.status = true; respondJson(response, 200, compilation); @@ -591,6 +599,30 @@ First Draft: Application compiled. First Draft: GitHub publication complete. `, }); + assert.equal(seen.publication, 1); + assert.equal(seen.compilationStarts, 0); + + const directOutput = path.join(projectDirectory, "application"); + const direct = await spawnPackedCliAsync( + ["plan", "compile", "--output", directOutput], + projectDirectory, + ); + assert.equal(direct.status, 0); + assert.equal( + direct.stderr, + `First Draft: Analyzing Foundation Plan... +First Draft: Foundation Plan analysis valid. +First Draft: Compiling application... +First Draft: Application compiled. +`, + ); + assert.equal(JSON.parse(direct.stdout).output.path, directOutput); + assert.equal(seen.publication, 1); + assert.equal(seen.compilationStarts, 1); + assert.equal( + readFileSync(path.join(directOutput, "app/models/movie.rb"), "utf8"), + contents.toString("utf8"), + ); const status = await spawnPackedCliAsync( ["compilation", "status", compilationId], @@ -623,10 +655,10 @@ First Draft: GitHub publication complete. assert.deepEqual(seen, { plan: true, analysis: true, - publication: true, + publication: 1, + compilationStarts: 1, status: true, artifact: true, - post: false, }); } finally { await new Promise( diff --git a/src/cli.js b/src/cli.js index d8f921d..f315eeb 100644 --- a/src/cli.js +++ b/src/cli.js @@ -14,11 +14,18 @@ import { CompilationArtifactInvalidError, CompilationArtifactResponseInvalidError, CompilationArtifactUnavailableError, + CompilationCancelledError, CompilationChangedError, + CompilationFailedError, + CompilationLocalPlanChangedError, + CompilationLocalStateError, CompilationMaterializationError, CompilationNotSucceededError, CompilationNotPushedError, CompilationOutputPathError, + CompilationRetainedError, + CompilationRequestOutcomeUnknownError, + CompilationStartRejectedError, CompilationStatusInvalidError, CompilationStatusUnavailableError, CompilationTimeoutError, @@ -32,6 +39,7 @@ import { PlanCompileAnalysisUnavailableError, PlanCompilePushRejectedError, compilePlan, + compilePlanToDirectory, } from "./commands/plan-compile.js"; import { initializePlan } from "./commands/plan-init.js"; import { @@ -92,7 +100,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 and publish the current Foundation Plan + compile Compile the current Foundation Plan Options: -h, --help Show help @@ -173,18 +181,22 @@ const PLAN_COMPILE_HELP = `First Draft CLI Usage: firstdraft plan compile + firstdraft plan compile --output Options: - -h, --help Show help + --output Materialize the generated application here + -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. Progress is written to -stderr. Success prints only the validated private GitHub repository URL. +and proceeds only when that analysis is valid. Without --output, it conditionally +creates or replays the internal GitHub Publication lifecycle and prints the +private repository URL. With --output, it starts one direct Compilation and +atomically materializes the verified artifact into an absent directory without +starting GitHub Publication. Progress is written to stderr. `; const COMPILATION_HELP = `First Draft CLI @@ -312,6 +324,24 @@ 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_COMPILE_LOCAL_PLAN_CHANGED_DETAIL = + "The local Foundation Plan changed after validation. Run 'firstdraft plan compile' again to submit the current bytes."; +const PLAN_COMPILE_DIRECT_REQUEST_OUTCOME_UNKNOWN_DETAIL = + "A Compilation may have started, but its response could not be verified. Do not rerun 'firstdraft plan compile --output' or start another Compilation until the current Project is reconciled."; +const PLAN_COMPILE_DIRECT_START_REJECTED_DETAIL = + "First Draft rejected the direct Compilation request."; +const PLAN_COMPILE_DIRECT_STATUS_UNAVAILABLE_DETAIL = + "Could not read the retained Compilation status. Use current.compilation.id with 'firstdraft compilation status'; do not start another Compilation."; +const PLAN_COMPILE_DIRECT_STATUS_INVALID_DETAIL = + "First Draft returned an invalid status for the retained Compilation. Preserve current.compilation.id for contract reconciliation; do not start another Compilation."; +const PLAN_COMPILE_DIRECT_CHANGED_DETAIL = + "The pinned Compilation changed while being polled. The command stopped without downloading an artifact."; +const PLAN_COMPILE_DIRECT_TIMEOUT_DETAIL = + "The retained Compilation is still processing after the bounded ten-minute wait. Use current.compilation.id with 'firstdraft compilation status'; do not rerun 'firstdraft plan compile --output' or start another Compilation."; +const PLAN_COMPILE_DIRECT_FAILED_DETAIL = + "The pinned Compilation failed. No artifact was downloaded or materialized."; +const PLAN_COMPILE_DIRECT_CANCELLED_DETAIL = + "The pinned Compilation was cancelled. No artifact was downloaded or materialized."; 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 = @@ -357,6 +387,12 @@ 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 PLAN_COMPILE_DIRECT_ARTIFACT_UNAVAILABLE_DETAIL = + "Could not download the retained Compilation artifact. Use current.compilation.id with 'firstdraft compilation download'; do not start another Compilation."; +const PLAN_COMPILE_DIRECT_ARTIFACT_INVALID_DETAIL = + "The retained Compilation artifact did not satisfy the integrity contract. Preserve current.compilation.id for reconciliation; do not start another Compilation."; +const PLAN_COMPILE_DIRECT_MATERIALIZATION_FAILED_DETAIL = + "The retained Compilation artifact was validated but could not be materialized. Use current.compilation.id with 'firstdraft compilation download' after repairing the output path; do not start another Compilation."; 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 = @@ -394,6 +430,7 @@ const GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL = * @property {typeof pushPlan} [planCompilePush] * @property {typeof readPlanStatus} [planCompileReadStatus] * @property {typeof import("./commands/plan-publish.js").publishPlan} [planCompilePublish] + * @property {typeof import("./commands/compilation.js").compileAndDownload} [planCompileDownload] * @property {string} [apiUrl] * @property {string} [apiToken] */ @@ -422,6 +459,7 @@ const GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL = * @property {typeof pushPlan} [planCompilePush] * @property {typeof readPlanStatus} [planCompileReadStatus] * @property {typeof import("./commands/plan-publish.js").publishPlan} [planCompilePublish] + * @property {typeof import("./commands/compilation.js").compileAndDownload} [planCompileDownload] * @property {string} [apiUrl] * @property {string} [apiToken] */ @@ -435,7 +473,7 @@ const GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL = */ /** - * @typedef {Omit & {cwd?: string, getCwd: () => string}} CompilationCommandOptions + * @typedef {Omit & {cwd?: string, getCwd: () => string}} CompilationCommandOptions */ /** @param {RunOptions} options */ @@ -463,6 +501,7 @@ export async function run({ planCompilePush, planCompileReadStatus, planCompilePublish, + planCompileDownload, apiUrl = process.env.FIRSTDRAFT_API_URL, apiToken = process.env.FIRSTDRAFT_API_TOKEN, }) { @@ -494,9 +533,12 @@ export async function run({ planCompileNow, planPublishSleep, planPublishNow, + compilationSleep, + compilationNow, planCompilePush, planCompileReadStatus, planCompilePublish, + planCompileDownload, apiUrl, apiToken, }); @@ -583,9 +625,12 @@ async function runPlan({ planCompileNow, planPublishSleep, planPublishNow, + compilationSleep, + compilationNow, planCompilePush, planCompileReadStatus, planCompilePublish, + planCompileDownload, apiUrl, apiToken, }) { @@ -644,9 +689,12 @@ async function runPlan({ planCompileNow, planPublishSleep, planPublishNow, + compilationSleep, + compilationNow, planCompilePush, planCompileReadStatus, planCompilePublish, + planCompileDownload, apiUrl, apiToken, }); @@ -1445,7 +1493,7 @@ async function runPlanStatus({ } /** - * @param {Pick} options + * @param {Pick} options */ async function runPlanCompile({ argv, @@ -1460,16 +1508,22 @@ async function runPlanCompile({ planCompileNow, planPublishSleep, planPublishNow, + compilationSleep, + compilationNow, planCompilePush, planCompileReadStatus, planCompilePublish, + planCompileDownload, apiUrl, apiToken, }) { const parsed = parseArguments(() => parseArgs({ args: [...argv], - options: { help: { type: "boolean", short: "h" } }, + options: { + output: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, allowPositionals: false, strict: true, tokens: true, @@ -1489,41 +1543,67 @@ async function runPlanCompile({ return 0; } + const output = parsed.values.output; + if (output !== undefined && output.length === 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; const reportProgress = createPlanCompileProgressReporter(stderr); + const shared = { + cwd, + apiUrl, + fetchFunction: authorizedFetch, + fileSystem: planPushFileSystem, + createTemporaryId, + createRequestSignal, + analysisSleep: planCompileSleep, + analysisNow: planCompileNow, + push: planCompilePush, + readStatus: planCompileReadStatus, + onProgress: reportProgress, + }; + if (output !== undefined) { + try { + const result = await compilePlanToDirectory({ + ...shared, + output, + compilationSleep, + compilationNow, + compile: planCompileDownload, + }); + writeJson(stdout, result); + return 0; + } catch (error) { + return writePlanCompileError(stderr, error); + } + } + try { - result = await compilePlan({ - cwd, - apiUrl, - fetchFunction: authorizedFetch, - fileSystem: planPushFileSystem, - createTemporaryId, - createRequestSignal, - analysisSleep: planCompileSleep, - analysisNow: planCompileNow, + const result = await compilePlan({ + ...shared, publicationSleep: planPublishSleep, publicationNow: planPublishNow, - push: planCompilePush, - readStatus: planCompileReadStatus, publish: planCompilePublish, - onProgress: reportProgress, }); + const repository = + /** @type {NonNullable} */ ( + result.publication.repository + ); + stdout.write(`${repository.html_url}\n`); + return 0; } catch (error) { return writePlanCompileError(stderr, error); } - - const repository = - /** @type {NonNullable} */ ( - result.publication.repository - ); - stdout.write(`${repository.html_url}\n`); - return 0; } /** @param {Writer} writer @param {unknown} error */ @@ -1671,6 +1751,196 @@ function writePlanCompileError(writer, error) { return 1; } + const retainedError = + error instanceof CompilationRetainedError ? error : null; + const compilationError = retainedError?.error ?? error; + const retainedCompilation = retainedError?.current; + + if (compilationError instanceof CompilationLocalStateError) { + writeJson(writer, { + error: "invalid_configuration", + detail: PLAN_COMPILE_INCOMPATIBLE_STATE_DETAIL, + }); + return 2; + } + + if (compilationError instanceof CompilationNotPushedError) { + writeJson(writer, { + error: "project_not_pushed", + detail: PLAN_COMPILE_NOT_PUSHED_DETAIL, + }); + return 1; + } + + if (compilationError instanceof CompilationLocalPlanChangedError) { + writeJson(writer, { + error: "local_plan_changed", + detail: PLAN_COMPILE_LOCAL_PLAN_CHANGED_DETAIL, + }); + return 1; + } + + if (compilationError instanceof CompilationRequestOutcomeUnknownError) { + writeJson(writer, { + error: "request_outcome_unknown", + phase: "compilation", + detail: PLAN_COMPILE_DIRECT_REQUEST_OUTCOME_UNKNOWN_DETAIL, + ...(typeof compilationError.status === "number" + ? { status: compilationError.status } + : {}), + ...(compilationError.response + ? { response: compilationError.response } + : {}), + }); + return 1; + } + + if ( + (compilationError instanceof CompilationStartRejectedError || + compilationError instanceof CompilationStatusUnavailableError) && + isAuthenticationProblem(compilationError.status, compilationError.response) + ) { + writeAuthenticationRequired( + writer, + compilationError.status, + /** @type {Record} */ (compilationError.response), + retainedCompilation, + ); + return 1; + } + + if (compilationError instanceof CompilationStartRejectedError) { + writeJson(writer, { + error: "compilation_start_rejected", + detail: PLAN_COMPILE_DIRECT_START_REJECTED_DETAIL, + status: compilationError.status, + response: compilationError.response, + }); + return 1; + } + + if (compilationError instanceof CompilationStatusUnavailableError) { + writeJson(writer, { + error: "compilation_status_unavailable", + detail: PLAN_COMPILE_DIRECT_STATUS_UNAVAILABLE_DETAIL, + ...(typeof compilationError.status === "number" + ? { status: compilationError.status } + : {}), + ...(compilationError.response + ? { response: compilationError.response } + : {}), + ...(retainedCompilation ? { current: retainedCompilation } : {}), + }); + return 1; + } + + if (compilationError instanceof CompilationStatusInvalidError) { + writeJson(writer, { + error: "invalid_compilation_status", + detail: PLAN_COMPILE_DIRECT_STATUS_INVALID_DETAIL, + status: compilationError.status, + ...(retainedCompilation ? { current: retainedCompilation } : {}), + }); + return 1; + } + + if (compilationError instanceof CompilationChangedError) { + writeJson(writer, { + error: "compilation_changed", + detail: PLAN_COMPILE_DIRECT_CHANGED_DETAIL, + current: compilationError.current, + }); + return 1; + } + + if (compilationError instanceof CompilationTimeoutError) { + writeJson(writer, { + error: "compilation_wait_timed_out", + detail: PLAN_COMPILE_DIRECT_TIMEOUT_DETAIL, + current: compilationError.current, + }); + return 1; + } + + if (compilationError instanceof CompilationFailedError) { + writeJson(writer, { + error: "compilation_failed", + detail: PLAN_COMPILE_DIRECT_FAILED_DETAIL, + current: compilationError.current, + }); + return 1; + } + + if (compilationError instanceof CompilationCancelledError) { + writeJson(writer, { + error: "compilation_cancelled", + detail: PLAN_COMPILE_DIRECT_CANCELLED_DETAIL, + current: compilationError.current, + }); + return 1; + } + + if (compilationError instanceof CompilationArtifactUnavailableError) { + if ( + isAuthenticationProblem( + compilationError.status, + compilationError.response, + ) + ) { + writeAuthenticationRequired( + writer, + compilationError.status, + /** @type {Record} */ (compilationError.response), + retainedCompilation, + ); + return 1; + } + writeJson(writer, { + error: "artifact_unavailable", + detail: PLAN_COMPILE_DIRECT_ARTIFACT_UNAVAILABLE_DETAIL, + ...(typeof compilationError.status === "number" + ? { status: compilationError.status } + : {}), + ...(compilationError.response + ? { response: compilationError.response } + : {}), + ...(retainedCompilation ? { current: retainedCompilation } : {}), + }); + return 1; + } + + if ( + compilationError instanceof CompilationArtifactResponseInvalidError || + compilationError instanceof CompilationArtifactInvalidError + ) { + writeJson(writer, { + error: "invalid_artifact", + detail: PLAN_COMPILE_DIRECT_ARTIFACT_INVALID_DETAIL, + ...(compilationError instanceof CompilationArtifactResponseInvalidError + ? { status: compilationError.status } + : {}), + ...(retainedCompilation ? { current: retainedCompilation } : {}), + }); + return 1; + } + + if (compilationError instanceof CompilationOutputPathError) { + writeJson(writer, { + error: "invalid_output_path", + detail: COMPILATION_INVALID_OUTPUT_PATH_DETAIL, + }); + return 2; + } + + if (compilationError instanceof CompilationMaterializationError) { + writeJson(writer, { + error: "materialization_failed", + detail: PLAN_COMPILE_DIRECT_MATERIALIZATION_FAILED_DETAIL, + ...(retainedCompilation ? { current: retainedCompilation } : {}), + }); + return 1; + } + if (error instanceof PublicationLocalStateError) { writeJson(writer, { error: "invalid_configuration", @@ -1935,13 +2205,15 @@ function writeJson(writer, value) { * @param {Writer} writer * @param {number} [status] * @param {Record} [response] + * @param {import("./commands/compilation.js").CompilationResponse} [current] */ -function writeAuthenticationRequired(writer, status, response) { +function writeAuthenticationRequired(writer, status, response, current) { writeJson(writer, { error: "authentication_required", detail: AUTHENTICATION_REQUIRED_DETAIL, ...(status === undefined ? {} : { status }), ...(response === undefined ? {} : { response }), + ...(current === undefined ? {} : { current }), }); } diff --git a/src/commands/compilation.js b/src/commands/compilation.js index 3f17acb..c895bb9 100644 --- a/src/commands/compilation.js +++ b/src/commands/compilation.js @@ -1,4 +1,6 @@ import { createHash } from "node:crypto"; +import { lstatSync, readFileSync } from "node:fs"; +import path from "node:path"; import { ARTIFACT_MEDIA_TYPE, @@ -19,8 +21,9 @@ import { responseMediaType, sendRequest, } from "../api-response.js"; -import { isUuidV7, readPlanState } from "../plan-state.js"; +import { isUuidV7, readLocalFile, readPlanState } from "../plan-state.js"; +const MAX_PLAN_BYTES = 1024 * 1024; const REQUEST_TIMEOUT_MS = 30_000; const WAIT_TIMEOUT_MS = 10 * 60_000; const POLL_INTERVAL_MS = 1_000; @@ -57,8 +60,50 @@ 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; +const HEAD_ETAG_PATTERN = /^"sha256:([0-9a-f]{64})"$/; + +/** @type {import("../plan-state.js").PlanStateFileSystem} */ +const DEFAULT_FILE_SYSTEM = { lstatSync, readFileSync }; export class CompilationNotPushedError extends Error {} +export class CompilationLocalStateError extends Error {} +export class CompilationLocalPlanChangedError extends Error {} + +export class CompilationRequestOutcomeUnknownError extends Error { + /** + * @param {number | undefined} status + * @param {Record | null} [response] + */ + constructor(status, response = null) { + super("The compilation start request outcome is unknown."); + this.status = status; + this.response = response; + } +} + +export class CompilationRetainedError extends Error { + /** + * @param {Error} error + * @param {CompilationResponse} current + */ + constructor(error, current) { + super("The retained Compilation requires recovery.", { cause: error }); + this.error = error; + this.current = current; + } +} + +export class CompilationStartRejectedError extends Error { + /** + * @param {number} status + * @param {Record} response + */ + constructor(status, response) { + super("First Draft rejected the compilation start request."); + this.status = status; + this.response = response; + } +} export class CompilationStatusUnavailableError extends Error { /** @@ -106,6 +151,22 @@ export class CompilationNotSucceededError extends Error { } } +export class CompilationFailedError extends Error { + /** @param {CompilationResponse} current */ + constructor(current) { + super("The compilation failed."); + 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 @@ -290,6 +351,186 @@ export async function downloadCompilation({ }; } +/** + * @typedef {object} CurrentCompilationIdentity + * @property {string} projectId + * @property {number} graphVersion + * @property {string} headSourceSha256 + * @property {string} analysisRunId + * @property {string} compilerRelease + * @property {{id: string, profile: string}} target + */ + +/** + * @typedef {object} CompileAndDownloadOptions + * @property {string} cwd + * @property {string} expectedEtag + * @property {CurrentCompilationIdentity} expected + * @property {string} output + * @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 {(progress: import("../plan-compile-progress.js").PlanCompileProgress) => void} [onProgress] + */ + +/** + * Start one Compilation for the exact Plan and reviewed Analysis accepted by + * the current command, wait for that retained Compilation, and materialize its + * authenticated artifact into an absent destination. + * + * @param {CompileAndDownloadOptions} options + */ +export async function compileAndDownload({ + cwd, + expectedEtag, + expected, + output, + fetchFunction = globalThis.fetch, + fileSystem = DEFAULT_FILE_SYSTEM, + createRequestSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), + sleep = sleepFor, + now = Date.now, + onProgress = () => {}, +}) { + const outputTarget = resolveOutputTarget({ cwd, output }); + const context = readStartContext({ + cwd, + expectedEtag, + expected, + fileSystem, + }); + const deadline = now() + WAIT_TIMEOUT_MS; + + onProgress({ phase: "compilation", status: "waiting" }); + const initial = await startCompilation({ + ...context, + expected, + fetchFunction, + createRequestSignal, + }); + let current = initial; + + try { + 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: context.apiUrl, + projectId: context.projectId, + compilationId: initial.compilation.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); + } + + onProgress({ phase: "compilation", status: "succeeded" }); + 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, + }; + } catch (error) { + if (requiresRetainedRecovery(error)) { + throw new CompilationRetainedError(error, current); + } + + throw error; + } +} + +/** + * @param {object} options + * @param {string} options.cwd + * @param {string} options.expectedEtag + * @param {CurrentCompilationIdentity} options.expected + * @param {import("../plan-state.js").PlanStateFileSystem} options.fileSystem + */ +function readStartContext({ cwd, expectedEtag, expected, fileSystem }) { + 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.", + ); + } + if ( + state.project_id !== expected.projectId || + state.foundation_plan_etag !== expectedEtag + ) { + throw new CompilationLocalPlanChangedError( + "Local Plan state changed after this command accepted its Plan.", + ); + } + + const match = HEAD_ETAG_PATTERN.exec(expectedEtag); + if (match === null) { + throw new CompilationLocalStateError( + "The saved Foundation Plan ETag cannot identify its accepted source.", + ); + } + const headSourceSha256 = match[1] ?? ""; + const planSource = readLocalFile( + path.join(cwd, ".firstdraft", "foundation-plan.json"), + MAX_PLAN_BYTES, + fileSystem, + ); + if ( + headSourceSha256 !== expected.headSourceSha256 || + sha256(planSource) !== headSourceSha256 + ) { + throw new CompilationLocalPlanChangedError( + "The local Foundation Plan differs from the accepted Plan.", + ); + } + + return { + apiUrl: state.api_url, + projectId: state.project_id, + etag: expectedEtag, + }; +} + /** * @param {object} options * @param {string} options.cwd @@ -315,6 +556,73 @@ function readContext({ cwd, compilationId, fileSystem }) { }; } +/** + * @param {object} options + * @param {string} options.apiUrl + * @param {string} options.projectId + * @param {string} options.etag + * @param {CurrentCompilationIdentity} options.expected + * @param {typeof globalThis.fetch} options.fetchFunction + * @param {(timeoutMs: number) => AbortSignal} options.createRequestSignal + */ +async function startCompilation({ + apiUrl, + projectId, + etag, + expected, + fetchFunction, + createRequestSignal, +}) { + const endpoint = new URL(`/v1/projects/${projectId}/compilations`, apiUrl); + 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); + } + if (response.status === 408 || response.status >= 500) { + throw new CompilationRequestOutcomeUnknownError(response.status, problem); + } + + throw new CompilationStartRejectedError(response.status, problem); + } + + const parsed = parseStartedCompilationResponse(body, projectId); + if ( + responseMediaType(response) !== "application/json" || + parsed === null || + response.headers.get("location") !== parsed.compilation.status_path || + !matchesExpectedCompilation(parsed, expected) + ) { + throw new CompilationRequestOutcomeUnknownError(response.status); + } + + return parsed; +} + /** * @param {object} options * @param {string} options.apiUrl @@ -377,6 +685,40 @@ async function readCompilationStatus({ return parsed; } +/** + * @param {unknown} value + * @param {string} projectId + * @returns {CompilationResponse | null} + */ +function parseStartedCompilationResponse(value, projectId) { + if ( + !hasExactKeySet(value, RESPONSE_KEYS) || + !hasExactKeySet(value.compilation, COMPILATION_KEYS) || + !isUuidV7(value.compilation.id) + ) { + return null; + } + + return parseCompilationResponse(value, projectId, value.compilation.id); +} + +/** + * @param {CompilationResponse} current + * @param {CurrentCompilationIdentity} expected + */ +function matchesExpectedCompilation(current, expected) { + return ( + current.project.id === expected.projectId && + current.project.graph_version === expected.graphVersion && + current.compilation.graph_version === expected.graphVersion && + current.compilation.head_source_sha256 === expected.headSourceSha256 && + current.compilation.analysis_run_id === expected.analysisRunId && + current.compilation.compiler_release === expected.compilerRelease && + current.compilation.target.id === expected.target.id && + current.compilation.target.profile === expected.target.profile + ); +} + /** * @param {object} options * @param {string} options.apiUrl @@ -700,6 +1042,18 @@ function validTransition(previous, current) { ); } +/** @param {unknown} error */ +function requiresRetainedRecovery(error) { + return ( + error instanceof CompilationStatusUnavailableError || + error instanceof CompilationStatusInvalidError || + error instanceof CompilationArtifactUnavailableError || + error instanceof CompilationArtifactResponseInvalidError || + error instanceof CompilationArtifactInvalidError || + error instanceof CompilationMaterializationError + ); +} + /** @param {Response} response @param {unknown} body */ function safeProblem(response, body) { if (!isRecord(body) || !isProblemBody(response, body)) return null; diff --git a/src/commands/plan-compile.js b/src/commands/plan-compile.js index 77de137..da1b743 100644 --- a/src/commands/plan-compile.js +++ b/src/commands/plan-compile.js @@ -1,4 +1,6 @@ import { publishPlan } from "./plan-publish.js"; +import { compileAndDownload } from "./compilation.js"; +import { resolveOutputTarget } from "../compilation-artifact.js"; import { PlanPushNetworkError, PlanPushProtocolError, @@ -90,6 +92,122 @@ export async function compilePlan({ push = pushPlan, readStatus = readPlanStatus, publish = publishPlan, +}) { + const prepared = await preparePlan({ + cwd, + apiUrl, + fetchFunction, + fileSystem, + createTemporaryId, + createRequestSignal, + analysisSleep, + analysisNow, + onProgress, + push, + readStatus, + }); + + return publish({ + cwd, + fetchFunction, + fileSystem, + createRequestSignal, + sleep: publicationSleep, + now: publicationNow, + expectedEtag: prepared.pushed.etag, + onProgress, + }); +} + +/** + * @typedef {CompilePlanOptions & { + * output: string, + * compilationSleep?: (delayMs: number) => Promise, + * compilationNow?: () => number, + * compile?: typeof compileAndDownload + * }} CompilePlanToDirectoryOptions + */ + +/** + * Submit and analyze the exact current local Plan, then start one direct + * Compilation and materialize its verified artifact into an absent directory. + * GitHub Publication remains the no-output mode owned by compilePlan. + * + * @param {CompilePlanToDirectoryOptions} options + */ +export async function compilePlanToDirectory({ + cwd, + output, + apiUrl, + fetchFunction, + fileSystem, + createTemporaryId, + createRequestSignal, + analysisSleep, + analysisNow, + compilationSleep, + compilationNow, + onProgress = () => {}, + push = pushPlan, + readStatus = readPlanStatus, + compile = compileAndDownload, +}) { + // Reject an unavailable destination before the Plan push can mutate remote + // state. The Compilation boundary checks it again after Analysis in case the + // filesystem changes while this command is waiting. + resolveOutputTarget({ cwd, output }); + + const prepared = await preparePlan({ + cwd, + apiUrl, + fetchFunction, + fileSystem, + createTemporaryId, + createRequestSignal, + analysisSleep, + analysisNow, + onProgress, + push, + readStatus, + }); + const body = prepared.status.body; + + return compile({ + cwd, + expectedEtag: prepared.pushed.etag, + expected: { + projectId: body.project.id, + graphVersion: body.project.graph_version, + headSourceSha256: body.analysis.head_source_sha256, + analysisRunId: body.analysis.id, + compilerRelease: body.analysis.compiler_release, + target: body.analysis.target, + }, + output, + fetchFunction, + fileSystem, + createRequestSignal, + sleep: compilationSleep, + now: compilationNow, + onProgress, + }); +} + +/** + * @param {Omit} options + */ +async function preparePlan({ + cwd, + apiUrl, + fetchFunction, + fileSystem, + createTemporaryId, + createRequestSignal, + analysisSleep, + analysisNow, + onProgress = () => {}, + push = pushPlan, + readStatus = readPlanStatus, }) { const pushed = await push({ cwd, @@ -146,14 +264,5 @@ export async function compilePlan({ } onProgress({ phase: "analysis", status: "valid" }); - return publish({ - cwd, - fetchFunction, - fileSystem, - createRequestSignal, - sleep: publicationSleep, - now: publicationNow, - expectedEtag: pushed.etag, - onProgress, - }); + return { pushed, status }; } diff --git a/src/plan-compile-progress.js b/src/plan-compile-progress.js index e60f24a..7cb8a67 100644 --- a/src/plan-compile-progress.js +++ b/src/plan-compile-progress.js @@ -27,7 +27,7 @@ const PUBLICATION_MESSAGES = new Map([ /** * @typedef {object} CompilationProgress * @property {"compilation"} phase - * @property {"waiting"} status + * @property {"waiting" | "succeeded"} status */ /** @@ -77,7 +77,11 @@ export function createPlanCompileProgressReporter(writer) { } if (progress.phase === "compilation") { - write("Compiling application..."); + write( + progress.status === "waiting" + ? "Compiling application..." + : "Application compiled.", + ); return; } diff --git a/test/compilation.test.js b/test/compilation.test.js index 3381537..85cce5b 100644 --- a/test/compilation.test.js +++ b/test/compilation.test.js @@ -13,6 +13,7 @@ import path from "node:path"; import test from "node:test"; import { run } from "../src/cli.js"; +import { compileAndDownload } from "../src/commands/compilation.js"; import { ARTIFACT_MEDIA_TYPE, FOUNDATION_PLAN_FORMAT, @@ -34,6 +35,74 @@ 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("direct Compilation pins the reviewed Plan and materializes one retained artifact", async (context) => { + const planSource = Buffer.from( + '{"format":"firstdraft.foundation-plan.sketch/0.19"}\n', + ); + const headSourceSha256 = sha256(planSource); + const etag = `"sha256:${headSourceSha256}"`; + const cwd = directDirectory(context, planSource, etag); + const fixture = artifactFixture({ headSourceSha256 }); + const queued = compilationBody("queued", { + compilation: { head_source_sha256: headSourceSha256 }, + }); + const succeeded = compilationBody("succeeded", { + artifact: fixture, + compilation: { head_source_sha256: headSourceSha256 }, + }); + /** @type {FetchCall[]} */ + const calls = []; + /** @type {unknown[]} */ + const progress = []; + const output = path.join(cwd, "application"); + const result = await compileAndDownload({ + cwd, + expectedEtag: etag, + expected: { + projectId: PROJECT_ID, + graphVersion: 7, + headSourceSha256, + analysisRunId: ANALYSIS_ID, + compilerRelease: COMPILER_RELEASE, + target: TARGET, + }, + output, + fetchFunction: sequenceFetch( + [ + jsonResponse(queued, 202, { Location: STATUS_PATH }), + jsonResponse(succeeded), + artifactResponse(fixture), + ], + calls, + ), + sleep: async () => {}, + onProgress: (event) => progress.push(event), + }); + + assert.deepEqual( + calls.map(({ input, init }) => [init?.method, String(input)]), + [ + [ + "POST", + `https://api.example.test/v1/projects/${PROJECT_ID}/compilations`, + ], + ["GET", `https://api.example.test${STATUS_PATH}`], + ["GET", `https://api.example.test${ARTIFACT_PATH}`], + ], + ); + assert.equal(new Headers(calls[0]?.init?.headers).get("if-match"), etag); + assert.deepEqual(progress, [ + { phase: "compilation", status: "waiting" }, + { phase: "compilation", status: "succeeded" }, + ]); + assert.equal(result.compilation.status, "succeeded"); + assert.equal(result.output.path, output); + assert.equal( + readFileSync(path.join(output, "README.md"), "utf8"), + "Movie Catalog\n", + ); +}); + test("compilation status makes one canonical GET and returns terminal failures successfully", async (context) => { for (const status of ["failed", "cancelled"]) { const cwd = remoteDirectory(context); @@ -561,6 +630,38 @@ function remoteDirectory(context) { return cwd; } +/** + * @param {import("node:test").TestContext} context + * @param {Buffer} planSource + * @param {string} etag + */ +function directDirectory(context, planSource, etag) { + const cwd = mkdtempSync( + path.join(tmpdir(), "firstdraft-direct-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: etag, + }, + null, + 2, + )}\n`, + { mode: 0o600 }, + ); + writeFileSync( + path.join(cwd, ".firstdraft", "foundation-plan.json"), + planSource, + ); + return cwd; +} + /** @param {readonly string[]} argv @param {Record} [options] */ async function invoke(argv, options = {}) { let stdout = ""; @@ -597,11 +698,15 @@ function sequenceFetch(responses, calls = []) { }; } -/** @param {unknown} body */ -function jsonResponse(body) { +/** + * @param {unknown} body + * @param {number} [status] + * @param {Record} [headers] + */ +function jsonResponse(body, status = 200, headers = {}) { return new Response(JSON.stringify(body), { - status: 200, - headers: { "Content-Type": "application/json" }, + status, + headers: { "Content-Type": "application/json", ...headers }, }); } diff --git a/test/plan-compile.test.js b/test/plan-compile.test.js index 20204aa..1d4300f 100644 --- a/test/plan-compile.test.js +++ b/test/plan-compile.test.js @@ -1,12 +1,23 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { run } from "../src/cli.js"; +import { + ARTIFACT_MEDIA_TYPE, + FOUNDATION_PLAN_FORMAT, +} from "../src/compilation-artifact.js"; const PROJECT_ID = "01900000-0000-7000-8000-000000002001"; const ANALYSIS_ID = "01900000-0000-7000-8000-000000002002"; @@ -84,6 +95,104 @@ test("plan compile submits exact bytes, waits for valid analysis, and publishes ); }); +test("plan compile --output completes the HTTP journey without GitHub Publication", async (context) => { + /** @type {{method: string | undefined, url: string | undefined, headers: import("node:http").IncomingHttpHeaders, body: Buffer}[]} */ + const requests = []; + const artifact = directArtifactFixture(); + let compilationReads = 0; + const server = createServer(async (request, response) => { + const body = await readRequestBody(request); + requests.push({ + method: request.method, + url: request.url, + headers: request.headers, + body, + }); + + if (request.method === "PUT" && request.url === planPath()) { + respondJson(response, 201, acceptedPlanBody(), { ETag: ETAG }); + return; + } + if (request.method === "GET" && request.url === analysisPath()) { + respondJson(response, 200, analysisBody("valid")); + return; + } + if ( + request.method === "POST" && + request.url === compilationCollectionPath() + ) { + respondJson(response, 202, directCompilationBody("queued"), { + Location: directCompilationPath(), + }); + return; + } + if (request.method === "GET" && request.url === directCompilationPath()) { + compilationReads += 1; + respondJson( + response, + 200, + compilationReads === 1 + ? directCompilationBody("running") + : directCompilationBody("succeeded", artifact), + ); + return; + } + if (request.method === "GET" && request.url === directArtifactPath()) { + response.writeHead(200, { + "Content-Type": ARTIFACT_MEDIA_TYPE, + "Content-Length": artifact.source.byteLength, + "Cache-Control": "no-store, no-transform", + ETag: `"sha256:${artifact.sha256}"`, + }); + response.end(artifact.source); + return; + } + response.writeHead(404).end(); + }); + const apiUrl = await listen(context, server); + const cwd = localDirectory(context, PLAN_SOURCE); + const result = await invoke( + ["plan", "compile", "--output", "./application"], + { + cwd, + apiUrl, + compilationSleep: async () => {}, + }, + ); + + assert.equal(result.status, 0); + assert.deepEqual( + requests.map(({ method, url }) => [method, url]), + [ + ["PUT", planPath()], + ["GET", analysisPath()], + ["POST", compilationCollectionPath()], + ["GET", directCompilationPath()], + ["GET", directCompilationPath()], + ["GET", directArtifactPath()], + ], + ); + assert.equal(requests[2]?.body.byteLength, 0); + assert.equal(requests[2]?.headers["if-match"], ETAG); + assert( + requests.every( + ({ headers }) => headers.authorization === `Bearer ${API_TOKEN}`, + ), + ); + const printed = JSON.parse(result.stdout); + assert.equal(printed.compilation.id, COMPILATION_ID); + assert.equal(printed.compilation.status, "succeeded"); + assert.equal(printed.output.path, path.join(cwd, "application")); + assert.equal( + readFileSync(path.join(cwd, "application", "README.md"), "utf8"), + "Movie Catalog\n", + ); + assert.equal( + result.stderr, + `First Draft: Analyzing Foundation Plan...\nFirst Draft: Foundation Plan analysis valid.\nFirst Draft: Compiling application...\nFirst Draft: Application compiled.\n`, + ); +}); + 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", @@ -130,6 +239,428 @@ test("plan compile may push unchanged bytes before analysis and Publication", as assert.equal(result.stderr, SUCCESS_PROGRESS); }); +test("plan compile --output starts a direct Compilation without Publication", async (context) => { + const cwd = localDirectory(context, PLAN_SOURCE, { + api_url: "https://api.example.test", + foundation_plan_etag: ETAG, + }); + const output = path.join(cwd, "application"); + /** @type {unknown[]} */ + const order = []; + const directResult = { + project: { id: PROJECT_ID, graph_version: 1 }, + compilation: { id: COMPILATION_ID, status: "succeeded" }, + output: { + path: output, + file_count: 264, + manifest_sha256: "9".repeat(64), + }, + }; + const result = await invoke( + ["plan", "compile", "--output", "./application"], + { + cwd, + planCompilePush: async () => { + order.push("push"); + return successfulPush(); + }, + planCompileReadStatus: async () => { + order.push("analysis"); + return { status: 200, body: analysisBody("valid") }; + }, + planCompilePublish: async () => { + throw new Error("Publication must remain untouched"); + }, + planCompileDownload: async ( + /** @type {{expectedEtag: string, expected: Record, output: string, onProgress: (progress: unknown) => void}} */ options, + ) => { + order.push("compilation"); + assert.equal(options.expectedEtag, ETAG); + assert.deepEqual(options.expected, { + projectId: PROJECT_ID, + graphVersion: 1, + headSourceSha256: HEAD_SHA256, + analysisRunId: ANALYSIS_ID, + compilerRelease: COMPILER_RELEASE, + target: TARGET, + }); + assert.equal(options.output, "./application"); + options.onProgress({ phase: "compilation", status: "waiting" }); + options.onProgress({ phase: "compilation", status: "succeeded" }); + return directResult; + }, + }, + ); + + assert.equal(result.status, 0); + assert.deepEqual(order, ["push", "analysis", "compilation"]); + assert.deepEqual(JSON.parse(result.stdout), directResult); + assert.equal( + result.stderr, + `First Draft: Analyzing Foundation Plan...\nFirst Draft: Foundation Plan analysis valid.\nFirst Draft: Compiling application...\nFirst Draft: Application compiled.\n`, + ); +}); + +test("plan compile --output rejects an existing destination before Plan mutation", async (context) => { + const cwd = localDirectory(context, PLAN_SOURCE); + mkdirSync(path.join(cwd, "application")); + let pushes = 0; + const result = await invoke( + ["plan", "compile", "--output", "./application"], + { + cwd, + planCompilePush: async () => { + pushes += 1; + throw new Error("Plan push must remain untouched"); + }, + }, + ); + + assertHandledFailure(result, "invalid_output_path", 2); + assert.equal(pushes, 0); +}); + +test("plan compile --output rechecks local Plan bytes before starting work", async (context) => { + /** @type {string | undefined} */ + let cwd; + let compilationStarts = 0; + const server = createServer(async (request, response) => { + await readRequestBody(request); + if (request.method === "PUT" && request.url === planPath()) { + respondJson(response, 201, acceptedPlanBody(), { ETag: ETAG }); + return; + } + if (request.method === "GET" && request.url === analysisPath()) { + assert(cwd); + writeFileSync(planFilePath(cwd), Buffer.from('{"changed":true}\n')); + respondJson(response, 200, analysisBody("valid")); + return; + } + if ( + request.method === "POST" && + request.url === compilationCollectionPath() + ) { + compilationStarts += 1; + } + response.writeHead(500).end(); + }); + const apiUrl = await listen(context, server); + cwd = localDirectory(context, PLAN_SOURCE); + const output = path.join(cwd, "application"); + const result = await invoke( + ["plan", "compile", "--output", "./application"], + { cwd, apiUrl }, + ); + + assertHandledFailure(result, "local_plan_changed"); + assert.equal(compilationStarts, 0); + assert.equal(existsSync(output), false); +}); + +test("plan compile --output never retries an ambiguous Compilation start", async (context) => { + const cwd = localDirectory(context, PLAN_SOURCE, { + api_url: "https://api.example.test", + foundation_plan_etag: ETAG, + }); + let requests = 0; + const result = await invoke( + ["plan", "compile", "--output", "./application"], + { + cwd, + planCompilePush: successfulPush, + planCompileReadStatus: async () => ({ + status: 200, + body: analysisBody("valid"), + }), + fetchFunction: async () => { + requests += 1; + throw new Error("network failure"); + }, + }, + ); + + assertHandledFailure(result, "request_outcome_unknown"); + assert.equal(errorEnvelope(result.stderr).phase, "compilation"); + assert.equal(requests, 1); + assert.equal(existsSync(path.join(cwd, "application")), false); +}); + +test("direct Compilation start maps rejection and ambiguity without retrying", async (context) => { + const cases = [ + { + name: "validated 408 problem", + response: problemResponse(408, "request_timeout", "Try later."), + error: "request_outcome_unknown", + status: 408, + responseCode: "request_timeout", + }, + { + name: "validated 503 problem", + response: problemResponse(503, "service_unavailable", "Try later."), + error: "request_outcome_unknown", + status: 503, + responseCode: "service_unavailable", + }, + { + name: "unvalidated 503 body", + response: jsonResponse({ secret: "start-response-canary" }, 503), + error: "request_outcome_unknown", + status: 503, + }, + { + name: "authentication rejection", + response: problemResponse( + 401, + "authentication_required", + "Provide a token.", + ), + error: "authentication_required", + status: 401, + responseCode: "authentication_required", + }, + { + name: "validated client rejection", + response: problemResponse(422, "compilation_rejected", "Fix the Plan."), + error: "compilation_start_rejected", + status: 422, + responseCode: "compilation_rejected", + }, + ]; + + for (const example of cases) { + 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", "./application"], + { + cwd, + planCompilePush: successfulPush, + planCompileReadStatus: async () => ({ + status: 200, + body: analysisBody("valid"), + }), + fetchFunction: sequenceFetch([example.response], calls), + }, + ); + + const envelope = errorEnvelope(result.stderr); + assert.equal(result.status, 1, example.name); + assert.equal(result.stdout, "", example.name); + assert.equal( + envelope.error, + example.error, + `${example.name}: ${JSON.stringify(envelope)}`, + ); + assert.equal(envelope.status, example.status, example.name); + assert.equal(calls.length, 1, example.name); + if (example.error === "request_outcome_unknown") { + assert.equal(envelope.phase, "compilation", example.name); + } + if (example.responseCode === undefined) { + assert.equal("response" in envelope, false, example.name); + } else { + assert.equal(envelope.response.code, example.responseCode, example.name); + } + assert.doesNotMatch( + result.stderr, + /start-response-canary|fd_[a-z]+/, + example.name, + ); + assert.equal(existsSync(path.join(cwd, "application")), false); + } +}); + +test("post-start failures retain one recoverable Compilation identity", async (context) => { + const artifact = directArtifactFixture(); + const queued = directCompilationBody("queued"); + const succeeded = directCompilationBody("succeeded", artifact); + /** @type {{name: string, initial: ReturnType, response: (output: string) => Promise, error: string, command?: string, status: string}[]} */ + const cases = [ + { + name: "status unavailable", + initial: queued, + response: async () => + problemResponse(503, "status_unavailable", "Try later."), + error: "compilation_status_unavailable", + command: "compilation status", + status: "queued", + }, + { + name: "status invalid", + initial: queued, + response: async () => jsonResponse({ secret: "status-response-canary" }), + error: "invalid_compilation_status", + command: "current.compilation.id", + status: "queued", + }, + { + name: "status authentication", + initial: queued, + response: async () => + problemResponse(401, "authentication_required", "Provide a token."), + error: "authentication_required", + status: "queued", + }, + { + name: "artifact unavailable", + initial: succeeded, + response: async () => + problemResponse(503, "artifact_unavailable", "Try later."), + error: "artifact_unavailable", + command: "compilation download", + status: "succeeded", + }, + { + name: "artifact authentication", + initial: succeeded, + response: async () => + problemResponse(401, "authentication_required", "Provide a token."), + error: "authentication_required", + status: "succeeded", + }, + { + name: "artifact invalid", + initial: succeeded, + response: async () => + new Response(artifact.source, { + status: 200, + headers: { + "Content-Type": ARTIFACT_MEDIA_TYPE, + "Content-Length": String(artifact.source.byteLength), + ETag: `"sha256:${artifact.sha256}"`, + }, + }), + error: "invalid_artifact", + command: "current.compilation.id", + status: "succeeded", + }, + { + name: "materialization failure", + initial: succeeded, + response: async (output) => { + mkdirSync(output); + return new Response(artifact.source, { + status: 200, + headers: { + "Content-Type": ARTIFACT_MEDIA_TYPE, + "Content-Length": String(artifact.source.byteLength), + "Cache-Control": "no-store, no-transform", + ETag: `"sha256:${artifact.sha256}"`, + }, + }); + }, + error: "materialization_failed", + command: "compilation download", + status: "succeeded", + }, + ]; + + for (const example of cases) { + const cwd = localDirectory(context, PLAN_SOURCE, { + api_url: "https://api.example.test", + foundation_plan_etag: ETAG, + }); + const output = path.join(cwd, "application"); + /** @type {unknown[]} */ + const calls = []; + const next = () => example.response(output); + const result = await invoke( + ["plan", "compile", "--output", "./application"], + { + cwd, + planCompilePush: successfulPush, + planCompileReadStatus: async () => ({ + status: 200, + body: analysisBody("valid"), + }), + fetchFunction: sequenceFetch( + [ + jsonResponse(example.initial, 202, { + Location: directCompilationPath(), + }), + next, + ], + calls, + ), + compilationSleep: async () => {}, + }, + ); + + const envelope = errorEnvelope(result.stderr); + assert.equal(result.status, 1, example.name); + assert.equal(result.stdout, "", example.name); + assert.equal( + envelope.error, + example.error, + `${example.name}: ${JSON.stringify(envelope)}`, + ); + assert.equal(envelope.current.compilation.id, COMPILATION_ID, example.name); + assert.equal( + envelope.current.compilation.status, + example.status, + example.name, + ); + assert.equal(calls.length, 2, example.name); + if (example.command !== undefined) { + assert.match(envelope.detail, new RegExp(example.command), example.name); + } + assert.doesNotMatch( + result.stderr, + /status-response-canary|fd_[a-z]+/, + example.name, + ); + } +}); + +test("direct Compilation timeout names read-only retained-ID recovery", async (context) => { + const cwd = localDirectory(context, PLAN_SOURCE, { + api_url: "https://api.example.test", + foundation_plan_etag: ETAG, + }); + const queued = directCompilationBody("queued"); + /** @type {unknown[]} */ + const calls = []; + let now = 0; + const result = await invoke( + ["plan", "compile", "--output", "./application"], + { + cwd, + planCompilePush: successfulPush, + planCompileReadStatus: async () => ({ + status: 200, + body: analysisBody("valid"), + }), + fetchFunction: sequenceFetch( + [ + jsonResponse(queued, 202, { + Location: directCompilationPath(), + }), + ], + calls, + ), + compilationNow: () => now, + compilationSleep: async () => { + now = 600_000; + }, + }, + ); + + assertHandledFailure(result, "compilation_wait_timed_out"); + const envelope = errorEnvelope(result.stderr); + assert.equal(envelope.current.compilation.id, COMPILATION_ID); + assert.equal(envelope.current.compilation.status, "queued"); + assert.match(envelope.detail, /firstdraft compilation status/); + assert.match( + envelope.detail, + /do not rerun 'firstdraft plan compile --output'/, + ); + assert.equal(calls.length, 1); +}); + 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", @@ -365,7 +896,7 @@ test("push ambiguity, analysis failures, and rejected reads have distinct errors } }); -test("help and removed local-output syntax have no prerequisites", async () => { +test("help and invalid direct-output syntax have no prerequisites", async () => { const inaccessible = () => { throw new Error("dependency must remain inaccessible"); }; @@ -376,15 +907,24 @@ test("help and removed local-output syntax have no prerequisites", async () => { fetchFunction: inaccessible, }); assert.equal(help.status, 0); - assert.match(help.stdout, /firstdraft plan compile/); + assert.match( + help.stdout, + /firstdraft plan compile --output /, + ); - const removed = await invoke(["plan", "compile", "--output", "generated"], { - cwd: process.cwd(), - apiToken: undefined, - getCwd: inaccessible, - fetchFunction: inaccessible, - }); - assertHandledFailure(removed, "invalid_arguments", 2); + for (const argv of [ + ["plan", "compile", "--output"], + ["plan", "compile", "--output", "one", "--output", "two"], + ["plan", "compile", "application"], + ]) { + const invalid = await invoke(argv, { + cwd: process.cwd(), + apiToken: undefined, + getCwd: inaccessible, + fetchFunction: inaccessible, + }); + assertHandledFailure(invalid, "invalid_arguments", 2); + } }); async function successfulPush() { @@ -505,6 +1045,93 @@ function publicationBody() { }; } +/** @param {string} status @param {ReturnType} [artifact] */ +function directCompilationBody(status, artifact) { + const terminal = ["succeeded", "failed", "cancelled"].includes(status); + return { + project: { id: PROJECT_ID, graph_version: 1 }, + compilation: { + id: COMPILATION_ID, + analysis_run_id: ANALYSIS_ID, + graph_version: 1, + head_source_sha256: HEAD_SHA256, + status, + compiler_release: COMPILER_RELEASE, + target: TARGET, + status_path: directCompilationPath(), + cancel_path: `${directCompilationPath()}/cancel`, + artifact: + status === "succeeded" && artifact + ? { + path: directArtifactPath(), + sha256: artifact.sha256, + media_type: ARTIFACT_MEDIA_TYPE, + byte_size: artifact.source.byteLength, + } + : null, + failure: + status === "failed" + ? { + phase: "render", + code: "render_failed", + message: "Rendering failed.", + } + : null, + created_at: "2026-08-04T12:00:00.000000Z", + started_at: status === "queued" ? null : "2026-08-04T12:00:01.000000Z", + completed_at: terminal ? "2026-08-04T12:00:02.000000Z" : null, + }, + }; +} + +function directArtifactFixture() { + 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: 1, + head_source_sha256: HEAD_SHA256, + foundation_plan: { + format: FOUNDATION_PLAN_FORMAT, + sha256: HEAD_SHA256, + }, + analysis: { id: ANALYSIS_ID, release: ANALYZER_RELEASE }, + 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 {string} code @param {string} [message] */ function diagnostic(code, message = code) { return { code, severity: "error", message }; @@ -535,6 +1162,18 @@ function publicationPath() { return `/v1/projects/${PROJECT_ID}/github-publication`; } +function compilationCollectionPath() { + return `/v1/projects/${PROJECT_ID}/compilations`; +} + +function directCompilationPath() { + return `${compilationCollectionPath()}/${COMPILATION_ID}`; +} + +function directArtifactPath() { + return `${directCompilationPath()}/artifact`; +} + /** @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-")); diff --git a/test/plan-init.test.js b/test/plan-init.test.js index a829634..ac8ebad 100644 --- a/test/plan-init.test.js +++ b/test/plan-init.test.js @@ -29,7 +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 and publish the current Foundation Plan + compile Compile the current Foundation Plan Options: -h, --help Show help diff --git a/test/plan-publish.test.js b/test/plan-publish.test.js index 294d3c0..0183f02 100644 --- a/test/plan-publish.test.js +++ b/test/plan-publish.test.js @@ -83,18 +83,22 @@ const PLAN_COMPILE_HELP = `First Draft CLI Usage: firstdraft plan compile + firstdraft plan compile --output Options: - -h, --help Show help + --output Materialize the generated application here + -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. Progress is written to -stderr. Success prints only the validated private GitHub repository URL. +and proceeds only when that analysis is valid. Without --output, it conditionally +creates or replays the internal GitHub Publication lifecycle and prints the +private repository URL. With --output, it starts one direct Compilation and +atomically materializes the verified artifact into an absent directory without +starting GitHub Publication. Progress is written to stderr. `; test("plan compile invokes Publication and one conditional singleton PUT and polls sequentially", async (context) => {