feat(M001): proactive secret management

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
This commit is contained in:
Lex Christopherson 2026-03-12 19:08:21 -06:00
parent dc3c2e7d76
commit 7a1eac6af3
8 changed files with 1481 additions and 56 deletions

View file

@ -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<string | null> {
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<void> {
if (!ctx.hasUI) return;
const existingSet = new Set(existingKeys);
await ctx.ui.custom<void>((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,

View file

@ -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);

View file

@ -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<ManifestStatus | null> {
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;
}

View file

@ -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 });
}
});

View file

@ -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<SecretsManifestEntry>[]): 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<string> {
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 });
}
});

View file

@ -0,0 +1,283 @@
/**
* Tests for getManifestStatus() the S01S02 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 });
}
});

View file

@ -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
// ═══════════════════════════════════════════════════════════════════════════

View file

@ -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 {