diff --git a/.github/workflows/drawing-board.yml b/.github/workflows/drawing-board.yml index 5508f8c..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: 5c7c84c1c6fe5568829c1a50b030b57be7eb25e1 + default: b8e1d77e3ef002597f9f220d6b525be4f709ae3c permissions: contents: read @@ -27,7 +27,7 @@ jobs: - name: Select the exact template candidate id: candidate env: - CANDIDATE: ${{ inputs.candidate || '5c7c84c1c6fe5568829c1a50b030b57be7eb25e1' }} + CANDIDATE: ${{ inputs.candidate || 'b8e1d77e3ef002597f9f220d6b525be4f709ae3c' }} 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..ce52fe0 --- /dev/null +++ b/drawing-board/script/check-workshop-helpers.mjs @@ -0,0 +1,147 @@ +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: /); + + // 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: /); + 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"