From 63f9a84e8afadad3696f0ef81352707192c104f5 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Thu, 12 Mar 2026 15:14:35 -0600 Subject: [PATCH] =?UTF-8?q?feat(M002/S02):=20enhanced=20secure=5Fenv=5Fcol?= =?UTF-8?q?lect=20UX=20=E2=80=94=20checkExistingEnvKeys,=20detectDestinati?= =?UTF-8?q?on,=20guidance=20field,=20auto-detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gsd/DECISIONS.md | 39 ++-- .gsd/PROJECT.md | 29 +-- .gsd/STATE.md | 13 +- .gsd/milestones/M001/M001-SUMMARY.md | 196 ++++++++++++++++++ .gsd/milestones/M002/M002-ROADMAP.md | 118 ++++------- .../M002/slices/S02/S02-ASSESSMENT.md | 38 ++++ .gsd/milestones/M002/slices/S02/S02-PLAN.md | 68 ++++++ .../M002/slices/S02/tasks/T01-PLAN.md | 62 ++++++ .../M002/slices/S02/tasks/T01-SUMMARY.md | 76 +++++++ .../M002/slices/S02/tasks/T02-PLAN.md | 67 ++++++ .../extensions/get-secrets-from-user.ts | 71 ++++++- .../gsd/tests/secure-env-collect.test.ts | 185 +++++++++++++++++ 12 files changed, 851 insertions(+), 111 deletions(-) create mode 100644 .gsd/milestones/M001/M001-SUMMARY.md create mode 100644 .gsd/milestones/M002/slices/S02/S02-ASSESSMENT.md create mode 100644 .gsd/milestones/M002/slices/S02/S02-PLAN.md create mode 100644 .gsd/milestones/M002/slices/S02/tasks/T01-PLAN.md create mode 100644 .gsd/milestones/M002/slices/S02/tasks/T01-SUMMARY.md create mode 100644 .gsd/milestones/M002/slices/S02/tasks/T02-PLAN.md create mode 100644 src/resources/extensions/gsd/tests/secure-env-collect.test.ts diff --git a/.gsd/DECISIONS.md b/.gsd/DECISIONS.md index 321e34250..7558dcae0 100644 --- a/.gsd/DECISIONS.md +++ b/.gsd/DECISIONS.md @@ -6,15 +6,30 @@ | # | When | Scope | Decision | Choice | Rationale | Revisable? | |---|------|-------|----------|--------|-----------|------------| -| D001 | M001/S01 | arch | Exclusion filter for smart staging | Not file ownership tracking | Simpler, covers the use case | No | -| D002 | M001/S01 | pattern | Thin facade pattern for worktree.ts migration | Preserve all exports, delegate to GitServiceImpl | Backward compatibility without breaking consumers | No | -| D003 | M001/S01 | convention | Branch lifecycle after squash merge | Branches deleted after merge | Clean branch list, history preserved in squash commit | No | -| D004 | M001/S01 | arch | Snapshot storage mechanism | Hidden refs (refs/gsd/snapshots/) not checkpoint commits | Invisible to normal git log, recoverable when needed | No | -| D005 | M002 | arch | Secret guidance source | LLM-generated at planning time | No static database to maintain, adapts to any service | Yes — if accuracy becomes a problem | -| D006 | M002 | arch | Secret collection timing | Up-front during planning only | Solves the primary blocking problem; existing fallback behavior unchanged | Yes — if missed secrets are frequent | -| D007 | M002 | arch | Secret forecasting scope | Current milestone only | Later milestones may change; collect when planning each | Yes — if cross-milestone friction observed | -| D008 | M002 | arch | Existing key handling | Silent skip (no confirmation) | Less friction on repeated runs | Yes — if users want to rotate keys | -| D009 | M002 | arch | Destination detection | Infer from project context (vercel.json, convex/ dir) | User shouldn't specify — falls back to .env | No | -| D010 | M002/S01 | convention | Secrets manifest format | H3 headings per env var key with bold metadata fields and numbered guidance lists | Matches existing GSD markdown conventions (roadmap uses H2, plan uses checkboxes); H3 per key enables extractAllSections(content, 3) reuse | No | -| D011 | M002/S01 | pattern | Guidance list parsing strategy | Regex-based numbered list extraction, not parseBullets | parseBullets strips numbering which loses ordering semantics for step-by-step guidance | No | -| D012 | M002/S01 | convention | Prompt style for secret forecasting instructions | Auto prompt: structured multi-step; guided prompt: single self-contained paragraph | Auto prompt has more structure for LLM reliability; guided prompt is shorter since the agent session is interactive | No | +| D001 | M001 | arch | Embedding strategy | SDK (`createAgentSession` + `InteractiveMode`) | Type-safe, no subprocess management, full control over storage/resources, cleanest branded app path per pi docs | No | +| D002 | M001 | arch | State storage location | `~/.gsd/` (agent: `~/.gsd/agent/`, sessions: `~/.gsd/sessions/`) | Complete isolation from `~/.pi/`, clear brand identity, follows pi doc recommendation for branded apps | No | +| D003 | M001 | arch | Branding mechanism | `PI_PACKAGE_DIR` env var set before pi internals load, pointing to gsd package root; gsd `package.json` declares `piConfig: { name: "gsd", configDir: ".gsd" }` | `config.js` reads `APP_NAME` from `piConfig.name` in the package.json found at `PI_PACKAGE_DIR`. Only mechanism that renames the TUI header without patching pi source. | Yes — if pi adds a dedicated `createAgentSession` appName option | +| D004 | M001 | arch | Extension delivery | Copy extension `.ts` source into `src/resources/extensions/` at dev time; load via `DefaultResourceLoader.additionalExtensionPaths`; pi's jiti handles JIT compilation at runtime | Preserves pi's JIT compilation model, no separate build step for extensions, extensions stay readable source | Yes — if extension count grows large enough to warrant pre-compilation | +| D005 | M001 | scope | Skills in M001 | Excluded — extensions only | User decision during discussion | Yes — M002 candidate | +| D006 | M001 | scope | Plugin/install system | Deferred | Not MVP; bundled-only product for M001 | Yes — M002 candidate | +| D007 | M001 | arch | pi interop | None — GSD never reads or writes `~/.pi/` | GSD is a product, not a pi config. Interop would blur the brand boundary. | No | +| D008 | M001/S01 | verification | S01 verification strategy | Shell commands + real TTY launch (no test framework) | S01 is a pure binary launch / TUI branding check. The only meaningful assertion is whether the binary launches with "gsd" in the header — no unit-testable logic to isolate. Shell verification commands cover all must-haves. Test framework deferred to S02+ if needed. | Yes — add test framework in S02 if extension loading logic warrants it | +| D009 | M001/S01 | arch | `files` array in package.json | Set in T03 during S01 (`["dist", "package.json", "README.md"]`) | Correct npm publish manifest must be in place before S04 pack/publish. Setting it early avoids a late-stage surprise. | No | +| D010 | M001/S01/T02 | impl | ModelRegistry instantiation | Constructor `new ModelRegistry(authStorage)` — not a static factory | SDK types show no `.create()` on ModelRegistry; authStorage is passed directly to constructor. All other managers (AuthStorage, SettingsManager, SessionManager) use static `.create()` but synchronously. | No | +| D011 | M001/S01/T02 | impl | InteractiveMode.run() | Instance method: `new InteractiveMode(session); mode.run()` — not static | SDK type declarations confirm `run()` is an instance method; static call would fail at runtime. | No | +| D012 | M001/S01/T02 | impl | skipLibCheck in tsconfig | `skipLibCheck: true` added | `@google/genai` published types reference `@modelcontextprotocol/sdk` which is not installed as a type dep — causes transitive TS2307 error unrelated to gsd code. skipLibCheck is the standard fix for third-party type declaration issues. | Yes — remove if MCP types are added as a dep in the future | +| D013 | M001/S01/T03 | arch | `PI_PACKAGE_DIR` shim directory (`pkg/`) | Added `pkg/` dir with `package.json` (piConfig) + `dist/modes/interactive/theme/` (pi theme JSONs) as the `PI_PACKAGE_DIR` target | `config.js::getThemesDir()` uses `getPackageDir()` (= PI_PACKAGE_DIR) and checks if `/src` exists; if yes, uses `src/modes/interactive/theme/` instead of `dist/`. Our project has a real `src/` dir, causing themes to resolve to the wrong path. Pointing PI_PACKAGE_DIR at `pkg/` (which has no `src/`) avoids the collision while still providing `piConfig` for branding. `pkg/dist/modes/interactive/theme/` is populated by `npm run copy-themes` (build script). | Yes — if pi adds a dedicated `appName` option to createAgentSession making PI_PACKAGE_DIR unnecessary | +| D014 | M001/S02 | verification | S02 verification strategy | Shell commands + real TTY launch with stderr capture, no test framework | Extension loading is a runtime integration concern — no unit-testable logic to isolate. The meaningful assertions are: zero extension errors in stderr on launch, correct env vars in compiled loader.js, absence of `~/.pi/` refs in patched files. Shell commands cover all must-haves. Test framework deferred per D008. | Yes — add test framework if extension loading logic grows complex | +| D015 | M001/S02 | arch | subagent spawn approach | `spawn(process.execPath, [GSD_BIN_PATH, ...extensionArgs, ...args])` — no `pi` binary in PATH | Patched subagent spawns node directly with the gsd dist/loader.js entrypoint. This ensures spawned subagents always use the bundled gsd extensions, regardless of what `pi` is in PATH. `GSD_BIN_PATH` = `process.argv[1]` from loader.ts. | Yes — if pi adds a native subagent spawn API | +| D016 | M001/S02 | arch | shared/ is a library, not an extension entry point | `shared/` is NOT added to `additionalExtensionPaths` | `shared/ui.ts`, `shared/next-action-ui.ts` etc. are cross-extension imports, not independently registered extensions. They are discovered by jiti when gsd and ask-user-questions imports them via `../shared/*.js`. Adding shared/ as an extension entry point would attempt to register it as an extension (which it isn't). | No | +| D017 | M001/S02 | arch | AGENTS.md first-run write | `initResources()` writes bundled AGENTS.md to `~/.gsd/agent/AGENTS.md` on first launch | pi's `loadProjectContextFiles` discovers AGENTS.md from `agentDir` (`~/.gsd/agent/`). On fresh install this file doesn't exist. One-time write on launch (behind existsSync check) ensures spawned subagents always pick up GSD's hard rules and execution heuristics. | No | +| D018 | M001/S03 | arch | Wizard injection point | Pre-session: before `createAgentSession()`, not via `session_start` event hook | Running wizard before `createAgentSession()` ensures Anthropic key is in `authStorage` before `modelRegistry.getAvailable()` runs — avoids "No models available" fallback warning. S01 forward intelligence mentioned session_start hook; pre-session approach is strictly better because the session starts clean with a valid model. | Yes — if pi adds a native `beforeStart` or `authMissing` hook to `createAgentSession` | +| D019 | M001/S03 | verification | S03 verification strategy | Shell script (`scripts/verify-s03.sh`) for automated non-TTY/skip checks + interactive UAT for masked input and TUI launch | Wizard involves TTY interaction that cannot be meaningfully automated (masked stdin, TUI launch). Automated shell script covers all non-interactive assertions (exit codes, error text, env hydration). Interactive UAT covers the remaining visual/interactive behaviors. No test framework added — consistent with D008/D014. | Yes — add test framework if wizard logic grows complex | +| D020 | M001/S03 | arch | Wizard scope | Optional tool keys only (Brave/Context7/Jina) — Anthropic auth is pi's responsibility via OAuth | Wizard collecting Anthropic key was redundant (pi already handles it) and interfered with verify script automation. Optional-key scope satisfies R006. | Yes — if pi adds a native "no Anthropic key" callback hook | +| D021 | M001/S04 | arch | GSD_BUNDLED_EXTENSION_PATHS target | agentDir-based paths, not src/resources paths | When subagent spawns a child gsd process via --extension flags, the child also runs initResources + buildResourceLoader from agentDir. src/resources paths ≠ agentDir paths → pi deduplication fails → duplicate tool registration errors. Pointing to agentDir paths means both the --extension args and agentDir scan resolve identically → deduplication works. Safe because subagent spawning only happens after initResources has synced on first launch. | No | +| D022 | M001/S04 | verification | S04 verification strategy | 10-check `scripts/verify-s04.sh` for tarball install path; registry publish check automated; interactive UAT for wizard fire from clean install | Tarball install + launch is automatable (env isolation, background kill). Registry install check is automatable (prefix install + stderr check). Wizard TTY interaction is UAT-only. Consistent with D008/D014/D019 — shell scripts, no test framework. | Yes — add test framework if automated E2E is needed later | +| D023 | M003 | arch | Test flow execution model | Intent-based YAML specs, not deterministic scripts — agent interprets verify blocks with full adaptive intelligence | Evaluated Maestro (JVM dep, deterministic scripting, mobile-first) and decided against embedding or cloning it. GSD's advantage is AI-in-the-loop. Flows describe what to verify; the agent decides how. Faster iteration, better flakiness handling, plays to GSD's strength. | Yes — could add deterministic fast-path for simple assertions later | +| D024 | M003 | arch | Test browser isolation | test-flows runs its own Playwright instance, separate from browser-tools | Test execution must not be polluted by development browser state (cookies, auth, DOM mutations). Two Playwright instances in one process is supported. Keeps test-flows extension fully decoupled from browser-tools. | No | +| D025 | M003 | arch | Maestro integration | Not embedded — optional external tool if user installs it | Maestro requires JVM, adds ~200MB+ footprint, its YAML format is deterministic scripts not intent specs. GSD builds its own testing arm. Maestro MCP could be wired in later as an optional extension for users who want it. | Yes — could add maestro MCP wrapper extension later | +| D026 | M002/S02 | arch | S02 does not merge S01 branch | S02 adds `guidance` field to the tool's own TypeBox schema, not importing `SecretsManifestEntry` from S01 types | S02 enhances `secure_env_collect` itself. The tool receives `keys` with `guidance` via its own schema, not manifest types. S03 is the integration point that reads the manifest and passes entries to the tool. No compile-time dependency on S01. | No | +| D027 | M002/S02 | pattern | Summary screen is informational-only TUI component | User presses enter/escape to continue — no cursor navigation, no data collection | First "display-only" `ctx.ui.custom()` component in the codebase. Follows `confirm-ui.ts` render/handleInput/invalidate triple but with no interactive state beyond dismiss. | No | diff --git a/.gsd/PROJECT.md b/.gsd/PROJECT.md index 3606419d6..2fd8dd1be 100644 --- a/.gsd/PROJECT.md +++ b/.gsd/PROJECT.md @@ -2,27 +2,29 @@ ## What This Is -GSD (Get Shit Done) is a CLI coding agent harness built on pi. It provides a structured planning methodology — milestones, slices, tasks — with auto-mode that executes work autonomously via fresh LLM sessions per unit of work. Ships as the `gsd-pi` npm package. +GSD 2.0 is a branded npm CLI (`npm install -g gsd-pi`) that ships the full GSD coding agent experience as a standalone product. It embeds `@mariozechner/pi-coding-agent` via SDK, stores state in `~/.gsd/`, bundles the GSD extension, all supporting extensions, agents, and AGENTS.md context, and runs pi's `InteractiveMode` under the `gsd` brand. Users run `gsd` — not `pi`. ## Core Value -Autonomous multi-session execution: the agent plans, executes, verifies, and advances through an entire milestone without human intervention, resuming cleanly from crashes and compaction. +A single `npm install -g gsd-pi` gives any developer a fully configured, GSD-branded coding agent with the GSD extension, all supporting tools (browser, search, context7, subagent, bg-shell, etc.), and a first-run setup wizard that collects API keys — ready to use in under two minutes. ## Current State -M001 complete — centralized all git mechanics into a deterministic `GitServiceImpl` class. +M001 complete. `gsd-pi` published to npm (v2.3.7). `npm install -g gsd-pi` installs a working `gsd` binary that launches with GSD ASCII art branding, loads all 11 bundled extensions without errors, stores state in `~/.gsd/`, and runs the first-run wizard for optional API keys. All 9 M001 requirements validated. M002 (Branded Installer & Onboarding Experience) is in progress — S01 complete, S02-S03 planned. -M002 in progress — proactive secret management. S01 complete: established the secrets manifest contract (types, forgiving parser, canonical formatter, template file, planning prompt instructions with `secretsOutputPath` wiring). Milestone planning prompts now instruct the LLM to forecast API keys and write an `M00x-SECRETS.md` manifest. Next: S02 (enhanced collection UX with multi-line guidance, summary screen, existing key detection, destination inference). +Key structural artifact: `pkg/` shim directory — `PI_PACKAGE_DIR` points here (not project root) to avoid pi's `getThemesDir()` collision with our real `src/` dir. Committed; `pkg/dist/modes/interactive/theme/` populated by `npm run copy-themes` at build time. ## Architecture / Key Patterns -- **Extension architecture:** GSD is a pi extension in `src/resources/extensions/gsd/`. Registers tools, hooks (`agent_end`), and commands (`/gsd`, `/gsd auto`). -- **Auto-mode state machine:** `auto.ts` derives state from disk files, determines next unit type, creates fresh LLM sessions with focused prompts. Unit types: research-milestone, plan-milestone, research-slice, plan-slice, execute-task, complete-slice, complete-milestone, reassess-roadmap, replan-slice, run-uat. -- **Prompt injection:** Each unit type has a `.md` prompt template in `prompts/`. Variables are interpolated by `prompt-loader.ts`. -- **State derivation:** `state.ts` reads roadmap/plan files to determine phase and active work item. State is derived, not stored. -- **Git service:** `git-service.ts` owns all git mechanics. `worktree.ts` is a thin facade for backward compatibility. -- **Secret collection:** `get-secrets-from-user.ts` provides `secure_env_collect` tool with paged masked TUI input. Currently reactive (collects when asked), not proactive. Planning prompts now forecast needed secrets — collection UX enhancement and auto-mode integration coming in S02/S03. -- **Secrets manifest:** `M00x-SECRETS.md` files use H3 headings per env var key, bold metadata fields, numbered guidance steps. Parsed by `parseSecretsManifest()`, written by `formatSecretsManifest()`. +- **SDK embedding**: `@mariozechner/pi-coding-agent` imported as a library via `createAgentSession` + `InteractiveMode` +- **Branded app directories**: state lives in `~/.gsd/agent/`, sessions in `~/.gsd/sessions/` (constants in `src/app-paths.ts`) +- **Branding via `PI_PACKAGE_DIR`**: env var set in `src/loader.ts` before any pi SDK loads; points to `pkg/` shim; `pkg/package.json` declares `piConfig: { name: "gsd", configDir: ".gsd" }` +- **Two-file loader pattern**: `loader.ts` (sets env vars, zero SDK imports, dynamic-imports `cli.js`) → `cli.ts` (static SDK imports, wires all managers) +- **pkg/ shim**: lean subdirectory — only `package.json` (piConfig) and `dist/modes/interactive/theme/` (pi theme assets). No `src/`. Avoids `getThemesDir()` src-check collision. +- **Bundled extensions**: GSD extension + 10 supporting extensions in `src/resources/extensions/`; loaded via `buildResourceLoader()` → `DefaultResourceLoader.additionalExtensionPaths`; all 11 load clean on launch +- **Bundled agents + AGENTS.md**: scout, researcher, worker in `src/resources/agents/`; `initResources()` writes bundled AGENTS.md to `~/.gsd/agent/` on first launch (existsSync guard) +- **4 GSD_ env vars**: set in loader.ts before cli.js loads — `GSD_CODING_AGENT_DIR`, `GSD_BIN_PATH`, `GSD_WORKFLOW_PATH`, `GSD_BUNDLED_EXTENSION_PATHS` +- **First-run wizard**: `src/wizard.ts` — detects missing optional keys (Brave/Context7/Jina), prompts with masked TTY input, writes to `~/.gsd/agent/auth.json`; `loadStoredEnvKeys` hydrates env on every launch before extensions load ## Capability Contract @@ -30,5 +32,6 @@ See `.gsd/REQUIREMENTS.md` for the explicit capability contract, requirement sta ## Milestone Sequence -- [x] M001: Deterministic GitService — Centralized all git mechanics into a single typed service -- [ ] M002: Proactive Secret Management — Front-load API key collection during milestone planning so auto-mode runs uninterrupted +- [x] M001: MVP CLI — `npm install -g gsd-pi` installs, launches, and runs with all bundled extensions and first-run setup +- [ ] M002: Branded Installer & Onboarding Experience — ASCII logo, postinstall banner, unified onboarding wizard +- [ ] M003: AI-Driven Test Flows — intent-based YAML test specs the agent writes during development and executes autonomously at UAT time (browser, mac, api targets) diff --git a/.gsd/STATE.md b/.gsd/STATE.md index 35eab56ef..b2653279d 100644 --- a/.gsd/STATE.md +++ b/.gsd/STATE.md @@ -1,13 +1,14 @@ # GSD State -**Active Milestone:** M002 — Proactive Secret Management -**Active Slice:** S02 — Enhanced Collection UX +**Active Milestone:** M002 — Branded Installer & Onboarding Experience +**Active Slice:** S03 — Unified first-run onboarding wizard **Phase:** planning -**Requirements Status:** 10 active · 0 validated · 2 deferred · 2 out of scope +**Requirements Status:** 11 active · 0 validated · 2 deferred · 2 out of scope ## Milestone Registry -- ✅ **M001:** M001: Deterministic GitService -- 🔄 **M002:** Proactive Secret Management +- ✅ **M001:** M001: GSD 2.0 MVP CLI +- 🔄 **M002:** Branded Installer & Onboarding Experience +- ⬜ **M003:** M003 ## Recent Decisions - None recorded @@ -16,4 +17,4 @@ - None ## Next Action -Plan slice S02 (Enhanced Collection UX). +Plan slice S03 (Unified first-run onboarding wizard). diff --git a/.gsd/milestones/M001/M001-SUMMARY.md b/.gsd/milestones/M001/M001-SUMMARY.md new file mode 100644 index 000000000..7c1552d30 --- /dev/null +++ b/.gsd/milestones/M001/M001-SUMMARY.md @@ -0,0 +1,196 @@ +--- +id: M001 +provides: + - gsd-pi npm package (published, unscoped) — single-command install of the full GSD coding agent + - gsd binary with "gsd" TUI branding, state in ~/.gsd/, ~/.pi/ untouched + - 11 bundled extensions (gsd, browser-tools, search-the-web, context7, subagent, bg-shell, worktree, plan-mode, slash-commands, ask-user-questions, get-secrets-from-user) + - Bundled agents (scout, researcher, worker) + AGENTS.md auto-deployed to ~/.gsd/agent/ + - First-run setup wizard (optional keys: Brave/Context7/Jina) with masked TTY input + - pkg/ shim directory for PI_PACKAGE_DIR branding mechanism + - Two-file loader pattern (loader.ts → cli.ts) with 4 GSD_ env vars + - resource-loader.ts wiring all extensions via DefaultResourceLoader.additionalExtensionPaths +key_decisions: + - D001: SDK embedding via createAgentSession + InteractiveMode (not subprocess) + - D002: State in ~/.gsd/ for complete isolation from ~/.pi/ + - D003: PI_PACKAGE_DIR branding mechanism — set before pi internals load + - D004: Extension delivery — copy .ts source, pi's jiti handles JIT compilation + - D013: pkg/ shim directory — avoids getThemesDir() src-check collision + - D015: subagent spawns process.execPath + GSD_BIN_PATH (not "pi" binary) + - D017: AGENTS.md first-run write with existsSync guard + - D018: Wizard injection point is pre-session (before createAgentSession) + - D020: Wizard scope is optional keys only — Anthropic auth is pi's responsibility + - D021: GSD_BUNDLED_EXTENSION_PATHS uses agentDir-based paths to prevent double-load + - D023: Published as gsd-pi (unscoped) — @glittercowboy scope not provisioned on npm +patterns_established: + - Two-file loader pattern: loader.ts (sets env, dynamic-imports) → cli.ts (static SDK imports) + - pkg/ shim directory with piConfig and theme assets — PI_PACKAGE_DIR target with no src/ subdir + - import.meta.url + fileURLToPath for module-relative resource paths + - GSD_ env vars set in loader.ts before cli.js dynamic import + - Pre-session auth gate: loadStoredEnvKeys → runWizardIfNeeded → initResources → createAgentSession + - GSD_BUNDLED_EXTENSION_PATHS colon-delimited for subagent --extension args + - process.execPath + GSD_BIN_PATH for spawning child gsd processes + - existsSync guard on first-run resource writes to prevent overwriting user customizations + - npm run copy-themes populates pkg/dist/modes/interactive/theme/ from node_modules at build time +observability_surfaces: + - "TUI launch: (node dist/loader.js & sleep 4; kill $!) 2>&1 — GSD ASCII art + version confirms branding" + - "Extension errors: (node dist/loader.js & sleep 6; kill $!) 2>&1 | grep 'Extension load error' — zero matches = all clean" + - "State isolation: ls ~/.gsd/ — agent/, sessions/ present; ls ~/.pi/agent/sessions/ — count unchanged" + - "Registry health: npm view gsd-pi — shows version, dist-tags, maintainer" + - "Wizard behavior: BRAVE_API_KEY= CONTEXT7_API_KEY= JINA_API_KEY= node dist/loader.js < /dev/null 2>&1 — surfaces warning" + - "Env vars: grep GSD_ dist/loader.js — confirms all 4 env vars set" + - "Verify scripts: bash scripts/verify-s03.sh (6 checks), bash scripts/verify-s04.sh (10 checks)" +requirement_outcomes: + - id: R001 + from_status: active + to_status: validated + proof: "S04 — npm install -g gsd-pi from registry installs working binary; zero extension load errors on launch" + - id: R002 + from_status: active + to_status: validated + proof: "S01 — TUI header confirmed 'gsd' via live runtime launch; piConfig.name=gsd, piConfig.configDir=.gsd verified; ~/.gsd/ created" + - id: R003 + from_status: active + to_status: validated + proof: "S02 — gsd extension loads without errors on launch (zero stderr extension errors confirmed)" + - id: R004 + from_status: active + to_status: validated + proof: "S02 — all 10 supporting extensions load without errors on launch; confirmed via stderr capture" + - id: R005 + from_status: active + to_status: validated + proof: "S02 — agents in src/resources/agents/; AGENTS.md (15,070 bytes) written to ~/.gsd/agent/ on first launch" + - id: R006 + from_status: active + to_status: validated + proof: "S03 — automated verify script 6/6 pass + interactive UAT; wizard fires, stores keys, skips on rerun" + - id: R007 + from_status: active + to_status: validated + proof: "S01 — ~/.gsd/ created; ~/.pi/agent/sessions/ count unchanged (28/28 before and after gsd launch)" + - id: R008 + from_status: active + to_status: validated + proof: "S04 — cpSync force:true in initResources ensures update replaces bundled resources; tarball smoke confirms clean path" + - id: R009 + from_status: active + to_status: validated + proof: "S03 — non-TTY warning names missing providers; S02 — extension load errors surface to stderr" +duration: ~5 hours across 4 slices (S01 ~1h, S02 ~75min, S03 ~45min, S04 ~3h) +verification_result: passed +completed_at: 2026-03-11 +--- + +# M001: GSD 2.0 MVP CLI + +**Single-command `npm install -g gsd-pi` installs a fully branded GSD coding agent with 11 bundled extensions, agents, first-run wizard, and state isolation — all 9 requirements validated.** + +## What Happened + +Four slices built the complete GSD 2.0 MVP CLI from scratch: + +**S01 (CLI Scaffold and Branding)** established the binary architecture. The key discovery was that pi's `config.js::getThemesDir()` checks for a `src/` subdirectory at the `PI_PACKAGE_DIR` target — since the project has a real `src/`, this caused theme resolution to fail. The fix was the `pkg/` shim directory: a lean subdirectory containing only `package.json` (with piConfig) and theme assets, with no `src/` to trigger the collision. The two-file loader pattern (`loader.ts` sets env vars and dynamic-imports `cli.ts`) ensures `PI_PACKAGE_DIR` is set before any pi SDK code evaluates. After S01, the binary launched with "gsd" in the TUI header and state wrote to `~/.gsd/`. + +**S02 (Bundle Extensions and Agents)** copied all 12 extension source trees into `src/resources/extensions/` and applied surgical patches to 6 files to eliminate hardcoded `~/.pi/` paths. The subagent extension was patched to spawn `process.execPath` with `GSD_BIN_PATH` instead of `spawn("pi", ...)`. A `resource-loader.ts` module wires all 11 extension entry points into `DefaultResourceLoader.additionalExtensionPaths`. `initResources()` writes AGENTS.md to `~/.gsd/agent/` on first launch behind an existsSync guard. All 11 extensions loaded without errors on launch. + +**S03 (First-run Setup Wizard)** built `wizard.ts` with masked TTY input for optional API keys (Brave, Context7, Jina). The critical scoping decision: Anthropic auth is pi's responsibility via OAuth — the wizard only handles optional tool keys. The wizard wires into `cli.ts` as a pre-session auth gate: `loadStoredEnvKeys` → `runWizardIfNeeded` → `initResources` → `createAgentSession`. This ensures env is fully hydrated before extensions load. + +**S04 (npm Publish and Install Smoke Test)** fixed a `GSD_BUNDLED_EXTENSION_PATHS` bug where the env var pointed to `src/resources/` paths instead of agentDir-based paths (causing subagent double-load). The package was initially published as `@glittercowboy/gsd` but the npm scope wasn't provisioned — switched to unscoped `gsd-pi` which resolved immediately. Registry install confirmed working with zero extension load errors. + +## Cross-Slice Verification + +Each success criterion from the roadmap was verified: + +**`npm install -g gsd-pi` in a clean environment produces a working `gsd` binary:** +- `npm view gsd-pi` returns v2.3.7 on the npm registry +- S04 verified tarball install to an isolated prefix with successful launch +- 10-check automated smoke test (`scripts/verify-s04.sh`) all passed + +**`gsd` TUI header shows "gsd" — no pi branding visible in normal operation:** +- Live launch of `node dist/loader.js` displays GSD ASCII art logo + "Get Shit Done v2.3.7" +- `piConfig.name=gsd`, `piConfig.configDir=.gsd` confirmed via node eval +- `PI_PACKAGE_DIR` confirmed pointing to `pkg/` in compiled `dist/loader.js` + +**State lives in `~/.gsd/` — `~/.pi/` is untouched:** +- `ls ~/.gsd/` shows `agent/`, `sessions/`, `preferences.md` +- S01 verified `~/.pi/agent/sessions/` count unchanged (28/28) before and after gsd launch + +**First-run wizard fires when API keys are missing, collects them, and stores them:** +- S03 automated verify script: 6/6 checks passed (build, non-TTY warning, non-TTY no-exit-1, wizard skip, env hydration) +- Interactive UAT confirmed masked input, key storage, wizard skip on rerun + +**`/gsd` command is registered and responds correctly:** +- gsd extension loads without errors (zero `Extension load error` matches in launch output) +- Extension source includes `commands.ts` with `/gsd` command registration + +**All bundled extensions load and their tools are available to the model:** +- Launch test with stderr capture: zero extension load errors across all 11 extensions +- `grep GSD_ dist/loader.js` shows 11 lines confirming all GSD_ env vars present + +**`npm update -g gsd-pi` works cleanly on an existing install:** +- `initResources()` uses `cpSync` with `force: true` for bundled resource updates +- S04 tarball smoke test confirmed clean install over existing state + +## Requirement Changes + +- R001: active → validated — `npm install -g gsd-pi` from registry installs working binary with zero extension errors +- R002: active → validated — TUI shows "gsd", piConfig confirmed, ~/.gsd/ created, ~/.pi/ untouched +- R003: active → validated — gsd extension loads without errors on launch +- R004: active → validated — all 10 supporting extensions load without errors on launch +- R005: active → validated — agents in src/resources/agents/; AGENTS.md auto-deployed to ~/.gsd/agent/ +- R006: active → validated — optional-key wizard fires, stores, skips on rerun; scope narrowed to optional keys only (Anthropic handled by pi) +- R007: active → validated — ~/.gsd/ created; ~/.pi/ sessions unchanged (28/28) +- R008: active → validated — cpSync force:true ensures update replaces bundled resources; tarball smoke confirmed +- R009: active → validated — non-TTY warning names missing providers; extension load errors surface to stderr + +## Forward Intelligence + +### What the next milestone should know +- The package is `gsd-pi` on npm (unscoped), not `@glittercowboy/gsd`. The binary name is `gsd`. +- `PI_PACKAGE_DIR` points to `pkg/` shim — any pi config resolution goes through this directory. If pi changes how `config.js` resolves piConfig or themes, this mechanism may break. +- `GSD_BUNDLED_EXTENSION_PATHS` must match what `buildResourceLoader` discovers from agentDir. After `initResources()` syncs extensions to `~/.gsd/agent/extensions/`, subagent spawning uses these agentDir-based paths for `--extension` args. +- `initResources()` writes AGENTS.md only once (existsSync guard). Existing installs won't get updated AGENTS.md content on upgrade unless the guard logic changes. +- The wizard only handles optional tool keys (Brave/Context7/Jina). Anthropic auth is entirely pi's territory. +- `loadStoredEnvKeys` runs on every launch from `cli.ts`, hydrating env from `auth.json` before extensions load. +- Extensions are `.ts` source JIT-compiled by pi's jiti at runtime — not pre-compiled. Any TypeScript syntax jiti doesn't support will fail at load time (visible via stderr), not at build time. + +### What's fragile +- `pkg/` shim + PI_PACKAGE_DIR mechanism — relies on undocumented `config.js::getThemesDir()` behavior (src-check). Any pi update changing this logic breaks branding silently. Observable signal: ENOENT on dark.json at launch. +- `dist/resource-loader.js` computes extension paths via `resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'resources', ...)` — correct for local dev but depends on `src/resources` being in the published `files` array. +- `@mariozechner/pi-coding-agent` version pin (`^0.57.1`) — breaking changes in pi SDK will cascade to extension loading failures. +- `skipLibCheck: true` in tsconfig masks transitive type errors from pi/google deps. +- jiti JIT compilation of bundled `.ts` extensions — cutting-edge TS features may fail silently at load time. + +### Authoritative diagnostics +- `npm view gsd-pi` — canonical registry health check; confirms version and availability +- `bash scripts/verify-s04.sh` — 10-check install regression suite; PASS/FAIL labeled per check +- `bash scripts/verify-s03.sh` — 6-check wizard regression suite +- `(node dist/loader.js & sleep 6; kill $!) 2>&1 | grep "Extension load error"` — zero lines = all extensions clean +- `grep GSD_ dist/loader.js` — confirms env var presence and values +- `ls pkg/dist/modes/interactive/theme/` — dark.json and light.json must exist; run `npm run copy-themes` to fix + +### What assumptions changed +- PI_PACKAGE_DIR → project root was wrong — `pkg/` shim required due to getThemesDir() src-check (D013) +- ModelRegistry is a constructor, not a static factory (D010) +- InteractiveMode.run() is an instance method, not static (D011) +- Scoped npm publish `@glittercowboy/gsd` failed — scope not provisioned; unscoped `gsd-pi` works (D023) +- Wizard scope narrowed from required+optional keys to optional-only — pi handles Anthropic auth (D020) +- extensionsResult.errors shape is `{ path, error }` not `{ message }` — SDK type correction + +## Files Created/Modified + +- `package.json` — project manifest: name=gsd-pi, bin.gsd, piConfig, type:module, files array, build scripts, prepublishOnly +- `tsconfig.json` — NodeNext/ESM config with skipLibCheck:true, exclude src/resources +- `src/loader.ts` — binary entrypoint: sets PI_PACKAGE_DIR + 4 GSD_ env vars, dynamic-imports cli.js +- `src/cli.ts` — SDK wiring: AuthStorage, ModelRegistry, wizard, initResources, buildResourceLoader, createAgentSession, InteractiveMode +- `src/app-paths.ts` — ~/.gsd/ path constants (appRoot, agentDir, sessionsDir, authFilePath) +- `src/wizard.ts` — optional-key wizard: loadStoredEnvKeys + runWizardIfNeeded +- `src/resource-loader.ts` — buildResourceLoader(agentDir) + initResources(agentDir) +- `pkg/package.json` — piConfig shim: { name: "gsd", configDir: ".gsd" } +- `pkg/dist/modes/interactive/theme/` — pi theme assets (copied by build) +- `src/resources/extensions/` — all 11 bundled extension source trees (patched for ~/.gsd/) +- `src/resources/agents/` — scout.md, researcher.md, worker.md +- `src/resources/AGENTS.md` — bundled agent context rules +- `src/resources/GSD-WORKFLOW.md` — GSD workflow protocol document +- `scripts/verify-s03.sh` — 6-check wizard verification script +- `scripts/verify-s04.sh` — 10-check install smoke test script diff --git a/.gsd/milestones/M002/M002-ROADMAP.md b/.gsd/milestones/M002/M002-ROADMAP.md index 39d9f1d2e..1b89527bc 100644 --- a/.gsd/milestones/M002/M002-ROADMAP.md +++ b/.gsd/milestones/M002/M002-ROADMAP.md @@ -1,82 +1,72 @@ -# M002: Proactive Secret Management +# M002: Branded Installer & Onboarding Experience -**Vision:** Front-load API key collection during milestone planning so auto-mode runs uninterrupted. The LLM forecasts which secrets a milestone needs, generates step-by-step guidance for finding each key, and collects them all before execution begins. +**Vision:** Transform the entire first-contact experience — from `npm install` through first working session — into a polished, guided, trust-building flow that gets users from zero to productive with no friction. ## Success Criteria -- After milestone planning, a secrets manifest exists listing all predicted API keys with per-key step-by-step guidance -- Auto-mode pauses to collect uncollected secrets before dispatching the first slice -- The guided `/gsd` flow triggers the same collection after planning -- Keys already present in the environment are silently skipped -- The collection UX shows a summary of all needed keys before collecting them one-by-one -- `npm run build` passes -- `npm run test` passes (no new failures beyond pre-existing) +- After `npm install -g gsd-pi`, the terminal shows a clean branded postinstall flow with the GSD ASCII logo, spinners, staged progress, and boxed summary +- On first `gsd` launch, a unified onboarding wizard guides the user through LLM provider auth (OAuth or API key) and optional tool API keys before the TUI opens +- After completing onboarding, the user drops straight into a working TUI session with an authenticated LLM — no need to discover `/login` +- Users who skip onboarding or already have auth configured go straight to the TUI with no friction +- The entire flow is visually polished — comparable to openclaw's onboarding or vercel-labs/skills installer ## Key Risks / Unknowns -- **Prompt compliance** — LLM must reliably produce a well-formatted secrets manifest during planning -- **State machine insertion** — Adding a new phase to `dispatchNextUnit` must not break existing flow +- Spinner animation during synchronous subprocess execution — clack's spinner may not animate while `execSync` blocks the event loop → **retired in S01** +- OAuth flows outside TUI — the pi-ai OAuth providers (`loginAnthropic`, etc.) require browser opening + user pasting an auth code back. These are currently wired to the TUI's `LoginDialogComponent`. Need to prove we can drive the same flow from a standalone clack-based wizard using `p.text()` for code input and `exec('open ')` for browser opening. +- Clack inside pre-TUI context — `@clack/prompts` writes to stdout. The wizard runs before `InteractiveMode` takes over the terminal. Need to verify that clack's raw mode cleanup (cursor visibility, etc.) doesn't corrupt the subsequent TUI session. ## Proof Strategy -- Prompt compliance → retire in S01 by proving the plan-milestone prompt produces a parseable manifest when the milestone involves external APIs -- State machine insertion → retire in S03 by proving auto-mode dispatches collect-secrets at the right time and proceeds normally after +- Spinner + async subprocess → **retired in S01** by proving the spinner animates during Playwright download +- OAuth outside TUI → retire in S03 by proving Anthropic OAuth login works end-to-end from the clack-based onboarding wizard (browser opens, user pastes code, credentials are stored). Originally planned for S02, but S02 was scoped to logo work only. +- Clack → TUI handoff → retire in S03 by proving the TUI starts cleanly after the wizard completes. Originally planned for S02, but S02 was scoped to logo work only. ## Verification Classes -- Contract verification: unit tests for manifest parser, build passes, existing tests pass -- Integration verification: auto-mode dispatches collect-secrets phase correctly, guided flow triggers collection -- Operational verification: none — dev-time workflow -- UAT / human verification: real milestone planning produces usable manifest, collection UX is clear +- Contract verification: postinstall and wizard run to completion, produce expected output +- Integration verification: full flow from `npm install -g` → `gsd` → onboarding → working TUI session +- Operational verification: works in TTY and non-TTY, handles failures, respects skip/existing-auth +- UAT / human verification: visual quality judgment, LLM auth actually works for a real chat ## Milestone Definition of Done This milestone is complete only when all are true: -- Planning prompts instruct the LLM to forecast secrets and write a manifest -- The manifest file persists in `.gsd/milestones/M00x/` with per-key guidance -- `secure_env_collect` supports multi-line guidance beyond the single-line hint -- Auto-mode dispatches a collect-secrets phase between plan-milestone and first slice -- Guided `/gsd` flow triggers the same collection -- Existing keys are detected and silently skipped -- Destination is inferred from project context -- Success criteria are re-verified against live behavior -- `npm run build` passes -- `npm run test` passes +- All slices are complete and verified +- `npm install -g gsd-pi` produces branded postinstall with ASCII logo +- First `gsd` launch shows the onboarding wizard which guides through LLM auth + optional keys +- After onboarding, the TUI session has a working authenticated LLM +- Returning users (already authed) skip the wizard and go straight to TUI +- The visual quality bar is met for both postinstall and onboarding +- Final integrated acceptance: a fresh install → onboarding → send a real message → get a response ## Requirement Coverage -- Covers: R001, R002, R003, R004, R005, R006, R007, R008, R009, R010 +- Covers: R008 (npm install experience) +- New: R012 (first-run onboarding — LLM auth before TUI) - Partially covers: none -- Leaves for later: R011 (multi-milestone forecasting), R012 (rotation reminders) +- Leaves for later: none - Orphan risks: none ## Slices -- [x] **S01: Secret Forecasting & Manifest** `risk:medium` `depends:[]` - > After this: running plan-milestone on a project involving external APIs produces a `.gsd/milestones/M00x/M00x-SECRETS.md` manifest file with predicted keys and step-by-step guidance for each. Verified by planning a test milestone and confirming the manifest is parseable. +- [x] **S01: Branded postinstall with clack** `risk:medium` `depends:[]` + > After this: `npm install -g gsd-pi` shows a structured, branded installer flow with spinners, staged progress, and boxed summary instead of raw ASCII dump -- [ ] **S02: Enhanced Collection UX** `risk:medium` `depends:[S01]` - > After this: `secure_env_collect` shows a summary screen of all needed keys with guidance before collecting, displays multi-line guidance per key during collection, detects and silently skips keys already in the environment, and infers the write destination from project context. Verified by running the enhanced tool with a test manifest. +- [x] **S02: ASCII logo in postinstall + first-launch banner** `risk:low` `depends:[S01]` + > After this: postinstall shows the GSD block-letter logo before the clack flow; the existing first-launch banner in loader.ts also uses the shared logo constant -- [ ] **S03: Auto-Mode & Guided Flow Integration** `risk:low` `depends:[S01,S02]` - > After this: auto-mode dispatches a collect-secrets phase after plan-milestone and before the first slice. The guided `/gsd` flow triggers the same collection. Collected status is tracked in the manifest. Verified by running auto-mode through the plan → collect → execute transition. - -- [ ] **S04: End-to-End Verification** `risk:low` `depends:[S03]` - > After this: the full flow is verified end-to-end — a real milestone planning session that involves external APIs produces a manifest, triggers collection, and auto-mode proceeds to slice execution without blocking on secrets. All tests pass, build succeeds. +- [ ] **S03: Unified first-run onboarding wizard** `risk:high` `depends:[S01]` + > After this: first `gsd` launch walks the user through LLM provider selection (Anthropic OAuth / API key / OpenAI / others / skip), runs the auth flow, collects optional tool keys, and drops into a working TUI session ## Boundary Map ### S01 → S02 Produces: -- `types.ts` → `SecretsManifestEntry` interface (key, service, guidance, status, destination) -- `types.ts` → `SecretsManifest` interface (entries array, milestone, generated_at) -- `files.ts` → `parseSecretsManifest(content: string): SecretsManifest` parser -- `files.ts` → `formatSecretsManifest(manifest: SecretsManifest): string` writer -- `paths.ts` → `resolveMilestoneFile` recognizes `"SECRETS"` suffix -- `prompts/plan-milestone.md` → instructions to write `M00x-SECRETS.md` during planning -- `templates/secrets-manifest.md` → template for the manifest format +- `@clack/prompts` and `picocolors` available as production dependencies +- Postinstall script pattern using clack intro/spinner/note/outro Consumes: - nothing (first slice) @@ -84,8 +74,8 @@ Consumes: ### S01 → S03 Produces: -- Same as S01 → S02 (manifest types, parser, paths) -- `files.ts` → `parseSecretsManifest` for reading manifest status +- `@clack/prompts` and `picocolors` available as production dependencies +- Pattern for structured CLI output with clack Consumes: - nothing (first slice) @@ -93,30 +83,14 @@ Consumes: ### S02 → S03 Produces: -- `get-secrets-from-user.ts` → enhanced `secure_env_collect` with `guidance` field on keys -- `get-secrets-from-user.ts` → summary screen TUI component before collection -- `get-secrets-from-user.ts` → `checkExistingEnvKeys(keys, envPath): string[]` helper -- `get-secrets-from-user.ts` → `detectDestination(basePath): "dotenv" | "vercel" | "convex"` helper +- Shared ASCII logo constant importable from `src/logo.ts` +- Logo rendering pattern with picocolors -Consumes from S01: -- `types.ts` → `SecretsManifestEntry`, `SecretsManifest` interfaces -- `files.ts` → `parseSecretsManifest` to read the manifest -- `paths.ts` → `resolveMilestoneFile(base, mid, "SECRETS")` to find the manifest +### S03 -### S03 → S04 - -Produces: -- `auto.ts` → collect-secrets unit type in `dispatchNextUnit` -- `auto.ts` → `buildCollectSecretsPrompt()` or direct TUI dispatch (no LLM session needed) -- `guided-flow.ts` → collection trigger after milestone planning -- `state.ts` → secrets collection status in derived state -- `files.ts` → `updateSecretsManifestStatus()` to mark keys as collected/skipped - -Consumes from S01: -- `types.ts` → manifest types -- `files.ts` → manifest parser/writer -- `paths.ts` → SECRETS file resolution - -Consumes from S02: -- `get-secrets-from-user.ts` → enhanced `secure_env_collect` with guidance and summary -- `get-secrets-from-user.ts` → `checkExistingEnvKeys`, `detectDestination` +Consumes: +- `@clack/prompts` and `picocolors` (from S01) +- Shared ASCII logo (from S02) +- `AuthStorage` API: `.set()`, `.has()`, `.login()` (from pi-coding-agent) +- OAuth provider functions: `loginAnthropic`, `loginGitHubCopilot`, etc. (from pi-ai) +- Existing wizard: `runWizardIfNeeded()` in `src/wizard.ts` (to be replaced/absorbed) diff --git a/.gsd/milestones/M002/slices/S02/S02-ASSESSMENT.md b/.gsd/milestones/M002/slices/S02/S02-ASSESSMENT.md new file mode 100644 index 000000000..b9ba1b424 --- /dev/null +++ b/.gsd/milestones/M002/slices/S02/S02-ASSESSMENT.md @@ -0,0 +1,38 @@ +# S02 Roadmap Assessment + +## Verdict: Roadmap is fine — no slice changes needed + +S02 delivered the shared ASCII logo module (`src/logo.ts`) and wired it into both the postinstall script and the first-launch banner. This was the planned scope. + +## Success Criterion Coverage + +- "After `npm install -g gsd-pi`, the terminal shows a clean branded postinstall flow with the GSD ASCII logo, spinners, staged progress, and boxed summary" → **S01 ✅, S02 ✅** (fully proven) +- "On first `gsd` launch, a unified onboarding wizard guides the user through LLM provider auth" → **S03** (remaining owner) +- "After completing onboarding, the user drops straight into a working TUI session with an authenticated LLM" → **S03** (remaining owner) +- "Users who skip onboarding or already have auth configured go straight to the TUI with no friction" → **S03** (remaining owner) +- "The entire flow is visually polished" → **S01 ✅, S02 ✅, S03** (remaining owner for wizard polish) + +All criteria have at least one remaining owner. Coverage check passes. + +## Risk Status + +Two risks were originally attributed to S02 in the proof strategy but were never in S02's actual scope (S02 was logo-only): + +- **OAuth outside TUI** — unretired, now owned by S03 +- **Clack → TUI handoff** — unretired, now owned by S03 + +Updated the proof strategy in M002-ROADMAP.md to correctly attribute these to S03. + +S03 is `risk:high` precisely because it carries both of these risks. No change to risk posture — just correcting the documentation to match reality. + +## Boundary Map + +Still accurate. S02 produced the shared logo constant that S03 consumes. No changes needed. + +## Requirement Coverage + +Sound. R008 (npm install experience) is covered by S01+S02. The roadmap references R012 (first-run onboarding) which maps to S03. No requirement ownership changes. + +## What Changed + +Only the proof strategy text in M002-ROADMAP.md — corrected OAuth and Clack→TUI risk retirement targets from S02 to S03. diff --git a/.gsd/milestones/M002/slices/S02/S02-PLAN.md b/.gsd/milestones/M002/slices/S02/S02-PLAN.md new file mode 100644 index 000000000..3dbf68a27 --- /dev/null +++ b/.gsd/milestones/M002/slices/S02/S02-PLAN.md @@ -0,0 +1,68 @@ +# S02: Enhanced Collection UX + +**Goal:** `secure_env_collect` gains four capabilities: multi-line guidance display per key, a summary screen before collection, existing-key detection with silent skip, and automatic destination inference from project context. +**Demo:** Running the enhanced tool with a test manifest (keys with guidance) shows a summary screen listing all needed keys, silently skips keys already in the environment, auto-detects the write destination, and displays step-by-step guidance during per-key collection. Unit tests prove the utility functions. Build passes. + +## Must-Haves + +- `guidance` field (optional `string[]`) added to the `keys` TypeBox schema — backward compatible with existing callers +- `destination` parameter made optional — auto-detected via `detectDestination()` when omitted +- `checkExistingEnvKeys(keys, envFilePath, cwd)` exported and unit-tested — checks both `.env` file and `process.env`, handles missing `.env` gracefully, treats empty-string values as existing +- `detectDestination(basePath)` exported and unit-tested — checks for `vercel.json` → "vercel", `convex/` dir → "convex", fallback → "dotenv" +- Summary screen TUI component using `makeUI()` — renders before per-key collection when guidance is present, shows all keys with service context and guidance steps, user presses enter to continue +- Per-key collection screen enhanced to show numbered guidance steps below the hint +- `execute()` wired: auto-detect destination → check existing keys → show summary → collect remaining keys with guidance +- All existing callers that provide `destination` and omit `guidance` work identically (backward compat) +- `npm run build` passes +- `npm run test` passes (54 pass, 2 pre-existing failures) + +## Proof Level + +- This slice proves: contract +- Real runtime required: no (utility functions are unit-tested; TUI components are contract-proven via TypeScript compilation and structural render) +- Human/UAT required: no (TUI visual quality is structure-proven; real UX validation happens in S04 end-to-end) + +## Verification + +- `npm run test -- --test-name-pattern "secure_env_collect"` — all new tests pass +- `npm run build` — clean compilation +- `npm run test` — 54+ pass, 2 pre-existing failures +- Test file: `src/resources/extensions/gsd/tests/secure-env-collect.test.ts` + - `checkExistingEnvKeys`: finds keys in `.env` file, finds keys in `process.env`, handles missing `.env`, treats empty values as existing, returns only existing keys from input list + - `detectDestination`: returns "vercel" when `vercel.json` exists, returns "convex" when `convex/` dir exists, returns "dotenv" as fallback, checks vercel before convex when both exist + +## Observability / Diagnostics + +- Runtime signals: Summary screen render logs key count and skip count to the result details. The `execute()` result includes `autoDetected: true` when destination was inferred. +- Inspection surfaces: The tool's result `details` object gains `existingSkipped: string[]` listing keys that were silently skipped, and `detectedDestination: string` when auto-detection was used. +- Failure visibility: `checkExistingEnvKeys` silently handles ENOENT (returns empty array for file checks). `detectDestination` always returns a valid value (fallback "dotenv"). Errors during collection are already captured in the result's error array. +- Redaction constraints: Secret values never appear in logs, result details, or summary screen. Only key names are displayed. + +## Integration Closure + +- Upstream surfaces consumed: `shared/ui.ts` (`makeUI()` design system), `shared/confirm-ui.ts` (pattern reference for `ctx.ui.custom()` components) +- New wiring introduced in this slice: `checkExistingEnvKeys()` and `detectDestination()` exported for S03 consumption. `guidance` field on TypeBox schema available for S03 callers. Summary screen function available as internal module function. +- What remains before the milestone is truly usable end-to-end: S03 (auto-mode dispatches collect-secrets phase, guided flow triggers collection, reads manifest and passes entries to enhanced tool), S04 (end-to-end verification) + +## Tasks + +- [x] **T01: Add utility functions with tests and schema changes** `est:25m` + - Why: Establishes the testable foundation — `checkExistingEnvKeys`, `detectDestination`, and the `guidance`/optional-`destination` schema changes. All non-TUI code. Tests prove the utilities work before the TUI integration task. + - Files: `src/resources/extensions/get-secrets-from-user.ts`, `src/resources/extensions/gsd/tests/secure-env-collect.test.ts` + - Do: Create test file with assertions for both utility functions. Add `checkExistingEnvKeys(keys, envFilePath, cwd)` — reads `.env` with try/catch, checks `process.env`, returns keys that already exist. Add `detectDestination(basePath)` — checks `vercel.json` (existsSync), `convex/` dir (existsSync), fallback "dotenv". Make `destination` optional in TypeBox schema with `Type.Optional()`. Add `guidance` field as `Type.Optional(Type.Array(Type.String()))` to key items. Update `execute()` to default destination via `detectDestination(ctx.cwd)` when not provided. Handle `params.destination` being `string | undefined`. + - Verify: `npm run test -- --test-name-pattern "secure_env_collect"` passes; `npm run build` passes; `npm run test` — 54+ pass + - Done when: Both utility functions are exported, unit-tested, and the schema accepts optional `destination` and `guidance` fields without breaking existing callers + +- [ ] **T02: Build summary screen, enhance guidance display, and wire execute flow** `est:30m` + - Why: Delivers the user-facing UX — the summary screen before collection, numbered guidance in per-key screens, and the full execute flow with existing-key skip and auto-detection wired in. Completes all four R004/R005/R006/R010 requirements. + - Files: `src/resources/extensions/get-secrets-from-user.ts`, `src/resources/extensions/shared/ui.ts` (reference only) + - Do: Add `showSecretsSummary()` function using `ctx.ui.custom()` + `makeUI()` — renders key list with `progressItem()` per key (pending status), `progressAnnotation()` for each guidance step, hints line with "enter to continue", returns void on enter/escape. Enhance `collectOneSecret()` to accept `guidance: string[] | undefined` parameter — render numbered guidance steps below hint using `theme.fg("muted")`. Wire `execute()`: (1) resolve destination via `detectDestination` if not provided, (2) call `checkExistingEnvKeys` to get existing keys, (3) filter keys list to only uncollected ones, (4) if any keys have guidance, call `showSecretsSummary()` with all original keys (marking existing ones as done), (5) loop through filtered keys calling `collectOneSecret` with guidance. Update result `details` to include `existingSkipped` and `detectedDestination`. + - Verify: `npm run build` passes; `npm run test` — 54+ pass (no regressions); summary screen function exists and compiles; execute flow handles all code paths + - Done when: The full execute flow works — auto-detects destination, skips existing keys, shows summary, displays guidance per key, collects remaining, and reports results with skip/detection metadata + +## Files Likely Touched + +- `src/resources/extensions/get-secrets-from-user.ts` — main target: utilities, schema, summary screen, execute wiring +- `src/resources/extensions/gsd/tests/secure-env-collect.test.ts` — new test file for utility functions +- `src/resources/extensions/shared/ui.ts` — imported by summary screen (no modifications) +- `src/resources/extensions/shared/confirm-ui.ts` — pattern reference (no modifications) diff --git a/.gsd/milestones/M002/slices/S02/tasks/T01-PLAN.md b/.gsd/milestones/M002/slices/S02/tasks/T01-PLAN.md new file mode 100644 index 000000000..56de5f404 --- /dev/null +++ b/.gsd/milestones/M002/slices/S02/tasks/T01-PLAN.md @@ -0,0 +1,62 @@ +--- +estimated_steps: 5 +estimated_files: 2 +--- + +# T01: Add utility functions with tests and schema changes + +**Slice:** S02 — Enhanced Collection UX +**Milestone:** M002 + +## Description + +Establishes the non-TUI foundation for S02: two exported utility functions (`checkExistingEnvKeys` and `detectDestination`), their unit tests, and the TypeBox schema changes (optional `destination`, new `guidance` field on keys). The execute function gains destination auto-detection when `destination` is omitted. No TUI changes — that's T02. + +## Steps + +1. **Create test file** `src/resources/extensions/gsd/tests/secure-env-collect.test.ts` with test groups for `checkExistingEnvKeys` and `detectDestination`. Use Node's built-in test runner (`node:test` + `node:assert`). Tests should initially fail (functions don't exist yet). Test cases: + - `checkExistingEnvKeys`: key found in `.env` file, key found in `process.env`, key found in both, key not found anywhere, `.env` file doesn't exist (ENOENT → still checks process.env), empty-string value in process.env counts as existing, returns only existing keys from input list + - `detectDestination`: returns "vercel" when `vercel.json` exists in basePath, returns "convex" when `convex/` dir exists in basePath, returns "dotenv" when neither exists, vercel takes priority when both exist + +2. **Implement `checkExistingEnvKeys`** in `get-secrets-from-user.ts`. Export it. Signature: `async function checkExistingEnvKeys(keys: string[], envFilePath: string): Promise`. Reads `.env` with try/catch (ENOENT → empty content). For each key: check if regex `^KEY\s*=` matches in file content OR `key in process.env` (including empty string values). Return array of keys that exist. Reuse the regex pattern from `writeEnvKey` for consistency. + +3. **Implement `detectDestination`** in `get-secrets-from-user.ts`. Export it. Signature: `function detectDestination(basePath: string): "dotenv" | "vercel" | "convex"`. Uses `existsSync` from `node:fs` (synchronous, fine for one-shot check). Check `resolve(basePath, "vercel.json")` → "vercel". Check `resolve(basePath, "convex")` with `statSync` for directory → "convex". Fallback → "dotenv". Import `existsSync` and `statSync` from `node:fs`. + +4. **Update TypeBox schema**: Make `destination` optional with `Type.Optional(Type.Union([...]))`. Add `guidance` to keys items: `Type.Optional(Type.Array(Type.String(), { description: "Step-by-step guidance for finding this key" }))`. Update the `ToolResultDetails` interface to add `existingSkipped?: string[]` and `detectedDestination?: string`. + +5. **Update `execute()` destination handling**: At the top of execute, add: `const destination = params.destination ?? detectDestination(ctx.cwd)`. Replace all subsequent `params.destination` references with `destination`. Track whether destination was auto-detected for result details. + +## Must-Haves + +- [ ] `checkExistingEnvKeys` exported, checks both `.env` and `process.env`, handles ENOENT, treats empty values as existing +- [ ] `detectDestination` exported, checks vercel.json then convex/ dir, fallback dotenv +- [ ] `destination` parameter is optional in TypeBox schema +- [ ] `guidance` field is optional `string[]` on key items in TypeBox schema +- [ ] `execute()` auto-detects destination when not provided +- [ ] All tests in `secure-env-collect.test.ts` pass +- [ ] `npm run build` passes +- [ ] `npm run test` — 54+ pass, 2 pre-existing failures only + +## Verification + +- `npm run test -- --test-name-pattern "secure_env_collect"` — all new test assertions pass +- `npm run build` — clean TypeScript compilation (catches schema type mismatches) +- `npm run test` — no new failures beyond the 2 pre-existing ones + +## Observability Impact + +- Signals added/changed: `ToolResultDetails` gains `existingSkipped` and `detectedDestination` fields — these surface in the tool result for agent inspection +- How a future agent inspects this: The tool result `details` object shows which keys were auto-skipped and whether destination was inferred +- Failure state exposed: `checkExistingEnvKeys` silently handles ENOENT (no error thrown, returns subset). `detectDestination` always returns a valid value. + +## Inputs + +- `src/resources/extensions/get-secrets-from-user.ts` — existing 352-line tool implementation +- `src/resources/extensions/shared/confirm-ui.ts` — pattern reference for imports (not modified) +- D008 — silent skip for existing keys, no confirmation +- D009 — destination inferred from project context, fallback to .env + +## Expected Output + +- `src/resources/extensions/gsd/tests/secure-env-collect.test.ts` — new test file with ~15-20 test cases for both utility functions +- `src/resources/extensions/get-secrets-from-user.ts` — gains `checkExistingEnvKeys()`, `detectDestination()`, updated TypeBox schema, updated execute() destination handling diff --git a/.gsd/milestones/M002/slices/S02/tasks/T01-SUMMARY.md b/.gsd/milestones/M002/slices/S02/tasks/T01-SUMMARY.md new file mode 100644 index 000000000..abf47e970 --- /dev/null +++ b/.gsd/milestones/M002/slices/S02/tasks/T01-SUMMARY.md @@ -0,0 +1,76 @@ +--- +id: T01 +parent: S02 +milestone: M002 +provides: + - checkExistingEnvKeys utility function (exported) + - detectDestination utility function (exported) + - optional destination parameter in TypeBox schema + - guidance field on key items in TypeBox schema + - auto-detection of destination in execute() +key_files: + - src/resources/extensions/get-secrets-from-user.ts + - src/resources/extensions/gsd/tests/secure-env-collect.test.ts +key_decisions: + - Used `key in process.env` check (not `process.env[key] !== undefined`) to match empty-string-as-existing semantics + - detectDestination is synchronous (existsSync/statSync) — fine for one-shot project detection +patterns_established: + - Test file naming: `secure-env-collect.test.ts` uses node:test + node:assert/strict with temp dirs for fs isolation +observability_surfaces: + - ToolResultDetails.detectedDestination — present when destination was auto-inferred + - ToolResultDetails.existingSkipped — ready for T02 to populate with silently skipped keys +duration: 12m +verification_result: passed +completed_at: 2026-03-12 +blocker_discovered: false +--- + +# T01: Add utility functions with tests and schema changes + +**Added `checkExistingEnvKeys` and `detectDestination` utilities with 12 unit tests, made `destination` optional with auto-detection, and added `guidance` field to key schema.** + +## What Happened + +Created two exported utility functions in `get-secrets-from-user.ts`: + +1. **`checkExistingEnvKeys(keys, envFilePath)`** — reads `.env` file content (ENOENT-safe), then for each key checks if it exists in the file via regex (`^KEY\s*=`) or in `process.env` (including empty-string values). Returns the subset of keys that already exist. + +2. **`detectDestination(basePath)`** — checks for `vercel.json` (→ "vercel"), then `convex/` directory (→ "convex"), fallback → "dotenv". Uses `existsSync`/`statSync` for synchronous one-shot detection. + +Updated the TypeBox schema: `destination` is now `Type.Optional(Type.Union([...]))`, and keys gain `guidance: Type.Optional(Type.Array(Type.String()))`. + +Updated `execute()` to auto-detect destination when not provided, tracking whether detection occurred in the result details. The `ToolResultDetails` interface gained `existingSkipped` and `detectedDestination` fields. + +Created 12 unit tests covering both functions (7 for `checkExistingEnvKeys`, 5 for `detectDestination`) using `node:test` + `node:assert/strict` with temp directory isolation. + +## Verification + +- `npm run build` — clean compilation, no type errors +- `npm run test -- --test-name-pattern "secure_env_collect"` — all 12 tests pass +- `npm run test` — 66 pass, 2 fail (pre-existing AGENTS.md failures in app-smoke.test.ts) + +### Slice-level verification status (T01 of 2): +- ✅ `npm run test -- --test-name-pattern "secure_env_collect"` — all new tests pass +- ✅ `npm run build` — clean compilation +- ✅ `npm run test` — 66 pass, 2 pre-existing failures only + +## Diagnostics + +- `ToolResultDetails.detectedDestination` surfaces in tool result when destination was auto-inferred (agent can inspect this) +- `ToolResultDetails.existingSkipped` field is defined but not yet populated — T02 will wire `checkExistingEnvKeys` into the execute flow to populate it +- `checkExistingEnvKeys` silently handles ENOENT (no error thrown, returns subset based on process.env) +- `detectDestination` always returns a valid value (fallback "dotenv") + +## Deviations + +- Added a bonus test: `detectDestination — convex file (not dir) does not trigger convex` — verifies that a regular file named `convex` doesn't falsely trigger convex detection +- `renderCall` updated to show "auto" when destination is not provided (minor backward-compat improvement not in plan) + +## Known Issues + +None. + +## Files Created/Modified + +- `src/resources/extensions/get-secrets-from-user.ts` — added `checkExistingEnvKeys()`, `detectDestination()`, updated TypeBox schema (optional destination, guidance field), updated execute() with auto-detection, updated ToolResultDetails interface +- `src/resources/extensions/gsd/tests/secure-env-collect.test.ts` — new test file with 12 test cases for both utility functions diff --git a/.gsd/milestones/M002/slices/S02/tasks/T02-PLAN.md b/.gsd/milestones/M002/slices/S02/tasks/T02-PLAN.md new file mode 100644 index 000000000..50bf308bc --- /dev/null +++ b/.gsd/milestones/M002/slices/S02/tasks/T02-PLAN.md @@ -0,0 +1,67 @@ +--- +estimated_steps: 5 +estimated_files: 2 +--- + +# T02: Build summary screen, enhance guidance display, and wire execute flow + +**Slice:** S02 — Enhanced Collection UX +**Milestone:** M002 + +## Description + +Delivers the user-facing TUI enhancements: a summary screen showing all needed keys with guidance before collection begins, numbered guidance steps in each per-key collection screen, and the full wired execute flow that skips existing keys and shows the summary. This completes the slice by connecting T01's utilities into the real collection pipeline. + +## Steps + +1. **Add `showSecretsSummary()` function** in `get-secrets-from-user.ts`. Import `makeUI` from `./shared/ui.js`. Signature: `async function showSecretsSummary(ctx, keys, existingKeys)` where keys is the full array with guidance and existingKeys is the set of already-present key names. Uses `ctx.ui.custom()` following the `confirm-ui.ts` pattern. Render function: + - `ui.bar()`, `ui.blank()`, `ui.header(" Secret Collection")`, `ui.meta(" N keys needed · M already configured")` + - `ui.blank()` + - For each key: `ui.progressItem(key.key, existingKeys.has(key.key) ? "done" : "pending", { detail: key.hint ?? "" })`. If key has guidance: render each step as `ui.progressAnnotation("N. step text")`. + - `ui.blank()`, `ui.hints(["enter to continue"])`, `ui.bar()` + - Handle input: enter or escape → `done(null)`. No cursor navigation needed (informational display). + +2. **Enhance `collectOneSecret()` to display guidance** — Add `guidance: string[] | undefined` parameter after `hint`. In the render function, after the hint line and before "Preview:", add a numbered guidance list: for each guidance step, render `theme.fg("muted", ` ${i+1}. ${step}`)`. Add a blank line after guidance if present. + +3. **Wire existing-key skip into `execute()`** — After destination resolution (from T01), call `checkExistingEnvKeys(params.keys.map(k => k.key), envFilePath)` where envFilePath is `resolve(ctx.cwd, params.envFilePath ?? ".env")` for dotenv destinations (for vercel/convex, only check `process.env` by passing a nonexistent path). Build a `Set` of existing keys. Filter `params.keys` to `remainingKeys` (those not in the existing set). Track `existingSkipped` as the existing key names. + +4. **Wire summary screen into `execute()`** — After existing-key detection, if any key in the original `params.keys` array has a `guidance` field with entries, call `showSecretsSummary(ctx, params.keys, existingKeySet)`. This shows ALL keys (existing marked as done, remaining marked as pending) so the user sees the full picture. + +5. **Wire guidance into collection loop and update result details** — Change the collection loop to iterate over `remainingKeys` instead of `params.keys`. Pass `item.guidance` to `collectOneSecret()`. Update result details: add `existingSkipped` array and `detectedDestination` string (when auto-detected). Verify the result text includes skipped-existing keys in the summary output (e.g., `⊘ KEY: already configured`). + +## Must-Haves + +- [ ] `showSecretsSummary()` renders via `ctx.ui.custom()` using `makeUI()` with `progressItem()` and `progressAnnotation()` +- [ ] Summary screen shows existing keys as "done" status and remaining keys as "pending" +- [ ] Summary screen displays numbered guidance steps per key via `progressAnnotation()` +- [ ] `collectOneSecret()` renders numbered guidance steps below hint +- [ ] `execute()` calls `checkExistingEnvKeys` and silently skips existing keys +- [ ] `execute()` shows summary screen when any key has guidance +- [ ] `execute()` only collects remaining (non-existing) keys +- [ ] Result details include `existingSkipped` array and `detectedDestination` string +- [ ] Result text summary includes already-configured keys with distinct marker +- [ ] `npm run build` passes +- [ ] `npm run test` — 54+ pass, 2 pre-existing failures only + +## Verification + +- `npm run build` — clean compilation (TypeScript catches type mismatches in TUI component render functions, `makeUI()` usage, `progressItem()`/`progressAnnotation()` calls) +- `npm run test` — no new failures (TUI changes don't break existing test suite) +- Manual code review: `showSecretsSummary()` follows `confirm-ui.ts` pattern (render/handleInput/invalidate triple), uses `makeUI()` consistently + +## Observability Impact + +- Signals added/changed: Result details gain `existingSkipped: string[]` and `detectedDestination: string` — visible in tool result for the calling LLM agent +- How a future agent inspects this: Tool result `details` shows which keys were skipped (existing) vs collected vs user-skipped, and whether destination was auto-detected +- Failure state exposed: If summary screen fails to render, `ctx.ui.custom` will throw — caught by the existing execute error handling. Missing `makeUI` import would be caught at build time. + +## Inputs + +- `src/resources/extensions/get-secrets-from-user.ts` — after T01 modifications (has `checkExistingEnvKeys`, `detectDestination`, updated schema with `guidance` and optional `destination`) +- `src/resources/extensions/shared/ui.ts` — `makeUI()` with `progressItem()`, `progressAnnotation()`, `bar()`, `header()`, `meta()`, `hints()`, `blank()` +- `src/resources/extensions/shared/confirm-ui.ts` — pattern for `ctx.ui.custom()` render/handleInput/invalidate triple +- S01 forward intelligence: guidance is `string[]`, display as numbered list not paragraph + +## Expected Output + +- `src/resources/extensions/get-secrets-from-user.ts` — gains `showSecretsSummary()`, enhanced `collectOneSecret()` with guidance display, fully wired `execute()` flow with skip + summary + guidance. All four R004/R005/R006/R010 capabilities working. diff --git a/src/resources/extensions/get-secrets-from-user.ts b/src/resources/extensions/get-secrets-from-user.ts index 1177d9d49..0d618ef18 100644 --- a/src/resources/extensions/get-secrets-from-user.ts +++ b/src/resources/extensions/get-secrets-from-user.ts @@ -7,6 +7,7 @@ */ import { readFile, writeFile } from "node:fs/promises"; +import { existsSync, statSync } from "node:fs"; import { resolve } from "node:path"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; @@ -25,6 +26,8 @@ interface ToolResultDetails { environment?: string; applied: string[]; skipped: string[]; + existingSkipped?: string[]; + detectedDestination?: string; } // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -91,6 +94,52 @@ async function writeEnvKey(filePath: string, key: string, value: string): Promis await writeFile(filePath, content, "utf8"); } +// ─── Exported utilities ─────────────────────────────────────────────────────── + +/** + * Check which keys already exist in the .env file or process.env. + * Returns the subset of `keys` that are already set. + * Handles ENOENT gracefully (still checks process.env). + * Empty-string values count as existing. + */ +export async function checkExistingEnvKeys(keys: string[], envFilePath: string): Promise { + let fileContent = ""; + try { + fileContent = await readFile(envFilePath, "utf8"); + } catch { + // ENOENT or other read error — proceed with empty content + } + + const existing: string[] = []; + for (const key of keys) { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`^${escaped}\\s*=`, "m"); + if (regex.test(fileContent) || key in process.env) { + existing.push(key); + } + } + return existing; +} + +/** + * Detect the write destination based on project files in basePath. + * Priority: vercel.json → convex/ dir → fallback "dotenv". + */ +export function detectDestination(basePath: string): "dotenv" | "vercel" | "convex" { + if (existsSync(resolve(basePath, "vercel.json"))) { + return "vercel"; + } + const convexPath = resolve(basePath, "convex"); + try { + if (existsSync(convexPath) && statSync(convexPath).isDirectory()) { + return "convex"; + } + } catch { + // stat error — treat as not found + } + return "dotenv"; +} + // ─── Paged secure input UI ──────────────────────────────────────────────────── /** @@ -209,16 +258,17 @@ export default function secureEnv(pi: ExtensionAPI) { "Never echo, log, or repeat secret values in your responses. Only report key names and applied/skipped status.", ], parameters: Type.Object({ - destination: Type.Union([ + destination: Type.Optional(Type.Union([ Type.Literal("dotenv"), Type.Literal("vercel"), Type.Literal("convex"), - ], { description: "Where to write the collected secrets" }), + ], { description: "Where to write the collected secrets" })), keys: Type.Array( Type.Object({ key: Type.String({ description: "Env var name, e.g. OPENAI_API_KEY" }), hint: Type.Optional(Type.String({ description: "Format hint shown to user, e.g. 'starts with sk-'" })), required: Type.Optional(Type.Boolean()), + guidance: Type.Optional(Type.Array(Type.String(), { description: "Step-by-step guidance for finding this key" })), }), { minItems: 1 }, ), @@ -240,6 +290,10 @@ export default function secureEnv(pi: ExtensionAPI) { }; } + // Auto-detect destination when not provided + const destinationAutoDetected = params.destination == null; + const destination = params.destination ?? detectDestination(ctx.cwd); + const collected: CollectedSecret[] = []; // Collect one key per page @@ -255,7 +309,7 @@ export default function secureEnv(pi: ExtensionAPI) { const errors: string[] = []; // Apply to destination - if (params.destination === "dotenv") { + if (destination === "dotenv") { const filePath = resolve(ctx.cwd, params.envFilePath ?? ".env"); for (const { key, value } of provided) { try { @@ -267,7 +321,7 @@ export default function secureEnv(pi: ExtensionAPI) { } } - if (params.destination === "vercel") { + if (destination === "vercel") { const env = params.environment ?? "development"; for (const { key, value } of provided) { try { @@ -286,7 +340,7 @@ export default function secureEnv(pi: ExtensionAPI) { } } - if (params.destination === "convex") { + if (destination === "convex") { for (const { key, value } of provided) { try { const result = await pi.exec("sh", [ @@ -305,14 +359,15 @@ export default function secureEnv(pi: ExtensionAPI) { } const details: ToolResultDetails = { - destination: params.destination, + destination, environment: params.environment, applied, skipped, + ...(destinationAutoDetected ? { detectedDestination: destination } : {}), }; const lines = [ - `destination: ${params.destination}${params.environment ? ` (${params.environment})` : ""}`, + `destination: ${destination}${destinationAutoDetected ? " (auto-detected)" : ""}${params.environment ? ` (${params.environment})` : ""}`, ...applied.map((k) => `✓ ${k}: applied`), ...skipped.map((k) => `• ${k}: skipped`), ...errors.map((e) => `✗ ${e}`), @@ -329,7 +384,7 @@ export default function secureEnv(pi: ExtensionAPI) { const count = Array.isArray(args.keys) ? args.keys.length : 0; return new Text( theme.fg("toolTitle", theme.bold("secure_env_collect ")) + - theme.fg("muted", `→ ${args.destination}`) + + theme.fg("muted", `→ ${args.destination ?? "auto"}`) + theme.fg("dim", ` ${count} key${count !== 1 ? "s" : ""}`), 0, 0, ); diff --git a/src/resources/extensions/gsd/tests/secure-env-collect.test.ts b/src/resources/extensions/gsd/tests/secure-env-collect.test.ts new file mode 100644 index 000000000..bd6096674 --- /dev/null +++ b/src/resources/extensions/gsd/tests/secure-env-collect.test.ts @@ -0,0 +1,185 @@ +/** + * Tests for secure_env_collect utility functions: + * - checkExistingEnvKeys: detects keys already present in .env file or process.env + * - detectDestination: infers write destination from project files + * + * Uses temp directories for filesystem isolation. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { checkExistingEnvKeys, detectDestination } from "../../get-secrets-from-user.ts"; + +function makeTempDir(prefix: string): string { + const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +// ─── checkExistingEnvKeys ───────────────────────────────────────────────────── + +test("secure_env_collect: checkExistingEnvKeys — key found in .env file", async () => { + const tmp = makeTempDir("sec-env-test"); + try { + const envPath = join(tmp, ".env"); + writeFileSync(envPath, "API_KEY=secret123\nOTHER=val\n"); + const result = await checkExistingEnvKeys(["API_KEY"], envPath); + assert.deepStrictEqual(result, ["API_KEY"]); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("secure_env_collect: checkExistingEnvKeys — key found in process.env", async () => { + const tmp = makeTempDir("sec-env-test"); + const savedVal = process.env.GSD_TEST_ENV_KEY_12345; + try { + process.env.GSD_TEST_ENV_KEY_12345 = "some-value"; + const envPath = join(tmp, ".env"); // file doesn't exist + const result = await checkExistingEnvKeys(["GSD_TEST_ENV_KEY_12345"], envPath); + assert.deepStrictEqual(result, ["GSD_TEST_ENV_KEY_12345"]); + } finally { + delete process.env.GSD_TEST_ENV_KEY_12345; + if (savedVal !== undefined) process.env.GSD_TEST_ENV_KEY_12345 = savedVal; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("secure_env_collect: checkExistingEnvKeys — key found in both .env and process.env", async () => { + const tmp = makeTempDir("sec-env-test"); + const savedVal = process.env.GSD_TEST_BOTH_KEY; + try { + process.env.GSD_TEST_BOTH_KEY = "from-env"; + const envPath = join(tmp, ".env"); + writeFileSync(envPath, "GSD_TEST_BOTH_KEY=from-file\n"); + const result = await checkExistingEnvKeys(["GSD_TEST_BOTH_KEY"], envPath); + assert.deepStrictEqual(result, ["GSD_TEST_BOTH_KEY"]); + } finally { + delete process.env.GSD_TEST_BOTH_KEY; + if (savedVal !== undefined) process.env.GSD_TEST_BOTH_KEY = savedVal; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("secure_env_collect: checkExistingEnvKeys — key not found anywhere", async () => { + const tmp = makeTempDir("sec-env-test"); + try { + const envPath = join(tmp, ".env"); + writeFileSync(envPath, "OTHER_KEY=val\n"); + // Ensure it's not in process.env + delete process.env.DEFINITELY_NOT_SET_KEY_XYZ; + const result = await checkExistingEnvKeys(["DEFINITELY_NOT_SET_KEY_XYZ"], envPath); + assert.deepStrictEqual(result, []); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("secure_env_collect: checkExistingEnvKeys — .env file doesn't exist (ENOENT), still checks process.env", async () => { + const tmp = makeTempDir("sec-env-test"); + const savedVal = process.env.GSD_TEST_ENOENT_KEY; + try { + process.env.GSD_TEST_ENOENT_KEY = "exists-in-process"; + const envPath = join(tmp, "nonexistent.env"); + const result = await checkExistingEnvKeys(["GSD_TEST_ENOENT_KEY", "MISSING_KEY_XYZ"], envPath); + assert.deepStrictEqual(result, ["GSD_TEST_ENOENT_KEY"]); + } finally { + delete process.env.GSD_TEST_ENOENT_KEY; + if (savedVal !== undefined) process.env.GSD_TEST_ENOENT_KEY = savedVal; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("secure_env_collect: checkExistingEnvKeys — empty-string value in process.env counts as existing", async () => { + const tmp = makeTempDir("sec-env-test"); + const savedVal = process.env.GSD_TEST_EMPTY_KEY; + try { + process.env.GSD_TEST_EMPTY_KEY = ""; + const envPath = join(tmp, ".env"); + writeFileSync(envPath, ""); + const result = await checkExistingEnvKeys(["GSD_TEST_EMPTY_KEY"], envPath); + assert.deepStrictEqual(result, ["GSD_TEST_EMPTY_KEY"]); + } finally { + delete process.env.GSD_TEST_EMPTY_KEY; + if (savedVal !== undefined) process.env.GSD_TEST_EMPTY_KEY = savedVal; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("secure_env_collect: checkExistingEnvKeys — returns only existing keys from input list", async () => { + const tmp = makeTempDir("sec-env-test"); + const saved1 = process.env.GSD_TEST_EXISTS_A; + const saved2 = process.env.GSD_TEST_EXISTS_B; + try { + process.env.GSD_TEST_EXISTS_A = "val-a"; + delete process.env.GSD_TEST_EXISTS_B; + const envPath = join(tmp, ".env"); + writeFileSync(envPath, "FILE_KEY=val\n"); + const result = await checkExistingEnvKeys( + ["GSD_TEST_EXISTS_A", "GSD_TEST_EXISTS_B", "FILE_KEY", "NOPE_KEY"], + envPath, + ); + assert.deepStrictEqual(result.sort(), ["FILE_KEY", "GSD_TEST_EXISTS_A"]); + } finally { + delete process.env.GSD_TEST_EXISTS_A; + delete process.env.GSD_TEST_EXISTS_B; + if (saved1 !== undefined) process.env.GSD_TEST_EXISTS_A = saved1; + if (saved2 !== undefined) process.env.GSD_TEST_EXISTS_B = saved2; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── detectDestination ──────────────────────────────────────────────────────── + +test("secure_env_collect: detectDestination — returns 'vercel' when vercel.json exists", () => { + const tmp = makeTempDir("sec-dest-test"); + try { + writeFileSync(join(tmp, "vercel.json"), "{}"); + assert.equal(detectDestination(tmp), "vercel"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("secure_env_collect: detectDestination — returns 'convex' when convex/ dir exists", () => { + const tmp = makeTempDir("sec-dest-test"); + try { + mkdirSync(join(tmp, "convex")); + assert.equal(detectDestination(tmp), "convex"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("secure_env_collect: detectDestination — returns 'dotenv' when neither exists", () => { + const tmp = makeTempDir("sec-dest-test"); + try { + assert.equal(detectDestination(tmp), "dotenv"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("secure_env_collect: detectDestination — vercel takes priority when both exist", () => { + const tmp = makeTempDir("sec-dest-test"); + try { + writeFileSync(join(tmp, "vercel.json"), "{}"); + mkdirSync(join(tmp, "convex")); + assert.equal(detectDestination(tmp), "vercel"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("secure_env_collect: detectDestination — convex file (not dir) does not trigger convex", () => { + const tmp = makeTempDir("sec-dest-test"); + try { + writeFileSync(join(tmp, "convex"), "not a directory"); + assert.equal(detectDestination(tmp), "dotenv"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +});