diff --git a/.devcontainer/agent-versions.env b/.devcontainer/agent-versions.env index afbd94f..256152a 100644 --- a/.devcontainer/agent-versions.env +++ b/.devcontainer/agent-versions.env @@ -2,12 +2,12 @@ # Record installed agent versions in qualification receipts. CODEX_VERSION=latest # First Draft compatibility pins are independent of the agents' update policy. -FIRSTDRAFT_CLI_VERSION=0.6.0 -# CLI 0.6.x uses First Draft API 0.6.x and defaults to local root output. +FIRSTDRAFT_CLI_VERSION=0.7.0 +# CLI 0.7.x uses First Draft API 0.6.x and defaults to local root output. FIRSTDRAFT_CLI_DEFAULT_API_URL=https://firstdraft.com FIRSTDRAFT_STAGING_API_URL=https://staging.firstdraft.com -# Shared Claude/Codex authoring Skill; exact source of published plugin 0.6.0 (API 0.6). -FIRSTDRAFT_SKILLS_REVISION=a322d1f6e46ea69b2d38257d79d3a22071bb2e74 +# Shared Claude/Codex authoring Skill; exact source for plugin 0.7.0 (API 0.6). +FIRSTDRAFT_SKILLS_REVISION=35f1553f19dd2ff688a409cda09f693f78d77965 FIRSTDRAFT_CLAUDE_SKILL_NAME=create-full-stack-app # Codex namespaces the canonical source checkout with its root plugin manifest. FIRSTDRAFT_CODEX_SKILL_NAME=firstdraft:create-full-stack-app diff --git a/AGENTS.md b/AGENTS.md index 748f137..45a3c0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,10 +59,13 @@ name the Skill or translate the request into a command. have reached the service, follow the Skill's mode-specific recovery to choose the next command; confirmation alone is not a retry instruction. - Drawing Board setup creates `.env` from `.env.example` without overwriting it. The user pastes the staging token - there once. Never read, print, edit, or commit `.env`; do not ask for `/plugin` configuration, Codespaces secrets, - shell exports, or a GitHub PAT. Use `bin/agent-doctor --installation-only` for installation diagnostics and the - full `bin/agent-doctor` to add validation of the shared wrapper and `.env` without printing the token. These are - pre-Compile diagnostics; after root adoption, use the generated README and the exact Rails error. + into its `FIRSTDRAFT_API_TOKEN` entry; the wrapper passes it to the CLI as `FIRSTDRAFT_STAGING_API_TOKEN`. If the + CLI or Skill names that staging variable, keep `.env`'s key unchanged. If authentication is rejected, have the + user replace its value with a fresh staging token. Never read, print, edit, or commit `.env`; do not ask for + `/plugin` configuration, Codespaces secrets, shell exports, or a GitHub PAT. Use + `bin/agent-doctor --installation-only` for installation diagnostics and the full `bin/agent-doctor` to validate the + shared wrapper and `.env` without printing the token. These are pre-Compile diagnostics; after root adoption, + use the generated README and the exact Rails error. ## Collaboration and credentials diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a1a737..7b64417 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -368,7 +368,12 @@ Never commit a First Draft API token, GitHub token, agent credential, or generat repository for common credential shapes and verifies that `.env` remains ignored. The shared ignored `.env` is the credential path for both agents; do not add agent-specific token configuration. -The template wrapper intentionally selects staging. Production defaults, GitHub Publication, and Service deployment +The template wrapper intentionally selects staging. Its existing `.env` format keeps the staging token under +`FIRSTDRAFT_API_TOKEN`; the wrapper maps it to the CLI's `FIRSTDRAFT_STAGING_API_TOKEN`, removes the production token +and legacy plugin settings from the child environment, and overrides any inherited staging token. This applies to +the version probe as well as the requested command. A blank `.env` token never falls back to shell credentials. +The standalone CLI defaults to production and selects staging with `--staging`; Drawing Board's wrapper continues +to select staging through its required URL. Production defaults, GitHub Publication, and Service deployment are owned by [firstdraft/firstdraft](https://github.com/firstdraft/firstdraft); Skill and plugin delivery are owned by [firstdraft/skills](https://github.com/firstdraft/skills). diff --git a/DIRECT_COMPILATION_PLAN.md b/DIRECT_COMPILATION_PLAN.md index 14209f4..cc80b36 100644 --- a/DIRECT_COMPILATION_PLAN.md +++ b/DIRECT_COMPILATION_PLAN.md @@ -17,7 +17,7 @@ hosted Compile-to-publication journey. Never run the nested initializer or appli The optional `./application` and Publication paths remain available. The packets and dated receipts below preserve the earlier nested-first delivery sequence; they are not instructions to initialize a nested app after root Compile. -## Current Skills source pin +## Package pins and prior release observations The exact released CLI, Skills source, and runtime pins live in [`.devcontainer/agent-versions.env`](.devcontainer/agent-versions.env). The September 22 local release selected diff --git a/README.md b/README.md index 84eacd5..3412611 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ You will need: - a Claude account with Claude Code access, or a ChatGPT account with Codex access. Use the same personal GitHub account to create the Codespace and sign in to First Draft. +Drawing Board uses staging. The standalone CLI and Skill use production by default, so a token from +`firstdraft.com` will not work in this workspace; create its token on `staging.firstdraft.com` instead. ## 1. Create your Drawing Board @@ -436,6 +438,10 @@ bin/agent-doctor The doctor reports whether the token is present without showing it. If it reports an `.env` permissions problem, run `chmod 600 .env` and try again. +If a present token is rejected, create a replacement at and replace the +value on `.env`'s `FIRSTDRAFT_API_TOKEN` line. Keep that key name even when a CLI message says +`FIRSTDRAFT_STAGING_API_TOKEN`; the Drawing Board wrapper translates it for you. + After root Compile, use the generated application's README and the exact Rails error instead of rerunning Drawing Board setup or its doctor. The old tooling is under `.firstdraft/design/`, and the existing container's PATH still reflects its pre-Compile layout. If a reconnect says the private-port refresh found an active listener, stop `bin/dev` before diff --git a/bin/firstdraft b/bin/firstdraft index ddd9e60..1282af7 100755 --- a/bin/firstdraft +++ b/bin/firstdraft @@ -49,6 +49,7 @@ function readConfiguration(root = repositoryRoot) { } function requiresApiToken(arguments_) { + arguments_ = arguments_.filter((argument) => argument !== "--staging"); if (arguments_.some((argument) => argument === "--help" || argument === "-h") || arguments_.length === 0 || arguments_[0] === "--version") { return false; @@ -91,9 +92,21 @@ async function run({ throw new Error("the pinned standalone First Draft CLI is missing; rerun .devcontainer/setup-agents."); } + const childEnvironment = { + ...environment, + FIRSTDRAFT_STAGING_API_TOKEN: configuration.apiToken, + FIRSTDRAFT_API_URL: configuration.apiUrl, + }; + delete childEnvironment.FIRSTDRAFT_API_TOKEN; + delete childEnvironment.FIRSTDRAFT_BASE_URL; + delete childEnvironment.CLAUDE_PLUGIN_OPTION_API_TOKEN; + delete childEnvironment.CLAUDE_PLUGIN_OPTION_API_URL; + delete childEnvironment.CLAUDE_PLUGIN_OPTION_api_token; + delete childEnvironment.CLAUDE_PLUGIN_OPTION_api_url; + const version = spawnSync(downstreamCli, ["--version"], { encoding: "utf8", - env: environment, + env: childEnvironment, }); const versionTokens = (version.stdout ?? "").trim().split(/\s+/); if (version.status !== 0 || version.signal || !versionTokens.includes(expected.cliVersion)) { @@ -106,17 +119,6 @@ async function run({ } } - const childEnvironment = { - ...environment, - FIRSTDRAFT_API_TOKEN: configuration.apiToken, - FIRSTDRAFT_API_URL: configuration.apiUrl, - }; - delete childEnvironment.FIRSTDRAFT_BASE_URL; - delete childEnvironment.CLAUDE_PLUGIN_OPTION_API_TOKEN; - delete childEnvironment.CLAUDE_PLUGIN_OPTION_API_URL; - delete childEnvironment.CLAUDE_PLUGIN_OPTION_api_token; - delete childEnvironment.CLAUDE_PLUGIN_OPTION_api_url; - return await new Promise((resolve, reject) => { const child = spawn(downstreamCli, arguments_, { env: childEnvironment, diff --git a/script/check b/script/check index 4f269b4..30825da 100755 --- a/script/check +++ b/script/check @@ -235,7 +235,7 @@ if [[ ! "$FIRSTDRAFT_CLI_VERSION" =~ ^0\.[0-9]+\.[0-9]+$ ]]; then fi set +e -git grep --untracked -IEn '(fd_[A-Za-z0-9_-]{20,}|FIRSTDRAFT_API_TOKEN=.+|gh[opsu]_[A-Za-z0-9]{20,})' \ +git grep --untracked -IEn '(fd_[A-Za-z0-9_-]{20,}|FIRSTDRAFT_(STAGING_)?API_TOKEN=.+|gh[opsu]_[A-Za-z0-9]{20,})' \ -- . ':(exclude)script/check' credential_status=$? set -e diff --git a/script/check-firstdraft-wrapper.mjs b/script/check-firstdraft-wrapper.mjs index ea0da30..000f066 100644 --- a/script/check-firstdraft-wrapper.mjs +++ b/script/check-firstdraft-wrapper.mjs @@ -17,6 +17,7 @@ try { const devcontainerDirectory = path.join(testRepository, ".devcontainer"); const fakeCli = path.join(temporaryRoot, "firstdraft"); const probeOutput = path.join(temporaryRoot, "probe.json"); + const versionProbeOutput = path.join(temporaryRoot, "version-probe.json"); fs.mkdirSync(devcontainerDirectory, {recursive: true}); fs.copyFileSync( path.join(repositoryRoot, ".devcontainer", "agent-versions.env"), @@ -25,26 +26,30 @@ try { fs.writeFileSync(fakeCli, `#!/usr/bin/env node const fs = require("node:fs"); const arguments_ = process.argv.slice(2); -if (arguments_.length === 1 && arguments_[0] === "--version") { - process.stdout.write("firstdraft " + - (process.env.FIRSTDRAFT_TEST_CLI_VERSION ?? "0.6.0") + "\\n"); - if (process.env.FIRSTDRAFT_TEST_CLI_NOTICE) { - process.stderr.write("A benign version notice.\\n"); - } - process.exit(0); -} -fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify({ +const probe = { apiUrl: process.env.FIRSTDRAFT_API_URL, arguments_, - tokenIsExpected: process.env.FIRSTDRAFT_API_TOKEN === "test-token", - tokenPresent: Boolean(process.env.FIRSTDRAFT_API_TOKEN), + stagingTokenIsExpected: process.env.FIRSTDRAFT_STAGING_API_TOKEN === "test-token", + stagingTokenPresent: Boolean(process.env.FIRSTDRAFT_STAGING_API_TOKEN), + productionTokenPresent: Object.prototype.hasOwnProperty.call(process.env, "FIRSTDRAFT_API_TOKEN"), + legacyUrlPresent: Object.prototype.hasOwnProperty.call(process.env, "FIRSTDRAFT_BASE_URL"), pluginOptionsPresent: [ "CLAUDE_PLUGIN_OPTION_API_TOKEN", "CLAUDE_PLUGIN_OPTION_API_URL", "CLAUDE_PLUGIN_OPTION_api_token", "CLAUDE_PLUGIN_OPTION_api_url", ].some((key) => Object.prototype.hasOwnProperty.call(process.env, key)), -})); +}; +if (arguments_.length === 1 && arguments_[0] === "--version") { + fs.writeFileSync(process.env.FIRSTDRAFT_TEST_VERSION_OUTPUT, JSON.stringify(probe)); + process.stdout.write("firstdraft " + + (process.env.FIRSTDRAFT_TEST_CLI_VERSION ?? "0.7.0") + "\\n"); + if (process.env.FIRSTDRAFT_TEST_CLI_NOTICE) { + process.stderr.write("A benign version notice.\\n"); + } + process.exit(0); +} +fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify(probe)); `); fs.chmodSync(fakeCli, 0o755); @@ -63,7 +68,8 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify({ }; const testEnvironment = { ...process.env, - FIRSTDRAFT_API_TOKEN: "ambient-token", + FIRSTDRAFT_API_TOKEN: "ambient-production-token", + FIRSTDRAFT_STAGING_API_TOKEN: "ambient-staging-token", FIRSTDRAFT_API_URL: "https://wrong.example.com", FIRSTDRAFT_BASE_URL: "https://legacy.example.com", CLAUDE_PLUGIN_OPTION_API_TOKEN: "uppercase-token", @@ -71,6 +77,7 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify({ CLAUDE_PLUGIN_OPTION_api_token: "lowercase-token", CLAUDE_PLUGIN_OPTION_api_url: "https://lowercase.example.com", FIRSTDRAFT_TEST_OUTPUT: probeOutput, + FIRSTDRAFT_TEST_VERSION_OUTPUT: versionProbeOutput, }; assert.throws( @@ -103,6 +110,10 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify({ assert.equal(requiresApiToken(["generate", "uuid"]), false); assert.equal(requiresApiToken(["future", "network-command"]), true); assert.equal(requiresApiToken(["--version"]), false); + assert.equal(requiresApiToken(["--staging", "--version"]), false); + assert.equal(requiresApiToken(["--staging", "plan", "init", "--name", "Test"]), false); + assert.equal(requiresApiToken(["--staging", "generate", "uuid"]), false); + assert.equal(requiresApiToken(["--staging", "plan", "push"]), true); await assert.rejects( run({ arguments_: ["plan", "push"], @@ -127,9 +138,48 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify({ assert.deepEqual(JSON.parse(fs.readFileSync(probeOutput, "utf8")), { apiUrl: "https://staging.firstdraft.com", arguments_: ["plan", "push"], + legacyUrlPresent: false, + pluginOptionsPresent: false, + productionTokenPresent: false, + stagingTokenIsExpected: true, + stagingTokenPresent: true, + }); + assert.deepEqual(JSON.parse(fs.readFileSync(versionProbeOutput, "utf8")), { + apiUrl: "https://staging.firstdraft.com", + arguments_: ["--version"], + legacyUrlPresent: false, + pluginOptionsPresent: false, + productionTokenPresent: false, + stagingTokenIsExpected: true, + stagingTokenPresent: true, + }); + + writeEnvironment(); + const localResult = await run({ + arguments_: ["--staging", "plan", "init", "--name", "Test"], + downstreamCli: fakeCli, + environment: testEnvironment, + root: testRepository, + stdio: "ignore", + }); + assert.deepEqual(localResult, {signal: null, status: 0}); + assert.deepEqual(JSON.parse(fs.readFileSync(probeOutput, "utf8")), { + apiUrl: "https://staging.firstdraft.com", + arguments_: ["--staging", "plan", "init", "--name", "Test"], + legacyUrlPresent: false, + pluginOptionsPresent: false, + productionTokenPresent: false, + stagingTokenIsExpected: false, + stagingTokenPresent: false, + }); + assert.deepEqual(JSON.parse(fs.readFileSync(versionProbeOutput, "utf8")), { + apiUrl: "https://staging.firstdraft.com", + arguments_: ["--version"], + legacyUrlPresent: false, pluginOptionsPresent: false, - tokenIsExpected: true, - tokenPresent: true, + productionTokenPresent: false, + stagingTokenIsExpected: false, + stagingTokenPresent: false, }); const injectionMarker = path.join(temporaryRoot, "injected"); @@ -164,7 +214,7 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify({ root: testRepository, stdio: "ignore", }), - /standalone First Draft CLI must be exactly 0\.6\.0/, + /standalone First Draft CLI must be exactly 0\.7\.0/, ); } finally { fs.rmSync(temporaryRoot, {force: true, recursive: true});