Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 8 additions & 5 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
54 changes: 42 additions & 12 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion release/compatibility.json
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
6 changes: 6 additions & 0 deletions scripts/run-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
);

Expand Down
54 changes: 51 additions & 3 deletions scripts/smoke-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -291,12 +292,19 @@ function spawnNpm(arguments_, cwd = process.cwd()) {
/**
* @param {string[]} arguments_
* @param {string} [cwd]
* @param {Record<string, string>} [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,
},
});
}

Expand All @@ -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 = "";
Expand All @@ -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";
Expand Down
61 changes: 53 additions & 8 deletions src/api-authentication.js
Original file line number Diff line number Diff line change
@@ -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;
}

/**
Expand Down
8 changes: 8 additions & 0 deletions src/api-response.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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.", {
Expand Down
Loading
Loading