From b55b33a553d98d92d8862566a888c21ff2b476c5 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Thu, 24 Sep 2026 14:47:09 -0500 Subject: [PATCH] Separate staging credentials from production Keep production as the default and add an explicit staging option. Select credentials from each Project's effective origin so an existing staging pin cannot receive a production token. Preserve pinned Projects and local output preflight. Bump the CLI minor version for the staging token configuration change while retaining the API 0.6 and Plan 0.22 contracts. --- README.md | 7 +- RELEASING.md | 13 +- docs/commands.md | 54 +++++-- docs/errors.md | 14 +- package-lock.json | 4 +- package.json | 2 +- release/compatibility.json | 2 +- scripts/run-tests.js | 6 + scripts/smoke-package.js | 54 ++++++- src/api-authentication.js | 61 ++++++-- src/api-response.js | 8 ++ src/cli.js | 258 ++++++++++++++++++++++++--------- test/api-environments.test.js | 260 ++++++++++++++++++++++++++++++++++ test/cli.test.js | 3 +- test/compilation.test.js | 45 ++++++ test/documentation.test.js | 2 +- test/plan-compile.test.js | 62 ++++++++ test/plan-publish.test.js | 8 +- test/plan-push.test.js | 44 ++++-- test/plan-status.test.js | 12 +- 20 files changed, 799 insertions(+), 120 deletions(-) create mode 100644 test/api-environments.test.js diff --git a/README.md b/README.md index 45a9364..9cb725d 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,10 @@ To exercise the checkout directly: node bin/firstdraft.js --help ``` -Remote commands read FIRSTDRAFT_API_TOKEN from the environment. See -[Push a Foundation Plan](docs/commands.md#push-a-foundation-plan) for FIRSTDRAFT_API_URL and origin pinning. Keep -tokens out of arguments, shell history, fixtures, snapshots, and logs. +Remote commands default to production and read `FIRSTDRAFT_API_TOKEN`. Use `firstdraft --staging plan compile` +and a separate `FIRSTDRAFT_STAGING_API_TOKEN` for staging. Existing Projects retain their saved origin. See +[environment selection](docs/commands.md#select-an-environment-and-authenticate) for custom URLs and credential +isolation. Keep tokens out of arguments, shell history, fixtures, snapshots, and logs. ## Package contract diff --git a/RELEASING.md b/RELEASING.md index 479f3b6..72fb710 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -16,10 +16,11 @@ release tag. An unpublished, untagged candidate can retain its proposed version The published version may remain in source during documentation and test maintenance; recording its release history does not require preparing another version. Choose an unused version when preparing the next publication. -CLI `0.6.x` requires API `0.6.x` and Plan `firstdraft.foundation-plan.sketch/0.22`. Plan `0.22` adds the optional -`application.pwa` boolean and replaces the sole accepted `0.21` input format. The new API and CLI minor -versions record that input and artifact compatibility break; they add no migration or compatibility bridge. -The target stays `rails-sketch/2026-09`. +CLI `0.7.x` requires API `0.6.x` and Plan `firstdraft.foundation-plan.sketch/0.22`. CLI `0.7.0` adds `--staging` +and requires `FIRSTDRAFT_STAGING_API_TOKEN` for the staging origin, including existing pinned Projects. This token +configuration change warrants a new CLI minor version; the API and Plan contracts stay unchanged. Production +remains the default and uses `FIRSTDRAFT_API_TOKEN`, as do custom origins. No Project is migrated. The target stays +`rails-sketch/2026-09`. Local output remains the default introduced in CLI `0.4.x`: `firstdraft plan compile` is equivalent to `firstdraft plan compile --output .`, with GitHub publication selected by explicit `--github`. The root archive @@ -34,7 +35,9 @@ 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. -2. Update the command, error, and Skill guidance affected by the change. Preserve dated release evidence. +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. 3. Run focused checks while developing and the repository's required CI for the merge candidate. For a fresh checkout, the complete local check is `npm ci --ignore-scripts`, `npm audit`, then `npm run check`. 4. Review and merge the change. Wait for the existing `CI` workflow to pass for the selected `main` SHA; publication diff --git a/docs/commands.md b/docs/commands.md index 099413f..496b488 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -3,9 +3,9 @@ This page owns the detailed public semantics of the current command surface. Run `firstdraft --help` or a command group's `--help` for concise executable syntax. See [Errors and recovery](errors.md) before retrying a failed mutation. -The current `0.6.x` source line contains the auditable command shell, local Foundation Plan initialization, local +The current `0.7.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, direct -Compile-and-materialize and private publish orchestration, and retained-Compilation inspection. CLI `0.6.x` +Compile-and-materialize and private publish orchestration, and retained-Compilation inspection. CLI `0.7.x` requires the service's `0.6.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. @@ -23,19 +23,49 @@ semantics and [release history](release-history.md) for the transition from prer | `firstdraft compilation status` | Yes | Inspect a retained Compilation by ID | | `firstdraft compilation download` | Yes | Verify and materialize a successful retained Compilation | -## Authenticate API commands +## Select an environment and authenticate -Create an API token in First Draft and provide it only through the environment when running a network command: +Production at `https://firstdraft.com` is the default. Create a token at +[First Draft](https://firstdraft.com/api-tokens) and provide it through `FIRSTDRAFT_API_TOKEN` when running a network +command. Keep token values out of shell history and command arguments. ```sh -export FIRSTDRAFT_API_TOKEN="your-token" firstdraft plan push ``` -`plan push`, `plan status`, `plan compile`, and `compilation` subcommands send the token as a Bearer credential on -every API request. The CLI does not save it in `.firstdraft`, print it, or require it for local commands such as -`plan init` and `generate`. Revoke the token in First Draft if it is exposed. A missing token, or First Draft's -validated `401` problem response with the `authentication_required` code, produces that stable CLI error. +For staging, create a separate token at [First Draft staging](https://staging.firstdraft.com/api-tokens), provide it +through `FIRSTDRAFT_STAGING_API_TOKEN`, and select staging on the first remote command: + +```sh +firstdraft --staging plan push +firstdraft plan compile --staging +``` + +`--staging` may precede the command group or appear among a remote command's options. It selects +`https://staging.firstdraft.com`. `plan init` and `generate` remain local; a global flag on a local command does not +save an environment selection. The first successful push, including the push within `plan compile`, pins the API +origin in `.firstdraft/state.json`. + +Existing Projects keep their pinned origin with or without the flag. A CLI upgrade does not migrate a Project or its +credentials. A staging flag that disagrees with a Project's pin stops before any request. +To work with another environment, initialize a separate project directory and submit the Plan there; do not edit +the existing Project's private state to redirect it. + +`FIRSTDRAFT_API_URL` remains available for an initial custom HTTPS origin or loopback HTTP development server. +`--staging` together with a different URL is an error; the equivalent normalized staging URL is allowed. Later +pushes and compilation reject an override that differs from the pin. Read-only status and retained download +commands use the pin and ignore `FIRSTDRAFT_API_URL` unless checking its conflict with an explicit `--staging`. + +Every remote command selects credentials from its effective origin: the exact `https://staging.firstdraft.com` +origin requires `FIRSTDRAFT_STAGING_API_TOKEN`; production and custom origins use `FIRSTDRAFT_API_TOKEN`. Neither +token is a fallback for the other. This includes existing staging Projects and retained status or artifact reads, +even when no flag is supplied. Upgrading from CLI `0.6.x` therefore requires moving the staging credential to +`FIRSTDRAFT_STAGING_API_TOKEN`; production tokens stay in `FIRSTDRAFT_API_TOKEN`. + +The CLI sends the selected token as a Bearer credential on every API request. It does not save it in `.firstdraft`, +print it, or require it for local commands. Revoke a token in the environment that issued it if it is exposed. A +missing token, or First Draft's validated `401` problem response with the `authentication_required` code, produces +that stable CLI error. ## Start a Foundation Plan @@ -101,9 +131,9 @@ The command sends the exact bytes in `.firstdraft/foundation-plan.json`. The fir Project; later pushes replay the complete ETag saved in `.firstdraft/state.json` so a stale writer cannot replace a newer Plan. Successful responses and server diagnostics are printed as JSON for an agent to inspect. -The initial API origin defaults to `https://firstdraft.com`. Set `FIRSTDRAFT_API_URL` to use another HTTPS origin or -a loopback HTTP development server. The first successful push pins the normalized origin in local state, and a later -override must match it. +The first successful push pins the normalized API origin in local state. See +[environment selection and authentication](#select-an-environment-and-authenticate) for production, staging, custom +origins, and the credentials each requires. If a failure happens after sending the request, the CLI leaves local state unchanged. It never constructs an ETag from the Plan digest or trusts an ETag from a response it could not fully verify. Follow diff --git a/docs/errors.md b/docs/errors.md index 481042a..9f36fbc 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -14,6 +14,18 @@ errors, or unvalidated response bodies. `local_state_not_saved` is the sole exce its `recovery_state` is required to repair the accepted ETag locally. Root-level and command-group usage failures remain human-readable text on standard error with exit 2. Unexpected programming defects remain loud. +## Environment and credential errors + +`invalid_configuration` stops before a request when `--staging` conflicts with `FIRSTDRAFT_API_URL` or a Project's +saved origin. Push and Compile also reject any URL override that differs from that pin. Unset the conflicting +override or use a separate initialized project directory for the other environment; do not redirect existing +private Project state. Status and retained download commands continue using their pin. + +`authentication_required` means the selected environment's token is missing or rejected. Staging requires +`FIRSTDRAFT_STAGING_API_TOKEN`, including old staging Projects with no flag. Production and custom origins require +`FIRSTDRAFT_API_TOKEN`. The CLI never substitutes one for the other. Obtain or refresh the credential from the +same environment, then follow the command's recovery instructions below. + ## Ambiguous mutations `plan compile` supplies `phase: "push" | "compilation" | "publication"` when `request_outcome_unknown` requires @@ -110,7 +122,7 @@ stopped without following the replacement. | -------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---: | ------------------------------------------------------------------------------------------------------ | | 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 | `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. | diff --git a/package-lock.json b/package-lock.json index 73d8071..226743a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@firstdraft.com/cli", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@firstdraft.com/cli", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "bin": { "firstdraft": "bin/firstdraft.js" diff --git a/package.json b/package.json index ed74fbe..548dcc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@firstdraft.com/cli", - "version": "0.6.0", + "version": "0.7.0", "description": "Command-line interface for First Draft", "license": "MIT", "type": "module", diff --git a/release/compatibility.json b/release/compatibility.json index 36f8a40..f574dd7 100644 --- a/release/compatibility.json +++ b/release/compatibility.json @@ -1,7 +1,7 @@ { "format": "firstdraft.release-compatibility/1", "component": "cli", - "version": "0.6.0", + "version": "0.7.0", "requires": { "api_contract": [">= 0.6.0", "< 0.7.0"], "foundation_plan_formats": ["firstdraft.foundation-plan.sketch/0.22"] diff --git a/scripts/run-tests.js b/scripts/run-tests.js index ccec04b..462a076 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -14,6 +14,12 @@ const result = spawnSync( ["--test", ...process.argv.slice(2), ...testFiles], { stdio: "inherit", + env: { + ...process.env, + FIRSTDRAFT_API_URL: undefined, + FIRSTDRAFT_API_TOKEN: undefined, + FIRSTDRAFT_STAGING_API_TOKEN: undefined, + }, }, ); diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index c0aee42..547bd7b 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -256,6 +256,7 @@ try { "Invalid arguments. Run 'firstdraft compilation status --help' for usage.", }); + exercisePackedEnvironmentSelection(temporaryDirectory); await exercisePackedCompilation(projectDirectory); } finally { rmSync(temporaryDirectory, { recursive: true, force: true }); @@ -291,12 +292,19 @@ function spawnNpm(arguments_, cwd = process.cwd()) { /** * @param {string[]} arguments_ * @param {string} [cwd] + * @param {Record} [environment] */ -function spawnPackedCli(arguments_, cwd = process.cwd()) { +function spawnPackedCli(arguments_, cwd = process.cwd(), environment = {}) { return spawnSync(process.execPath, [packedExecutable, ...arguments_], { cwd, encoding: "utf8", - env: { ...process.env, FIRSTDRAFT_API_TOKEN: apiToken }, + env: { + ...process.env, + FIRSTDRAFT_API_URL: undefined, + FIRSTDRAFT_API_TOKEN: apiToken, + FIRSTDRAFT_STAGING_API_TOKEN: "", + ...environment, + }, }); } @@ -307,7 +315,12 @@ function spawnPackedCli(arguments_, cwd = process.cwd()) { async function spawnPackedCliAsync(arguments_, cwd) { const child = spawn(process.execPath, [packedExecutable, ...arguments_], { cwd, - env: { ...process.env, FIRSTDRAFT_API_TOKEN: apiToken }, + env: { + ...process.env, + FIRSTDRAFT_API_URL: undefined, + FIRSTDRAFT_API_TOKEN: apiToken, + FIRSTDRAFT_STAGING_API_TOKEN: "", + }, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; @@ -325,6 +338,41 @@ async function spawnPackedCliAsync(arguments_, cwd) { return { status, stdout, stderr }; } +/** @param {string} temporaryDirectory */ +function exercisePackedEnvironmentSelection(temporaryDirectory) { + const cwd = path.join(temporaryDirectory, "staging-project"); + mkdirSync(cwd); + const initialized = spawnPackedCli( + ["plan", "init", "--name", "Staging Project"], + cwd, + ); + assert.equal(initialized.status, 0); + + const missing = spawnPackedCli(["--staging", "plan", "push"], cwd); + assert.equal(missing.status, 1); + assert.equal(JSON.parse(missing.stderr).error, "authentication_required"); + assert.match(missing.stderr, /FIRSTDRAFT_STAGING_API_TOKEN/); + + const conflict = spawnPackedCli(["plan", "push", "--staging"], cwd, { + FIRSTDRAFT_API_URL: "http://127.0.0.1:1", + FIRSTDRAFT_STAGING_API_TOKEN: "canary-staging-token", + }); + assert.equal(conflict.status, 2); + assert.equal(JSON.parse(conflict.stderr).error, "invalid_configuration"); + assert.match(conflict.stderr, /--staging conflicts with FIRSTDRAFT_API_URL/); + assert.doesNotMatch(conflict.stderr, /canary/); + + const statePath = path.join(cwd, ".firstdraft", "state.json"); + const state = JSON.parse(readFileSync(statePath, "utf8")); + state.api_url = "https://staging.firstdraft.com"; + state.foundation_plan_etag = '"retained-staging-head"'; + writeFileSync(statePath, `${JSON.stringify(state)}\n`); + const retained = spawnPackedCli(["plan", "status"], cwd); + assert.equal(retained.status, 1); + assert.equal(JSON.parse(retained.stderr).error, "authentication_required"); + assert.match(retained.stderr, /FIRSTDRAFT_STAGING_API_TOKEN/); +} + /** @param {string} projectDirectory */ async function exercisePackedCompilation(projectDirectory) { const projectId = "01900000-0000-7000-8000-000000000901"; diff --git a/src/api-authentication.js b/src/api-authentication.js index afc9e8b..c95dc11 100644 --- a/src/api-authentication.js +++ b/src/api-authentication.js @@ -1,20 +1,65 @@ +import { PlanStateConfigurationError, normalizeApiUrl } from "./plan-state.js"; + +export const STAGING_API_URL = "https://staging.firstdraft.com"; + +export class ApiAuthenticationRequiredError extends Error {} + /** - * @param {typeof globalThis.fetch | undefined} fetchFunction - * @param {string | undefined} apiToken - * @returns {typeof globalThis.fetch | null} + * @param {object} options + * @param {typeof globalThis.fetch} [options.fetchFunction] + * @param {string} [options.apiToken] + * @param {string} [options.stagingApiToken] + * @param {string} [options.apiUrl] + * @param {boolean} [options.staging] */ -export function authenticatedFetch(fetchFunction, apiToken) { - if (apiToken === undefined || apiToken.trim().length === 0) return null; +export function authenticateApiCommand({ + fetchFunction, + apiToken, + stagingApiToken, + apiUrl, + staging = false, +}) { + if ( + staging && + apiUrl !== undefined && + normalizeApiUrl(apiUrl) !== STAGING_API_URL + ) { + throw new PlanStateConfigurationError( + "--staging conflicts with FIRSTDRAFT_API_URL. Unset it or select the staging origin.", + ); + } + const configured = staging ? STAGING_API_URL : apiUrl; + if (!hasToken(apiToken) && !hasToken(stagingApiToken)) return null; + + let selectedOrigin = staging ? STAGING_API_URL : undefined; const request = fetchFunction ?? globalThis.fetch; - return (input, init) => - request(input, { + /** @type {typeof globalThis.fetch} */ + const authorizedFetch = (input, init) => { + const endpoint = new URL(input instanceof Request ? input.url : input); + if (selectedOrigin !== undefined && endpoint.origin !== selectedOrigin) { + throw new PlanStateConfigurationError( + "The requested API environment does not match the Project origin. No request was made.", + ); + } + const token = + endpoint.origin === STAGING_API_URL ? stagingApiToken : apiToken; + if (!hasToken(token)) throw new ApiAuthenticationRequiredError(); + selectedOrigin = endpoint.origin; + return request(input, { ...init, headers: { ...init?.headers, - Authorization: `Bearer ${apiToken}`, + Authorization: `Bearer ${token}`, }, }); + }; + return { apiUrl: configured, fetchFunction: authorizedFetch }; +} + +/** @param {string | undefined} token */ +function hasToken(token) { + return token !== undefined && token.trim().length > 0; } /** diff --git a/src/api-response.js b/src/api-response.js index 040c1b0..7b838b1 100644 --- a/src/api-response.js +++ b/src/api-response.js @@ -1,3 +1,6 @@ +import { ApiAuthenticationRequiredError } from "./api-authentication.js"; +import { PlanStateConfigurationError } from "./plan-state.js"; + const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; export class FirstDraftNetworkError extends Error { @@ -28,6 +31,11 @@ export async function sendRequest(fetchFunction, endpoint, request) { try { return await fetchFunction(endpoint, request); } catch (error) { + if ( + error instanceof PlanStateConfigurationError || + error instanceof ApiAuthenticationRequiredError + ) + throw error; if (!(error instanceof Error)) throw error; throw new FirstDraftNetworkError("The First Draft request failed.", { diff --git a/src/cli.js b/src/cli.js index 04f46fc..d062ba3 100644 --- a/src/cli.js +++ b/src/cli.js @@ -7,7 +7,8 @@ import { isValidApplicationName, } from "./application-identity.js"; import { - authenticatedFetch, + ApiAuthenticationRequiredError, + authenticateApiCommand, isAuthenticationProblem, } from "./api-authentication.js"; import { @@ -78,7 +79,7 @@ import { VERSION } from "./version.js"; const ROOT_HELP = `First Draft CLI Usage: - firstdraft [options] + firstdraft [--staging] [options] firstdraft [options] Commands: @@ -87,6 +88,7 @@ Commands: plan Work with Foundation Plans Options: + --staging Use staging for API commands (production is the default) -h, --help Show help -V, --version Show version `; @@ -151,11 +153,13 @@ Usage: firstdraft plan push Options: - -h, --help Show help + --staging Use staging; reject a different saved origin + -h, --help Show help Environment: - FIRSTDRAFT_API_TOKEN Authenticate API requests - FIRSTDRAFT_API_URL Override the initial API origin + FIRSTDRAFT_API_TOKEN Authenticate production or custom API origins + FIRSTDRAFT_STAGING_API_TOKEN Authenticate staging.firstdraft.com + FIRSTDRAFT_API_URL Override the initial API origin The first successful push saves its API origin in .firstdraft/state.json. Later pushes reject a different origin. @@ -167,11 +171,13 @@ Usage: firstdraft plan status [--wait] Options: - --wait Poll until the current analysis reaches a terminal status - -h, --help Show help + --staging Use staging; reject a different saved origin + --wait Poll until the current analysis reaches a terminal status + -h, --help Show help Environment: - FIRSTDRAFT_API_TOKEN Authenticate API requests + FIRSTDRAFT_API_TOKEN Authenticate production or custom API origins + FIRSTDRAFT_STAGING_API_TOKEN Authenticate staging.firstdraft.com The command uses only the API origin pinned by a successful plan push. Without --wait, it makes exactly one status request. @@ -185,13 +191,15 @@ Usage: firstdraft plan compile --github Options: + --staging Use staging; reject a different saved origin --output Materialize here (default: .) - --github Publish to a private GitHub repository + --github Publish to a private GitHub repository -h, --help Show help Environment: - FIRSTDRAFT_API_TOKEN Authenticate API requests - FIRSTDRAFT_API_URL Override the initial API origin + FIRSTDRAFT_API_TOKEN Authenticate production or custom API origins + FIRSTDRAFT_STAGING_API_TOKEN Authenticate staging.firstdraft.com + 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. By default it materializes the @@ -220,11 +228,13 @@ Usage: firstdraft compilation status [--wait] Options: - --wait Poll until the Compilation reaches a terminal status - -h, --help Show help + --staging Use staging; reject a different saved origin + --wait Poll until the Compilation reaches a terminal status + -h, --help Show help Environment: - FIRSTDRAFT_API_TOKEN Authenticate API requests + FIRSTDRAFT_API_TOKEN Authenticate production or custom API origins + FIRSTDRAFT_STAGING_API_TOKEN Authenticate staging.firstdraft.com Without --wait, the command makes exactly one metadata-only GET. With --wait, it polls the same retained Compilation for at most ten minutes. Failed and @@ -237,11 +247,13 @@ Usage: firstdraft compilation download --output Options: + --staging Use staging; reject a different saved origin --output Materialize the generated application here - -h, --help Show help + -h, --help Show help Environment: - FIRSTDRAFT_API_TOKEN Authenticate API requests + FIRSTDRAFT_API_TOKEN Authenticate production or custom API origins + FIRSTDRAFT_STAGING_API_TOKEN Authenticate staging.firstdraft.com The command reads the retained Compilation once, requires it to have succeeded, downloads and verifies its exact artifact once, and atomically @@ -286,15 +298,13 @@ const PLAN_INIT_FAILED_DETAIL = const PLAN_INIT_SUCCESS = "Initialized .firstdraft/foundation-plan.json.\n"; const PLAN_PUSH_INVALID_ARGUMENTS_DETAIL = "Invalid arguments. Run 'firstdraft plan push --help' for usage."; -const PLAN_PUSH_INVALID_CONFIGURATION_DETAIL = - "Invalid First Draft API configuration. Run 'firstdraft plan push --help' for usage."; const PLAN_PUSH_LOCAL_INPUT_UNREADABLE_DETAIL = "Could not read the local First Draft Plan or state. No network request was made. Preserve the local files for manual recovery."; const PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL = "The Plan may have been accepted, but the response could not be verified. Stop and reconcile before pushing again; local state was not changed."; const PLAN_PUSH_SERVER_REJECTED_DETAIL = "First Draft rejected the Plan."; const AUTHENTICATION_REQUIRED_DETAIL = - "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token."; + "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN for production or custom origins, or FIRSTDRAFT_STAGING_API_TOKEN for staging."; const PLAN_STATUS_INVALID_ARGUMENTS_DETAIL = "Invalid arguments. Run 'firstdraft plan status --help' for usage."; const PLAN_STATUS_LOCAL_INPUT_UNREADABLE_DETAIL = @@ -315,8 +325,6 @@ const PLAN_COMPILE_INVALID_ARGUMENTS_DETAIL = "Invalid arguments. Run 'firstdraft plan compile --help' for usage."; const PLAN_COMPILE_LOCAL_INPUT_UNREADABLE_DETAIL = "Could not read the local First Draft Plan or state. No network request was made. Preserve the local files for manual recovery."; -const PLAN_COMPILE_INCOMPATIBLE_STATE_DETAIL = - "The configured API origin or saved Foundation Plan state is incompatible with compilation. No network request was made."; const PLAN_COMPILE_NOT_PUSHED_DETAIL = "The current Foundation Plan could not be associated with a pushed Project."; const PLAN_COMPILE_REQUEST_OUTCOME_UNKNOWN_DETAIL = @@ -436,6 +444,7 @@ const GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL = * @property {typeof import("./commands/compilation.js").compileAndDownload} [planCompileDownload] * @property {string} [apiUrl] * @property {string} [apiToken] + * @property {string} [stagingApiToken] */ /** @@ -465,6 +474,8 @@ const GENERATE_APPLICATION_KEY_INVALID_ARGUMENTS_DETAIL = * @property {typeof import("./commands/compilation.js").compileAndDownload} [planCompileDownload] * @property {string} [apiUrl] * @property {string} [apiToken] + * @property {string} [stagingApiToken] + * @property {boolean} [staging] */ /** @@ -476,7 +487,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 */ @@ -507,7 +518,11 @@ export async function run({ planCompileDownload, apiUrl = process.env.FIRSTDRAFT_API_URL, apiToken = process.env.FIRSTDRAFT_API_TOKEN, + stagingApiToken = process.env.FIRSTDRAFT_STAGING_API_TOKEN, }) { + const staging = argv[0] === "--staging"; + if (staging) argv = argv.slice(1); + if (argv[0] === "generate") { return runGenerate({ argv: argv.slice(1), @@ -544,6 +559,8 @@ export async function run({ planCompileDownload, apiUrl, apiToken, + stagingApiToken, + staging, }); } @@ -559,7 +576,10 @@ export async function run({ createRequestSignal, compilationSleep, compilationNow, + apiUrl, apiToken, + stagingApiToken, + staging, }); } @@ -636,6 +656,8 @@ async function runPlan({ planCompileDownload, apiUrl, apiToken, + stagingApiToken, + staging, }) { if (argv[0] === "init") { return runPlanInit({ @@ -660,6 +682,8 @@ async function runPlan({ createRequestSignal, apiUrl, apiToken, + stagingApiToken, + staging, }); } @@ -674,7 +698,10 @@ async function runPlan({ createRequestSignal, planStatusSleep, planStatusNow, + apiUrl, apiToken, + stagingApiToken, + staging, }); } @@ -700,6 +727,8 @@ async function runPlan({ planCompileDownload, apiUrl, apiToken, + stagingApiToken, + staging, }); } @@ -743,7 +772,10 @@ async function runCompilation({ createRequestSignal, compilationSleep, compilationNow, + apiUrl, apiToken, + stagingApiToken, + staging, }) { if (argv[0] === "status") { return runCompilationStatus({ @@ -756,7 +788,10 @@ async function runCompilation({ createRequestSignal, compilationSleep, compilationNow, + apiUrl, apiToken, + stagingApiToken, + staging, }); } @@ -769,7 +804,10 @@ async function runCompilation({ fetchFunction, planPushFileSystem, createRequestSignal, + apiUrl, apiToken, + stagingApiToken, + staging, }); } @@ -802,7 +840,7 @@ async function runCompilation({ } /** - * @param {Pick} options + * @param {Pick} options */ async function runCompilationStatus({ argv, @@ -814,12 +852,16 @@ async function runCompilationStatus({ createRequestSignal, compilationSleep, compilationNow, + apiUrl, apiToken, + stagingApiToken, + staging, }) { const parsed = parseArguments(() => parseArgs({ args: [...argv], options: { + staging: { type: "boolean" }, wait: { type: "boolean" }, help: { type: "boolean", short: "h" }, }, @@ -851,18 +893,23 @@ async function runCompilationStatus({ return 2; } - const authorizedFetch = authenticatedFetch(fetchFunction, apiToken); - if (authorizedFetch === null) { - writeAuthenticationRequired(stderr); - return 1; - } - try { + const authentication = authenticateApiCommand({ + fetchFunction, + apiUrl, + apiToken, + stagingApiToken, + staging: staging || parsed.values.staging, + }); + if (authentication === null) { + writeAuthenticationRequired(stderr); + return 1; + } const result = await readCompilation({ cwd, compilationId, wait: parsed.values.wait, - fetchFunction: authorizedFetch, + fetchFunction: authentication.fetchFunction, fileSystem: planPushFileSystem, createRequestSignal, sleep: compilationSleep, @@ -878,7 +925,7 @@ async function runCompilationStatus({ } /** - * @param {Pick} options + * @param {Pick} options */ async function runCompilationDownload({ argv, @@ -888,12 +935,16 @@ async function runCompilationDownload({ fetchFunction, planPushFileSystem, createRequestSignal, + apiUrl, apiToken, + stagingApiToken, + staging, }) { const parsed = parseArguments(() => parseArgs({ args: [...argv], options: { + staging: { type: "boolean" }, output: { type: "string" }, help: { type: "boolean", short: "h" }, }, @@ -931,18 +982,23 @@ async function runCompilationDownload({ return 2; } - const authorizedFetch = authenticatedFetch(fetchFunction, apiToken); - if (authorizedFetch === null) { - writeAuthenticationRequired(stderr); - return 1; - } - try { + const authentication = authenticateApiCommand({ + fetchFunction, + apiUrl, + apiToken, + stagingApiToken, + staging: staging || parsed.values.staging, + }); + if (authentication === null) { + writeAuthenticationRequired(stderr); + return 1; + } const result = await downloadCompilation({ cwd, compilationId, output, - fetchFunction: authorizedFetch, + fetchFunction: authentication.fetchFunction, fileSystem: planPushFileSystem, createRequestSignal, }); @@ -1027,6 +1083,19 @@ function writeCompilationInvalidArguments(writer, detail) { * @param {boolean} [throwUnknown] */ function writeCompilationReadError(writer, error, throwUnknown = true) { + if (error instanceof ApiAuthenticationRequiredError) { + writeAuthenticationRequired(writer); + return 1; + } + + if (error instanceof PlanPushConfigurationError) { + writeJson(writer, { + error: "invalid_configuration", + detail: error.message, + }); + return 2; + } + if (error instanceof PlanPushLocalError) { writeJson(writer, { error: "local_input_unreadable", @@ -1227,7 +1296,7 @@ function runGenerateApplicationKey({ argv, stdout, stderr }) { } /** - * @param {Pick} options + * @param {Pick} options */ async function runPlanPush({ argv, @@ -1240,11 +1309,16 @@ async function runPlanPush({ createRequestSignal, apiUrl, apiToken, + stagingApiToken, + staging, }) { const parsed = parseArguments(() => parseArgs({ args: [...argv], - options: { help: { type: "boolean", short: "h" } }, + options: { + staging: { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, allowPositionals: false, strict: true, tokens: true, @@ -1264,27 +1338,37 @@ async function runPlanPush({ return 0; } - const authorizedFetch = authenticatedFetch(fetchFunction, apiToken); - if (authorizedFetch === null) { - writeAuthenticationRequired(stderr); - return 1; - } - let result; try { + const authentication = authenticateApiCommand({ + fetchFunction, + apiUrl, + apiToken, + stagingApiToken, + staging: staging || parsed.values.staging, + }); + if (authentication === null) { + writeAuthenticationRequired(stderr); + return 1; + } result = await pushPlan({ cwd, - apiUrl, - fetchFunction: authorizedFetch, + apiUrl: authentication.apiUrl, + fetchFunction: authentication.fetchFunction, fileSystem: planPushFileSystem, createTemporaryId, createRequestSignal, }); } catch (error) { + if (error instanceof ApiAuthenticationRequiredError) { + writeAuthenticationRequired(stderr); + return 1; + } + if (error instanceof PlanPushConfigurationError) { writeJson(stderr, { error: "invalid_configuration", - detail: PLAN_PUSH_INVALID_CONFIGURATION_DETAIL, + detail: error.message, }); return 2; } @@ -1357,7 +1441,7 @@ async function runPlanPush({ } /** - * @param {Pick} options + * @param {Pick} options */ async function runPlanStatus({ argv, @@ -1369,12 +1453,16 @@ async function runPlanStatus({ createRequestSignal, planStatusSleep, planStatusNow, + apiUrl, apiToken, + stagingApiToken, + staging, }) { const parsed = parseArguments(() => parseArgs({ args: [...argv], options: { + staging: { type: "boolean" }, help: { type: "boolean", short: "h" }, wait: { type: "boolean" }, }, @@ -1397,24 +1485,42 @@ async function runPlanStatus({ return 0; } - const authorizedFetch = authenticatedFetch(fetchFunction, apiToken); - if (authorizedFetch === null) { - writeAuthenticationRequired(stderr); - return 1; - } - let result; try { + const authentication = authenticateApiCommand({ + fetchFunction, + apiUrl, + apiToken, + stagingApiToken, + staging: staging || parsed.values.staging, + }); + if (authentication === null) { + writeAuthenticationRequired(stderr); + return 1; + } result = await readPlanStatus({ cwd, wait: parsed.values.wait, - fetchFunction: authorizedFetch, + fetchFunction: authentication.fetchFunction, fileSystem: planPushFileSystem, createRequestSignal, sleep: planStatusSleep, now: planStatusNow, }); } catch (error) { + if (error instanceof ApiAuthenticationRequiredError) { + writeAuthenticationRequired(stderr); + return 1; + } + + if (error instanceof PlanPushConfigurationError) { + writeJson(stderr, { + error: "invalid_configuration", + detail: error.message, + }); + return 2; + } + if (error instanceof PlanPushLocalError) { writeJson(stderr, { error: "local_input_unreadable", @@ -1499,7 +1605,7 @@ async function runPlanStatus({ } /** - * @param {Pick} options + * @param {Pick} options */ async function runPlanCompile({ argv, @@ -1522,11 +1628,14 @@ async function runPlanCompile({ planCompileDownload, apiUrl, apiToken, + stagingApiToken, + staging, }) { const parsed = parseArguments(() => parseArgs({ args: [...argv], options: { + staging: { type: "boolean" }, output: { type: "string" }, github: { type: "boolean" }, help: { type: "boolean", short: "h" }, @@ -1559,17 +1668,28 @@ async function runPlanCompile({ return 2; } - const authorizedFetch = authenticatedFetch(fetchFunction, apiToken); - if (authorizedFetch === null) { - writeAuthenticationRequired(stderr); - return 1; + let authentication; + try { + authentication = authenticateApiCommand({ + fetchFunction, + apiUrl, + apiToken, + stagingApiToken, + staging: staging || parsed.values.staging, + }); + if (authentication === null) { + writeAuthenticationRequired(stderr); + return 1; + } + } catch (error) { + return writePlanCompileError(stderr, error); } const reportProgress = createPlanCompileProgressReporter(stderr); const shared = { cwd, - apiUrl, - fetchFunction: authorizedFetch, + apiUrl: authentication.apiUrl, + fetchFunction: authentication.fetchFunction, fileSystem: planPushFileSystem, createTemporaryId, createRequestSignal, @@ -1615,10 +1735,15 @@ async function runPlanCompile({ /** @param {Writer} writer @param {unknown} error */ function writePlanCompileError(writer, error) { + if (error instanceof ApiAuthenticationRequiredError) { + writeAuthenticationRequired(writer); + return 1; + } + if (error instanceof PlanPushConfigurationError) { writeJson(writer, { error: "invalid_configuration", - detail: PLAN_COMPILE_INCOMPATIBLE_STATE_DETAIL, + detail: error.message, }); return 2; } @@ -1766,7 +1891,8 @@ function writePlanCompileError(writer, error) { if (compilationError instanceof CompilationLocalStateError) { writeJson(writer, { error: "invalid_configuration", - detail: PLAN_COMPILE_INCOMPATIBLE_STATE_DETAIL, + detail: + "The configured API origin or saved Foundation Plan state is incompatible with compilation. No network request was made.", }); return 2; } diff --git a/test/api-environments.test.js b/test/api-environments.test.js new file mode 100644 index 0000000..3fa7dd9 --- /dev/null +++ b/test/api-environments.test.js @@ -0,0 +1,260 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { run } from "../src/cli.js"; + +const PRODUCTION = "https://firstdraft.com"; +const STAGING = "https://staging.firstdraft.com"; +const PROJECT_ID = "01900000-0000-7000-8000-000000008001"; +const COMPILATION_ID = "01900000-0000-7000-8000-000000008002"; +const PRODUCTION_TOKEN = "canary-production-token"; +const STAGING_TOKEN = "canary-staging-token"; +const REMOTE_COMMANDS = [ + ["plan", "push"], + ["plan", "status"], + ["plan", "compile", "--output", "application"], + ["plan", "compile", "--github"], + ["compilation", "status", COMPILATION_ID], + ["compilation", "download", COMPILATION_ID, "--output", "application"], +]; + +test("production and staging credentials follow the pinned origin on every remote command", async (context) => { + for (const argv of REMOTE_COMMANDS) { + for (const [origin, token] of [ + [PRODUCTION, PRODUCTION_TOKEN], + [STAGING, STAGING_TOKEN], + ]) { + const cwd = projectDirectory(context, origin); + let requests = 0; + const result = await invoke(argv, { + cwd, + fetchFunction: async (input, init) => { + requests += 1; + assert.equal(new URL(String(input)).origin, origin); + assert.equal( + new Headers(init?.headers).get("authorization"), + `Bearer ${token}`, + ); + return authenticationProblem(); + }, + }); + assert.equal(requests, 1, argv.join(" ")); + assert.equal(result.status, 1); + assert.equal(errorEnvelope(result.stderr).status, 401); + assert.equal( + errorEnvelope(result.stderr).error, + "authentication_required", + ); + assert.doesNotMatch(result.stderr, /canary/); + } + } +}); + +test("staging accepts a root or remote-command flag and a matching URL override", async (context) => { + for (const command of REMOTE_COMMANDS) { + for (const argv of [ + ["--staging", ...command], + [...command, "--staging"], + ]) { + const cwd = projectDirectory(context, STAGING); + let requests = 0; + const result = await invoke(argv, { + cwd, + apiUrl: `${STAGING}/`, + fetchFunction: async (input, init) => { + requests += 1; + assert.equal(new URL(String(input)).origin, STAGING); + assert.equal( + new Headers(init?.headers).get("authorization"), + `Bearer ${STAGING_TOKEN}`, + ); + return authenticationProblem(); + }, + }); + assert.equal(requests, 1); + assert.equal(errorEnvelope(result.stderr).status, 401); + } + } +}); + +test("the first remote request defaults to production or explicitly selects staging", async (context) => { + for (const command of [REMOTE_COMMANDS[0], REMOTE_COMMANDS[2]]) { + assert(command); + for (const staging of [false, true]) { + const cwd = projectDirectory(context); + let requests = 0; + const result = await invoke( + staging ? ["--staging", ...command] : command, + { + cwd, + fetchFunction: async (input, init) => { + requests += 1; + assert.equal( + new URL(String(input)).origin, + staging ? STAGING : PRODUCTION, + ); + assert.equal( + new Headers(init?.headers).get("authorization"), + `Bearer ${staging ? STAGING_TOKEN : PRODUCTION_TOKEN}`, + ); + return authenticationProblem(); + }, + }, + ); + assert.equal(requests, 1); + assert.equal(errorEnvelope(result.stderr).status, 401); + } + } +}); + +test("neither environment token substitutes for the other", async (context) => { + for (const argv of REMOTE_COMMANDS) { + for (const origin of [STAGING, PRODUCTION]) { + const result = await invoke(argv, { + cwd: projectDirectory(context, origin), + apiToken: origin === STAGING ? PRODUCTION_TOKEN : "", + stagingApiToken: origin === PRODUCTION ? STAGING_TOKEN : "", + }); + assert.equal(result.status, 1); + assert.equal( + errorEnvelope(result.stderr).error, + "authentication_required", + ); + assert.equal(errorEnvelope(result.stderr).status, undefined); + assert.doesNotMatch(result.stderr, /canary/); + } + } +}); + +test("staging rejects a conflicting URL or pinned origin before any request", async (context) => { + for (const command of REMOTE_COMMANDS) { + for (const options of [ + { + cwd: projectDirectory(context, STAGING), + apiUrl: "https://canary-conflict.test", + }, + { cwd: projectDirectory(context, PRODUCTION) }, + { cwd: projectDirectory(context, "http://localhost:3000") }, + ]) { + const result = await invoke([...command, "--staging"], options); + assert.equal(result.status, 2); + assert.equal(errorEnvelope(result.stderr).error, "invalid_configuration"); + assert.doesNotMatch(result.stderr, /canary|token/); + if (options.apiUrl) + assert.match( + result.stderr, + /--staging conflicts with FIRSTDRAFT_API_URL/, + ); + } + } +}); + +test("retained reads ignore an initial URL override when selecting staging credentials", async (context) => { + for (const argv of [ + REMOTE_COMMANDS[1], + REMOTE_COMMANDS[4], + REMOTE_COMMANDS[5], + ]) { + assert(argv); + let requests = 0; + const result = await invoke(argv, { + cwd: projectDirectory(context, STAGING), + apiUrl: "https://unused-origin.test", + fetchFunction: async (input, init) => { + requests += 1; + assert.equal(new URL(String(input)).origin, STAGING); + assert.equal( + new Headers(init?.headers).get("authorization"), + `Bearer ${STAGING_TOKEN}`, + ); + return authenticationProblem(); + }, + }); + assert.equal(requests, 1); + assert.equal(errorEnvelope(result.stderr).status, 401); + } +}); + +test("a custom initial origin retains explicit support and uses its own supplied token", async (context) => { + const origin = "http://127.0.0.1:4300"; + let requests = 0; + const result = await invoke(["plan", "push"], { + cwd: projectDirectory(context), + apiUrl: origin, + fetchFunction: async (input, init) => { + requests += 1; + assert.equal(new URL(String(input)).origin, origin); + assert.equal( + new Headers(init?.headers).get("authorization"), + `Bearer ${PRODUCTION_TOKEN}`, + ); + return authenticationProblem(); + }, + }); + assert.equal(requests, 1); + assert.equal(errorEnvelope(result.stderr).status, 401); +}); + +/** @param {import("node:test").TestContext} context @param {string} [origin] */ +function projectDirectory(context, origin) { + const cwd = mkdtempSync(path.join(tmpdir(), "firstdraft-environment-")); + context.after(() => rmSync(cwd, { recursive: true, force: true })); + mkdirSync(path.join(cwd, ".firstdraft")); + writeFileSync(path.join(cwd, ".firstdraft", "foundation-plan.json"), "{}\n"); + writeFileSync( + path.join(cwd, ".firstdraft", "state.json"), + `${JSON.stringify({ + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + ...(origin + ? { + api_url: origin, + foundation_plan_etag: '"sha256:' + "1".repeat(64) + '"', + } + : {}), + })}\n`, + ); + return cwd; +} + +/** @param {readonly string[]} argv @param {Partial} [options] */ +async function invoke(argv, options = {}) { + let stdout = ""; + let stderr = ""; + const status = await run({ + argv, + stdout: { write: (text) => (stdout += text) }, + stderr: { write: (text) => (stderr += text) }, + apiToken: PRODUCTION_TOKEN, + stagingApiToken: STAGING_TOKEN, + fetchFunction: async () => assert.fail("No network request was expected"), + ...options, + }); + return { status, stdout, stderr }; +} + +/** @param {string} stderr */ +function errorEnvelope(stderr) { + return JSON.parse( + stderr + .split("\n") + .filter((line) => !line.startsWith("First Draft: ")) + .join("\n"), + ); +} + +function authenticationProblem() { + return new Response( + JSON.stringify({ + type: "about:blank", + title: "Unauthorized", + status: 401, + code: "authentication_required", + detail: "A valid token is required.", + }), + { status: 401, headers: { "Content-Type": "application/problem+json" } }, + ); +} diff --git a/test/cli.test.js b/test/cli.test.js index e73e731..84a227d 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -11,7 +11,7 @@ import { VERSION } from "../src/version.js"; const HELP = `First Draft CLI Usage: - firstdraft [options] + firstdraft [--staging] [options] firstdraft [options] Commands: @@ -20,6 +20,7 @@ Commands: plan Work with Foundation Plans Options: + --staging Use staging for API commands (production is the default) -h, --help Show help -V, --version Show version `; diff --git a/test/compilation.test.js b/test/compilation.test.js index 3d14876..9feba85 100644 --- a/test/compilation.test.js +++ b/test/compilation.test.js @@ -216,6 +216,51 @@ test("compilation status has a bounded wait and validates exact response shapes" } }); +test("a retained staging download authenticates metadata and artifact from its pin", async (context) => { + const cwd = remoteDirectory(context); + const statePath = path.join(cwd, ".firstdraft", "state.json"); + const state = JSON.parse(readFileSync(statePath, "utf8")); + state.api_url = "https://staging.firstdraft.com"; + writeFileSync(statePath, `${JSON.stringify(state)}\n`); + const token = "canary-staging-token"; + const fixture = artifactFixture(); + /** @type {FetchCall[]} */ + const calls = []; + const result = await invoke( + ["compilation", "download", COMPILATION_ID, "--output", "application"], + { + cwd, + apiUrl: "https://firstdraft.com", + stagingApiToken: token, + fetchFunction: sequenceFetch( + [ + jsonResponse(compilationBody("succeeded", { artifact: fixture })), + artifactResponse(fixture), + ], + calls, + ), + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.equal(calls.length, 2); + for (const { input, init } of calls) { + assert.equal( + new URL(String(input)).origin, + "https://staging.firstdraft.com", + ); + assert.equal( + new Headers(init?.headers).get("authorization"), + `Bearer ${token}`, + ); + } + assert.equal( + readFileSync(path.join(cwd, "application", "README.md"), "utf8"), + "Movie Catalog\n", + ); + assert.doesNotMatch(result.stdout + result.stderr, /canary/); +}); + test("compilation download distinguishes Head and Plan provenance without starting work", async (context) => { const cwd = remoteDirectory(context); const fixture = artifactFixture(); diff --git a/test/documentation.test.js b/test/documentation.test.js index 6fae17d..0ca902b 100644 --- a/test/documentation.test.js +++ b/test/documentation.test.js @@ -28,7 +28,7 @@ test("public documentation avoids unavailable destinations", () => { assert.equal( markdownLinkTargets(readme).includes("https://firstdraft.com"), false, - "public onboarding must not route readers to the unrelated site at the API origin", + "public onboarding must route readers to a guide rather than the API landing page", ); for (const source of sources.values()) { for (const target of markdownLinkTargets(source)) { diff --git a/test/plan-compile.test.js b/test/plan-compile.test.js index d0b8043..2d79d06 100644 --- a/test/plan-compile.test.js +++ b/test/plan-compile.test.js @@ -98,6 +98,57 @@ test("plan compile submits exact bytes, waits for valid analysis, and publishes ); }); +test("staging compilation keeps its own credential through push, analysis, start, and artifact", async (context) => { + const cwd = localDirectory(context, PLAN_SOURCE); + const artifact = directArtifactFixture(); + const token = "canary-staging-token"; + const responses = [ + jsonResponse(acceptedPlanBody(), 201, { ETag: ETAG }), + jsonResponse(analysisBody("valid")), + jsonResponse(directCompilationBody("succeeded", artifact), 202, { + Location: directCompilationPath(), + }), + new Response(artifact.source, { + headers: { + "Content-Type": ARTIFACT_MEDIA_TYPE, + "Content-Length": String(artifact.source.byteLength), + "Cache-Control": "no-store, no-transform", + ETag: `"sha256:${artifact.sha256}"`, + }, + }), + ]; + const result = await invoke( + ["plan", "compile", "--staging", "--output", "application"], + { + cwd, + stagingApiToken: token, + fetchFunction: async ( + /** @type {string | URL | Request} */ input, + /** @type {RequestInit | undefined} */ init, + ) => { + assert.equal( + new URL(String(input)).origin, + "https://staging.firstdraft.com", + ); + assert.equal( + new Headers(init?.headers).get("authorization"), + `Bearer ${token}`, + ); + const response = responses.shift(); + assert(response); + return response; + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(responses.length, 0); + assert.equal( + readFileSync(path.join(cwd, "application", "README.md"), "utf8"), + "Movie Catalog\n", + ); + assert.doesNotMatch(result.stdout + result.stderr, /canary/); +}); + 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 = []; @@ -1053,6 +1104,17 @@ test("push ambiguity, analysis failures, and rejected reads have distinct errors } }); +test("local output preflight rejects an existing directory before reading Plan state", async (context) => { + const cwd = mkdtempSync(path.join(tmpdir(), "firstdraft-output-preflight-")); + context.after(() => rmSync(cwd, { recursive: true, force: true })); + mkdirSync(path.join(cwd, "existing")); + const result = await invoke(["plan", "compile", "--output", "existing"], { + cwd, + fetchFunction: async () => assert.fail("No request should be sent"), + }); + assertHandledFailure(result, "invalid_output_path", 2); +}); + test("help and invalid direct-output syntax have no prerequisites", async () => { const inaccessible = () => { throw new Error("dependency must remain inaccessible"); diff --git a/test/plan-publish.test.js b/test/plan-publish.test.js index 6480c7d..c8bd7e0 100644 --- a/test/plan-publish.test.js +++ b/test/plan-publish.test.js @@ -88,13 +88,15 @@ Usage: firstdraft plan compile --github Options: + --staging Use staging; reject a different saved origin --output Materialize here (default: .) - --github Publish to a private GitHub repository + --github Publish to a private GitHub repository -h, --help Show help Environment: - FIRSTDRAFT_API_TOKEN Authenticate API requests - FIRSTDRAFT_API_URL Override the initial API origin + FIRSTDRAFT_API_TOKEN Authenticate production or custom API origins + FIRSTDRAFT_STAGING_API_TOKEN Authenticate staging.firstdraft.com + 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. By default it materializes the diff --git a/test/plan-push.test.js b/test/plan-push.test.js index 9522fed..deb0b5e 100644 --- a/test/plan-push.test.js +++ b/test/plan-push.test.js @@ -39,11 +39,13 @@ Usage: firstdraft plan push Options: - -h, --help Show help + --staging Use staging; reject a different saved origin + -h, --help Show help Environment: - FIRSTDRAFT_API_TOKEN Authenticate API requests - FIRSTDRAFT_API_URL Override the initial API origin + FIRSTDRAFT_API_TOKEN Authenticate production or custom API origins + FIRSTDRAFT_STAGING_API_TOKEN Authenticate staging.firstdraft.com + FIRSTDRAFT_API_URL Override the initial API origin The first successful push saves its API origin in .firstdraft/state.json. Later pushes reject a different origin. @@ -54,8 +56,7 @@ const PLAN_PUSH_INVALID_ARGUMENTS_ERROR = jsonOutput({ }); const PLAN_PUSH_CONFIGURATION_ERROR = jsonOutput({ error: "invalid_configuration", - detail: - "Invalid First Draft API configuration. Run 'firstdraft plan push --help' for usage.", + detail: "The API URL is invalid.", }); const PLAN_PUSH_LOCAL_ERROR = jsonOutput({ error: "local_input_unreadable", @@ -185,6 +186,30 @@ test("plan push preserves explicit PWA choices without rewriting the Plan", asyn } }); +test("a first staging push pins staging and uses its separate credential", async (context) => { + const stagingToken = "canary-staging-token"; + const cwd = await initializedDirectory(context); + /** @type {FetchCall[]} */ + const calls = []; + const result = await invoke(["--staging", "plan", "push"], { + cwd, + stagingApiToken: stagingToken, + fetchFunction: recordingFetch( + acceptedResponse(planSource(cwd), 201, FIRST_ETAG), + calls, + ), + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(readState(cwd).api_url, "https://staging.firstdraft.com"); + assert.equal(calls.length, 1); + assert.equal( + new Headers(calls[0]?.init?.headers).get("authorization"), + `Bearer ${stagingToken}`, + ); + assert.doesNotMatch(JSON.stringify(readState(cwd)), /canary/); +}); + test("the initial push defaults to the First Draft production origin", async (context) => { const cwd = await initializedDirectory(context); const source = planSource(cwd); @@ -264,7 +289,10 @@ test("a saved origin may be repeated but never changed", async (context) => { assert.deepEqual(different, { status: 2, stdout: "", - stderr: PLAN_PUSH_CONFIGURATION_ERROR, + stderr: jsonOutput({ + error: "invalid_configuration", + detail: "The configured API URL does not match local state.", + }), }); assert.doesNotMatch(different.stderr, /canary-secret/); assert.notDeepEqual(stateSource(cwd), before); @@ -493,7 +521,7 @@ test("missing credentials and a validated 401 use one stable authentication erro assert.deepEqual(JSON.parse(missing.stderr), { error: "authentication_required", detail: - "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token.", + "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN for production or custom origins, or FIRSTDRAFT_STAGING_API_TOKEN for staging.", }); assert.equal(missing.status, 1); } @@ -516,7 +544,7 @@ test("missing credentials and a validated 401 use one stable authentication erro assert.deepEqual(JSON.parse(rejected.stderr), { error: "authentication_required", detail: - "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token.", + "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN for production or custom origins, or FIRSTDRAFT_STAGING_API_TOKEN for staging.", status: 401, response: { type: "about:blank", diff --git a/test/plan-status.test.js b/test/plan-status.test.js index c6030bf..2fb37e7 100644 --- a/test/plan-status.test.js +++ b/test/plan-status.test.js @@ -42,11 +42,13 @@ Usage: firstdraft plan status [--wait] Options: - --wait Poll until the current analysis reaches a terminal status - -h, --help Show help + --staging Use staging; reject a different saved origin + --wait Poll until the current analysis reaches a terminal status + -h, --help Show help Environment: - FIRSTDRAFT_API_TOKEN Authenticate API requests + FIRSTDRAFT_API_TOKEN Authenticate production or custom API origins + FIRSTDRAFT_STAGING_API_TOKEN Authenticate staging.firstdraft.com The command uses only the API origin pinned by a successful plan push. Without --wait, it makes exactly one status request. @@ -665,7 +667,7 @@ test("missing credentials and a validated 401 use one stable authentication erro assert.deepEqual(JSON.parse(missing.stderr), { error: "authentication_required", detail: - "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token.", + "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN for production or custom origins, or FIRSTDRAFT_STAGING_API_TOKEN for staging.", }); assert.equal(missing.status, 1); assert.equal(requests, 0); @@ -686,7 +688,7 @@ test("missing credentials and a validated 401 use one stable authentication erro assert.deepEqual(JSON.parse(rejected.stderr), { error: "authentication_required", detail: - "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token.", + "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN for production or custom origins, or FIRSTDRAFT_STAGING_API_TOKEN for staging.", status: 401, response: { type: "about:blank",