diff --git a/AGENTS.md b/AGENTS.md index ac9653c..4aa5788 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,8 @@ handled-error recovery in `docs/errors.md`, living release policy in `RELEASING. in `docs/release-history.md`. When behavior changes, update its owning document in the same change. - Verify with `npm run check`, which takes about 30 seconds. A fresh checkout needs `npm ci --ignore-scripts` first. +- To add a command, follow `docs/commands.md#add-a-command`. To change the version, follow the `npm version` step in + `RELEASING.md`. - Sibling repositories: `firstdraft/firstdraft` (private) owns the Service API and Plan format; `firstdraft/skills` owns the Skill and plugin packaging. - Changes to the accepted API-contract range, accepted Plan formats, command names or flags, handled `error` values, diff --git a/README.md b/README.md index 179bb98..8ad498b 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Run firstdraft --help or a command group's --help for concise terminal syntax. | bin/ | Published executable entrypoint | | src/ | Commands, API client, local Plan state, and output contracts | | test/ | Command, protocol, recovery, and package tests | -| scripts/ | Test runner and package allowlist/smoke checks | +| scripts/ | Test runner, package checks, and version sync | | docs/ | Command, error, release-history, and maintainer documentation | ## Development diff --git a/RELEASING.md b/RELEASING.md index 50e21d2..c6cd182 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -37,7 +37,10 @@ not authorization or runtime proof. Its closed `firstdraft.release-compatibility ## Prepare before merge -1. Update `package.json`, `package-lock.json`, and `release/compatibility.json`, and align the Skills CLI requirement. +1. Set the version with `npm version --no-git-tag-version --ignore-scripts=false`. npm updates `package.json` + and `package-lock.json`, and the package's `version` script copies the version into `release/compatibility.json`. + The last flag is required because `.npmrc` sets `ignore-scripts=true`, which also skips that script. If it was + omitted, run `node scripts/sync-version.js --apply`. Then align the Skills CLI requirement. 2. Update the command, error, and Skill guidance affected by the change. When onboarding changes, coordinate the [local guide](https://gist.github.com/raghubetina/3d424a97a1eaa6de8c406e67f32a237e) publication from the Service's `docs/guides/local-app.md` before the new CLI reaches `latest`. Preserve dated release evidence. diff --git a/docs/README.md b/docs/README.md index 21e8a84..87d58d0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ evidence for implemented behavior; if they contradict a document, surface the co | Local app development | [Local guide](https://gist.github.com/raghubetina/3d424a97a1eaa6de8c406e67f32a237e) | | Codespaces fallback | [Drawing Board guide](https://github.com/firstdraft/drawing-board#build-an-app-with-first-draft) | | Installation or package contract | [Root README](../README.md) | -| Commands, API, or output | [Command reference](commands.md) | +| Commands, Service API, or output | [Command reference](commands.md); [add a command](commands.md#add-a-command) | | Errors and recovery | [Errors and recovery](errors.md) | | Versioning and publication | [Release policy and runbook](../RELEASING.md) | | Dated release observations | [Release history](release-history.md) | @@ -17,8 +17,8 @@ evidence for implemented behavior; if they contradict a document, surface the co ## Authority boundaries - [README.md](../README.md) owns repository orientation, direct installation, package boundaries, and routes. -- [commands.md](commands.md) owns detailed command semantics. Built-in `--help`, runtime source, and tests own exact - executable syntax and behavior. +- [commands.md](commands.md) owns detailed command semantics, Service endpoints, and the add-a-command checklist. + Built-in `--help`, runtime source, and tests own exact executable syntax and behavior. - [errors.md](errors.md) owns handled-error interpretation and recovery guidance. - [RELEASING.md](../RELEASING.md) owns living release policy and the operator runbook. - [release-history.md](release-history.md) preserves dated release observations. Recheck live tags, package versions, @@ -31,11 +31,10 @@ evidence for implemented behavior; if they contradict a document, surface the co Start here, then load the one owning document for the task. Follow a cross-link only when the task crosses an authority boundary. Create another page only for a distinct audience, task, or authority. -The documentation tests keep `AGENTS.md` at or below 2 KiB, the root README at or below 6 KiB, and this map at or -below 4 KiB. They also require every public topic to remain reachable from this map or the root README and verify -repository-local links and fragments. Outside release history, they reject retired version identities and -release-status labels on the current version. The package check separately verifies that every relative link in the -packaged Markdown resolves inside that exact package. +The documentation tests cap `AGENTS.md` at 2 KiB, the root README at 6 KiB, and this map at 4 KiB. They also +require every public topic to be reachable from this map or the root README, and they check local links and +fragments. Outside release history, they reject retired version identities and release-status labels on the current +version. The package check verifies that relative links in packaged Markdown resolve inside the package. ## Work on the repository diff --git a/docs/commands.md b/docs/commands.md index a2a95c1..3e544fc 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -152,8 +152,8 @@ or `superseded`. Every validated analysis status is a successful read with exit `analysis.status` value and inspect `analysis.diagnostics` rather than treating a completed analysis with issues as a transport failure. -The projection includes the exact Head digest, Analyzer and Compiler releases, selected target, and -`analysis.gap_set` plus `analysis.gap_set_sha256`. A `valid` run always returns the complete parsed canonical +The projection includes the exact [Head](#head-and-its-etag) digest, Analyzer and Compiler releases, selected target, +and `analysis.gap_set` plus `analysis.gap_set_sha256`. A `valid` run always returns the complete parsed canonical `firstdraft.foundation-gaps/2` object, including every ordered gap record and an empty `gaps` array when nothing is missing. Both GapSet fields are `null` for every other status. The CLI validates the GapSet's Head, Project, generation, releases, target, canonical digest, and complete record shapes, then prints the records without @@ -399,3 +399,95 @@ sibling directory, verifies the complete tree, and atomically renames it into th POSIX, directories use mode `0755` and files use artifact-declared `0644` or `0755`; Windows verifies structure, contents, and digests without claiming POSIX mode bits. The declared and streamed artifact envelope is bounded at 128 MiB. + +## Service endpoints + +The CLI calls these Service API routes at the origin pinned for the Project. Every request sends the selected token +as a Bearer credential, refuses redirects, and has a bounded timeout. Each request takes its method and path from one +`SERVICE_ROUTES` entry in `src/api-response.js`. `test/service-endpoints.test.js` fails when this table and +`SERVICE_ROUTES` differ, or when no file in `src/` uses a declared route. + +| Method | Path | Purpose | +| ------ | ------------------------------------------------------------------ | ------------------------------------ | +| `PUT` | `/v1/projects/{project_id}/foundation-plan` | Create or replace the Head | +| `GET` | `/v1/projects/{project_id}/analysis` | Read the Head's current analysis | +| `POST` | `/v1/projects/{project_id}/compilations` | Start a Compilation of the Head | +| `GET` | `/v1/projects/{project_id}/compilations/{compilation_id}` | Read one retained Compilation | +| `GET` | `/v1/projects/{project_id}/compilations/{compilation_id}/artifact` | Download that Compilation's artifact | +| `PUT` | `/v1/projects/{project_id}/github-publication` | Start or rejoin the Publication | +| `GET` | `/v1/projects/{project_id}/github-publication` | Poll or reconcile the Publication | + +`plan push` sends the Plan `PUT`, and `plan status` reads the analysis. `plan compile` does both. It then starts a +Compilation, polls it, and downloads its artifact. With `--github`, it starts and polls the Publication instead. +`compilation status` reads one retained Compilation. `compilation download` reads it and downloads its artifact. + +The first push sends `If-None-Match: *`. Later pushes, the Compilation `POST`, and the Publication `PUT` send the +saved Head ETag in `If-Match`. The Service also has a Compilation cancel route, which the CLI does not call. + +These routes belong to the API-contract range that `release/compatibility.json` accepts. The CLI does not read the +Service's `FirstDraft-API-Contract` response header. Instead, the Service's release compatibility check compares the +declared ranges before a release. The Service documents the routes in its Foundation Plan machine reference, +`docs/architecture/reference/README.md` in the private `firstdraft/firstdraft` repository. + +### Head and its ETag + +The Head is the exact Foundation Plan bytes that First Draft holds for a Project. The latest accepted +`PUT /v1/projects/{project_id}/foundation-plan` sets it. The Service stores those bytes unchanged. A byte change that +keeps the Plan's meaning still makes a new Head, although the Project's `graph_version` stays the same. + +A successful Plan `PUT` returns a strong `ETag` of the form `"sha256:"`, quotes included. `` is the +64-character lowercase SHA-256 of the Head bytes. The CLI saves the complete header value in `.firstdraft/state.json` +and replays it in `If-Match`. `plan compile` also extracts `` to check that the local Plan still matches the +Head before it starts a Compilation or Publication. A saved ETag in any other form stops that check with +`invalid_configuration`. + +The same digest appears as `head_source_sha256` in analysis and Compilation responses. To tell whether the local Plan +is the Head, compare the SHA-256 of `.firstdraft/foundation-plan.json` with `analysis.head_source_sha256` from +`plan status`. The artifact download expects an ETag of the same form over the artifact bytes. + +The Service reference asks clients to replay the Plan ETag without interpreting it. This CLI parses it anyway. A +Service ETag in another form would make every `plan compile` stop with `invalid_configuration`. + +## Add a command + +Use this checklist when a change adds a command or subcommand. Each step names the file to change. + +1. **Implement it.** Put the command's logic in a module under `src/commands/`, usually `-.js`. Accept + `fetchFunction`, file system functions, clocks, and request signals as options, as the existing commands do, so + tests can replace them. Throw a named error class for each failure the command handles. Build each Service + request with `serviceEndpoint` and a route declared in `SERVICE_ROUTES` in `src/api-response.js`. +2. **Dispatch it in `src/cli.js`.** + - Add a `__HELP` string, and list the command in its group's help: `PLAN_HELP`, + `COMPILATION_HELP`, or `GENERATE_HELP`. A new group also needs a line in `ROOT_HELP` and a branch in `run`. + - Add the branch in `runPlan`, `runCompilation`, or `runGenerate`, and a `run` function. + - Parse arguments with strict `parseArgs`. Invalid syntax writes `invalid_arguments` and exits 2. + - A command that calls the Service also accepts `--staging` and calls `authenticateApiCommand`. It maps each + error class to one `writeJson(stderr, …)` envelope and its exit status. +3. **Test it.** + - Add `test/-.test.js`. Cover the help text, invalid arguments, the success output, and every + handled `error` value. + - Update the exact group help in the tests: `HELP` in `test/cli.test.js`, `PLAN_HELP` in `test/plan-init.test.js`, + or `GENERATE_HELP` in `test/generate-uuid.test.js`. No test asserts the whole `compilation` group help. + - Append a command that calls the Service to `REMOTE_COMMANDS` in `test/api-environments.test.js`. Some tests + there select entries by index, so add it at the end. The command's first request must reach the pinned origin + with that origin's token, and one `401` must produce `authentication_required`. + - Add packed-package cases to `scripts/smoke-package.js`, including at least an invalid-arguments case. +4. **Package it.** Add each new `src/` file to the exact list in `scripts/check-pack.js`. `firstdraft/skills` keeps + a copy of that list as `packedFileAllowlist` in `script/cli-contract/config.mjs`. The copy must change when + Skills bundles this CLI version. +5. **Document it.** + - In this page, add a row to the [command map](#command-map) and a section for the command. Add a row to + [Service endpoints](#service-endpoints) for each new route, and name the command in the paragraph below the + table. If the command calls a route that section says the CLI does not call, such as Compilation cancel, + rewrite that sentence. + - In [errors.md](errors.md#error-index), add an Error index row for each new `error` value. Add recovery guidance + when retrying the command is safe in a different way than for the existing commands. + - When the Skill will call the command, update the Skill's CLI references and contract checks in + `firstdraft/skills` with its CLI pin. +6. **Review it.** New command names, flags, `error` values, and exit statuses need the independent review named in + `AGENTS.md`. +7. **Version it.** Choose the next version with the [version policy](../RELEASING.md#version-and-compatibility-policy) + and apply it with the [version step](../RELEASING.md#prepare-before-merge). If the command needs a route or + response that the accepted API range lacks, the Service ships it first under a new API-contract version. Then + raise `requires.api_contract` in `release/compatibility.json`, and align the Skills CLI requirement. Because the + CLI does not read the contract header, an older Service rejects the new route as not found. diff --git a/package.json b/package.json index 3e9ac9b..7820649 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "pack:check": "node scripts/check-pack.js", "pack:smoke": "node scripts/smoke-package.js", "test": "node scripts/run-tests.js", - "typecheck": "tsc --project jsconfig.json" + "typecheck": "tsc --project jsconfig.json", + "version": "node scripts/sync-version.js --apply" }, "devDependencies": { "@eslint/js": "10.0.1", diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index 024bf58..93b7fcb 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -15,11 +15,17 @@ import { createServer } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; +import { RAILS_TARGET_PROFILE } from "../src/compilation-artifact.js"; + const npmCli = requiredEnvironmentVariable("npm_execpath"); const apiToken = `fd_${"a".repeat(43)}`; /** @type {{name: string, version: string}} */ const packageMetadata = JSON.parse(readFileSync("package.json", "utf8")); +/** @type {[string]} */ +const [planFormat] = JSON.parse( + readFileSync("release/compatibility.json", "utf8"), +).requires.foundation_plan_formats; const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "firstdraft-cli-")); const installationDirectory = path.join(temporaryDirectory, "installation"); const packedExecutable = path.join( @@ -149,8 +155,8 @@ try { name: initializedPlan.application.name, }, { - format: "firstdraft.foundation-plan.sketch/0.23", - target: { id: "rails", profile: "rails-sketch/2026-09-bookmark-assets" }, + format: planFormat, + target: { id: "rails", profile: RAILS_TARGET_PROFILE }, key: "oscar_party", name: "Oscar Party", }, @@ -392,10 +398,7 @@ async function exercisePackedCompilation(projectDirectory) { "foundation-plan-rails/application-2026-09-27-bookmark-assets"; const compilerRelease = "foundation-plan-rails/compiler-application-2026-09-27-bookmark-assets"; - const target = { - id: "rails", - profile: "rails-sketch/2026-09-bookmark-assets", - }; + const target = { id: "rails", profile: RAILS_TARGET_PROFILE }; const gapSet = { format: "firstdraft.foundation-gaps/2", source: { sha256: headSha256 }, @@ -450,7 +453,7 @@ async function exercisePackedCompilation(projectDirectory) { graph_version: 1, head_source_sha256: headSha256, foundation_plan: { - format: "firstdraft.foundation-plan.sketch/0.23", + format: planFormat, sha256: foundationPlanSha256, }, analysis: { @@ -585,7 +588,7 @@ async function exercisePackedCompilation(projectDirectory) { { project: { id: projectId, graph_version: 1 }, foundation_plan: { - format: "firstdraft.foundation-plan.sketch/0.23", + format: planFormat, source_sha256: headSha256, }, diagnostics: [], diff --git a/scripts/sync-version.js b/scripts/sync-version.js new file mode 100644 index 0000000..e412a8c --- /dev/null +++ b/scripts/sync-version.js @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { parseArgs } from "node:util"; +import { fileURLToPath } from "node:url"; + +const USAGE = `Usage: node scripts/sync-version.js [--apply] + +Copies the version in package.json into every other field that must +match it: package-lock.json (version and packages[""].version) and +release/compatibility.json (version). + +Without --apply, prints the changes and writes nothing. npm runs this +script with --apply as the package's \`version\` lifecycle, so +\`npm version --no-git-tag-version --ignore-scripts=false\` +updates every field in one step. + +Options: + --apply Write the changes + -h, --help Show this help +`; + +const { values } = parseArgs({ + options: { + apply: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + strict: true, +}); + +if (values.help) { + process.stdout.write(USAGE); +} else { + const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + const version = readJson(repository, "package.json").version; + assert.equal(typeof version, "string", "package.json must declare version"); + + const changes = [ + ...packageLockChanges(repository, version), + ...compatibilityChanges(repository, version), + ]; + + process.stdout.write( + `${values.apply ? "Applying" : "Dry run"}: package.json version ${version} in ${repository}\n`, + ); + for (const change of changes) { + for (const field of change.fields) { + process.stdout.write( + `${change.file}: ${field.name} ${field.from} -> ${version}\n`, + ); + } + if (values.apply) { + writeFileSync(path.join(repository, change.file), change.source); + } + } + if (changes.length === 0) { + process.stdout.write("Every version field already matches.\n"); + } else if (!values.apply) { + process.stdout.write("Nothing was written. Pass --apply to write.\n"); + } +} + +/** + * npm writes package-lock.json as two-space JSON.stringify output, so + * rewriting it that way keeps the file byte-identical apart from the fields. + * + * @param {string} repository + * @param {string} version + */ +function packageLockChanges(repository, version) { + const file = "package-lock.json"; + const lock = readJson(repository, file); + const fields = []; + + if (lock.version !== version) { + fields.push({ name: "version", from: lock.version }); + lock.version = version; + } + if (lock.packages[""].version !== version) { + fields.push({ + name: 'packages[""].version', + from: lock.packages[""].version, + }); + lock.packages[""].version = version; + } + + return fields.length === 0 + ? [] + : [{ file, fields, source: `${JSON.stringify(lock, null, 2)}\n` }]; +} + +/** + * Prettier keeps this file's short arrays on one line, which JSON.stringify + * would expand, so only the version value's text is replaced. + * + * @param {string} repository + * @param {string} version + */ +function compatibilityChanges(repository, version) { + const file = path.posix.join("release", "compatibility.json"); + const original = readFileSync(path.join(repository, file), "utf8"); + const declaration = JSON.parse(original); + if (declaration.version === version) return []; + + const member = /^( {2}"version": )"[^"\\]*"/gm; + const source = original.replace(member, `$1${JSON.stringify(version)}`); + assert.deepEqual( + JSON.parse(source), + { ...declaration, version }, + `${file} must hold exactly one top-level "version" member on its own line`, + ); + + return [ + { file, fields: [{ name: "version", from: declaration.version }], source }, + ]; +} + +/** @param {string} repository @param {string} file */ +function readJson(repository, file) { + return JSON.parse(readFileSync(path.join(repository, file), "utf8")); +} diff --git a/src/api-response.js b/src/api-response.js index 7b838b1..ed53e9d 100644 --- a/src/api-response.js +++ b/src/api-response.js @@ -22,14 +22,72 @@ export class FirstDraftProtocolError extends Error { } } +/** + * Every Service API route the CLI requests. docs/commands.md#service-endpoints + * has one row for each, and test/service-endpoints.test.js compares the two. + */ +export const SERVICE_ROUTES = { + pushPlan: { + method: "PUT", + path: "/v1/projects/{project_id}/foundation-plan", + }, + readAnalysis: { + method: "GET", + path: "/v1/projects/{project_id}/analysis", + }, + startCompilation: { + method: "POST", + path: "/v1/projects/{project_id}/compilations", + }, + readCompilation: { + method: "GET", + path: "/v1/projects/{project_id}/compilations/{compilation_id}", + }, + downloadArtifact: { + method: "GET", + path: "/v1/projects/{project_id}/compilations/{compilation_id}/artifact", + }, + startPublication: { + method: "PUT", + path: "/v1/projects/{project_id}/github-publication", + }, + readPublication: { + method: "GET", + path: "/v1/projects/{project_id}/github-publication", + }, +}; + +/** @typedef {{method: string, url: URL}} ServiceEndpoint */ + +/** + * @param {{method: string, path: string}} route + * @param {string} apiUrl + * @param {Record} parameters + * @returns {ServiceEndpoint} + */ +export function serviceEndpoint(route, apiUrl, parameters) { + const endpointPath = route.path.replace(/\{(\w+)\}/g, (_, name) => { + const value = parameters[name]; + if (value === undefined) { + throw new Error(`${route.path} needs a ${name} value.`); + } + return value; + }); + + return { method: route.method, url: new URL(endpointPath, apiUrl) }; +} + /** * @param {typeof globalThis.fetch} fetchFunction - * @param {URL} endpoint - * @param {RequestInit} request + * @param {ServiceEndpoint} endpoint + * @param {Omit} request */ export async function sendRequest(fetchFunction, endpoint, request) { try { - return await fetchFunction(endpoint, request); + return await fetchFunction(endpoint.url, { + ...request, + method: endpoint.method, + }); } catch (error) { if ( error instanceof PlanStateConfigurationError || diff --git a/src/commands/compilation.js b/src/commands/compilation.js index f8bc358..9b35f29 100644 --- a/src/commands/compilation.js +++ b/src/commands/compilation.js @@ -17,11 +17,13 @@ import { import { FirstDraftNetworkError, FirstDraftProtocolError, + SERVICE_ROUTES, isProblemBody, readResponseBody, readResponseBytes, responseMediaType, sendRequest, + serviceEndpoint, } from "../api-response.js"; import { isUuidV7, readLocalFile, readPlanState } from "../plan-state.js"; @@ -332,6 +334,8 @@ export async function downloadCompilation({ ); const source = await downloadArtifact({ apiUrl: context.apiUrl, + projectId: context.projectId, + compilationId: current.compilation.id, metadata, fetchFunction, createRequestSignal, @@ -472,6 +476,8 @@ export async function compileAndDownload({ ); const source = await downloadArtifact({ apiUrl: context.apiUrl, + projectId: context.projectId, + compilationId: current.compilation.id, metadata, fetchFunction, createRequestSignal, @@ -600,12 +606,13 @@ async function startCompilation({ fetchFunction, createRequestSignal, }) { - const endpoint = new URL(`/v1/projects/${projectId}/compilations`, apiUrl); + const endpoint = serviceEndpoint(SERVICE_ROUTES.startCompilation, apiUrl, { + project_id: projectId, + }); let response; let body; try { response = await sendRequest(fetchFunction, endpoint, { - method: "POST", headers: { Accept: "application/json, application/problem+json", "If-Match": etag, @@ -667,15 +674,14 @@ async function readCompilationStatus({ createRequestSignal, requestTimeout, }) { - const endpoint = new URL( - `/v1/projects/${projectId}/compilations/${compilationId}`, - apiUrl, - ); + const endpoint = serviceEndpoint(SERVICE_ROUTES.readCompilation, apiUrl, { + project_id: projectId, + compilation_id: compilationId, + }); let response; let body; try { response = await sendRequest(fetchFunction, endpoint, { - method: "GET", headers: { Accept: "application/json, application/problem+json" }, redirect: "error", signal: createRequestSignal(requestTimeout), @@ -749,22 +755,28 @@ function matchesExpectedCompilation(current, expected) { /** * @param {object} options * @param {string} options.apiUrl + * @param {string} options.projectId + * @param {string} options.compilationId * @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, + projectId, + compilationId, metadata, fetchFunction, createRequestSignal, }) { - const endpoint = new URL(metadata.path, apiUrl); + const endpoint = serviceEndpoint(SERVICE_ROUTES.downloadArtifact, apiUrl, { + project_id: projectId, + compilation_id: compilationId, + }); let response; let source; try { response = await sendRequest(fetchFunction, endpoint, { - method: "GET", headers: { Accept: `${ARTIFACT_MEDIA_TYPE}, application/problem+json`, }, diff --git a/src/commands/plan-publish.js b/src/commands/plan-publish.js index 610c617..1564125 100644 --- a/src/commands/plan-publish.js +++ b/src/commands/plan-publish.js @@ -6,10 +6,12 @@ import { isAuthenticationProblem } from "../api-authentication.js"; import { FirstDraftNetworkError, FirstDraftProtocolError, + SERVICE_ROUTES, isProblemBody, readResponseBody, responseMediaType, sendRequest, + serviceEndpoint, } from "../api-response.js"; import { isUuidV7, readLocalFile, readPlanState } from "../plan-state.js"; @@ -284,10 +286,7 @@ export async function publishPlan({ ); } - const endpoint = new URL( - `/v1/projects/${state.project_id}/github-publication`, - state.api_url, - ); + const apiUrl = state.api_url; const deadline = now() + WAIT_TIMEOUT_MS; let initial; @@ -295,7 +294,7 @@ export async function publishPlan({ try { initial = await startPublication({ - endpoint, + apiUrl, projectId: state.project_id, headSourceSha256, etag: publicationEtag, @@ -307,7 +306,7 @@ export async function publishPlan({ try { initial = await readPublicationStatus({ - endpoint, + apiUrl, projectId: state.project_id, headSourceSha256, fetchFunction, @@ -359,7 +358,7 @@ export async function publishPlan({ if (now() >= deadline) throw new PublicationTimeoutError(current); const next = await readPublicationStatus({ - endpoint, + apiUrl, projectId: state.project_id, headSourceSha256, fetchFunction, @@ -391,7 +390,7 @@ export async function publishPlan({ /** * @param {object} options - * @param {URL} options.endpoint + * @param {string} options.apiUrl * @param {string} options.projectId * @param {string} options.headSourceSha256 * @param {string} options.etag @@ -399,18 +398,20 @@ export async function publishPlan({ * @param {(timeoutMs: number) => AbortSignal} options.createRequestSignal */ async function startPublication({ - endpoint, + apiUrl, projectId, headSourceSha256, etag, fetchFunction, createRequestSignal, }) { + const endpoint = serviceEndpoint(SERVICE_ROUTES.startPublication, apiUrl, { + project_id: projectId, + }); let response; let body; try { response = await sendRequest(fetchFunction, endpoint, { - method: "PUT", headers: { Accept: "application/json, application/problem+json", "If-Match": etag, @@ -452,7 +453,7 @@ async function startPublication({ /** * @param {object} options - * @param {URL} options.endpoint + * @param {string} options.apiUrl * @param {string} options.projectId * @param {string} options.headSourceSha256 * @param {typeof globalThis.fetch} options.fetchFunction @@ -460,18 +461,20 @@ async function startPublication({ * @param {number} options.requestTimeout */ async function readPublicationStatus({ - endpoint, + apiUrl, projectId, headSourceSha256, fetchFunction, createRequestSignal, requestTimeout, }) { + const endpoint = serviceEndpoint(SERVICE_ROUTES.readPublication, apiUrl, { + project_id: projectId, + }); let response; let body; try { response = await sendRequest(fetchFunction, endpoint, { - method: "GET", headers: { Accept: "application/json, application/problem+json" }, redirect: "error", signal: createRequestSignal(requestTimeout), diff --git a/src/commands/plan-push.js b/src/commands/plan-push.js index d8bfd70..b23121c 100644 --- a/src/commands/plan-push.js +++ b/src/commands/plan-push.js @@ -4,11 +4,13 @@ import path from "node:path"; import { FirstDraftProtocolError, + SERVICE_ROUTES, isDiagnostic, isProblemBody, readResponseBody, responseMediaType, sendRequest, + serviceEndpoint, } from "../api-response.js"; import { isFileSystemError } from "../file-system.js"; import { @@ -109,10 +111,9 @@ export async function pushPlan({ const statePath = path.join(directory, "state.json"); const planSource = readLocalFile(planPath, MAX_PLAN_BYTES, fileSystem); const origin = resolveApiUrl(apiUrl, state.api_url); - const endpoint = new URL( - `/v1/projects/${state.project_id}/foundation-plan`, - origin, - ); + const endpoint = serviceEndpoint(SERVICE_ROUTES.pushPlan, origin, { + project_id: state.project_id, + }); const headers = { Accept: "application/json, application/problem+json", "Content-Type": FOUNDATION_PLAN_MEDIA_TYPE, @@ -122,7 +123,6 @@ export async function pushPlan({ }; const response = await sendRequest(fetchFunction, endpoint, { - method: "PUT", headers, body: planSource, redirect: "error", diff --git a/src/commands/plan-status.js b/src/commands/plan-status.js index 7818b10..d59737e 100644 --- a/src/commands/plan-status.js +++ b/src/commands/plan-status.js @@ -4,10 +4,12 @@ import { lstatSync, readFileSync } from "node:fs"; import { FirstDraftNetworkError, FirstDraftProtocolError, + SERVICE_ROUTES, isProblemBody, readResponseBody, responseMediaType, sendRequest, + serviceEndpoint, } from "../api-response.js"; import { isUuidV7, readPlanState } from "../plan-state.js"; @@ -166,10 +168,9 @@ export async function readPlanStatus({ ); } - const endpoint = new URL( - `/v1/projects/${state.project_id}/analysis`, - state.api_url, - ); + const endpoint = serviceEndpoint(SERVICE_ROUTES.readAnalysis, state.api_url, { + project_id: state.project_id, + }); const deadline = wait ? now() + WAIT_TIMEOUT_MS : null; /** @type {AnalysisResponse | null} */ let first = null; @@ -189,7 +190,6 @@ export async function readPlanStatus({ let body; try { response = await sendRequest(fetchFunction, endpoint, { - method: "GET", headers: { Accept: "application/json, application/problem+json" }, redirect: "error", signal: createRequestSignal(requestTimeout), diff --git a/test/compilation-artifact.test.js b/test/compilation-artifact.test.js index 5e80619..00ee957 100644 --- a/test/compilation-artifact.test.js +++ b/test/compilation-artifact.test.js @@ -25,6 +25,7 @@ import { MAX_ARTIFACT_BYTES, materializeCompilationArtifact, parseCompilationArtifact, + RAILS_TARGET_PROFILE, resolveOutputTarget, prepareCompilationOutputTarget, } from "../src/compilation-artifact.js"; @@ -37,7 +38,7 @@ const HEAD_SHA256 = "1".repeat(64); const FOUNDATION_PLAN_SHA256 = "5".repeat(64); const COMPILER_RELEASE = "foundation-plan-rails/compiler-application-2026-09-27-bookmark-assets"; -const TARGET = { id: "rails", profile: "rails-sketch/2026-09-bookmark-assets" }; +const TARGET = { id: "rails", profile: RAILS_TARGET_PROFILE }; const EXPECTED = { projectId: PROJECT_ID, compilationId: COMPILATION_ID, diff --git a/test/compilation.test.js b/test/compilation.test.js index 73e5426..1b48f06 100644 --- a/test/compilation.test.js +++ b/test/compilation.test.js @@ -19,6 +19,7 @@ import { ARTIFACT_MEDIA_TYPE, FOUNDATION_PLAN_FORMAT, MAX_ARTIFACT_BYTES, + RAILS_TARGET_PROFILE, } from "../src/compilation-artifact.js"; import { ROOT_TRANSACTION_NAME } from "../src/root-output.js"; @@ -34,7 +35,7 @@ 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-application-2026-09-27-bookmark-assets"; -const TARGET = { id: "rails", profile: "rails-sketch/2026-09-bookmark-assets" }; +const TARGET = { id: "rails", profile: RAILS_TARGET_PROFILE }; const STATUS_PATH = `/v1/projects/${PROJECT_ID}/compilations/${COMPILATION_ID}`; const ARTIFACT_PATH = `${STATUS_PATH}/artifact`; diff --git a/test/plan-compile.test.js b/test/plan-compile.test.js index 1c70bbe..cbede3e 100644 --- a/test/plan-compile.test.js +++ b/test/plan-compile.test.js @@ -18,6 +18,7 @@ import { run } from "../src/cli.js"; import { ARTIFACT_MEDIA_TYPE, FOUNDATION_PLAN_FORMAT, + RAILS_TARGET_PROFILE, } from "../src/compilation-artifact.js"; import { ROOT_TRANSACTION_NAME } from "../src/root-output.js"; @@ -40,7 +41,7 @@ const ANALYZER_RELEASE = "foundation-plan-rails/application-2026-09-27-bookmark-assets"; const COMPILER_RELEASE = "foundation-plan-rails/compiler-application-2026-09-27-bookmark-assets"; -const TARGET = { id: "rails", profile: "rails-sketch/2026-09-bookmark-assets" }; +const TARGET = { id: "rails", profile: RAILS_TARGET_PROFILE }; const SUCCESS_PROGRESS = `First Draft: Analyzing Foundation Plan... First Draft: Foundation Plan analysis valid. First Draft: Compiling application... diff --git a/test/plan-init.test.js b/test/plan-init.test.js index 64112da..874b4b6 100644 --- a/test/plan-init.test.js +++ b/test/plan-init.test.js @@ -18,6 +18,7 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { run } from "../src/cli.js"; +import { RAILS_TARGET_PROFILE } from "../src/compilation-artifact.js"; const PROJECT_ID = "01900000-0000-7000-8000-000000000301"; const PLAN_HELP = `First Draft CLI @@ -61,11 +62,17 @@ const PLAN_INIT_ERROR = jsonOutput({ "Could not initialize .firstdraft. The directory may be incomplete; no existing files were overwritten.", }); +const [PLAN_FORMAT] = JSON.parse( + readFileSync( + new URL("../release/compatibility.json", import.meta.url), + "utf8", + ), +).requires.foundation_plan_formats; const EXPECTED_PLAN = `{ - "format": "firstdraft.foundation-plan.sketch/0.23", + "format": ${JSON.stringify(PLAN_FORMAT)}, "target": { "id": "rails", - "profile": "rails-sketch/2026-09-bookmark-assets" + "profile": ${JSON.stringify(RAILS_TARGET_PROFILE)} }, "application": { "key": "oscar_party", diff --git a/test/plan-publish.test.js b/test/plan-publish.test.js index 147fa73..5cbf4f8 100644 --- a/test/plan-publish.test.js +++ b/test/plan-publish.test.js @@ -7,6 +7,7 @@ import path from "node:path"; import test from "node:test"; import { run } from "../src/cli.js"; +import { RAILS_TARGET_PROFILE } from "../src/compilation-artifact.js"; const PROJECT_ID = "01900000-0000-7000-8000-000000001001"; const COMPILATION_ID = "01900000-0000-7000-8000-000000001002"; @@ -28,7 +29,7 @@ const ANALYZER_RELEASE = "foundation-plan-rails/application-2026-09-27-bookmark-assets"; const COMPILER_RELEASE = "foundation-plan-rails/compiler-application-2026-09-27-bookmark-assets"; -const TARGET = { id: "rails", profile: "rails-sketch/2026-09-bookmark-assets" }; +const TARGET = { id: "rails", profile: RAILS_TARGET_PROFILE }; const ARTIFACT = { sha256: "1".repeat(64), manifest_sha256: "2".repeat(64), diff --git a/test/plan-status.test.js b/test/plan-status.test.js index 13ae1ab..4525289 100644 --- a/test/plan-status.test.js +++ b/test/plan-status.test.js @@ -31,6 +31,8 @@ const ETAG = '"opaque:plan-validator"'; const STARTED_AT = "2026-07-30T12:00:00.123Z"; const COMPLETED_AT = "2026-07-30T12:00:01.456Z"; const HEAD_SOURCE_SHA256 = "1".repeat(64); +// The pinned Service gap-set digest covers these three values. Change them only +// together with a digest that the Service computed. const ANALYZER_RELEASE = "foundation-plan-rails/application-2026-09-27-bookmark-assets"; const COMPILER_RELEASE = diff --git a/test/release-compatibility.test.js b/test/release-compatibility.test.js index c029109..043c6d6 100644 --- a/test/release-compatibility.test.js +++ b/test/release-compatibility.test.js @@ -28,19 +28,23 @@ test("release compatibility declares the coordinated CLI contract", () => { assert.equal(compatibility.component, "cli"); assert.equal(typeof compatibility.version, "string"); assert.match(compatibility.version, semverPattern); - assert.equal(compatibility.version, packageMetadata.version); + assert.equal( + compatibility.version, + packageMetadata.version, + "Run `node scripts/sync-version.js --apply` to copy package.json's version", + ); assertExactKeys(compatibility.requires, [ "api_contract", "foundation_plan_formats", ]); - assert.deepEqual(compatibility.requires.api_contract, [ - ">= 0.7.0", - "< 0.8.0", - ]); - assert.deepEqual(compatibility.requires.foundation_plan_formats, [ - FOUNDATION_PLAN_FORMAT, - ]); + assert.ok(Array.isArray(compatibility.requires.api_contract)); + assert.notEqual(compatibility.requires.api_contract.length, 0); + assert.deepEqual( + compatibility.requires.foundation_plan_formats, + [FOUNDATION_PLAN_FORMAT], + "Declare exactly the Plan formats that plan init writes and artifact validation accepts", + ); for (const requirement of compatibility.requires.api_contract) { assert.equal(typeof requirement, "string"); diff --git a/test/service-endpoints.test.js b/test/service-endpoints.test.js new file mode 100644 index 0000000..a06b86b --- /dev/null +++ b/test/service-endpoints.test.js @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { SERVICE_ROUTES } from "../src/api-response.js"; + +const repository = fileURLToPath(new URL("..", import.meta.url)); +const ROUTES_FILE = path.join("src", "api-response.js"); + +test("the Service endpoints table lists exactly the routes the CLI requests", () => { + const rows = endpointRows( + readFileSync(path.join(repository, "docs", "commands.md"), "utf8"), + ); + const routes = Object.entries(SERVICE_ROUTES).map(([name, route]) => ({ + name, + key: `${route.method} ${route.path}`, + })); + const rowKeys = rows.map((row) => `${row.method} ${row.path}`); + const routeKeys = new Set(routes.map((route) => route.key)); + const requestSources = listJavaScriptFiles(path.join(repository, "src")) + .map((file) => ({ + name: path.relative(repository, file), + source: readFileSync(file, "utf8"), + })) + .filter(({ name }) => name !== ROUTES_FILE); + const findings = []; + + for (const route of routes) { + if (!rowKeys.includes(route.key)) { + findings.push(`SERVICE_ROUTES.${route.name} (${route.key}) has no row.`); + } + const reference = new RegExp(`\\bSERVICE_ROUTES\\.${route.name}\\b`); + if (!requestSources.some(({ source }) => reference.test(source))) { + findings.push(`No file in src/ requests SERVICE_ROUTES.${route.name}.`); + } + } + + rowKeys.forEach((key, index) => { + if (!routeKeys.has(key)) { + findings.push(`The row ${key} matches no SERVICE_ROUTES entry.`); + } else if (rowKeys.indexOf(key) !== index) { + findings.push(`The row ${key} appears more than once.`); + } + }); + + // sendRequest takes the method from the route, so a literal method means a + // request that bypasses SERVICE_ROUTES. + for (const { name, source } of requestSources) { + if (/\bmethod: "/.test(source)) { + findings.push( + `${name} names an HTTP method; declare its route in SERVICE_ROUTES.`, + ); + } + } + + assert.notEqual(rows.length, 0, "the Service endpoints table has no rows"); + assert.ok( + findings.length === 0, + `Update docs/commands.md#service-endpoints or SERVICE_ROUTES:\n${findings.join("\n")}`, + ); +}); + +/** + * @param {string} markdown + * @returns {{method: string, path: string}[]} + */ +function endpointRows(markdown) { + const lines = markdown.split("\n"); + const start = lines.indexOf("## Service endpoints"); + assert.notEqual( + start, + -1, + "docs/commands.md needs a Service endpoints section", + ); + const end = lines.findIndex( + (line, index) => index > start && line.startsWith("## "), + ); + + return lines + .slice(start + 1, end === -1 ? undefined : end) + .filter((line) => line.startsWith("|")) + .slice(2) + .map((line) => { + const [method, endpointPath] = line + .split("|") + .slice(1, 3) + .map((cell) => cell.trim().replace(/^`|`$/g, "")); + assert(method && endpointPath, `malformed endpoint row: ${line}`); + return { method, path: endpointPath }; + }); +} + +/** @param {string} directory @returns {string[]} */ +function listJavaScriptFiles(directory) { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) return listJavaScriptFiles(entryPath); + return entry.isFile() && entry.name.endsWith(".js") ? [entryPath] : []; + }) + .sort(); +} diff --git a/test/sync-version.test.js b/test/sync-version.test.js new file mode 100644 index 0000000..d9948f3 --- /dev/null +++ b/test/sync-version.test.js @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repository = fileURLToPath(new URL("..", import.meta.url)); +const packageMetadata = readJson(repository, "package.json"); +const compatibilitySource = readFileSync( + path.join(repository, "release", "compatibility.json"), + "utf8", +); +const nextVersion = packageMetadata.version.replace( + /\d+$/, + (/** @type {string} */ patch) => String(Number(patch) + 1), +); + +test("the version sync script prints its usage", () => { + const result = spawnSync( + process.execPath, + [path.join(repository, "scripts", "sync-version.js"), "--help"], + { encoding: "utf8" }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^Usage: node scripts\/sync-version\.js/); + assert.match(result.stdout, /--apply/); +}); + +test("the version sync script is a dry run until --apply", (context) => { + const directory = copyRepository(context); + writeJson(directory, "package.json", { + ...packageMetadata, + version: nextVersion, + }); + const before = snapshot(directory); + + const dryRun = syncVersion(directory, []); + assert.equal(dryRun.status, 0, dryRun.stderr); + assert.match(dryRun.stdout, /^Dry run: package\.json version /); + for (const field of [ + `package-lock.json: version ${packageMetadata.version} -> ${nextVersion}`, + `package-lock.json: packages[""].version ${packageMetadata.version} -> ${nextVersion}`, + `release/compatibility.json: version ${packageMetadata.version} -> ${nextVersion}`, + ]) { + assert.ok(dryRun.stdout.includes(`${field}\n`), field); + } + assert.deepEqual(snapshot(directory), before); + + const applied = syncVersion(directory, ["--apply"]); + assert.equal(applied.status, 0, applied.stderr); + assertSynchronized(directory); + + const repeated = syncVersion(directory, []); + assert.equal(repeated.status, 0, repeated.stderr); + assert.match(repeated.stdout, /Every version field already matches\.\n$/); +}); + +test( + "npm version updates every version field through the lifecycle script", + { skip: !process.env.npm_execpath && "run through npm to locate npm" }, + (context) => { + const npmCli = process.env.npm_execpath; + assert(npmCli); + const directory = copyRepository(context); + const environment = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !/^npm_/i.test(name)), + ); + + const result = spawnSync( + process.execPath, + [ + npmCli, + "version", + nextVersion, + "--no-git-tag-version", + "--ignore-scripts=false", + "--update-notifier=false", + ], + { cwd: directory, env: environment, encoding: "utf8" }, + ); + + assert.equal(result.status, 0, result.stderr); + assertSynchronized(directory); + }, +); + +/** @param {import("node:test").TestContext} context */ +function copyRepository(context) { + const directory = mkdtempSync( + path.join(tmpdir(), "firstdraft-sync-version-"), + ); + context.after(() => rmSync(directory, { recursive: true, force: true })); + mkdirSync(path.join(directory, "release")); + mkdirSync(path.join(directory, "scripts")); + + for (const file of [ + ".npmrc", + "package.json", + "package-lock.json", + path.join("release", "compatibility.json"), + path.join("scripts", "sync-version.js"), + ]) { + copyFileSync(path.join(repository, file), path.join(directory, file)); + } + + return directory; +} + +/** @param {string} directory @param {string[]} options */ +function syncVersion(directory, options) { + return spawnSync( + process.execPath, + [path.join(directory, "scripts", "sync-version.js"), ...options], + { encoding: "utf8" }, + ); +} + +/** @param {string} directory */ +function assertSynchronized(directory) { + const lock = readJson(directory, "package-lock.json"); + assert.equal(readJson(directory, "package.json").version, nextVersion); + assert.equal(lock.version, nextVersion); + assert.equal(lock.packages[""].version, nextVersion); + assert.equal( + readFileSync(path.join(directory, "package-lock.json"), "utf8"), + `${JSON.stringify(lock, null, 2)}\n`, + ); + assert.equal( + readFileSync(path.join(directory, "release", "compatibility.json"), "utf8"), + compatibilitySource.replace( + `"version": "${packageMetadata.version}"`, + `"version": "${nextVersion}"`, + ), + ); +} + +/** @param {string} directory */ +function snapshot(directory) { + return ["package-lock.json", path.join("release", "compatibility.json")].map( + (file) => readFileSync(path.join(directory, file), "utf8"), + ); +} + +/** @param {string} directory @param {string} file */ +function readJson(directory, file) { + return JSON.parse(readFileSync(path.join(directory, file), "utf8")); +} + +/** @param {string} directory @param {string} file @param {unknown} value */ +function writeJson(directory, file, value) { + writeFileSync( + path.join(directory, file), + `${JSON.stringify(value, null, 2)}\n`, + ); +}