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
4 changes: 2 additions & 2 deletions .github/workflows/drawing-board.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:
candidate:
description: Exact firstdraft/drawing-board commit to qualify
required: true
default: 058fb28aff15782b9ff764628f7ff77651a7e77e
default: 5c7c84c1c6fe5568829c1a50b030b57be7eb25e1

permissions:
contents: read
Expand All @@ -27,7 +27,7 @@ jobs:
- name: Select the exact template candidate
id: candidate
env:
CANDIDATE: ${{ inputs.candidate || '058fb28aff15782b9ff764628f7ff77651a7e77e' }}
CANDIDATE: ${{ inputs.candidate || '5c7c84c1c6fe5568829c1a50b030b57be7eb25e1' }}
run: |
[[ "$CANDIDATE" =~ ^[0-9a-f]{40}$ ]]
echo "sha=$CANDIDATE" >> "$GITHUB_OUTPUT"
Expand Down
14 changes: 6 additions & 8 deletions drawing-board/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ A repository created from this template must provide one ready-to-use workspace

- the Dev Container installs the latest public Claude Code and Codex releases and the exact reviewed First Draft CLI;
- every Skill declared by one exact source revision is linked into both agents;
- `.env` supplies the shared staging origin and token without entering Git;
- `.env` supplies the production origin and token without entering Git;
- bare `firstdraft` on the Codespace PATH resolves to `bin/firstdraft`, and AGENTS.md routes Skill-issued commands
through that wrapper; and
- the same container carries the current generated Foundation's Ruby and Node toolchain plus healthy PostgreSQL;
Expand Down Expand Up @@ -352,13 +352,11 @@ Never commit a First Draft API token, GitHub token, agent credential, or generat
Board candidate 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. 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
The template wrapper requires the production origin, `https://firstdraft.com`, which matches the pinned CLI's
default. It passes the `.env` token to the CLI as `FIRSTDRAFT_API_TOKEN`, replacing any inherited value, and removes
legacy plugin settings from the child environment. This applies to the version probe as well as the requested
command. A blank `.env` token never falls back to shell credentials. 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).

## Documentation
Expand Down
9 changes: 4 additions & 5 deletions drawing-board/script/check
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ if [[ "$actual_bin_entries" != "$expected_bin_entries" ]]; then
exit 1
fi

if [[ "$(cat .env.example)" != $'FIRSTDRAFT_API_URL=https://staging.firstdraft.com\nFIRSTDRAFT_API_TOKEN=' ]]; then
echo ".env.example must select staging and leave its token blank." >&2
if [[ "$(cat .env.example)" != $'FIRSTDRAFT_API_URL=https://firstdraft.com\nFIRSTDRAFT_API_TOKEN=' ]]; then
echo ".env.example must select production and leave its token blank." >&2
exit 1
fi
if ! git check-ignore --quiet .env || git ls-files --error-unmatch .env >/dev/null 2>&1; then
Expand Down Expand Up @@ -170,9 +170,8 @@ fi

"$checks/check-depth-one"

if [[ "$FIRSTDRAFT_CLI_DEFAULT_API_URL" != "https://firstdraft.com" || \
"$FIRSTDRAFT_STAGING_API_URL" != "https://staging.firstdraft.com" ]]; then
echo "The pinned releases no longer match the documented staging-wrapper contract." >&2
if [[ "$FIRSTDRAFT_CLI_DEFAULT_API_URL" != "https://firstdraft.com" ]]; then
echo "The pinned releases no longer match the documented production-wrapper contract." >&2
exit 1
fi

