From 7a1eac6af39b2aa6a00d7968e1010b2b0def6522 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Thu, 12 Mar 2026 19:08:21 -0600 Subject: [PATCH] feat(M001): proactive secret management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Front-load API key collection into GSD's planning phase so auto-mode runs uninterrupted. Planning prompts forecast secrets into a manifest, auto-mode collects pending keys before dispatching the first slice. - getManifestStatus() queries manifest state against env - collectSecretsFromManifest() orchestrates summary, collection, manifest update - showSecretsSummary() read-only TUI summary with status indicators - collectOneSecret() enhanced with guidance display above masked input - Secrets gate in startAuto() — non-fatal, inherited by guided flow - 19 new tests (manifest-status, collect-from-manifest, auto-secrets-gate) - All 10 requirements (R001-R010) validated --- .../extensions/get-secrets-from-user.ts | 325 ++++++++++-- src/resources/extensions/gsd/auto.ts | 21 +- src/resources/extensions/gsd/files.ts | 46 +- .../gsd/tests/auto-secrets-gate.test.ts | 196 ++++++++ .../gsd/tests/collect-from-manifest.test.ts | 469 ++++++++++++++++++ .../gsd/tests/manifest-status.test.ts | 283 +++++++++++ .../extensions/gsd/tests/parsers.test.ts | 190 +++++++ src/resources/extensions/gsd/types.ts | 7 + 8 files changed, 1481 insertions(+), 56 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts create mode 100644 src/resources/extensions/gsd/tests/collect-from-manifest.test.ts create mode 100644 src/resources/extensions/gsd/tests/manifest-status.test.ts diff --git a/src/resources/extensions/get-secrets-from-user.ts b/src/resources/extensions/get-secrets-from-user.ts index 0d618ef18..e026b9386 100644 --- a/src/resources/extensions/get-secrets-from-user.ts +++ b/src/resources/extensions/get-secrets-from-user.ts @@ -10,9 +10,13 @@ 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"; -import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent"; +import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; +import { makeUI, type ProgressStatus } from "./shared/ui.js"; +import { parseSecretsManifest, formatSecretsManifest } from "./gsd/files.js"; +import { resolveMilestoneFile } from "./gsd/paths.js"; +import type { SecretsManifestEntry } from "./gsd/types.js"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -152,6 +156,7 @@ async function collectOneSecret( totalPages: number, keyName: string, hint: string | undefined, + guidance?: string[], ): Promise { if (!ctx.hasUI) return null; @@ -209,6 +214,21 @@ async function collectOneSecret( if (hint) { add(theme.fg("muted", ` ${hint}`)); } + + // Guidance steps (numbered, dim, wrapped for long URLs) + if (guidance && guidance.length > 0) { + lines.push(""); + for (let g = 0; g < guidance.length; g++) { + const prefix = ` ${g + 1}. `; + const step = guidance[g] as string; + const wrappedLines = wrapTextWithAnsi(step, width - 4); + for (let w = 0; w < wrappedLines.length; w++) { + const indent = w === 0 ? prefix : " ".repeat(prefix.length); + lines.push(theme.fg("dim", `${indent}${wrappedLines[w]}`)); + } + } + } + lines.push(""); // Masked preview @@ -239,6 +259,248 @@ async function collectOneSecret( }); } +/** + * Exported wrapper around collectOneSecret for testing. + * Exposes the same interface with guidance parameter for test verification. + */ +export const collectOneSecretWithGuidance = collectOneSecret; + +// ─── Summary Screen ─────────────────────────────────────────────────────────── + +/** + * Read-only summary screen showing all manifest entries with status indicators. + * Follows the confirm-ui.ts pattern: render → any key → done. + * + * Status mapping: + * - collected → done + * - pending → pending + * - skipped → skipped + * - existing keys (in existingKeys) → done with "already set" annotation + */ +export async function showSecretsSummary( + ctx: { ui: any; hasUI: boolean }, + entries: SecretsManifestEntry[], + existingKeys: string[], +): Promise { + if (!ctx.hasUI) return; + + const existingSet = new Set(existingKeys); + + await ctx.ui.custom((tui: any, theme: Theme, _kb: any, done: () => void) => { + let cachedLines: string[] | undefined; + + function handleInput(_data: string) { + // Any key dismisses + done(); + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + + const ui = makeUI(theme, width); + const lines: string[] = []; + const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); }; + + push(ui.bar()); + push(ui.blank()); + push(ui.header(" Secrets Summary")); + push(ui.blank()); + + for (const entry of entries) { + let status: ProgressStatus; + let detail: string | undefined; + + if (existingSet.has(entry.key)) { + status = "done"; + detail = "already set"; + } else if (entry.status === "collected") { + status = "done"; + } else if (entry.status === "skipped") { + status = "skipped"; + } else { + status = "pending"; + } + + push(ui.progressItem(entry.key, status, { detail })); + } + + push(ui.blank()); + push(ui.hints(["any key to continue"])); + push(ui.bar()); + + cachedLines = lines; + return lines; + } + + return { + render, + invalidate: () => { cachedLines = undefined; }, + handleInput, + }; + }); +} + +// ─── Destination Write Helper ───────────────────────────────────────────────── + +/** + * Apply collected secrets to the target destination. + * Dotenv writes are handled directly; vercel/convex require pi.exec. + */ +async function applySecrets( + provided: Array<{ key: string; value: string }>, + destination: "dotenv" | "vercel" | "convex", + opts: { + envFilePath: string; + environment?: string; + exec?: (cmd: string, args: string[]) => Promise<{ code: number; stderr: string }>; + }, +): Promise<{ applied: string[]; errors: string[] }> { + const applied: string[] = []; + const errors: string[] = []; + + if (destination === "dotenv") { + for (const { key, value } of provided) { + try { + await writeEnvKey(opts.envFilePath, key, value); + applied.push(key); + } catch (err: any) { + errors.push(`${key}: ${err.message}`); + } + } + } + + if (destination === "vercel" && opts.exec) { + const env = opts.environment ?? "development"; + for (const { key, value } of provided) { + try { + const result = await opts.exec("sh", [ + "-c", + `printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}`, + ]); + if (result.code !== 0) { + errors.push(`${key}: ${result.stderr.slice(0, 200)}`); + } else { + applied.push(key); + } + } catch (err: any) { + errors.push(`${key}: ${err.message}`); + } + } + } + + if (destination === "convex" && opts.exec) { + for (const { key, value } of provided) { + try { + const result = await opts.exec("sh", [ + "-c", + `npx convex env set ${key} ${shellEscapeSingle(value)}`, + ]); + if (result.code !== 0) { + errors.push(`${key}: ${result.stderr.slice(0, 200)}`); + } else { + applied.push(key); + } + } catch (err: any) { + errors.push(`${key}: ${err.message}`); + } + } + } + + return { applied, errors }; +} + +// ─── Manifest Orchestrator ──────────────────────────────────────────────────── + +/** + * Full orchestrator: reads manifest, checks env, shows summary, collects + * only pending keys (with guidance + hint), updates manifest statuses, + * writes back, and applies collected values to the destination. + * + * Returns a structured result matching the tool result shape. + */ +export async function collectSecretsFromManifest( + base: string, + milestoneId: string, + ctx: { ui: any; hasUI: boolean; cwd: string }, +): Promise<{ applied: string[]; skipped: string[]; existingSkipped: string[] }> { + // (a) Resolve manifest path + const manifestPath = resolveMilestoneFile(base, milestoneId, "SECRETS"); + if (!manifestPath) { + throw new Error(`Secrets manifest not found for milestone ${milestoneId} in ${base}`); + } + + // (b) Read and parse manifest + const content = await readFile(manifestPath, "utf8"); + const manifest = parseSecretsManifest(content); + + // (c) Check existing keys + const envPath = resolve(base, ".env"); + const allKeys = manifest.entries.map((e) => e.key); + const existingKeys = await checkExistingEnvKeys(allKeys, envPath); + const existingSet = new Set(existingKeys); + + // (d) Build categorization + const existingSkipped: string[] = []; + const alreadySkipped: string[] = []; + const pendingEntries: SecretsManifestEntry[] = []; + + for (const entry of manifest.entries) { + if (existingSet.has(entry.key)) { + existingSkipped.push(entry.key); + } else if (entry.status === "skipped") { + alreadySkipped.push(entry.key); + } else if (entry.status === "pending") { + pendingEntries.push(entry); + } + // collected entries that are not in env are left as-is + } + + // (e) Show summary screen + await showSecretsSummary(ctx, manifest.entries, existingKeys); + + // (f) Detect destination + const destination = detectDestination(ctx.cwd); + + // (g) Collect only pending keys that are not already existing + const collected: CollectedSecret[] = []; + for (let i = 0; i < pendingEntries.length; i++) { + const entry = pendingEntries[i] as SecretsManifestEntry; + const value = await collectOneSecret( + ctx, + i, + pendingEntries.length, + entry.key, + entry.formatHint || undefined, + entry.guidance.length > 0 ? entry.guidance : undefined, + ); + collected.push({ key: entry.key, value }); + } + + // (h) Update manifest entry statuses + for (const { key, value } of collected) { + const entry = manifest.entries.find((e) => e.key === key); + if (entry) { + entry.status = value !== null ? "collected" : "skipped"; + } + } + + // (i) Write manifest back to disk + await writeFile(manifestPath, formatSecretsManifest(manifest), "utf8"); + + // (j) Apply collected values to destination + const provided = collected.filter((c) => c.value !== null) as Array<{ key: string; value: string }>; + const { applied } = await applySecrets(provided, destination, { + envFilePath: resolve(ctx.cwd, ".env"), + }); + + const skipped = [ + ...alreadySkipped, + ...collected.filter((c) => c.value === null).map((c) => c.key), + ]; + + return { applied, skipped, existingSkipped }; +} + // ─── Extension ──────────────────────────────────────────────────────────────── export default function secureEnv(pi: ExtensionAPI) { @@ -299,64 +561,19 @@ export default function secureEnv(pi: ExtensionAPI) { // Collect one key per page for (let i = 0; i < params.keys.length; i++) { const item = params.keys[i]; - const value = await collectOneSecret(ctx, i, params.keys.length, item.key, item.hint); + const value = await collectOneSecret(ctx, i, params.keys.length, item.key, item.hint, item.guidance); collected.push({ key: item.key, value }); } const provided = collected.filter((c) => c.value !== null) as Array<{ key: string; value: string }>; const skipped = collected.filter((c) => c.value === null).map((c) => c.key); - const applied: string[] = []; - const errors: string[] = []; - // Apply to destination - if (destination === "dotenv") { - const filePath = resolve(ctx.cwd, params.envFilePath ?? ".env"); - for (const { key, value } of provided) { - try { - await writeEnvKey(filePath, key, value); - applied.push(key); - } catch (err: any) { - errors.push(`${key}: ${err.message}`); - } - } - } - - if (destination === "vercel") { - const env = params.environment ?? "development"; - for (const { key, value } of provided) { - try { - const result = await pi.exec("sh", [ - "-c", - `printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}`, - ]); - if (result.code !== 0) { - errors.push(`${key}: ${result.stderr.slice(0, 200)}`); - } else { - applied.push(key); - } - } catch (err: any) { - errors.push(`${key}: ${err.message}`); - } - } - } - - if (destination === "convex") { - for (const { key, value } of provided) { - try { - const result = await pi.exec("sh", [ - "-c", - `npx convex env set ${key} ${shellEscapeSingle(value)}`, - ]); - if (result.code !== 0) { - errors.push(`${key}: ${result.stderr.slice(0, 200)}`); - } else { - applied.push(key); - } - } catch (err: any) { - errors.push(`${key}: ${err.message}`); - } - } - } + // Apply to destination via shared helper + const { applied, errors } = await applySecrets(provided, destination, { + envFilePath: resolve(ctx.cwd, params.envFilePath ?? ".env"), + environment: params.environment, + exec: (cmd, args) => pi.exec(cmd, args), + }); const details: ToolResultDetails = { destination, diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 52c694125..033a77eb9 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -18,9 +18,10 @@ import type { import { deriveState } from "./state.js"; import type { GSDState } from "./types.js"; -import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary } from "./files.js"; +import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary, getManifestStatus } from "./files.js"; export { inlinePriorMilestoneSummary }; import type { UatType } from "./files.js"; +import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; import { loadPrompt } from "./prompt-loader.js"; import { gsdRoot, resolveMilestoneFile, resolveSliceFile, resolveSlicePath, @@ -474,6 +475,24 @@ export async function startAuto( : "Will loop until milestone complete."; ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info"); + // Secrets collection gate — collect pending secrets before first dispatch + const mid = state.activeMilestone.id; + try { + const manifestStatus = await getManifestStatus(base, mid); + if (manifestStatus && manifestStatus.pending.length > 0) { + const result = await collectSecretsFromManifest(base, mid, ctx); + ctx.ui.notify( + `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, + "info", + ); + } + } catch (err) { + ctx.ui.notify( + `Secrets collection error: ${err instanceof Error ? err.message : String(err)}`, + "warning", + ); + } + // Self-heal: clear stale runtime records where artifacts already exist await selfHealRuntimeRecords(base, ctx); diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts index 877cf1493..343b663c9 100644 --- a/src/resources/extensions/gsd/files.ts +++ b/src/resources/extensions/gsd/files.ts @@ -4,7 +4,7 @@ // Pure functions, zero Pi dependencies — uses only Node built-ins. import { promises as fs, readdirSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { milestonesDir, resolveMilestoneFile, relMilestoneFile } from './paths.js'; import type { @@ -14,8 +14,11 @@ import type { Continue, ContinueFrontmatter, ContinueStatus, RequirementCounts, SecretsManifest, SecretsManifestEntry, SecretsManifestEntryStatus, + ManifestStatus, } from './types.ts'; +import { checkExistingEnvKeys } from '../get-secrets-from-user.ts'; + // ─── Helpers ─────────────────────────────────────────────────────────────── /** @@ -800,3 +803,44 @@ export async function inlinePriorMilestoneSummary(mid: string, base: string): Pr if (!content) return null; return `### Prior Milestone Summary\nSource: \`${relPath}\`\n\n${content.trim()}`; } + +// ─── Manifest Status ────────────────────────────────────────────────────── + +/** + * Read a secrets manifest from disk and cross-reference each entry's status + * with the current environment (.env + process.env). + * + * Returns `null` when no manifest file exists (path resolution failure or + * file not on disk) — callers can distinguish "no manifest" from "empty manifest". + */ +export async function getManifestStatus( + base: string, milestoneId: string, +): Promise { + const resolvedPath = resolveMilestoneFile(base, milestoneId, 'SECRETS'); + if (!resolvedPath) return null; + + const content = await loadFile(resolvedPath); + if (!content) return null; + + const manifest = parseSecretsManifest(content); + const keys = manifest.entries.map(e => e.key); + const existingKeys = await checkExistingEnvKeys(keys, resolve(base, '.env')); + const existingSet = new Set(existingKeys); + + const result: ManifestStatus = { + pending: [], + collected: [], + skipped: [], + existing: [], + }; + + for (const entry of manifest.entries) { + if (existingSet.has(entry.key)) { + result.existing.push(entry.key); + } else { + result[entry.status].push(entry.key); + } + } + + return result; +} diff --git a/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts b/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts new file mode 100644 index 000000000..e73fe849c --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts @@ -0,0 +1,196 @@ +/** + * Integration tests for the secrets collection gate in startAuto(). + * + * Exercises getManifestStatus() → collectSecretsFromManifest() composition + * end-to-end using real filesystem state. Proves the three gate paths: + * 1. No manifest exists — gate skips silently + * 2. Pending keys exist — gate triggers collection + * 3. No pending keys — gate skips silently + * + * Uses temp directories with real .gsd/milestones/M001/ structure, mirroring + * the pattern from manifest-status.test.ts. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { getManifestStatus } from '../files.ts'; +import { collectSecretsFromManifest } 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; +} + +/** Create the .gsd/milestones/M001/ directory structure and write a secrets manifest. */ +function writeManifest(base: string, content: string): void { + const mDir = join(base, '.gsd', 'milestones', 'M001'); + mkdirSync(mDir, { recursive: true }); + writeFileSync(join(mDir, 'M001-SECRETS.md'), content); +} + +/** Stub ctx with hasUI: false — collectOneSecret returns null (skip), showSecretsSummary is a no-op. */ +function makeNoUICtx(cwd: string) { + return { + ui: {}, + hasUI: false, + cwd, + }; +} + +// ─── Scenario 1: No manifest exists ────────────────────────────────────────── + +test('secrets gate: no manifest exists — getManifestStatus returns null', async () => { + const tmp = makeTempDir('gate-no-manifest'); + try { + // No .gsd directory at all + const result = await getManifestStatus(tmp, 'M001'); + assert.strictEqual(result, null, 'should return null when no manifest file exists'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── Scenario 2: Pending keys exist ───────────────────────────────────────── + +test('secrets gate: pending keys exist — gate triggers collection, manifest updated on disk', async () => { + const tmp = makeTempDir('gate-pending'); + const savedA = process.env.GSD_GATE_TEST_EXISTING; + try { + // Simulate one key already in env + process.env.GSD_GATE_TEST_EXISTING = 'already-here'; + + // Ensure pending keys are NOT in env + delete process.env.GSD_GATE_TEST_PEND_A; + delete process.env.GSD_GATE_TEST_PEND_B; + + writeManifest(tmp, `# Secrets Manifest + +**Milestone:** M001 +**Generated:** 2025-06-20T10:00:00Z + +### GSD_GATE_TEST_PEND_A + +**Service:** ServiceA +**Status:** pending +**Destination:** dotenv + +1. Get key A from dashboard + +### GSD_GATE_TEST_PEND_B + +**Service:** ServiceB +**Status:** pending +**Destination:** dotenv + +1. Get key B from dashboard + +### GSD_GATE_TEST_EXISTING + +**Service:** ServiceC +**Status:** pending +**Destination:** dotenv + +1. Already in env +`); + + // (a) Verify getManifestStatus shows pending keys + const status = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(status, null, 'manifest should exist'); + assert.ok(status!.pending.length > 0, 'should have pending keys'); + assert.deepStrictEqual(status!.pending, ['GSD_GATE_TEST_PEND_A', 'GSD_GATE_TEST_PEND_B']); + assert.deepStrictEqual(status!.existing, ['GSD_GATE_TEST_EXISTING']); + + // (b) Call collectSecretsFromManifest with no-UI context + // With hasUI: false, collectOneSecret returns null → pending keys become "skipped" + const result = await collectSecretsFromManifest(tmp, 'M001', makeNoUICtx(tmp)); + + // (c) Verify return shape + assert.deepStrictEqual(result.applied, [], 'no keys applied (no UI to enter values)'); + assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_A'), 'PEND_A should be skipped'); + assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_B'), 'PEND_B should be skipped'); + assert.deepStrictEqual(result.existingSkipped, ['GSD_GATE_TEST_EXISTING']); + + // (d) Verify manifest on disk was updated — pending entries that went through + // collection are now "skipped". The existing-in-env entry retains its manifest + // status ("pending") because collectSecretsFromManifest only updates entries + // that flow through collectOneSecret. At runtime, getManifestStatus overrides + // env-present entries to "existing" regardless of manifest status. + const manifestPath = join(tmp, '.gsd', 'milestones', 'M001', 'M001-SECRETS.md'); + const updatedContent = readFileSync(manifestPath, 'utf8'); + assert.ok( + updatedContent.includes('**Status:** skipped'), + 'formerly-pending entries should now have status "skipped" in the manifest file', + ); + // Count: PEND_A → skipped, PEND_B → skipped, EXISTING stays pending on disk + const skippedMatches = updatedContent.match(/\*\*Status:\*\* skipped/g); + assert.strictEqual(skippedMatches?.length, 2, 'two entries should have status "skipped"'); + const pendingMatches = updatedContent.match(/\*\*Status:\*\* pending/g); + assert.strictEqual(pendingMatches?.length, 1, 'one entry (existing-in-env) retains pending on disk'); + + // (e) Verify getManifestStatus now shows no pending + const statusAfter = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(statusAfter, null); + assert.deepStrictEqual(statusAfter!.pending, [], 'no pending keys after collection'); + } finally { + delete process.env.GSD_GATE_TEST_EXISTING; + if (savedA !== undefined) process.env.GSD_GATE_TEST_EXISTING = savedA; + delete process.env.GSD_GATE_TEST_PEND_A; + delete process.env.GSD_GATE_TEST_PEND_B; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── Scenario 3: No pending keys — all collected or in env ────────────────── + +test('secrets gate: no pending keys — getManifestStatus shows pending.length === 0', async () => { + const tmp = makeTempDir('gate-no-pending'); + const savedKey = process.env.GSD_GATE_TEST_ENVKEY; + try { + process.env.GSD_GATE_TEST_ENVKEY = 'some-value'; + + writeManifest(tmp, `# Secrets Manifest + +**Milestone:** M001 +**Generated:** 2025-06-20T10:00:00Z + +### ALREADY_COLLECTED + +**Service:** ServiceX +**Status:** collected +**Destination:** dotenv + +1. Was collected previously + +### ALREADY_SKIPPED + +**Service:** ServiceY +**Status:** skipped +**Destination:** dotenv + +1. Not needed + +### GSD_GATE_TEST_ENVKEY + +**Service:** ServiceZ +**Status:** pending +**Destination:** dotenv + +1. In env already +`); + + const result = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(result, null, 'manifest should exist'); + assert.deepStrictEqual(result!.pending, [], 'no pending keys — gate would skip'); + assert.deepStrictEqual(result!.collected, ['ALREADY_COLLECTED']); + assert.deepStrictEqual(result!.skipped, ['ALREADY_SKIPPED']); + assert.deepStrictEqual(result!.existing, ['GSD_GATE_TEST_ENVKEY']); + } finally { + delete process.env.GSD_GATE_TEST_ENVKEY; + if (savedKey !== undefined) process.env.GSD_GATE_TEST_ENVKEY = savedKey; + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts b/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts new file mode 100644 index 000000000..3ac66bba9 --- /dev/null +++ b/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts @@ -0,0 +1,469 @@ +/** + * Tests for S02 Enhanced Collection TUI functions: + * - collectSecretsFromManifest() orchestrator categorization and flow + * - showSecretsSummary() render output + * - collectOneSecret() guidance rendering + * + * These tests import functions that don't exist yet (T02/T03 will build them). + * They are expected to fail until implementation is complete. + * + * Uses dynamic imports so individual tests fail with clear messages + * instead of the entire file crashing at import time. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { SecretsManifest, SecretsManifestEntry } from "../types.ts"; + +// Dynamic imports for files.ts functions to avoid cascading failure +// when paths.js isn't available (files.ts statically imports paths.js) +async function loadFilesExports(): Promise<{ + formatSecretsManifest: (m: SecretsManifest) => string; + parseSecretsManifest: (content: string) => SecretsManifest; +}> { + const mod = await import("../files.ts"); + return { + formatSecretsManifest: mod.formatSecretsManifest, + parseSecretsManifest: mod.parseSecretsManifest, + }; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeTempDir(prefix: string): string { + const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function makeManifest(entries: Partial[]): SecretsManifest { + return { + milestone: "M001", + generatedAt: "2026-03-12T00:00:00Z", + entries: entries.map((e) => ({ + key: e.key ?? "TEST_KEY", + service: e.service ?? "TestService", + dashboardUrl: e.dashboardUrl ?? "", + guidance: e.guidance ?? [], + formatHint: e.formatHint ?? "", + status: e.status ?? "pending", + destination: e.destination ?? "dotenv", + })), + }; +} + +async function writeManifestFile(dir: string, manifest: SecretsManifest): Promise { + const { formatSecretsManifest } = await loadFilesExports(); + const milestoneDir = join(dir, ".gsd", "milestones", "M001"); + mkdirSync(milestoneDir, { recursive: true }); + const filePath = join(milestoneDir, "M001-SECRETS.md"); + writeFileSync(filePath, formatSecretsManifest(manifest)); + return filePath; +} + +async function loadOrchestrator(): Promise<{ + collectSecretsFromManifest: Function; + showSecretsSummary: Function; +}> { + const mod = await import("../../get-secrets-from-user.ts"); + if (typeof mod.collectSecretsFromManifest !== "function") { + throw new Error("collectSecretsFromManifest is not exported from get-secrets-from-user.ts — T03 will implement this"); + } + if (typeof mod.showSecretsSummary !== "function") { + throw new Error("showSecretsSummary is not exported from get-secrets-from-user.ts — T03 will implement this"); + } + return { + collectSecretsFromManifest: mod.collectSecretsFromManifest, + showSecretsSummary: mod.showSecretsSummary, + }; +} + +async function loadGuidanceExport(): Promise<{ collectOneSecretWithGuidance: Function }> { + const mod = await import("../../get-secrets-from-user.ts"); + if (typeof mod.collectOneSecretWithGuidance !== "function") { + throw new Error("collectOneSecretWithGuidance is not exported from get-secrets-from-user.ts — T02 will implement this"); + } + return { collectOneSecretWithGuidance: mod.collectOneSecretWithGuidance }; +} + +// ─── collectSecretsFromManifest: categorization ─────────────────────────────── + +test("collectSecretsFromManifest: categorizes entries — pending keys need collection, existing keys are skipped", async () => { + const { collectSecretsFromManifest } = await loadOrchestrator(); + + const tmp = makeTempDir("manifest-collect"); + const savedA = process.env.EXISTING_KEY_A; + try { + process.env.EXISTING_KEY_A = "already-set"; + + const manifest = makeManifest([ + { key: "EXISTING_KEY_A", status: "pending" }, + { key: "PENDING_KEY_B", status: "pending", guidance: ["Step 1: Go to dashboard", "Step 2: Click create key"] }, + { key: "SKIPPED_KEY_C", status: "skipped" }, + ]); + await writeManifestFile(tmp, manifest); + + let callIndex = 0; + const mockCtx = { + cwd: tmp, + hasUI: true, + ui: { + custom: async (_factory: any) => { + callIndex++; + if (callIndex <= 1) return null; // summary screen dismiss + return "mock-secret-value"; // collect pending key + }, + }, + }; + + const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any); + + // EXISTING_KEY_A should be in existingSkipped (it's in process.env) + assert.ok(result.existingSkipped?.includes("EXISTING_KEY_A"), + "EXISTING_KEY_A should be in existingSkipped"); + + // PENDING_KEY_B should have been collected (applied) + assert.ok(result.applied.includes("PENDING_KEY_B"), + "PENDING_KEY_B should be in applied"); + + // SKIPPED_KEY_C should remain skipped + assert.ok(result.skipped.includes("SKIPPED_KEY_C"), + "SKIPPED_KEY_C should be in skipped"); + } finally { + delete process.env.EXISTING_KEY_A; + if (savedA !== undefined) process.env.EXISTING_KEY_A = savedA; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("collectSecretsFromManifest: existing keys are excluded from the collection list — not prompted", async () => { + const { collectSecretsFromManifest } = await loadOrchestrator(); + + const tmp = makeTempDir("manifest-collect-skip"); + const savedA = process.env.ALREADY_SET_KEY; + try { + process.env.ALREADY_SET_KEY = "present"; + + const manifest = makeManifest([ + { key: "ALREADY_SET_KEY", status: "pending" }, + { key: "NEEDS_COLLECTION", status: "pending" }, + ]); + await writeManifestFile(tmp, manifest); + + const collectedKeyNames: string[] = []; + let summaryShown = false; + const mockCtx = { + cwd: tmp, + hasUI: true, + ui: { + custom: async (factory: any) => { + // Intercept the factory to check what key is being collected + if (!summaryShown) { + summaryShown = true; + return null; // dismiss summary + } + collectedKeyNames.push("prompted"); + return "mock-value"; + }, + }, + }; + + const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any); + + // ALREADY_SET_KEY should not have been prompted — only NEEDS_COLLECTION should + assert.ok(!result.applied.includes("ALREADY_SET_KEY"), + "ALREADY_SET_KEY should not be in applied (it was auto-skipped)"); + assert.ok(result.existingSkipped?.includes("ALREADY_SET_KEY"), + "ALREADY_SET_KEY should be in existingSkipped"); + } finally { + delete process.env.ALREADY_SET_KEY; + if (savedA !== undefined) process.env.ALREADY_SET_KEY = savedA; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("collectSecretsFromManifest: manifest statuses are updated after collection", async () => { + const { collectSecretsFromManifest } = await loadOrchestrator(); + + const tmp = makeTempDir("manifest-update"); + try { + const manifest = makeManifest([ + { key: "KEY_TO_COLLECT", status: "pending" }, + { key: "KEY_TO_SKIP", status: "pending" }, + ]); + const manifestPath = await writeManifestFile(tmp, manifest); + + let callIndex = 0; + const mockCtx = { + cwd: tmp, + hasUI: true, + ui: { + custom: async (_factory: any) => { + callIndex++; + if (callIndex <= 1) return null; // summary screen dismiss + if (callIndex === 2) return "secret-value"; // KEY_TO_COLLECT + return null; // KEY_TO_SKIP — user skips + }, + }, + }; + + await collectSecretsFromManifest(tmp, "M001", mockCtx as any); + + // Read back the manifest file and verify statuses were updated + const { parseSecretsManifest } = await loadFilesExports(); + const updatedContent = readFileSync(manifestPath, "utf8"); + const updatedManifest = parseSecretsManifest(updatedContent); + + const keyToCollect = updatedManifest.entries.find(e => e.key === "KEY_TO_COLLECT"); + const keyToSkip = updatedManifest.entries.find(e => e.key === "KEY_TO_SKIP"); + + assert.equal(keyToCollect?.status, "collected", + "KEY_TO_COLLECT should have status 'collected' after providing a value"); + assert.equal(keyToSkip?.status, "skipped", + "KEY_TO_SKIP should have status 'skipped' after user skipped it"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── showSecretsSummary: render output ──────────────────────────────────────── + +test("showSecretsSummary: produces lines with correct status glyphs for each entry status", async () => { + const { showSecretsSummary } = await loadOrchestrator(); + + const entries: SecretsManifestEntry[] = [ + { key: "PENDING_KEY", service: "Svc", dashboardUrl: "", guidance: [], formatHint: "", status: "pending", destination: "dotenv" }, + { key: "COLLECTED_KEY", service: "Svc", dashboardUrl: "", guidance: [], formatHint: "", status: "collected", destination: "dotenv" }, + { key: "SKIPPED_KEY", service: "Svc", dashboardUrl: "", guidance: [], formatHint: "", status: "skipped", destination: "dotenv" }, + ]; + + // showSecretsSummary renders a ctx.ui.custom screen. We capture the render output. + let renderFn: ((width: number) => string[]) | undefined; + const mockCtx = { + hasUI: true, + ui: { + custom: async (factory: any) => { + const mockTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + }; + const mockTui = { requestRender: () => {}, terminal: { rows: 24, columns: 80 } }; + const component = factory(mockTui, mockTheme, {}, () => {}); + renderFn = component.render; + // Simulate immediate dismiss + component.handleInput("\x1b"); // escape + }, + }, + }; + + await showSecretsSummary(mockCtx as any, entries, []); + + assert.ok(renderFn, "render function should have been captured from factory"); + const lines = renderFn!(80); + + // Verify each key appears in the output + const output = lines.join("\n"); + assert.ok(output.includes("PENDING_KEY"), "should include PENDING_KEY"); + assert.ok(output.includes("COLLECTED_KEY"), "should include COLLECTED_KEY"); + assert.ok(output.includes("SKIPPED_KEY"), "should include SKIPPED_KEY"); + + // Verify we have at least one line per entry plus header/footer + assert.ok(lines.length >= 5, `should have at least 5 lines (got ${lines.length})`); +}); + +test("showSecretsSummary: existing keys shown with distinct status indicator", async () => { + const { showSecretsSummary } = await loadOrchestrator(); + + const entries: SecretsManifestEntry[] = [ + { key: "NEW_KEY", service: "Svc", dashboardUrl: "", guidance: [], formatHint: "", status: "pending", destination: "dotenv" }, + { key: "OLD_KEY", service: "Svc", dashboardUrl: "", guidance: [], formatHint: "", status: "collected", destination: "dotenv" }, + ]; + const existingKeys = ["OLD_KEY"]; + + let renderFn: ((width: number) => string[]) | undefined; + const mockCtx = { + hasUI: true, + ui: { + custom: async (factory: any) => { + const mockTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + }; + const mockTui = { requestRender: () => {}, terminal: { rows: 24, columns: 80 } }; + const component = factory(mockTui, mockTheme, {}, () => {}); + renderFn = component.render; + component.handleInput("\x1b"); + }, + }, + }; + + await showSecretsSummary(mockCtx as any, entries, existingKeys); + + assert.ok(renderFn, "render function should have been captured"); + const lines = renderFn!(80); + const output = lines.join("\n"); + + assert.ok(output.includes("NEW_KEY"), "should include NEW_KEY"); + assert.ok(output.includes("OLD_KEY"), "should include OLD_KEY"); +}); + +// ─── collectOneSecret: guidance rendering ───────────────────────────────────── + +test("collectOneSecret: guidance lines appear in render output when guidance is provided", async () => { + const { collectOneSecretWithGuidance } = await loadGuidanceExport(); + + const guidanceSteps = [ + "Navigate to https://platform.openai.com/api-keys", + "Click 'Create new secret key'", + "Copy the key value", + ]; + + // Use the exported test helper to capture render output with guidance + let renderFn: ((width: number) => string[]) | undefined; + const mockCtx = { + hasUI: true, + ui: { + custom: async (factory: any) => { + const mockTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + }; + const mockTui = { requestRender: () => {}, terminal: { rows: 24, columns: 80 } }; + const component = factory(mockTui, mockTheme, {}, () => {}); + renderFn = component.render; + component.handleInput("\x1b"); // escape to dismiss + }, + }, + }; + + await collectOneSecretWithGuidance(mockCtx, 0, 1, "OPENAI_API_KEY", "starts with sk-", guidanceSteps); + + assert.ok(renderFn, "render function should have been captured"); + const lines = renderFn!(80); + const output = lines.join("\n"); + + // Verify guidance steps appear in the output + assert.ok(output.includes("Navigate to"), "should include first guidance step"); + assert.ok(output.includes("Create new secret key"), "should include second guidance step"); + assert.ok(output.includes("Copy the key value"), "should include third guidance step"); +}); + +test("collectOneSecret: guidance lines wrap long URLs instead of truncating", async () => { + const { collectOneSecretWithGuidance } = await loadGuidanceExport(); + + const longGuidance = [ + "Navigate to https://platform.openai.com/account/api-keys and click 'Create new secret key'", + ]; + + let renderFn: ((width: number) => string[]) | undefined; + const mockCtx = { + hasUI: true, + ui: { + custom: async (factory: any) => { + const mockTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + }; + const mockTui = { requestRender: () => {}, terminal: { rows: 24, columns: 80 } }; + const component = factory(mockTui, mockTheme, {}, () => {}); + renderFn = component.render; + component.handleInput("\x1b"); + }, + }, + }; + + await collectOneSecretWithGuidance(mockCtx, 0, 1, "TEST_KEY", undefined, longGuidance); + + assert.ok(renderFn, "render function should have been captured"); + // Render at narrow width to force wrapping + const lines = renderFn!(50); + const output = lines.join("\n"); + + // The full URL should be present (wrapped, not truncated) + assert.ok(output.includes("platform.openai.com"), "URL should not be truncated"); + assert.ok(output.includes("Create new secret key"), "text after URL should not be truncated"); +}); + +test("collectOneSecret: no guidance provided — render output has no guidance section", async () => { + const { collectOneSecretWithGuidance } = await loadGuidanceExport(); + + let renderFn: ((width: number) => string[]) | undefined; + const mockCtx = { + hasUI: true, + ui: { + custom: async (factory: any) => { + const mockTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + }; + const mockTui = { requestRender: () => {}, terminal: { rows: 24, columns: 80 } }; + const component = factory(mockTui, mockTheme, {}, () => {}); + renderFn = component.render; + component.handleInput("\x1b"); + }, + }, + }; + + // Call without guidance (undefined) + await collectOneSecretWithGuidance(mockCtx, 0, 1, "SOME_KEY", "hint text", undefined); + + assert.ok(renderFn, "render function should have been captured"); + const lines = renderFn!(80); + const output = lines.join("\n"); + + // Should include the key name and hint but no numbered guidance steps + assert.ok(output.includes("SOME_KEY"), "should include key name"); + assert.ok(output.includes("hint text"), "should include hint"); + // Should NOT have numbered step indicators (1., 2., etc.) for guidance + assert.ok(!output.match(/^\s*1\.\s/m), "should not have numbered guidance steps when no guidance provided"); +}); + +// ─── collectSecretsFromManifest: returns structured result ──────────────────── + +test("collectSecretsFromManifest: returns result with applied, skipped, and existingSkipped arrays", async () => { + const { collectSecretsFromManifest } = await loadOrchestrator(); + + const tmp = makeTempDir("manifest-result"); + const savedKey = process.env.RESULT_TEST_EXISTING; + try { + process.env.RESULT_TEST_EXISTING = "already-here"; + + const manifest = makeManifest([ + { key: "RESULT_TEST_EXISTING", status: "pending" }, + { key: "RESULT_TEST_NEW", status: "pending" }, + ]); + await writeManifestFile(tmp, manifest); + + let callIndex = 0; + const mockCtx = { + cwd: tmp, + hasUI: true, + ui: { + custom: async (_factory: any) => { + callIndex++; + if (callIndex <= 1) return null; // summary dismiss + return "secret-value"; // collect the pending key + }, + }, + }; + + const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any); + + // Verify result shape + assert.ok(Array.isArray(result.applied), "result should have applied array"); + assert.ok(Array.isArray(result.skipped), "result should have skipped array"); + assert.ok(Array.isArray(result.existingSkipped), "result should have existingSkipped array"); + + assert.ok(result.existingSkipped.includes("RESULT_TEST_EXISTING"), + "existing key should be in existingSkipped"); + assert.ok(result.applied.includes("RESULT_TEST_NEW"), + "collected key should be in applied"); + } finally { + delete process.env.RESULT_TEST_EXISTING; + if (savedKey !== undefined) process.env.RESULT_TEST_EXISTING = savedKey; + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/src/resources/extensions/gsd/tests/manifest-status.test.ts b/src/resources/extensions/gsd/tests/manifest-status.test.ts new file mode 100644 index 000000000..3020caa87 --- /dev/null +++ b/src/resources/extensions/gsd/tests/manifest-status.test.ts @@ -0,0 +1,283 @@ +/** + * Tests for getManifestStatus() — the S01→S02 boundary contract. + * + * Verifies that manifest entries are correctly categorized into + * pending, collected, skipped, and existing arrays based on + * manifest status and environment presence. + * + * Uses temp directories with real .gsd/milestones/M001/ structure. + */ + +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 { getManifestStatus } from '../files.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; +} + +/** Create the .gsd/milestones/M001/ directory structure and write a secrets manifest. */ +function writeManifest(base: string, content: string): void { + const mDir = join(base, '.gsd', 'milestones', 'M001'); + mkdirSync(mDir, { recursive: true }); + writeFileSync(join(mDir, 'M001-SECRETS.md'), content); +} + +// ─── Mixed statuses ────────────────────────────────────────────────────────── + +test('getManifestStatus: mixed statuses — categorizes entries correctly', async () => { + const tmp = makeTempDir('manifest-mixed'); + const savedVal = process.env.GSD_TEST_EXISTING_KEY_001; + try { + process.env.GSD_TEST_EXISTING_KEY_001 = 'some-value'; + + writeManifest(tmp, `# Secrets Manifest + +**Milestone:** M001 +**Generated:** 2025-06-20T10:00:00Z + +### PENDING_KEY + +**Service:** SomeService +**Status:** pending +**Destination:** dotenv + +1. Get the key + +### COLLECTED_KEY + +**Service:** AnotherService +**Status:** collected +**Destination:** dotenv + +1. Already collected + +### SKIPPED_KEY + +**Service:** OptionalService +**Status:** skipped +**Destination:** dotenv + +1. Not needed + +### GSD_TEST_EXISTING_KEY_001 + +**Service:** EnvService +**Status:** pending +**Destination:** dotenv + +1. Already in env +`); + + const result = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(result, null, 'should not be null'); + assert.deepStrictEqual(result!.pending, ['PENDING_KEY']); + assert.deepStrictEqual(result!.collected, ['COLLECTED_KEY']); + assert.deepStrictEqual(result!.skipped, ['SKIPPED_KEY']); + assert.deepStrictEqual(result!.existing, ['GSD_TEST_EXISTING_KEY_001']); + } finally { + delete process.env.GSD_TEST_EXISTING_KEY_001; + if (savedVal !== undefined) process.env.GSD_TEST_EXISTING_KEY_001 = savedVal; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── All pending ───────────────────────────────────────────────────────────── + +test('getManifestStatus: all pending — 3 pending entries, none in env', async () => { + const tmp = makeTempDir('manifest-pending'); + try { + // Ensure none of these are in process.env + delete process.env.PEND_A; + delete process.env.PEND_B; + delete process.env.PEND_C; + + writeManifest(tmp, `# Secrets Manifest + +**Milestone:** M001 +**Generated:** 2025-06-20T10:00:00Z + +### PEND_A + +**Service:** A +**Status:** pending +**Destination:** dotenv + +1. Step one + +### PEND_B + +**Service:** B +**Status:** pending +**Destination:** dotenv + +1. Step one + +### PEND_C + +**Service:** C +**Status:** pending +**Destination:** dotenv + +1. Step one +`); + + const result = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(result, null); + assert.deepStrictEqual(result!.pending, ['PEND_A', 'PEND_B', 'PEND_C']); + assert.deepStrictEqual(result!.collected, []); + assert.deepStrictEqual(result!.skipped, []); + assert.deepStrictEqual(result!.existing, []); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── All collected ─────────────────────────────────────────────────────────── + +test('getManifestStatus: all collected — 2 collected entries, none in env', async () => { + const tmp = makeTempDir('manifest-collected'); + try { + delete process.env.COLL_X; + delete process.env.COLL_Y; + + writeManifest(tmp, `# Secrets Manifest + +**Milestone:** M001 +**Generated:** 2025-06-20T10:00:00Z + +### COLL_X + +**Service:** X +**Status:** collected +**Destination:** dotenv + +1. Done + +### COLL_Y + +**Service:** Y +**Status:** collected +**Destination:** dotenv + +1. Done +`); + + const result = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(result, null); + assert.deepStrictEqual(result!.pending, []); + assert.deepStrictEqual(result!.collected, ['COLL_X', 'COLL_Y']); + assert.deepStrictEqual(result!.skipped, []); + assert.deepStrictEqual(result!.existing, []); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── Key in env overrides manifest status ──────────────────────────────────── + +test('getManifestStatus: key in env overrides manifest status — collected key in env goes to existing', async () => { + const tmp = makeTempDir('manifest-override'); + const savedVal = process.env.GSD_TEST_OVERRIDE_KEY; + try { + process.env.GSD_TEST_OVERRIDE_KEY = 'already-here'; + + writeManifest(tmp, `# Secrets Manifest + +**Milestone:** M001 +**Generated:** 2025-06-20T10:00:00Z + +### GSD_TEST_OVERRIDE_KEY + +**Service:** Override +**Status:** collected +**Destination:** dotenv + +1. Was collected but now in env +`); + + const result = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(result, null); + assert.deepStrictEqual(result!.pending, []); + assert.deepStrictEqual(result!.collected, []); + assert.deepStrictEqual(result!.skipped, []); + assert.deepStrictEqual(result!.existing, ['GSD_TEST_OVERRIDE_KEY']); + } finally { + delete process.env.GSD_TEST_OVERRIDE_KEY; + if (savedVal !== undefined) process.env.GSD_TEST_OVERRIDE_KEY = savedVal; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── Missing manifest ──────────────────────────────────────────────────────── + +test('getManifestStatus: missing manifest — returns null', async () => { + const tmp = makeTempDir('manifest-missing'); + try { + // No .gsd directory at all + const result = await getManifestStatus(tmp, 'M001'); + assert.strictEqual(result, null); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── Empty manifest (no entries) ───────────────────────────────────────────── + +test('getManifestStatus: empty manifest — exists but no H3 sections', async () => { + const tmp = makeTempDir('manifest-empty'); + try { + writeManifest(tmp, `# Secrets Manifest + +**Milestone:** M001 +**Generated:** 2025-06-20T10:00:00Z +`); + + const result = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(result, null); + assert.deepStrictEqual(result!.pending, []); + assert.deepStrictEqual(result!.collected, []); + assert.deepStrictEqual(result!.skipped, []); + assert.deepStrictEqual(result!.existing, []); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── Env via .env file (not just process.env) ──────────────────────────────── + +test('getManifestStatus: key in .env file counts as existing', async () => { + const tmp = makeTempDir('manifest-dotenv'); + try { + delete process.env.DOTENV_ONLY_KEY; + + writeManifest(tmp, `# Secrets Manifest + +**Milestone:** M001 +**Generated:** 2025-06-20T10:00:00Z + +### DOTENV_ONLY_KEY + +**Service:** DotenvService +**Status:** pending +**Destination:** dotenv + +1. Get key +`); + + // Write a .env file at the project root with the key + writeFileSync(join(tmp, '.env'), 'DOTENV_ONLY_KEY=from-dotenv-file\n'); + + const result = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(result, null); + assert.deepStrictEqual(result!.existing, ['DOTENV_ONLY_KEY']); + assert.deepStrictEqual(result!.pending, []); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/src/resources/extensions/gsd/tests/parsers.test.ts b/src/resources/extensions/gsd/tests/parsers.test.ts index c44f6d703..3ccc1f7a7 100644 --- a/src/resources/extensions/gsd/tests/parsers.test.ts +++ b/src/resources/extensions/gsd/tests/parsers.test.ts @@ -1488,6 +1488,196 @@ console.log('\n=== parseSecretsManifest + formatSecretsManifest: round-trip ===' } } +// ═══════════════════════════════════════════════════════════════════════════ +// LLM-style round-trip tests — realistic manifest variations +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== LLM round-trip: extra whitespace ==='); +{ + // LLMs often produce inconsistent indentation and trailing spaces + const messy = `# Secrets Manifest + +**Milestone:** M010 +**Generated:** 2025-07-01T12:00:00Z + +### OPENAI_API_KEY + +**Service:** OpenAI +**Dashboard:** https://platform.openai.com/api-keys +**Format hint:** starts with sk- +**Status:** pending +**Destination:** dotenv + +1. Go to the API keys page +2. Create a new key + +### REDIS_URL + +**Service:** Upstash +**Status:** collected +**Destination:** vercel + +1. Open console +`; + + const parsed1 = parseSecretsManifest(messy); + const formatted = formatSecretsManifest(parsed1); + const parsed2 = parseSecretsManifest(formatted); + + assertEq(parsed2.milestone, parsed1.milestone, 'whitespace round-trip milestone'); + assertEq(parsed2.generatedAt, parsed1.generatedAt, 'whitespace round-trip generatedAt'); + assertEq(parsed2.entries.length, parsed1.entries.length, 'whitespace round-trip entry count'); + assertEq(parsed2.entries.length, 2, 'whitespace: two entries parsed'); + + for (let i = 0; i < parsed1.entries.length; i++) { + const e1 = parsed1.entries[i]; + const e2 = parsed2.entries[i]; + assertEq(e2.key, e1.key, `whitespace round-trip entry ${i} key`); + assertEq(e2.service, e1.service, `whitespace round-trip entry ${i} service`); + assertEq(e2.dashboardUrl, e1.dashboardUrl, `whitespace round-trip entry ${i} dashboardUrl`); + assertEq(e2.formatHint, e1.formatHint, `whitespace round-trip entry ${i} formatHint`); + assertEq(e2.status, e1.status, `whitespace round-trip entry ${i} status`); + assertEq(e2.destination, e1.destination, `whitespace round-trip entry ${i} destination`); + assertEq(e2.guidance.length, e1.guidance.length, `whitespace round-trip entry ${i} guidance length`); + for (let j = 0; j < e1.guidance.length; j++) { + assertEq(e2.guidance[j], e1.guidance[j], `whitespace round-trip entry ${i} guidance[${j}]`); + } + } + + // Verify the parser correctly stripped trailing whitespace + assertEq(parsed1.milestone, 'M010', 'whitespace: milestone trimmed'); + assertEq(parsed1.entries[0].key, 'OPENAI_API_KEY', 'whitespace: key trimmed'); + assertEq(parsed1.entries[0].service, 'OpenAI', 'whitespace: service trimmed'); +} + +console.log('\n=== LLM round-trip: missing optional fields ==='); +{ + // LLMs may omit Dashboard and Format hint lines entirely + const minimal = `# Secrets Manifest + +**Milestone:** M011 +**Generated:** 2025-07-02T08:00:00Z + +### DATABASE_URL + +**Service:** Neon +**Status:** pending +**Destination:** dotenv + +1. Create a Neon project +2. Copy connection string + +### WEBHOOK_SECRET + +**Service:** Stripe +**Status:** collected +**Destination:** dotenv + +1. Go to webhooks +`; + + const parsed1 = parseSecretsManifest(minimal); + + // Verify missing optional fields get defaults + assertEq(parsed1.entries[0].dashboardUrl, '', 'missing-optional: no dashboard → empty string'); + assertEq(parsed1.entries[0].formatHint, '', 'missing-optional: no format hint → empty string'); + assertEq(parsed1.entries[1].dashboardUrl, '', 'missing-optional: entry 2 no dashboard → empty string'); + assertEq(parsed1.entries[1].formatHint, '', 'missing-optional: entry 2 no format hint → empty string'); + + // Round-trip: formatter omits empty optional fields, re-parse preserves defaults + const formatted = formatSecretsManifest(parsed1); + const parsed2 = parseSecretsManifest(formatted); + + assertEq(parsed2.entries.length, parsed1.entries.length, 'missing-optional round-trip entry count'); + + for (let i = 0; i < parsed1.entries.length; i++) { + const e1 = parsed1.entries[i]; + const e2 = parsed2.entries[i]; + assertEq(e2.key, e1.key, `missing-optional round-trip entry ${i} key`); + assertEq(e2.service, e1.service, `missing-optional round-trip entry ${i} service`); + assertEq(e2.dashboardUrl, e1.dashboardUrl, `missing-optional round-trip entry ${i} dashboardUrl`); + assertEq(e2.formatHint, e1.formatHint, `missing-optional round-trip entry ${i} formatHint`); + assertEq(e2.status, e1.status, `missing-optional round-trip entry ${i} status`); + assertEq(e2.destination, e1.destination, `missing-optional round-trip entry ${i} destination`); + assertEq(e2.guidance.length, e1.guidance.length, `missing-optional round-trip entry ${i} guidance length`); + } +} + +console.log('\n=== LLM round-trip: extra blank lines ==='); +{ + // LLMs sometimes insert excessive blank lines between sections + const blanky = `# Secrets Manifest + + +**Milestone:** M012 +**Generated:** 2025-07-03T14:00:00Z + + + +### API_KEY_ONE + + +**Service:** ServiceOne +**Dashboard:** https://one.example.com + + +**Format hint:** key_... +**Status:** pending +**Destination:** dotenv + + + +1. Go to settings + + +2. Generate key + + + +### API_KEY_TWO + + + +**Service:** ServiceTwo +**Status:** skipped +**Destination:** dotenv + + +1. Not needed +`; + + const parsed1 = parseSecretsManifest(blanky); + + assertEq(parsed1.entries.length, 2, 'blank-lines: two entries parsed'); + assertEq(parsed1.milestone, 'M012', 'blank-lines: milestone parsed'); + assertEq(parsed1.entries[0].key, 'API_KEY_ONE', 'blank-lines: first key'); + assertEq(parsed1.entries[0].guidance.length, 2, 'blank-lines: first entry guidance count'); + assertEq(parsed1.entries[1].key, 'API_KEY_TWO', 'blank-lines: second key'); + assertEq(parsed1.entries[1].status, 'skipped', 'blank-lines: second entry status'); + + // Round-trip produces clean output + const formatted = formatSecretsManifest(parsed1); + const parsed2 = parseSecretsManifest(formatted); + + assertEq(parsed2.entries.length, parsed1.entries.length, 'blank-lines round-trip entry count'); + + for (let i = 0; i < parsed1.entries.length; i++) { + const e1 = parsed1.entries[i]; + const e2 = parsed2.entries[i]; + assertEq(e2.key, e1.key, `blank-lines round-trip entry ${i} key`); + assertEq(e2.service, e1.service, `blank-lines round-trip entry ${i} service`); + assertEq(e2.dashboardUrl, e1.dashboardUrl, `blank-lines round-trip entry ${i} dashboardUrl`); + assertEq(e2.formatHint, e1.formatHint, `blank-lines round-trip entry ${i} formatHint`); + assertEq(e2.status, e1.status, `blank-lines round-trip entry ${i} status`); + assertEq(e2.destination, e1.destination, `blank-lines round-trip entry ${i} destination`); + assertEq(e2.guidance.length, e1.guidance.length, `blank-lines round-trip entry ${i} guidance length`); + } + + // Verify the formatted output is cleaner (fewer consecutive blank lines) + const consecutiveBlanks = formatted.match(/\n{4,}/g); + assert(consecutiveBlanks === null, 'blank-lines: formatted output has no 4+ consecutive newlines'); +} + // ═══════════════════════════════════════════════════════════════════════════ // Results // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index d99404c57..839c97506 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -136,6 +136,13 @@ export interface SecretsManifest { entries: SecretsManifestEntry[]; } +export interface ManifestStatus { + pending: string[]; // manifest status = pending AND not in env + collected: string[]; // manifest status = collected AND not in env + skipped: string[]; // manifest status = skipped + existing: string[]; // key present in .env or process.env (regardless of manifest status) +} + // ─── GSD State (Derived Dashboard) ──────────────────────────────────────── export interface ActiveRef {