From f5690c3437868cfa308b929006d594f4c9ad91d8 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Thu, 24 Sep 2026 14:48:16 -0500 Subject: [PATCH] Separate staging credentials in the Skill Keep student work on production while making staging an explicit choice with its own token. Preserve saved Project origins and the workspace credential wrapper when resuming existing work. Pin CLI 0.7.0 and prove the packaged environment arguments, credential isolation, and redaction. Keep API and Plan identities unchanged and leave the public catalog on its published version. --- README.md | 14 ++++-- RELEASING.md | 11 ++--- .../claude-plugin/.claude-plugin/plugin.json | 2 +- packages/claude-plugin/package.template.json | 2 +- release/compatibility.json | 6 +-- script/check-claude-plugin-package.mjs | 40 +++++++++++++++-- script/cli-contract/config.mjs | 6 +-- script/cli-contract/local-commands.mjs | 44 +++++++++++++++++++ skills/create-full-stack-app/SKILL.md | 15 ++++--- .../references/diagnostics-and-recovery.md | 25 ++++++++--- .../references/foundation-plan-022.md | 4 +- test/release-compatibility.test.mjs | 2 +- test/repository.test.mjs | 4 +- 13 files changed, 137 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 575ed04..2453db3 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,10 @@ canonical Skills and bundled CLI are packaged once for Claude Code and Codex. UI app's own design and components. The public catalog and Drawing Board pins determine what an installed workspace actually receives. -Source candidate `0.6.0` requires CLI `0.6.0`, Service API `0.6`, and Foundation Plan `0.22`. It preserves the planning -workspace under `.firstdraft/design/` and defaults to current-folder local output. Use explicit `--github` for server +Source candidate `0.7.0` requires CLI `0.7.0`, Service API `0.6`, and Foundation Plan `0.22`. New remote work uses production +at `https://firstdraft.com`; staging is explicit with `firstdraft --staging ...` and a separate staging token. +It preserves the planning workspace under `.firstdraft/design/` and defaults to current-folder local output. +Use explicit `--github` for server Publication. Its identities belong in [release compatibility](release/compatibility.json). The [public catalog](.claude-plugin/marketplace.json) owns the installed version; source compatibility does not establish publication. Only `create-full-stack-app` is packaged; @@ -173,8 +175,12 @@ Those actions follow the machine-owned [compatibility record](release/compatibil ## Credential boundary -The Skill expects FIRSTDRAFT_API_URL and FIRSTDRAFT_API_TOKEN to be supplied by its workspace. Its shared helper -prefers the project's `bin/firstdraft` credential wrapper, then the bundled CLI, then an installed CLI on PATH. +The workspace supplies `FIRSTDRAFT_API_TOKEN` for production at `https://firstdraft.com`. For staging, use +`firstdraft --staging ...` and `FIRSTDRAFT_STAGING_API_TOKEN` from `https://staging.firstdraft.com`; the CLI never +falls back to the production token for staging. Existing Plans keep their saved origin. `FIRSTDRAFT_API_URL` is an +advanced custom-server override; a conflicting URL cannot be combined with `--staging` or redirect an existing Project. +The shared helper prefers the project's `bin/firstdraft` credential wrapper, then the bundled CLI, then an +installed CLI on PATH. It preserves the working directory and arguments. The adapter forwards ambient credentials; it does not read ignored credential files or provide an authentication UI. Claude Code does not deliver plugin `userConfig` to `bin/` executables; a secure bridge is tracked in [issue #27](https://github.com/firstdraft/skills/issues/27). diff --git a/RELEASING.md b/RELEASING.md index 68f29c7..8ed1d25 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -8,15 +8,16 @@ longer part of an ordinary release. Coordinate the service, CLI, and Skills thro [`release/compatibility.json`](release/compatibility.json) owns the candidate version, compatible CLI/API/Plan identities, and deterministic package SHA-256. The current source candidate is -`@firstdraft.com/claude-code@0.6.0` with CLI `0.6.0`, API `>= 0.6.0`, `< 0.7.0`, and Plan `sketch/0.22`. +`@firstdraft.com/claude-code@0.7.0` with CLI `0.7.0`, API `>= 0.6.0`, `< 0.7.0`, and Plan `sketch/0.22`. The [marketplace manifest](.claude-plugin/marketplace.json) independently selects a published plugin version; retain its selection until the intended new version is actually published. Source compatibility is not public catalog selection. Query npm when releasing rather than treating a dated distribution snapshot as current. -Plan `0.22` adds optional `application.pwa` and replaces the sole accepted `0.21` input format. Omission and `true` -enable ordinary online installation metadata; `false` omits it. API, CLI, and plugin `0.6.0` record that input and -artifact compatibility break. Target `rails-sketch/2026-09`, local-output defaults, and `.firstdraft/design/` stay -the same. There is no retained-Project migration or compatibility bridge. +CLI and plugin `0.7.0` require `FIRSTDRAFT_STAGING_API_TOKEN` for the staging origin, including existing staging +Plans that previously used `FIRSTDRAFT_API_TOKEN`. The separate credential is a breaking CLI configuration change; +API `0.6` and Plan `0.22` are unchanged. New remote work defaults to production and root `--staging` selects staging. +Saved Project origins, target `rails-sketch/2026-09`, local-output defaults, and `.firstdraft/design/` stay the same. +There is no retained-Project migration or compatibility bridge. Use an ordinary pre-1.0 minor bump for a breaking compatibility change and a patch bump for a compatible change. Never reuse a published npm version, protected release tag, or marketplace version for different package bytes. diff --git a/packages/claude-plugin/.claude-plugin/plugin.json b/packages/claude-plugin/.claude-plugin/plugin.json index 338bb67..76215d0 100644 --- a/packages/claude-plugin/.claude-plugin/plugin.json +++ b/packages/claude-plugin/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "firstdraft", "displayName": "First Draft", - "version": "0.6.0", + "version": "0.7.0", "description": "Author a Foundation Plan and compile a bounded Rails, iPhone, and Android application", "author": { "name": "First Draft", diff --git a/packages/claude-plugin/package.template.json b/packages/claude-plugin/package.template.json index e8664d4..0c15d37 100644 --- a/packages/claude-plugin/package.template.json +++ b/packages/claude-plugin/package.template.json @@ -1,6 +1,6 @@ { "name": "@firstdraft.com/claude-code", - "version": "0.6.0", + "version": "0.7.0", "description": "First Draft Foundation Plan authoring and UI development for Claude Code and Codex", "license": "MIT", "type": "module", diff --git a/release/compatibility.json b/release/compatibility.json index e0e6204..64e148e 100644 --- a/release/compatibility.json +++ b/release/compatibility.json @@ -1,10 +1,10 @@ { "format": "firstdraft.release-compatibility/1", "component": "skills", - "version": "0.6.0", + "version": "0.7.0", "plugin_source": { "package": "@firstdraft.com/claude-code", - "tarball_sha256": "23ca0098605ed1a2981648961fe7c5355b5b6280f3f3db6f4b957872778a9b08" + "tarball_sha256": "7a445058a78abf401308a79a7cf4decb3a8462f483284b8bf74c5d11b0d265c7" }, "requires": { "api_contract": [ @@ -12,7 +12,7 @@ "< 0.7.0" ], "cli": [ - "= 0.6.0" + "= 0.7.0" ], "foundation_plan_formats": [ "firstdraft.foundation-plan.sketch/0.22" diff --git a/script/check-claude-plugin-package.mjs b/script/check-claude-plugin-package.mjs index f873280..82cacfe 100644 --- a/script/check-claude-plugin-package.mjs +++ b/script/check-claude-plugin-package.mjs @@ -59,6 +59,7 @@ const cleanEnvironment = {...process.env}; for (const name of [ "FIRSTDRAFT_API_URL", "FIRSTDRAFT_API_TOKEN", + "FIRSTDRAFT_STAGING_API_TOKEN", "CLAUDE_PLUGIN_OPTION_api_url", "CLAUDE_PLUGIN_OPTION_api_token", "CLAUDE_PLUGIN_OPTION_API_URL", @@ -151,22 +152,23 @@ try { ); } const canaryToken = `fd_${"a".repeat(43)}`; + const stagingCanaryToken = `fd_${"s".repeat(43)}`; const execution = run( pluginExecutable(fakeInstallation), - ["probe"], + ["--staging", "probe"], fakeInstallation, { ...cleanEnvironment, CLAUDE_PLUGIN_OPTION_API_URL: "https://wrong.example.com", CLAUDE_PLUGIN_OPTION_API_TOKEN: `fd_${"b".repeat(43)}`, - FIRSTDRAFT_API_URL: "https://staging.firstdraft.com", FIRSTDRAFT_API_TOKEN: canaryToken, + FIRSTDRAFT_STAGING_API_TOKEN: stagingCanaryToken, }, ); assert.deepEqual(JSON.parse(execution.stdout), { apiToken: canaryToken, - apiUrl: "https://staging.firstdraft.com", - arguments: ["probe"], + stagingApiToken: stagingCanaryToken, + arguments: ["--staging", "probe"], lowercasePluginApiTokenPresent: false, lowercasePluginApiUrlPresent: false, uppercasePluginApiTokenPresent: true, @@ -221,6 +223,35 @@ try { ); assert.equal(version.stdout, `${cliPackageVersion}\n`); assert.equal(version.stderr, ""); + + const project = path.join(actualInstallation, "environment-check"); + mkdirSync(project); + run( + pluginExecutable(actualInstallation), + ["plan", "init", "--name", "Environment check"], + project, + cleanEnvironment, + ); + const conflictingEnvironment = spawnSync( + pluginExecutable(actualInstallation), + ["--staging", "plan", "push"], + { + cwd: project, + encoding: "utf8", + env: { + ...cleanEnvironment, + FIRSTDRAFT_API_URL: "http://127.0.0.1:1", + FIRSTDRAFT_API_TOKEN: canaryToken, + FIRSTDRAFT_STAGING_API_TOKEN: stagingCanaryToken, + }, + }, + ); + assert.equal(conflictingEnvironment.status, 2); + assert.equal(conflictingEnvironment.stdout, ""); + assert.equal(JSON.parse(conflictingEnvironment.stderr).error, "invalid_configuration"); + for (const token of [canaryToken, stagingCanaryToken]) { + assert(!`${conflictingEnvironment.stdout}${conflictingEnvironment.stderr}`.includes(token)); + } } } finally { rmSync(temporaryDirectory, {recursive: true, force: true}); @@ -246,6 +277,7 @@ function createFakeCli(directory) { `#!/usr/bin/env node process.stdout.write(JSON.stringify({ apiToken: process.env.FIRSTDRAFT_API_TOKEN, + stagingApiToken: process.env.FIRSTDRAFT_STAGING_API_TOKEN, apiUrl: process.env.FIRSTDRAFT_API_URL, arguments: process.argv.slice(2), lowercasePluginApiTokenPresent: Object.hasOwn(process.env, "CLAUDE_PLUGIN_OPTION_api_token"), diff --git a/script/cli-contract/config.mjs b/script/cli-contract/config.mjs index 932a8b5..1b84cef 100644 --- a/script/cli-contract/config.mjs +++ b/script/cli-contract/config.mjs @@ -1,8 +1,8 @@ -export const cliRevision = "38528f4402bc0a75ac855f0226569fbbc92488e3"; +export const cliRevision = "fb45245c9030e1898f81fcafe108d877116bd752"; export const cliRuntimeSha256 = - "648cc0b32520d11c36d2c434fd078c56ca7e60e3e7770c7bd9349d7b331c128d"; + "cb07b7e35938662383979dfd497d4d056ed2d1d2c2d871236454730cc5f9a8b2"; export const cliPackageName = "@firstdraft.com/cli"; -export const cliPackageVersion = "0.6.0"; +export const cliPackageVersion = "0.7.0"; export const safeGithubReasonCodes = Object.freeze([ "github.configuration_missing", diff --git a/script/cli-contract/local-commands.mjs b/script/cli-contract/local-commands.mjs index 04a2128..e810d10 100644 --- a/script/cli-contract/local-commands.mjs +++ b/script/cli-contract/local-commands.mjs @@ -5,16 +5,21 @@ import path from "node:path"; import Ajv2020 from "ajv/dist/2020.js"; import { + apiToken, compilationTarget, configuredApiUrl, foundationPlanFormat, storedApiUrl, } from "./config.mjs"; +import { acceptedPlanResponse } from "./fixtures.mjs"; import { assertErrorEnvelope, initializedProject, invokeRunner, pinRemoteState, + planPath, + sequenceFetch, + statePath, } from "./harness.mjs"; const planSchema = JSON.parse( @@ -28,6 +33,7 @@ const validatePlan = ajv.compile(planSchema); export async function verifyLocalCommands(context) { await verifyLocalFailureBoundaries(context); + await verifyStagingCredentials(context); const rootHelp = await invokeRunner( context.runCli, @@ -140,6 +146,44 @@ export async function verifyLocalCommands(context) { assertErrorEnvelope(protectedOutput, "invalid_output_path", { status: 2 }); } +async function verifyStagingCredentials(context) { + const cwd = await initializedProject(context, "staging-credentials"); + const apiUrl = "https://staging.firstdraft.com"; + const stagingApiToken = "canary-private-staging-token"; + const missing = await invokeRunner( + context.runCli, + ["--staging", "plan", "push"], + cwd, + { + apiUrl, + stagingApiToken: "", + fetchFunction: async () => assert.fail("production credentials must not reach staging"), + }, + ); + assertErrorEnvelope(missing, "authentication_required", { + privateValues: [apiToken, stagingApiToken], + }); + + const calls = []; + const result = await invokeRunner( + context.runCli, + ["--staging", "plan", "push"], + cwd, + { + apiUrl, + stagingApiToken, + fetchFunction: sequenceFetch([acceptedPlanResponse(readFileSync(planPath(cwd)))], calls), + }, + ); + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert(!result.stdout.includes(stagingApiToken)); + assert.equal(calls.length, 1); + assert.equal(new URL(calls[0].input).origin, apiUrl); + assert.equal(calls[0].init.headers.Authorization, `Bearer ${stagingApiToken}`); + assert.equal(JSON.parse(readFileSync(statePath(cwd), "utf8")).api_url, apiUrl); +} + async function verifyLocalFailureBoundaries(context) { const invalid = await invokeRunner( context.runCli, diff --git a/skills/create-full-stack-app/SKILL.md b/skills/create-full-stack-app/SKILL.md index cfe048d..5380d86 100644 --- a/skills/create-full-stack-app/SKILL.md +++ b/skills/create-full-stack-app/SKILL.md @@ -23,7 +23,7 @@ explicit handoff. Follow [writing notes](references/modeling-guide.md#retain-imp ## Current boundary -Targets plugin 0.6.0, CLI 0.6.0, API 0.6, Plan 0.22; catalog selection is separate. +Targets plugin 0.7.0, CLI 0.7.0, API 0.6, Plan 0.22; catalog selection is separate. - Bounded generation includes Web Accounts/Policies/Scaffolds, development data, and selected iPhone/Android clients. [Appearance](references/foundation-plan-022.md#application-and-clients) controls theme, native colors, @@ -82,7 +82,7 @@ firstdraft_cli --version firstdraft_cli --help ``` -Require the version probe to succeed with one exact `0.6.0` output line and no other output, and top-level help that +Require the version probe to succeed with one exact `0.7.0` output line and no other output, and top-level help that lists `generate`, `plan`, and `compilation`. Contract tests own separate stdout and stderr assertions for leaf commands; do not repeat them in a startup shell loop. The compatible CLI supplies these public commands: @@ -94,11 +94,12 @@ There is no public `plan publish` or `plan subject-id`. Never replace the CLI au If its version or help differs, report it and stop remote work instead of using HTTP directly; local Plan work may continue. Verify the registry and catalog before recommending an installation or upgrade; a source candidate may be unreleased. -Treat `.firstdraft/state.json` as private CLI-owned concurrency state. Never print, paste, commit, or treat it as -Plan content. Let the user configure `FIRSTDRAFT_API_TOKEN` and any initial `FIRSTDRAFT_API_URL` outside the -conversation. Never request or expose a token. Follow a project wrapper's documented credential bootstrap without -reading or bypassing its ignored environment files. After the user confirms authentication is configured, resume -the already requested CLI operation without asking them to authorize it again. +New remote work uses `https://firstdraft.com`; for requested staging use `firstdraft_cli --staging plan push`. +Read [environment setup](references/diagnostics-and-recovery.md#local-state-and-credentials) before remote work. + +`.firstdraft/state.json` is private CLI-owned state: never print, paste, commit, or treat it as Plan content. +Never request or expose tokens. Follow a wrapper's credential bootstrap without reading ignored environment files. +Once configured, resume the requested operation without fresh authorization. ## Initialize or resume the local Plan diff --git a/skills/create-full-stack-app/references/diagnostics-and-recovery.md b/skills/create-full-stack-app/references/diagnostics-and-recovery.md index 1ec77c6..24b37fc 100644 --- a/skills/create-full-stack-app/references/diagnostics-and-recovery.md +++ b/skills/create-full-stack-app/references/diagnostics-and-recovery.md @@ -8,8 +8,8 @@ JSON object. An unrecognized prefixed line, a progress line after the envelope, interleaved output fail closed. Branch on the object's stable `error` and structured fields rather than the human-readable `detail` or broad process exit status. -The source candidate uses `@firstdraft.com/cli@0.6.0`. Its exact reviewed revision and runtime digest are owned by -[the CLI contract configuration](https://github.com/firstdraft/skills/blob/claude-v0.6.0/script/cli-contract/config.mjs) +The source candidate uses `@firstdraft.com/cli@0.7.0`. Its exact reviewed revision and runtime digest are owned by +[the CLI contract configuration](https://github.com/firstdraft/skills/blob/claude-v0.7.0/script/cli-contract/config.mjs) at this plugin's protected release tag. Check the command surface rather than assuming the version alone establishes compatibility. These source checks do not prove plugin/catalog publication, service authentication, staging compatibility, or a complete user journey. @@ -33,7 +33,22 @@ CLI-owned Project, origin, and ETag state. Inspect the Plan; do not print, paste agent-authored Plan content. Inspect only its `api_url` when a persistent read-only status failure requires checking the pinned origin. -Let the user configure `FIRSTDRAFT_API_TOKEN` outside the conversation. Do not request its value, print it, place +`plan init` only creates local files; it does not choose a server. New remote work defaults to production at +`https://firstdraft.com`, using `FIRSTDRAFT_API_TOKEN`. For requested staging work, use the root flag before each +remote command (`firstdraft_cli --staging plan push`) and configure +`FIRSTDRAFT_STAGING_API_TOKEN` from `https://staging.firstdraft.com`. Token selection follows the saved origin, +including existing staging Plans resumed without the flag. The CLI never sends the production token to staging +as a fallback. Staging Plans made with an older CLI now need the separate staging token variable. + +`FIRSTDRAFT_API_URL` selects an advanced custom server and uses `FIRSTDRAFT_API_TOKEN`, except that the exact +staging origin always requires `FIRSTDRAFT_STAGING_API_TOKEN`. Combining `--staging` with a different URL, or with +a non-staging saved origin, returns `invalid_configuration`. `plan push` and `plan compile` also reject a URL +override that differs from the saved origin; status and download commands keep using that origin and ignore the +URL override. None of these selections migrates a Project. Preserve the existing Plan and state. To start +independent work in another environment, use a separate folder with a new Plan; do not hand-edit private state or +automatically copy Project identity across environments. + +Let the user configure credentials outside the conversation. Do not request either token's value, print it, place it on a command line, or persist it in project files. When the user confirms authentication is configured, resume the already requested operation without asking for fresh authorization. @@ -133,7 +148,7 @@ local development is unsuitable, not a prerequisite. ### Direct local output -CLI 0.6.0 defaults to current-root adoption and archives the original workspace under `.firstdraft/design/`. +CLI 0.7.0 defaults to current-root adoption and archives the original workspace under `.firstdraft/design/`. CLI 0.3.0 supports the same archive via explicit `--output .`; older 0.2.2 uses top-level `design/`. Preserve the actual layout of an already materialized application; this change does not migrate it. @@ -478,7 +493,7 @@ bytes and private state; create replacement work only within the user's authoriz | `plan push`, `plan status`, `plan compile`, `compilation status`, `compilation download` | `authentication_required` | Configure the token outside the conversation. | | `plan push`, `plan status`, `plan compile`, `compilation status`, `compilation download` | `local_input_unreadable` | Preserve unreadable local files; do not reconstruct private state. | | `plan status`, `plan compile`, `compilation status`, `compilation download` | `project_not_pushed` | No accepted Project/origin is pinned locally. | -| `plan push`, `plan compile` | `invalid_configuration` | The API origin or saved Head state is incompatible. | +| `plan push`, `plan status`, `plan compile`, `compilation status`, `compilation download` | `invalid_configuration` | The API origin, `--staging` selection, or saved Head state is incompatible. | | `plan push`, `plan compile` | `server_rejected` | Inspect the validated status, bounded response, and diagnostics. | | `plan push`, `plan compile` | `local_state_not_saved` | The Plan was accepted but private ETag state was not saved. | | `plan push`, `plan compile` | `request_outcome_unknown` | A mutation or its response was not fully verified. See phase rules below. | diff --git a/skills/create-full-stack-app/references/foundation-plan-022.md b/skills/create-full-stack-app/references/foundation-plan-022.md index 3976f5c..75c940c 100644 --- a/skills/create-full-stack-app/references/foundation-plan-022.md +++ b/skills/create-full-stack-app/references/foundation-plan-022.md @@ -117,13 +117,13 @@ The bundled schema was copied byte-for-byte from release or execution evidence. The source candidate and pinned contract check use the exact reviewed CLI revision and runtime digest in -[the CLI contract configuration](https://github.com/firstdraft/skills/blob/claude-v0.6.0/script/cli-contract/config.mjs) +[the CLI contract configuration](https://github.com/firstdraft/skills/blob/claude-v0.7.0/script/cli-contract/config.mjs) at this plugin's protected release tag, as contract provenance rather than release or execution evidence. The CLI exposes `generate uuid`, `generate application-key`, `plan init`, `plan push`, `plan status`, local `plan compile` (equivalent to `--output .`), explicit `plan compile --github`, `plan compile --output`, `compilation status`, and `compilation download`. It has no public `plan subject-id` or `plan publish`. The coordinated checkout declares the -`@firstdraft.com/cli@0.6.0` package. Direct output accepts the ordinary absent destination and, on POSIX, +`@firstdraft.com/cli@0.7.0` package. Direct output accepts the ordinary absent destination and, on POSIX, current-root adoption by default or with `--output .`; the recovery reference owns its preconditions. Check commands rather than inferring compatibility from a version number. These source checks do not prove plugin/catalog publication, authentication, staging compatibility, or a complete user journey. diff --git a/test/release-compatibility.test.mjs b/test/release-compatibility.test.mjs index 41cc865..817203e 100644 --- a/test/release-compatibility.test.mjs +++ b/test/release-compatibility.test.mjs @@ -42,7 +42,7 @@ test("release compatibility matches the installable plugin manifest", async () = assert.deepEqual(compatibility, { format: "firstdraft.release-compatibility/1", component: "skills", - version: "0.6.0", + version: "0.7.0", plugin_source: { package: "@firstdraft.com/claude-code", tarball_sha256: compatibility.plugin_source.tarball_sha256, diff --git a/test/repository.test.mjs b/test/repository.test.mjs index 02072c6..7d46d8a 100644 --- a/test/repository.test.mjs +++ b/test/repository.test.mjs @@ -215,8 +215,8 @@ test("Claude Code packaging selects canonical authoring source exactly once", as version: marketplace.plugins[0].version, registry: "https://registry.npmjs.org/", }); - assert.equal(packageTemplate.version, "0.6.0"); - assert.equal(installableManifest.version, "0.6.0"); + assert.equal(packageTemplate.version, "0.7.0"); + assert.equal(installableManifest.version, "0.7.0"); assert.equal(packageTemplate.dependencies, undefined); assert.deepEqual(installableManifest.skills, checkoutManifest.skills); assert.equal(installableManifest.userConfig, undefined);