Expand Down
2 changes: 1 addition & 1 deletion drawing-board/script/check-agent-setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ writeFileSync(path.join(prefix, "bin/firstdraft"), "#!/usr/bin/env node\\nconsol
[path.join(environment.CODEX_HOME, "sessions/session.jsonl"), '{"fixture":"existing Codex conversation"}\n'],
[path.join(environment.CLAUDE_CONFIG_DIR, "skills/user-skill/SKILL.md"), "Claude user Skill\n"],
[path.join(home, ".agents/skills/user-skill/SKILL.md"), "Codex user Skill\n"],
[path.join(workspace, ".env"), `FIRSTDRAFT_API_URL=https://staging.firstdraft.com\n${"FIRSTDRAFT_API_TOKEN"}=fixture-placeholder\n`],
[path.join(workspace, ".env"), `FIRSTDRAFT_API_URL=https://firstdraft.com\n${"FIRSTDRAFT_API_TOKEN"}=fixture-placeholder\n`],
]);
for (const [file, content] of preserved) write(file, content);
const verifyPreserved = () => {
Expand Down
52 changes: 21 additions & 31 deletions drawing-board/script/check-firstdraft-wrapper.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,8 @@ const arguments_ = process.argv.slice(2);
const probe = {
apiUrl: process.env.FIRSTDRAFT_API_URL,
arguments_,
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"),
tokenIsExpected: process.env.FIRSTDRAFT_API_TOKEN === "test-token",
tokenPresent: Boolean(process.env.FIRSTDRAFT_API_TOKEN),
legacyUrlPresent: Object.prototype.hasOwnProperty.call(process.env, "FIRSTDRAFT_BASE_URL"),
pluginOptionsPresent: [
"CLAUDE_PLUGIN_OPTION_API_TOKEN",
Expand All @@ -58,7 +57,7 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify(probe));

const writeEnvironment = ({
apiToken = "",
apiUrl = "https://staging.firstdraft.com",
apiUrl = "https://firstdraft.com",
extra = "",
mode = 0o600,
} = {}) => {
Expand All @@ -72,7 +71,6 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify(probe));
const testEnvironment = {
...process.env,
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",
Expand All @@ -91,7 +89,7 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify(probe));
const symlinkTarget = path.join(testRepository, "environment-target");
fs.writeFileSync(
symlinkTarget,
`FIRSTDRAFT_API_URL=https://staging.firstdraft.com\n${"FIRSTDRAFT_API_TOKEN"}=\n`,
`FIRSTDRAFT_API_URL=https://firstdraft.com\n${"FIRSTDRAFT_API_TOKEN"}=\n`,
);
fs.chmodSync(symlinkTarget, 0o600);
fs.symlinkSync(symlinkTarget, path.join(testRepository, ".env"));
Expand All @@ -105,18 +103,14 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify(probe));
writeEnvironment();
assert.deepEqual(readConfiguration(testRepository), {
apiToken: "",
apiUrl: "https://staging.firstdraft.com",
apiUrl: "https://firstdraft.com",
});
assert.equal(requiresApiToken(["plan", "push"]), true);
assert.equal(requiresApiToken(["plan", "push", "--help"]), false);
assert.equal(requiresApiToken(["plan", "init", "--name", "Test"]), false);
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"],
Expand All @@ -139,50 +133,46 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify(probe));
});
assert.deepEqual(result, {signal: null, status: 0});
assert.deepEqual(JSON.parse(fs.readFileSync(probeOutput, "utf8")), {
apiUrl: "https://staging.firstdraft.com",
apiUrl: "https://firstdraft.com",
arguments_: ["plan", "push"],
legacyUrlPresent: false,
pluginOptionsPresent: false,
productionTokenPresent: false,
stagingTokenIsExpected: true,
stagingTokenPresent: true,
tokenIsExpected: true,
tokenPresent: true,
});
assert.deepEqual(JSON.parse(fs.readFileSync(versionProbeOutput, "utf8")), {
apiUrl: "https://staging.firstdraft.com",
apiUrl: "https://firstdraft.com",
arguments_: ["--version"],
legacyUrlPresent: false,
pluginOptionsPresent: false,
productionTokenPresent: false,
stagingTokenIsExpected: true,
stagingTokenPresent: true,
tokenIsExpected: true,
tokenPresent: true,
});

writeEnvironment();
const localResult = await run({
arguments_: ["--staging", "plan", "init", "--name", "Test"],
arguments_: ["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"],
apiUrl: "https://firstdraft.com",
arguments_: ["plan", "init", "--name", "Test"],
legacyUrlPresent: false,
pluginOptionsPresent: false,
productionTokenPresent: false,
stagingTokenIsExpected: false,
stagingTokenPresent: false,
tokenIsExpected: false,
tokenPresent: false,
});
assert.deepEqual(JSON.parse(fs.readFileSync(versionProbeOutput, "utf8")), {
apiUrl: "https://staging.firstdraft.com",
apiUrl: "https://firstdraft.com",
arguments_: ["--version"],
legacyUrlPresent: false,
pluginOptionsPresent: false,
productionTokenPresent: false,
stagingTokenIsExpected: false,
stagingTokenPresent: false,
tokenIsExpected: false,
tokenPresent: false,
});

const injectionMarker = path.join(temporaryRoot, "injected");
Expand All @@ -196,7 +186,7 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify(probe));
writeEnvironment({apiToken: "test-token", mode: 0o644});
assert.throws(() => readConfiguration(testRepository), /mode 0600/);

writeEnvironment({apiToken: "test-token", apiUrl: "https://firstdraft.com"});
writeEnvironment({apiToken: "test-token", apiUrl: "https://staging.firstdraft.com"});
await assert.rejects(
run({
arguments_: ["plan", "compile"],
Expand All @@ -205,7 +195,7 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify(probe));
root: testRepository,
stdio: "ignore",
}),
/FIRSTDRAFT_API_URL in .env must be https:\/\/staging\.firstdraft\.com/,
/FIRSTDRAFT_API_URL in .env must be https:\/\/firstdraft\.com\./,
);

writeEnvironment({apiToken: "test-token"});
Expand Down
4 changes: 2 additions & 2 deletions drawing-board/script/devcontainer-smoke
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,12 @@ test -f .env
test -O .env
test "$(stat -c '%a' .env)" = "600"

FIRSTDRAFT_STAGING_API_URL="$FIRSTDRAFT_STAGING_API_URL" node -e '
FIRSTDRAFT_CLI_DEFAULT_API_URL="$FIRSTDRAFT_CLI_DEFAULT_API_URL" node -e '
const {parseEnv} = require("node:util");
const parsed = parseEnv(require("node:fs").readFileSync(".env", "utf8"));
const valid = Object.keys(parsed).sort().join(",") ===
"FIRSTDRAFT_API_TOKEN,FIRSTDRAFT_API_URL" &&
parsed.FIRSTDRAFT_API_URL === process.env.FIRSTDRAFT_STAGING_API_URL &&
parsed.FIRSTDRAFT_API_URL === process.env.FIRSTDRAFT_CLI_DEFAULT_API_URL &&
parsed.FIRSTDRAFT_API_TOKEN === "";
process.exit(valid ? 0 : 1);
'
Expand Down
Loading