From 9f8128aa1d3f2e3c0ea9a9ff3d845f46865477f2 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Sun, 4 Oct 2026 23:06:28 -0500 Subject: [PATCH 1/3] Check the Drawing Board workshop contract The Board now mirrors the laptop workshop kit: First Draft sign-in is the CLI's saved device login instead of a pasted .env token, setup installs pinned Render, Neon and Revyl CLIs, and the kit's sign-in helpers, Skill and app-session notes arrive in their Codespaces form. The wrapper check now asserts that the wrapper selects production and drops inherited token variables, which the CLI would otherwise prefer to its saved login. The setup fixture serves stand-in release assets whose digests it pins, and checks the helpers, Skill copies and the notes block that must leave the user's own CLAUDE.md intact. A new offline check exercises the helpers' secret handling with stubs, and the smoke checks the CLIs on the shared PATH and the installed notes. --- .github/workflows/drawing-board.yml | 4 +- drawing-board/README.md | 66 ++++-- drawing-board/script/check | 23 +- drawing-board/script/check-agent-setup.mjs | 104 +++++++-- .../script/check-firstdraft-wrapper.mjs | 206 ++++++------------ .../script/check-workshop-helpers.mjs | 137 ++++++++++++ drawing-board/script/devcontainer-smoke | 24 +- 7 files changed, 360 insertions(+), 204 deletions(-) create mode 100644 drawing-board/script/check-workshop-helpers.mjs diff --git a/.github/workflows/drawing-board.yml b/.github/workflows/drawing-board.yml index 5508f8c..12de290 100644 --- a/.github/workflows/drawing-board.yml +++ b/.github/workflows/drawing-board.yml @@ -11,7 +11,7 @@ on: candidate: description: Exact firstdraft/drawing-board commit to qualify required: true - default: 5c7c84c1c6fe5568829c1a50b030b57be7eb25e1 + default: f8910717071a0933fa35b9cb0d2d8619aa069633 permissions: contents: read @@ -27,7 +27,7 @@ jobs: - name: Select the exact template candidate id: candidate env: - CANDIDATE: ${{ inputs.candidate || '5c7c84c1c6fe5568829c1a50b030b57be7eb25e1' }} + CANDIDATE: ${{ inputs.candidate || 'f8910717071a0933fa35b9cb0d2d8619aa069633' }} run: | [[ "$CANDIDATE" =~ ^[0-9a-f]{40}$ ]] echo "sha=$CANDIDATE" >> "$GITHUB_OUTPUT" diff --git a/drawing-board/README.md b/drawing-board/README.md index bff4f08..5212766 100644 --- a/drawing-board/README.md +++ b/drawing-board/README.md @@ -12,9 +12,12 @@ disposable Drawing Board checkout. Keep credentials and user work out of that ch A repository created from this template must provide one ready-to-use workspace for Claude or Codex: -- the Dev Container installs the latest public Claude Code and Codex releases and the exact reviewed First Draft CLI; +- the Dev Container installs the latest public Claude Code and Codex releases, the exact reviewed First Draft CLI, + and the exact Render, Neon and Revyl CLIs that the workshop's command-line deploy and phone preview use; - every Skill declared by one exact source revision is linked into both agents; -- `.env` supplies the production origin and token without entering Git; +- First Draft sign-in is the CLI's own saved login from `firstdraft login --device`, kept outside the workspace; +- the laptop kit's sign-in helpers, `workshop-signin` Skill and app-session notes are installed in their Codespaces + form, the notes in the user-level `CLAUDE.md` and Codex `AGENTS.md` so they survive root Compile; - 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; @@ -30,7 +33,7 @@ The template itself does not contain generated application source. For the inter `AGENTS.md` selects explicit `--output .` approval: the application replaces the workspace layout, original material moves under `.firstdraft/design/`, and the same Git repository holds both. The primary **Use this template → Open in a codespace** route starts without a remote. Inspect and commit the staged baseline, then publish it from VS Code or -the [Codespaces publication API](https://github.com/firstdraft/drawing-board/blob/main/README.md#publish-from-the-codespace-terminal) to the user's own private repository before +the [Codespaces publication API](https://github.com/firstdraft/drawing-board/blob/main/AGENTS.md#save-the-app-to-github) to the user's own private repository before setup or edits. Preserve and use an existing remote when the user chooses repository-first creation. Do not run the nested initializer or application smoke after root adoption, including their relocated copies. The optional `--output ./application` mode keeps an ignored, separate nested application. Its initializer remains @@ -44,13 +47,14 @@ The accepted cross-repository sequence and its safety boundaries live in | Location | Responsibility | |---|---| | Drawing Board `.devcontainer/` | Runtime Compose, lifecycle configuration, agent setup and selected tool pins | +| Drawing Board `.devcontainer/workshop/` | Codespaces copies of the laptop kit's sign-in helpers, Skill and app-session notes | | Drawing Board `bin/` | CLI wrapper, installation diagnostics and optional Plan review | | Drawing Board `script/initialize-application` and inventory modules | Nested application's initial Git checkpoint | | Drawing Board `script/refresh-codespaces-private-port` | Existing Codespaces attachment behavior | | `image/` | Dockerfile, locked build Features and the historical published-image receipt | | `action.yml` | Source checks and two installation smokes in the exact caller checkout's Dev Container | | `image/action.yml`, `image/verify/action.yml` | Authorized image build, then AMD64 runtime / ARM64 metadata verification in a dependent job | -| `script/check*` | Source, credential, installation, preservation, discovery and Git-initialization checks | +| `script/check*` | Source, credential, installation, preservation, discovery, workshop-helper and Git-initialization checks | | `script/agent-smoke`, `script/devcontainer-smoke` | Installed-agent and workspace runtime checks | | `script/application-smoke`, `script/selenium` | Optional nested-app qualification; not the root materialization path | | `docs/` | Relocation inventory and retained investigation/qualification evidence | @@ -185,7 +189,7 @@ anonymous access and the retained comparison Codespace as passed. That exact Cod needed. The helper remains in use by `script/application-smoke` for the optional nested application and by `script/devcontainer-smoke` to verify that workspace setup has not started Selenium. Root-adopted applications use their generated `.devcontainer/compose.yaml` and the running container's Compose project, as shown in the -[browser-testing instructions](https://github.com/firstdraft/drawing-board/blob/main/README.md#7-open-your-app). The generated health check uses Selenium's supplied +[browser-testing instructions](https://github.com/firstdraft/drawing-board/blob/main/AGENTS.md#continue-after-compile). The generated health check uses Selenium's supplied `/opt/bin/check-grid.sh`; Compose owns readiness for that command and fresh generated Dev Container startup. Selenium uses its upstream session-request queue deadline, currently 300 seconds. The generated app's Ruby client @@ -259,7 +263,8 @@ future generated target revision. Drawing Board is a post-publication follow-up in the [coordinated release process](https://github.com/firstdraft/firstdraft/blob/main/RELEASE_COORDINATION.md#drawing-board-release-handoff). After the service and packages are released, update `FIRSTDRAFT_CLI_VERSION` and `FIRSTDRAFT_SKILLS_REVISION` in -`.devcontainer/agent-versions.env` to the published compatible CLI and the released plugin's exact source revision. +`.devcontainer/agent-versions.env` to the published compatible CLI and the released plugin's exact source revision +(the commit of its `claude-v*` tag). Reconcile the wrapper, setup messages, guide, root-adoption paths, and affected fixtures. Run `script/check "$DRAWING_BOARD_PATH"`, require the pull request's built-container CI, and verify the merged revision's prebuild before declaring the template ready. Record the selected pins and observed checks; installation and discovery do not prove authenticated Compilation. @@ -269,8 +274,9 @@ the Codespaces fallback. Review tools weekly as well as during releases. Fresh setup already selects the vendors' latest public Claude and Codex releases; verify those installers still work with the template. Review the pinned First Draft CLI/Skills, -Ruby, Node, PostgreSQL, Dev Container Features, GitHub CLI, and Selenium/image dependencies against their official -releases. Prepare small compatible updates and run the checks for the affected surface. A runtime pin must continue +Render, Neon and Revyl CLIs, Ruby, Node, PostgreSQL, Dev Container Features, GitHub CLI, and Selenium/image +dependencies against their official releases. Render and Revyl pins carry each Linux asset's published SHA-256; +Revyl stays on the laptop kit's version unless the kit moves. Prepare small compatible updates and run the checks for the affected surface. A runtime pin must continue to match generated Foundations; record a concrete compatibility reason when retaining an older version. Use the existing vendor updaters for running workspaces, as described above. Do not reinstall tools on every attach, @@ -342,36 +348,48 @@ the [startup investigation](docs/STARTUP_INVESTIGATION.md) for measurements and ## Publish from the Codespace terminal -The student-facing [terminal publication instructions](https://github.com/firstdraft/drawing-board/blob/main/README.md#publish-from-the-codespace-terminal) +The agent-facing [terminal publication instructions](https://github.com/firstdraft/drawing-board/blob/main/AGENTS.md#save-the-app-to-github) remain with the template. The [historical credential receipt](docs/STARTUP_INVESTIGATION.md#publication-credentials) records the existing built-in-token behavior; relocation adds no publication permission or provider observation. ## Credentials and external systems -Never commit a First Draft API token, GitHub token, agent credential, or generated `.env`. `script/check` scans the -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 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 +Never commit a First Draft API token, GitHub token, agent credential, provider key, or `.env`. `script/check` scans +the Board candidate for common credential shapes and verifies that `.env` remains ignored. + +First Draft sign-in is the pinned CLI's saved login: `firstdraft login --device` (run by the `workshop-signin` Skill +through `~/.workshop/login.sh`) stores a token per origin in `~/.config/firstdraft/credentials.json`, mode 0600, for +both agents. The template wrapper selects the production origin, `https://firstdraft.com`, which matches the pinned +CLI's default, and removes any inherited `FIRSTDRAFT_API_TOKEN` or `FIRSTDRAFT_STAGING_API_TOKEN`, because the CLI +prefers an environment token to its saved login. This applies to the version probe as well as the requested +command. `bin/agent-doctor` reports whether a production login is saved without reading the token out. + +Render, Neon and Revyl logins live in each CLI's own home-directory store. Render and Revyl use device approval, +which works from a browser Codespace. Neon's browser sign-in returns to a 127.0.0.1 listener inside the container +for 60 seconds, which VS Code Desktop forwards and VS Code for the Web cannot; its fallback stores a personal API key +with `neonctl profile create DEFAULT --api-key -` from a private form. The Cloudinary key goes from a private form +to `~/.workshop/cloudinary.env`. Home-directory sign-ins survive Codespace stop and start but not a rebuild, after +which `/workshop-signin` redoes only what is missing. 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 -Keep [README.md](https://github.com/firstdraft/drawing-board/blob/main/README.md) focused on the beginner journey. Put maintainer commands and implementation details here, -and keep student agent guardrails in Drawing Board `AGENTS.md`. If a workflow change affects what a tester must do, update -the README and verify the affected journey before landing it: root adoption for the internal-alpha path, nested -`application/` when selected, or the separate-repository journey for Publication. +Keep [README.md](https://github.com/firstdraft/drawing-board/blob/main/README.md) focused on the beginner journey: it +mirrors the [laptop kit](https://github.com/firstdraft/fd-workshop-setup)'s parts, prompts and troubleshooting, and +differs only where a Codespace does (a forwarded port instead of a tunnel, GitHub already signed in, no WSL setup, +Neon's sign-in route, and one app per Codespace). Put maintainer commands and implementation details here, keep +student agent guardrails in Drawing Board `AGENTS.md`, and keep app-session notes in +`.devcontainer/workshop/app-instructions.md` in step with the kit's. If a workflow change affects what a tester must +do, update the README and verify the affected journey before landing it: root adoption for the workshop path, or +nested `application/` when selected. [DIRECT_COMPILATION_PLAN.md](docs/DIRECT_COMPILATION_PLAN.md) owns the current direct-journey acceptance steps and every explicitly unfinished step; do not call that journey complete until those steps are observed. -The internal-alpha delivery scope is the editor-first loop in the README: Codespace, installed Skill, existing -agent, approved root Compile, private-repository publication, boot, source inspection, ordinary source iteration, and saving to the same repository. -Deployment is optional follow-on work, not a pre-send gate for that code-sharing test. A separate Plan web editor, +The workshop scope is the README's loop: Codespace, sign-in, installed Skill, approved root Compile, boot, +private-repository publication, command-line deploy to Render and Neon, Revyl phone previews, and ordinary source +iteration saved to the same repository. A separate Plan web editor, public plugin promotion, and completion of all realization gaps are not prerequisites. The existing web surface supplies access and credentials; an explorable read-only Plan view can improve independently. diff --git a/drawing-board/script/check b/drawing-board/script/check index 5b8d087..0e7fcb3 100755 --- a/drawing-board/script/check +++ b/drawing-board/script/check @@ -72,7 +72,7 @@ node "$checks/check-image-receipt.mjs" # shellcheck disable=SC1091 source .devcontainer/agent-versions.env -for script in .devcontainer/setup-agents bin/agent-doctor bin/review-plan-with-claude bin/review-plan-with-codex script/initialize-application script/refresh-codespaces-private-port; do +for script in .devcontainer/setup-agents .devcontainer/workshop/*.sh bin/agent-doctor bin/review-plan-with-claude bin/review-plan-with-codex script/initialize-application script/refresh-codespaces-private-port; do bash -n "$script" test -x "$script" done @@ -84,7 +84,7 @@ for javascript in .devcontainer/*.mjs bin/firstdraft script/*.mjs "$checks"/*.mj node --check "$javascript" done test -x bin/firstdraft -for check in check-firstdraft-wrapper check-agent-skills check-agent-setup check-initialize-application; do +for check in check-firstdraft-wrapper check-agent-skills check-agent-setup check-workshop-helpers check-initialize-application; do node "$checks/$check.mjs" done @@ -95,12 +95,12 @@ if [[ "$actual_bin_entries" != "$expected_bin_entries" ]]; then exit 1 fi -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 +if [[ -e .env.example ]]; then + echo "First Draft sign-in is the CLI's saved login; do not restore the .env token template." >&2 exit 1 fi if ! git check-ignore --quiet .env || git ls-files --error-unmatch .env >/dev/null 2>&1; then - echo ".env must be ignored and untracked." >&2 + echo ".env must stay ignored and untracked." >&2 exit 1 fi @@ -206,6 +206,19 @@ if [[ ! "$FIRSTDRAFT_CLI_VERSION" =~ ^0\.[0-9]+\.[0-9]+$ ]]; then exit 1 fi +for version in "$RENDER_CLI_VERSION" "$NEONCTL_VERSION" "$REVYL_CLI_VERSION"; do + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "The Render, Neon and Revyl CLIs must each be pinned to one exact version." >&2 + exit 1 + fi +done +for digest in "$RENDER_CLI_SHA256_AMD64" "$RENDER_CLI_SHA256_ARM64" "$REVYL_CLI_SHA256_AMD64" "$REVYL_CLI_SHA256_ARM64"; do + if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then + echo "The Render and Revyl downloads must each be pinned to one SHA-256 digest." >&2 + exit 1 + fi +done + set +e git grep --untracked -qIE '(fd_[A-Za-z0-9_-]{20,}|FIRSTDRAFT_(STAGING_)?API_TOKEN=.+|gh[opsu]_[A-Za-z0-9]{20,})' \ -- . ':(exclude)script/check' diff --git a/drawing-board/script/check-agent-setup.mjs b/drawing-board/script/check-agent-setup.mjs index 6876bbf..e55318c 100644 --- a/drawing-board/script/check-agent-setup.mjs +++ b/drawing-board/script/check-agent-setup.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { + cpSync, copyFileSync, + existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, @@ -9,6 +13,7 @@ import { renameSync, rmSync, symlinkSync, + statSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -17,7 +22,7 @@ import path from "node:path"; const repository = path.resolve(process.env.DRAWING_BOARD_PATH ?? process.cwd()); const configuration = JSON.parse(readFileSync(path.join(repository, ".devcontainer/devcontainer.json"), "utf8")); const versions = readFileSync(path.join(repository, ".devcontainer/agent-versions.env"), "utf8"); -const pins = Object.fromEntries([...versions.matchAll(/^([A-Z_]+)=(.+)$/gm)].map((match) => match.slice(1))); +const pins = Object.fromEntries([...versions.matchAll(/^([A-Z0-9_]+)=(.+)$/gm)].map((match) => match.slice(1))); assert(!("CLAUDE_CODE_VERSION" in pins), "Claude uses the no-argument native bootstrap's latest default; a temporary regression pin or frozen experiment must update this check and its qualification receipt"); assert.equal(pins.CODEX_VERSION, "latest", "CODEX_VERSION: normal policy is latest; a temporary regression pin or frozen experiment must update this check and its qualification receipt"); const temporary = realpathSync(mkdtempSync(path.join(tmpdir(), "drawing-board-agent-setup-"))); @@ -65,17 +70,51 @@ try { symlinkSync(process.execPath, path.join(stubs, "node")); write(path.join(stubs, "id"), '#!/bin/sh\n[ "$1" = "-u" ] || exit 1\nprintf "1000\\n"\n', 0o755); write(path.join(stubs, "sudo"), '#!/bin/sh\necho "Host integration is outside this fixture" >&2\nexit 1\n', 0o755); + const renderAsset = `cli_${pins.RENDER_CLI_VERSION}_linux_`; + const downloads = { + render: `https://github.com/render-oss/cli/releases/download/v${pins.RENDER_CLI_VERSION}/${renderAsset}`, + revyl: `https://github.com/RevylAI/revyl-cli/releases/download/v${pins.REVYL_CLI_VERSION}/revyl-linux-`, + }; + // The fixture's release assets: a stand-in Render zip that the unzip stub expands, and a Revyl + // script binary. Setup verifies their digests, so the fixture pins those digests below. + const fixtureAssets = { + render: "fixture Render CLI archive\n", + revyl: `#!/usr/bin/env node\nconsole.log("revyl version v${pins.REVYL_CLI_VERSION}");\n`, + }; + const sha256 = (content) => createHash("sha256").update(content).digest("hex"); write(path.join(stubs, "curl"), `#!/usr/bin/env node import assert from "node:assert/strict"; +import { appendFileSync, writeFileSync } from "node:fs"; const args = process.argv.slice(2); -assert.equal(args.length, 2); assert.equal(args[0], "-fsSL"); -const agent = { - "https://claude.ai/install.sh": "claude", - "https://chatgpt.com/codex/install.sh": "codex", -}[args[1]]; -assert(agent, "Only supported vendor installer URLs may be requested"); -process.stdout.write('exec node "$SETUP_TEST_NATIVE_INSTALLER" ' + agent + ' "$@"\\n'); +if (args.length === 2) { + const agent = { + "https://claude.ai/install.sh": "claude", + "https://chatgpt.com/codex/install.sh": "codex", + }[args[1]]; + assert(agent, "Only supported vendor installer URLs may be piped to a shell"); + process.stdout.write('exec node "$SETUP_TEST_NATIVE_INSTALLER" ' + agent + ' "$@"\\n'); +} else { + assert.equal(args.length, 4, "Pinned downloads use curl -fsSL -o FILE URL"); + assert.equal(args[1], "-o"); + const downloads = ${JSON.stringify(downloads)}; + const assets = ${JSON.stringify(fixtureAssets)}; + const tool = Object.keys(downloads).find((name) => + new RegExp("^" + downloads[name].replace(/[.]/g, "[.]") + "(amd64|arm64)" + (name === "render" ? "[.]zip" : "") + "$").test(args[3])); + assert(tool, "Only the pinned Render and Revyl release assets may be downloaded: " + args[3]); + writeFileSync(args[2], assets[tool]); + appendFileSync(process.env.SETUP_TEST_INSTALLS, JSON.stringify({ agent: tool, args: [args[3].replace(/(amd64|arm64)/, "ARCH")] }) + "\\n"); +} +`, 0o755); + write(path.join(stubs, "unzip"), `#!/usr/bin/env node +import assert from "node:assert/strict"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +const args = process.argv.slice(2); +assert.deepEqual([args[0], args[1], args[3], args[4]], ["-q", "-o", "cli_v${pins.RENDER_CLI_VERSION}", "-d"]); +assert.equal(readFileSync(args[2], "utf8"), ${JSON.stringify(fixtureAssets.render)}); +writeFileSync(path.join(args[5], args[3]), "#!/usr/bin/env node\\nconsole.log(" + + JSON.stringify("render v${pins.RENDER_CLI_VERSION}") + ");\\n", { mode: 0o755 }); `, 0o755); write(environment.SETUP_TEST_NATIVE_INSTALLER, `import assert from "node:assert/strict"; import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; @@ -100,20 +139,21 @@ const args = process.argv.slice(2); const prefix = path.join(process.env.HOME, ".local"); assert.deepEqual(args.slice(0, 4), ["install", "--global", "--prefix", prefix]); assert.equal(args.length, 5); -const version = args[4].match(/^@firstdraft[.]com\\/cli@([0-9]+[.][0-9]+[.][0-9]+)$/)?.[1]; -assert(version, "Only the pinned First Draft CLI may use npm"); -appendFileSync(process.env.SETUP_TEST_INSTALLS, JSON.stringify({ agent: "firstdraft", args }) + "\\n"); +const [, name, version] = args[4].match(/^(@firstdraft[.]com\\/cli|neonctl)@([0-9]+[.][0-9]+[.][0-9]+)$/) ?? []; +assert(name, "Only the pinned First Draft CLI and neonctl may use npm"); +const command = name === "neonctl" ? "neonctl" : "firstdraft"; +appendFileSync(process.env.SETUP_TEST_INSTALLS, JSON.stringify({ agent: command, args }) + "\\n"); mkdirSync(path.join(prefix, "bin"), { recursive: true }); -writeFileSync(path.join(prefix, "bin/firstdraft"), "#!/usr/bin/env node\\nconsole.log(" + - JSON.stringify("firstdraft " + version) + ");\\n", { mode: 0o755 }); +writeFileSync(path.join(prefix, "bin", command), "#!/usr/bin/env node\\nconsole.log(" + + JSON.stringify(command === "neonctl" ? version : "firstdraft " + version) + ");\\n", { mode: 0o755 }); `, 0o755); const devcontainer = path.join(workspace, ".devcontainer"); mkdirSync(devcontainer); - for (const helper of ["agent-skills.mjs", "configure-codex.mjs"]) { + for (const helper of ["agent-skills.mjs", "configure-codex.mjs", "workshop-notes.mjs"]) { copyFileSync(path.join(repository, ".devcontainer", helper), path.join(devcontainer, helper)); } - copyFileSync(path.join(repository, ".env.example"), path.join(workspace, ".env.example")); + cpSync(path.join(repository, ".devcontainer", "workshop"), path.join(devcontainer, "workshop"), { recursive: true }); const setup = readFileSync(path.join(repository, ".devcontainer/setup-agents"), "utf8"); const hostEnvironmentGuard = "if [[ -x /usr/sbin/sshd && -f /etc/environment ]]; then"; assert.equal(setup.split(hostEnvironmentGuard).length, 2, "Review fixture isolation if the host integration changes"); @@ -134,8 +174,10 @@ writeFileSync(path.join(prefix, "bin/firstdraft"), "#!/usr/bin/env node\\nconsol const revision = git(["rev-parse", "HEAD"]); const checkout = path.join(cache, revision); renameSync(candidate, checkout); - write(path.join(devcontainer, "agent-versions.env"), versions.replace(/^FIRSTDRAFT_SKILLS_REVISION=.+$/m, - `FIRSTDRAFT_SKILLS_REVISION=${revision}`)); + write(path.join(devcontainer, "agent-versions.env"), versions + .replace(/^FIRSTDRAFT_SKILLS_REVISION=.+$/m, `FIRSTDRAFT_SKILLS_REVISION=${revision}`) + .replace(/^RENDER_CLI_SHA256_(AMD64|ARM64)=.+$/gm, (_, arch) => `RENDER_CLI_SHA256_${arch}=${sha256(fixtureAssets.render)}`) + .replace(/^REVYL_CLI_SHA256_(AMD64|ARM64)=.+$/gm, (_, arch) => `REVYL_CLI_SHA256_${arch}=${sha256(fixtureAssets.revyl)}`)); const preserved = new Map([ [path.join(home, ".claude.json"), '{"fixture":"existing user settings"}\n'], @@ -148,14 +190,31 @@ 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://firstdraft.com\n${"FIRSTDRAFT_API_TOKEN"}=fixture-placeholder\n`], ]); + const userNotes = "# The user's own Claude notes\n"; + write(path.join(environment.CLAUDE_CONFIG_DIR, "CLAUDE.md"), userNotes); for (const [file, content] of preserved) write(file, content); + const notes = readFileSync(path.join(repository, ".devcontainer/workshop/app-instructions.md"), "utf8").trimEnd(); + const block = `\n${notes}\n\n`; const verifyPreserved = () => { for (const [file, content] of preserved) assert.equal(readFileSync(file, "utf8"), content, `Setup changed ${file}`); for (const root of [path.join(environment.CLAUDE_CONFIG_DIR, "skills"), path.join(home, ".agents/skills")]) { assert.equal(realpathSync(path.join(root, skillName)), path.join(checkout, "skills", skillName)); + assert.equal(readFileSync(path.join(root, "workshop-signin/SKILL.md"), "utf8"), + readFileSync(path.join(repository, ".devcontainer/workshop/skills/workshop-signin/SKILL.md"), "utf8")); + } + assert.equal(readFileSync(path.join(environment.CLAUDE_CONFIG_DIR, "CLAUDE.md"), "utf8"), `${userNotes}\n${block}`, + "Setup must keep the user's own Claude notes and hold exactly one workshop block"); + assert.equal(readFileSync(path.join(environment.CODEX_HOME, "AGENTS.md"), "utf8"), block); + assert.equal(statSync(path.join(home, ".workshop")).mode & 0o777, 0o700); + for (const helper of ["auth.sh", "cloudinary.sh", "git-identity.sh", "login.sh", "neon-key.sh", "open.sh", "render-workspace.sh"]) { + assert.equal(statSync(path.join(home, ".workshop", helper)).mode & 0o777, 0o755, `missing workshop helper ${helper}`); } + assert.equal(run(path.join(home, ".local/bin/render"), ["--version"]), `render v${pins.RENDER_CLI_VERSION}`); + assert.equal(run(path.join(home, ".local/bin/neonctl"), ["--version"]), pins.NEONCTL_VERSION); + assert(lstatSync(path.join(home, ".local/bin/revyl")).isSymbolicLink()); + assert.equal(run(path.join(home, ".local/bin/revyl"), ["--version"]), `revyl version v${pins.REVYL_CLI_VERSION}`); + assert.equal(existsSync(path.join(workspace, ".env")), false, "Setup must not create a First Draft .env"); }; const publishFixtureRelease = (version) => write(environment.SETUP_TEST_REGISTRY, JSON.stringify({ claude: version, codex: version, @@ -164,6 +223,9 @@ writeFileSync(path.join(prefix, "bin/firstdraft"), "#!/usr/bin/env node\\nconsol { agent: "claude", args: [] }, { agent: "codex", args: ["--release", "latest"] }, { agent: "firstdraft", args: ["install", "--global", "--prefix", path.join(home, ".local"), `@firstdraft.com/cli@${pins.FIRSTDRAFT_CLI_VERSION}`] }, + { agent: "neonctl", args: ["install", "--global", "--prefix", path.join(home, ".local"), `neonctl@${pins.NEONCTL_VERSION}`] }, + { agent: "render", args: [`${downloads.render}ARCH.zip`] }, + { agent: "revyl", args: [`${downloads.revyl}ARCH`] }, ]; const installs = () => readFileSync(environment.SETUP_TEST_INSTALLS, "utf8").trim().split("\n").map(JSON.parse); const byAgent = (records) => records.toSorted((left, right) => left.agent.localeCompare(right.agent)); @@ -180,8 +242,8 @@ writeFileSync(path.join(prefix, "bin/firstdraft"), "#!/usr/bin/env node\\nconsol run("bash", ["-o", "pipefail", "-c", 'curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh -s -- --release latest']); for (const agent of ["claude", "codex"]) assert.equal(run(path.join(home, ".local/bin", agent), ["--version"]), `${agent} 2.0.0`); const rerun = run("bash", [path.join(devcontainer, "setup-agents")]); - assert.equal(installs().length, 8); - assert.deepEqual(byAgent(installs().slice(-3)), expectedInstalls, "Reruns must not reapply an obsolete client pin"); + assert.equal(installs().length, 2 * expectedInstalls.length + 2); + assert.deepEqual(byAgent(installs().slice(-expectedInstalls.length)), expectedInstalls, "Reruns must not reapply an obsolete client pin"); assert(rerun.includes("claude 2.0.0") && rerun.includes("codex 2.0.0")); assert(rerun.includes(`firstdraft ${pins.FIRSTDRAFT_CLI_VERSION}`)); verifyPreserved(); @@ -189,4 +251,4 @@ writeFileSync(path.join(prefix, "bin/firstdraft"), "#!/usr/bin/env node\\nconsol rmSync(temporary, { recursive: true, force: true }); } -console.log("Offline native installer selectors, pinned CLI, rerun, and user-state preservation checks passed."); +console.log("Offline native installer selectors, pinned CLIs, workshop helpers, rerun, and user-state preservation checks passed."); diff --git a/drawing-board/script/check-firstdraft-wrapper.mjs b/drawing-board/script/check-firstdraft-wrapper.mjs index 9380b81..7e4e88a 100644 --- a/drawing-board/script/check-firstdraft-wrapper.mjs +++ b/drawing-board/script/check-firstdraft-wrapper.mjs @@ -7,7 +7,7 @@ import {parseEnv} from "node:util"; const require = createRequire(import.meta.url); const repositoryRoot = path.resolve(process.env.DRAWING_BOARD_PATH ?? process.cwd()); -const {readConfiguration, requiresApiToken, run} = require( +const {childEnvironment, credentialsPath, hasSavedLogin, run} = require( path.join(repositoryRoot, "bin", "firstdraft"), ); const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "drawing-board-wrapper-")); @@ -23,192 +23,120 @@ try { path.join(repositoryRoot, ".devcontainer", "agent-versions.env"), path.join(devcontainerDirectory, "agent-versions.env"), ); - const cliVersion = parseEnv(fs.readFileSync( - path.join(devcontainerDirectory, "agent-versions.env"), "utf8", - )).FIRSTDRAFT_CLI_VERSION; + const pins = parseEnv(fs.readFileSync(path.join(devcontainerDirectory, "agent-versions.env"), "utf8")); + const cliVersion = pins.FIRSTDRAFT_CLI_VERSION; + const production = pins.FIRSTDRAFT_CLI_DEFAULT_API_URL; + assert.equal(production, "https://firstdraft.com"); + assert.equal(fs.existsSync(path.join(repositoryRoot, ".env.example")), false, + "First Draft sign-in is the CLI's saved login; no .env token path remains"); + fs.writeFileSync(fakeCli, `#!/usr/bin/env node const fs = require("node:fs"); const arguments_ = process.argv.slice(2); const probe = { apiUrl: process.env.FIRSTDRAFT_API_URL, arguments_, - 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", - "CLAUDE_PLUGIN_OPTION_API_URL", - "CLAUDE_PLUGIN_OPTION_api_token", - "CLAUDE_PLUGIN_OPTION_api_url", - ].some((key) => Object.prototype.hasOwnProperty.call(process.env, key)), + tokenPresent: Object.prototype.hasOwnProperty.call(process.env, "FIRSTDRAFT_API_TOKEN"), + stagingTokenPresent: Object.prototype.hasOwnProperty.call(process.env, "FIRSTDRAFT_STAGING_API_TOKEN"), + home: process.env.HOME, }; 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 ?? ${JSON.stringify(cliVersion)}) + "\\n"); - if (process.env.FIRSTDRAFT_TEST_CLI_NOTICE) { - process.stderr.write("A benign version notice.\\n"); - } + process.stdout.write((process.env.FIRSTDRAFT_TEST_CLI_VERSION ?? ${JSON.stringify(cliVersion)}) + "\\n"); process.exit(0); } fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify(probe)); +process.exit(Number(process.env.FIRSTDRAFT_TEST_EXIT ?? 0)); `); fs.chmodSync(fakeCli, 0o755); - const writeEnvironment = ({ - apiToken = "", - apiUrl = "https://firstdraft.com", - extra = "", - mode = 0o600, - } = {}) => { - const environmentPath = path.join(testRepository, ".env"); - fs.writeFileSync( - environmentPath, - `FIRSTDRAFT_API_URL=${apiUrl}\n${"FIRSTDRAFT_API_TOKEN"}=${apiToken}\n${extra}`, - ); - fs.chmodSync(environmentPath, mode); - }; + const home = path.join(temporaryRoot, "home"); const testEnvironment = { ...process.env, + HOME: home, 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", - CLAUDE_PLUGIN_OPTION_API_URL: "https://uppercase.example.com", - 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, }; + const expectedProbe = (arguments_) => ({ + apiUrl: production, + arguments_, + tokenPresent: false, + stagingTokenPresent: false, + home, + }); - assert.throws( - () => readConfiguration(testRepository), - /.env is missing/, - ); - - const symlinkTarget = path.join(testRepository, "environment-target"); - fs.writeFileSync( - symlinkTarget, - `FIRSTDRAFT_API_URL=https://firstdraft.com\n${"FIRSTDRAFT_API_TOKEN"}=\n`, - ); - fs.chmodSync(symlinkTarget, 0o600); - fs.symlinkSync(symlinkTarget, path.join(testRepository, ".env")); - assert.throws( - () => readConfiguration(testRepository), - /must be a regular file, not a link/, - ); - fs.unlinkSync(path.join(testRepository, ".env")); - fs.unlinkSync(symlinkTarget); - - writeEnvironment(); - assert.deepEqual(readConfiguration(testRepository), { - apiToken: "", - apiUrl: "https://firstdraft.com", + assert.deepEqual(childEnvironment({FIRSTDRAFT_API_TOKEN: "x", OTHER: "kept"}, production), { + FIRSTDRAFT_API_URL: production, + OTHER: "kept", }); - 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); - await assert.rejects( - run({ - arguments_: ["plan", "push"], + + // Every command reaches the CLI, which owns authentication through its saved login. + for (const arguments_ of [["login", "--device"], ["plan", "push"], ["plan", "init", "--name", "Test"]]) { + const result = await run({ + arguments_, downstreamCli: fakeCli, environment: testEnvironment, root: testRepository, stdio: "ignore", - }), - /FIRSTDRAFT_API_TOKEN is blank/, - ); - assert.equal(fs.existsSync(probeOutput), false); - - writeEnvironment({apiToken: "test-token"}); - const result = await run({ - arguments_: ["plan", "push"], - downstreamCli: fakeCli, - environment: {...testEnvironment, FIRSTDRAFT_TEST_CLI_NOTICE: "1"}, - root: testRepository, - stdio: "ignore", - }); - assert.deepEqual(result, {signal: null, status: 0}); - assert.deepEqual(JSON.parse(fs.readFileSync(probeOutput, "utf8")), { - apiUrl: "https://firstdraft.com", - arguments_: ["plan", "push"], - legacyUrlPresent: false, - pluginOptionsPresent: false, - tokenIsExpected: true, - tokenPresent: true, - }); - assert.deepEqual(JSON.parse(fs.readFileSync(versionProbeOutput, "utf8")), { - apiUrl: "https://firstdraft.com", - arguments_: ["--version"], - legacyUrlPresent: false, - pluginOptionsPresent: false, - tokenIsExpected: true, - tokenPresent: true, - }); + }); + assert.deepEqual(result, {signal: null, status: 0}); + assert.deepEqual(JSON.parse(fs.readFileSync(probeOutput, "utf8")), expectedProbe(arguments_)); + assert.deepEqual(JSON.parse(fs.readFileSync(versionProbeOutput, "utf8")), expectedProbe(["--version"])); + } - writeEnvironment(); - const localResult = await run({ - arguments_: ["plan", "init", "--name", "Test"], + const failed = await run({ + arguments_: ["plan", "compile"], downstreamCli: fakeCli, - environment: testEnvironment, + environment: {...testEnvironment, FIRSTDRAFT_TEST_EXIT: "1"}, root: testRepository, stdio: "ignore", }); - assert.deepEqual(localResult, {signal: null, status: 0}); - assert.deepEqual(JSON.parse(fs.readFileSync(probeOutput, "utf8")), { - apiUrl: "https://firstdraft.com", - arguments_: ["plan", "init", "--name", "Test"], - legacyUrlPresent: false, - pluginOptionsPresent: false, - tokenIsExpected: false, - tokenPresent: false, - }); - assert.deepEqual(JSON.parse(fs.readFileSync(versionProbeOutput, "utf8")), { - apiUrl: "https://firstdraft.com", - arguments_: ["--version"], - legacyUrlPresent: false, - pluginOptionsPresent: false, - tokenIsExpected: false, - tokenPresent: false, - }); - - const injectionMarker = path.join(temporaryRoot, "injected"); - writeEnvironment({extra: `UNEXPECTED=$(touch ${injectionMarker})\n`}); - assert.throws( - () => readConfiguration(testRepository), - /must contain only FIRSTDRAFT_API_URL and FIRSTDRAFT_API_TOKEN/, - ); - assert.equal(fs.existsSync(injectionMarker), false); + assert.deepEqual(failed, {signal: null, status: 1}, "The wrapper must return the CLI's own exit status"); - writeEnvironment({apiToken: "test-token", mode: 0o644}); - assert.throws(() => readConfiguration(testRepository), /mode 0600/); - - writeEnvironment({apiToken: "test-token", apiUrl: "https://staging.firstdraft.com"}); await assert.rejects( run({ - arguments_: ["plan", "compile"], + arguments_: ["--version"], downstreamCli: fakeCli, - environment: testEnvironment, + environment: {...testEnvironment, FIRSTDRAFT_TEST_CLI_VERSION: "9.9.9"}, root: testRepository, stdio: "ignore", }), - /FIRSTDRAFT_API_URL in .env must be https:\/\/firstdraft\.com\./, + {message: `the standalone First Draft CLI must be exactly ${cliVersion}.`}, ); - - writeEnvironment({apiToken: "test-token"}); await assert.rejects( run({ arguments_: ["--version"], - downstreamCli: fakeCli, - environment: {...testEnvironment, FIRSTDRAFT_TEST_CLI_VERSION: "9.9.9"}, + downstreamCli: path.join(temporaryRoot, "missing"), + environment: testEnvironment, root: testRepository, stdio: "ignore", }), - {message: `the standalone First Draft CLI must be exactly ${cliVersion}.`}, + /pinned standalone First Draft CLI is missing/, ); + + // The saved-login check mirrors the CLI's credentials path and never returns the token. + assert.equal(credentialsPath({HOME: home}), path.join(home, ".config", "firstdraft", "credentials.json")); + assert.equal(credentialsPath({HOME: home, XDG_CONFIG_HOME: "relative"}), + path.join(home, ".config", "firstdraft", "credentials.json")); + const xdg = path.join(temporaryRoot, "xdg"); + assert.equal(credentialsPath({HOME: home, XDG_CONFIG_HOME: xdg}), path.join(xdg, "firstdraft", "credentials.json")); + assert.equal(hasSavedLogin(production, {HOME: home}), false); + const writeCredentials = (origins) => { + const file = credentialsPath({HOME: home}); + fs.mkdirSync(path.dirname(file), {recursive: true, mode: 0o700}); + fs.writeFileSync(file, JSON.stringify({format: "firstdraft.cli-credentials/1", origins}), {mode: 0o600}); + }; + writeCredentials({"https://staging.firstdraft.com": {access_token: "staging", token_type: "Bearer"}}); + assert.equal(hasSavedLogin(production, {HOME: home}), false); + writeCredentials({[production]: {access_token: " ", token_type: "Bearer"}}); + assert.equal(hasSavedLogin(production, {HOME: home}), false); + writeCredentials({[production]: {access_token: "fixture-login", token_type: "Bearer"}}); + assert.equal(hasSavedLogin(production, {HOME: home}), true); + fs.writeFileSync(credentialsPath({HOME: home}), "{not json"); + assert.throws(() => hasSavedLogin(production, {HOME: home}), SyntaxError); } finally { fs.rmSync(temporaryRoot, {force: true, recursive: true}); } diff --git a/drawing-board/script/check-workshop-helpers.mjs b/drawing-board/script/check-workshop-helpers.mjs new file mode 100644 index 0000000..cbd9cf3 --- /dev/null +++ b/drawing-board/script/check-workshop-helpers.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +// Offline checks for the Codespaces copies of the laptop kit's sign-in helpers. Stubs stand in for +// curl, neonctl and render; no check signs in or reaches a provider. +const repository = path.resolve(process.env.DRAWING_BOARD_PATH ?? process.cwd()); +const workshop = path.join(repository, ".devcontainer/workshop"); +const temporary = realpathSync(mkdtempSync(path.join(tmpdir(), "drawing-board-workshop-"))); +const home = path.join(temporary, "home"); +const stubs = path.join(temporary, "stubs"); +const write = (file, content, mode = 0o644) => { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, content, { mode }); +}; +const environment = { HOME: home, PATH: `${stubs}:/usr/bin:/bin`, WORKSHOP_NO_OPEN: "1", LC_ALL: "C" }; +const helper = (name, args, extra = {}) => { + const result = spawnSync("bash", [path.join(workshop, name), ...args], { + cwd: home, env: { ...environment, ...extra }, encoding: "utf8", timeout: 60_000, + }); + assert.equal(result.error, undefined); + return { status: result.status, output: result.stdout + result.stderr }; +}; +const mode = (file) => statSync(file).mode & 0o777; + +try { + mkdirSync(home, { recursive: true }); + for (const name of ["auth.sh", "cloudinary.sh", "git-identity.sh", "login.sh", "neon-key.sh", "open.sh", "render-workspace.sh"]) { + const syntax = spawnSync("bash", ["-n", path.join(workshop, name)], { encoding: "utf8" }); + assert.equal(syntax.status, 0, syntax.stderr); + assert.equal(mode(path.join(workshop, name)) & 0o111, 0o111, `${name} must be executable`); + } + const skill = readFileSync(path.join(workshop, "skills/workshop-signin/SKILL.md"), "utf8"); + assert.match(skill, /^---\nname: workshop-signin\n/); + assert.match(skill, /^disable-model-invocation: true$/m, "Only the attendee starts the sign-in Skill"); + + // Cloudinary: the attendee's three pasted values become one private CLOUDINARY_URL, verified + // over stdin, and the form is deleted. The secret never reaches the output. + const secret = "fixtureSecretValue123"; + const key = "123456789012345"; + write(path.join(stubs, "curl"), `#!/bin/sh +config="$(cat)" +case "$config" in *"${key}:${secret}"*) ;; *) echo "credentials missing from stdin" >&2; exit 9 ;; esac +case "$*" in *"${secret}"*) echo "secret on the command line" >&2; exit 9 ;; esac +printf '%s' "\${CURL_TEST_STATUS:-200}" +`, 0o755); + let result = helper("cloudinary.sh", ["open"]); + assert.match(result.output, /^NOT OPENED: /); + const form = path.join(home, ".workshop/cloudinary-key.txt"); + assert.equal(mode(form), 0o600); + assert.equal(mode(path.join(home, ".workshop")), 0o700); + result = helper("cloudinary.sh", ["save"]); + assert.match(result.output, /^NEXT: the file needs the cloud name/); + const formText = readFileSync(form, "utf8") + .replace(/^CLOUD_NAME=$/m, "CLOUD_NAME=CLOUDINARY_URL=cloudinary://:@fixture-cloud") + .replace(/^API_KEY=$/m, `API_KEY= ${key} `) + .replace(/^API_SECRET=$/m, `API_SECRET="${secret}"`); + writeFileSync(form, formText); + result = helper("cloudinary.sh", ["save"], { CURL_TEST_STATUS: "401" }); + assert.match(result.output, /^INVALID: Cloudinary did not accept/); + assert(existsSync(form), "A rejected key keeps the form for correction"); + result = helper("cloudinary.sh", ["save"]); + assert.match(result.output, /^SAVED: /); + assert(!result.output.includes(secret)); + assert.equal(existsSync(form), false); + const saved = path.join(home, ".workshop/cloudinary.env"); + assert.equal(readFileSync(saved, "utf8"), `CLOUDINARY_URL=cloudinary://${key}:${secret}@fixture-cloud\n`); + assert.equal(mode(saved), 0o600); + assert.match(helper("cloudinary.sh", ["check"]).output, /^saved in /); + const app = path.join(temporary, "app"); + write(path.join(app, "config/application.rb"), ""); + result = helper("cloudinary.sh", ["install", app]); + assert.match(result.output, /^NOT NEEDED: /); + write(path.join(app, "config/initializers/cloudinary.rb"), ""); + write(path.join(app, ".env.development.local"), "OTHER=kept\nCLOUDINARY_URL=old\n"); + result = helper("cloudinary.sh", ["install", app]); + assert.match(result.output, /^INSTALLED: /); + assert(!result.output.includes(secret)); + assert.equal(readFileSync(path.join(app, ".env.development.local"), "utf8"), + `OTHER=kept\nCLOUDINARY_URL=cloudinary://${key}:${secret}@fixture-cloud\n`); + assert.equal(mode(path.join(app, ".env.development.local")), 0o600); + + // Neon: the pasted API key reaches neonctl on stdin only, and the form is deleted. + const neonKey = "napi_fixturefixturefixturefixture"; + write(path.join(stubs, "neonctl"), `#!/bin/sh +case "$*" in *"${neonKey}"*) echo "key on the command line" >&2; exit 9 ;; esac +[ "$*" = "profile create DEFAULT --api-key -" ] || { echo "unexpected: $*" >&2; exit 9; } +[ "$(cat)" = "${neonKey}" ] || { echo "key missing from stdin" >&2; exit 9; } +`, 0o755); + helper("neon-key.sh", ["open"]); + const neonForm = path.join(home, ".workshop/neon-key.txt"); + assert.equal(mode(neonForm), 0o600); + assert.match(helper("neon-key.sh", ["save"]).output, /^NEXT: /); + writeFileSync(neonForm, readFileSync(neonForm, "utf8").replace(/^NEON_API_KEY=$/m, `NEON_API_KEY=${neonKey}`)); + result = helper("neon-key.sh", ["save"]); + assert.equal(result.status, 0, result.output); + assert.match(result.output, /^SAVED: /); + assert(!result.output.includes(neonKey)); + assert.equal(existsSync(neonForm), false); + + // login.sh returns the device link and code while the sign-in keeps waiting in the background. + write(path.join(stubs, "render"), `#!/bin/sh +echo "Complete login in the Render Dashboard: https://dashboard.render.com/device-authorization/ABCD-EFGH-IJKL-MNOP" +echo "Docs: https://docs.render.com/cli" +exec sleep 30 +`, 0o755); + result = helper("login.sh", ["start", "render"]); + assert.match(result.output, /^URL: https:\/\/dashboard\.render\.com\/device-authorization\/ABCD-EFGH-IJKL-MNOP$/m); + assert.match(result.output, /^CODE: ABCD-EFGH-IJKL-MNOP$/m); + assert.match(result.output, /^WAITING: /m); + assert.match(helper("login.sh", ["status", "render"]).output, /^WAITING: /); + assert.match(helper("login.sh", ["stop", "render"]).output, /^STOPPED: /); + assert.match(helper("login.sh", ["status", "render"]).output, /^ENDED: /); + + // open.sh uses VS Code's $BROWSER helper and reports when there is none. + write(path.join(stubs, "fake-browser"), `#!/bin/sh\nprintf '%s' "$1" > "${path.join(temporary, "opened")}"\n`, 0o755); + assert.match(helper("open.sh", ["https://example.com/a"], { BROWSER: path.join(stubs, "fake-browser") }).output, /^OPENED: /); + assert.equal(readFileSync(path.join(temporary, "opened"), "utf8"), "https://example.com/a"); + assert.match(helper("open.sh", ["https://example.com/a"]).output, /^NOT OPENED: /); + + // auth.sh reads the First Draft login without printing its token. + const token = "fixture-firstdraft-token"; + write(path.join(home, ".config/firstdraft/credentials.json"), + JSON.stringify({ origins: { "https://firstdraft.com": { access_token: token } } }), 0o600); + result = helper("auth.sh", ["check", "firstdraft"]); + assert.equal(result.output, "[PASS] firstdraft: signed in\n"); + write(path.join(home, ".config/firstdraft/credentials.json"), + JSON.stringify({ origins: { "https://staging.firstdraft.com": { access_token: token } } }), 0o600); + assert.equal(helper("auth.sh", ["check", "firstdraft"]).output, "[FAIL] firstdraft: not signed in\n"); + assert.equal(helper("auth.sh", ["check", "neon"]).output, "[FAIL] neon: not signed in\n"); +} finally { + rmSync(temporary, { recursive: true, force: true }); +} + +console.log("Workshop sign-in helper checks passed."); diff --git a/drawing-board/script/devcontainer-smoke b/drawing-board/script/devcontainer-smoke index 44ea828..1de8ecf 100755 --- a/drawing-board/script/devcontainer-smoke +++ b/drawing-board/script/devcontainer-smoke @@ -61,20 +61,18 @@ test "$(PATH="$shared_path" command -v codex)" = "$HOME/.local/bin/codex" test "$(command -v firstdraft)" = "$PWD/bin/firstdraft" test "$(ruby -rrbconfig -e 'print RbConfig.ruby')" = "$(mise which ruby)" -test ! -L .env -test -f .env -test -O .env -test "$(stat -c '%a' .env)" = "600" +test ! -e .env -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_CLI_DEFAULT_API_URL && - parsed.FIRSTDRAFT_API_TOKEN === ""; - process.exit(valid ? 0 : 1); -' +# The deploy and preview CLIs resolve on the shared PATH, and every Claude and Codex session loads +# the workshop's app-session notes. +for command in render neonctl revyl; do + test "$(PATH="$shared_path" command -v "$command")" = "$HOME/.local/bin/$command" +done +for notes in "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/CLAUDE.md" "${CODEX_HOME:-$HOME/.codex}/AGENTS.md"; do + test "$(grep -Fxc '' "$notes")" = 1 +done +# A fresh workspace has no First Draft login, and the check says so without failing to run. +test "$(bash "$HOME/.workshop/auth.sh" check firstdraft || true)" = "[FAIL] firstdraft: not signed in" test "$DB_HOST" = "postgres" test "$SELENIUM_HOST" = "selenium" From b3c4eb3570e67f2868ba5d1880dd561689c41210 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Sun, 4 Oct 2026 23:14:31 -0500 Subject: [PATCH 2/3] Check that login.sh waits for an approved sign-in The Board's login.sh wait gives a device sign-in time to save after its approval before checking it. Exercise that with a stub that saves two seconds after starting, and qualify the Board commit that adds it. --- .github/workflows/drawing-board.yml | 4 ++-- drawing-board/script/check-workshop-helpers.mjs | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/drawing-board.yml b/.github/workflows/drawing-board.yml index 12de290..a1d4aa5 100644 --- a/.github/workflows/drawing-board.yml +++ b/.github/workflows/drawing-board.yml @@ -11,7 +11,7 @@ on: candidate: description: Exact firstdraft/drawing-board commit to qualify required: true - default: f8910717071a0933fa35b9cb0d2d8619aa069633 + default: 61d5e2f8572d5a65d8983f1a741306bf8ada529b permissions: contents: read @@ -27,7 +27,7 @@ jobs: - name: Select the exact template candidate id: candidate env: - CANDIDATE: ${{ inputs.candidate || 'f8910717071a0933fa35b9cb0d2d8619aa069633' }} + CANDIDATE: ${{ inputs.candidate || '61d5e2f8572d5a65d8983f1a741306bf8ada529b' }} run: | [[ "$CANDIDATE" =~ ^[0-9a-f]{40}$ ]] echo "sha=$CANDIDATE" >> "$GITHUB_OUTPUT" diff --git a/drawing-board/script/check-workshop-helpers.mjs b/drawing-board/script/check-workshop-helpers.mjs index cbd9cf3..ce52fe0 100644 --- a/drawing-board/script/check-workshop-helpers.mjs +++ b/drawing-board/script/check-workshop-helpers.mjs @@ -114,6 +114,16 @@ exec sleep 30 assert.match(helper("login.sh", ["stop", "render"]).output, /^STOPPED: /); assert.match(helper("login.sh", ["status", "render"]).output, /^ENDED: /); + // login.sh wait lets an approved device sign-in finish saving before it checks. + write(path.join(stubs, "revyl"), `#!/bin/sh +case "$1 $2" in + "auth login") echo "Approve at https://app.revyl.ai/cli/device?code=WXYZ-2345"; sleep 2; touch "${path.join(temporary, "revyl-saved")}" ;; + "auth status") [ -f "${path.join(temporary, "revyl-saved")}" ] && echo "Authenticated" || echo "Not authenticated" ;; +esac +`, 0o755); + assert.match(helper("login.sh", ["start", "revyl"]).output, /^URL: https:\/\/app\.revyl\.ai\/cli\/device\?code=WXYZ-2345$/m); + assert.equal(helper("login.sh", ["wait", "revyl"]).output, "[PASS] revyl: signed in\n"); + // open.sh uses VS Code's $BROWSER helper and reports when there is none. write(path.join(stubs, "fake-browser"), `#!/bin/sh\nprintf '%s' "$1" > "${path.join(temporary, "opened")}"\n`, 0o755); assert.match(helper("open.sh", ["https://example.com/a"], { BROWSER: path.join(stubs, "fake-browser") }).output, /^OPENED: /); From cabfce87ad6117372c3f59bf14912979bd17b0aa Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Sun, 4 Oct 2026 23:19:20 -0500 Subject: [PATCH 3/3] Qualify the Board's zombie-aware login helper The Board's login.sh now treats an exited sign-in that PID 1 has not reaped as finished, which the login.sh wait check needs inside the Dev Container. --- .github/workflows/drawing-board.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/drawing-board.yml b/.github/workflows/drawing-board.yml index a1d4aa5..8a9c0bd 100644 --- a/.github/workflows/drawing-board.yml +++ b/.github/workflows/drawing-board.yml @@ -11,7 +11,7 @@ on: candidate: description: Exact firstdraft/drawing-board commit to qualify required: true - default: 61d5e2f8572d5a65d8983f1a741306bf8ada529b + default: b8e1d77e3ef002597f9f220d6b525be4f709ae3c permissions: contents: read @@ -27,7 +27,7 @@ jobs: - name: Select the exact template candidate id: candidate env: - CANDIDATE: ${{ inputs.candidate || '61d5e2f8572d5a65d8983f1a741306bf8ada529b' }} + CANDIDATE: ${{ inputs.candidate || 'b8e1d77e3ef002597f9f220d6b525be4f709ae3c' }} run: | [[ "$CANDIDATE" =~ ^[0-9a-f]{40}$ ]] echo "sha=$CANDIDATE" >> "$GITHUB_OUTPUT"