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:
parent
dc3c2e7d76
commit
7a1eac6af3
8 changed files with 1481 additions and 56 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
196
src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts
Normal file
196
src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
469
src/resources/extensions/gsd/tests/collect-from-manifest.test.ts
Normal file
469
src/resources/extensions/gsd/tests/collect-from-manifest.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
283
src/resources/extensions/gsd/tests/manifest-status.test.ts
Normal file
283
src/resources/extensions/gsd/tests/manifest-status.test.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue