gsd-pi 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/cli.js +7 -1
- package/dist/loader.js +21 -3
- package/dist/logo.d.ts +3 -3
- package/dist/logo.js +2 -2
- package/package.json +1 -1
- package/src/resources/extensions/get-secrets-from-user.ts +331 -59
- package/src/resources/extensions/gsd/auto.ts +80 -18
- package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -0
- package/src/resources/extensions/gsd/doctor.ts +23 -4
- package/src/resources/extensions/gsd/files.ts +115 -1
- package/src/resources/extensions/gsd/git-service.ts +67 -105
- package/src/resources/extensions/gsd/gitignore.ts +1 -0
- package/src/resources/extensions/gsd/guided-flow.ts +6 -3
- package/src/resources/extensions/gsd/preferences.ts +8 -0
- package/src/resources/extensions/gsd/prompts/complete-slice.md +7 -5
- package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
- package/src/resources/extensions/gsd/prompts/execute-task.md +2 -6
- package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -0
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +33 -1
- package/src/resources/extensions/gsd/prompts/plan-slice.md +24 -32
- package/src/resources/extensions/gsd/session-forensics.ts +19 -6
- package/src/resources/extensions/gsd/templates/plan.md +8 -10
- package/src/resources/extensions/gsd/templates/secrets-manifest.md +22 -0
- package/src/resources/extensions/gsd/templates/task-plan.md +6 -6
- package/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +196 -0
- package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +469 -0
- package/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts +170 -0
- package/src/resources/extensions/gsd/tests/git-service.test.ts +106 -0
- package/src/resources/extensions/gsd/tests/manifest-status.test.ts +283 -0
- package/src/resources/extensions/gsd/tests/parsers.test.ts +401 -65
- package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
- package/src/resources/extensions/gsd/types.ts +27 -0
package/README.md
CHANGED
|
@@ -224,6 +224,7 @@ On first run, GSD launches a branded setup wizard that walks you through LLM pro
|
|
|
224
224
|
| `Ctrl+Alt+V` | Toggle voice transcription |
|
|
225
225
|
| `Ctrl+Alt+B` | Show background shell processes |
|
|
226
226
|
| `gsd config` | Re-run the setup wizard (LLM provider + tool keys) |
|
|
227
|
+
| `gsd --continue` (`-c`) | Resume the most recent session for the current directory |
|
|
227
228
|
|
|
228
229
|
---
|
|
229
230
|
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,9 @@ function parseCliArgs(argv) {
|
|
|
20
20
|
else if (arg === '--print' || arg === '-p') {
|
|
21
21
|
flags.print = true;
|
|
22
22
|
}
|
|
23
|
+
else if (arg === '--continue' || arg === '-c') {
|
|
24
|
+
flags.continue = true;
|
|
25
|
+
}
|
|
23
26
|
else if (arg === '--no-session') {
|
|
24
27
|
flags.noSession = true;
|
|
25
28
|
}
|
|
@@ -45,6 +48,7 @@ function parseCliArgs(argv) {
|
|
|
45
48
|
process.stdout.write('Options:\n');
|
|
46
49
|
process.stdout.write(' --mode <text|json|rpc> Output mode (default: interactive)\n');
|
|
47
50
|
process.stdout.write(' --print, -p Single-shot print mode\n');
|
|
51
|
+
process.stdout.write(' --continue, -c Resume the most recent session\n');
|
|
48
52
|
process.stdout.write(' --model <id> Override model (e.g. claude-opus-4-6)\n');
|
|
49
53
|
process.stdout.write(' --no-session Disable session persistence\n');
|
|
50
54
|
process.stdout.write(' --extension <path> Load additional extension\n');
|
|
@@ -200,7 +204,9 @@ if (existsSync(sessionsDir)) {
|
|
|
200
204
|
// Non-fatal — don't block startup if migration fails
|
|
201
205
|
}
|
|
202
206
|
}
|
|
203
|
-
const sessionManager =
|
|
207
|
+
const sessionManager = cliFlags.continue
|
|
208
|
+
? SessionManager.continueRecent(cwd, projectSessionsDir)
|
|
209
|
+
: SessionManager.create(cwd, projectSessionsDir);
|
|
204
210
|
initResources(agentDir);
|
|
205
211
|
const resourceLoader = buildResourceLoader(agentDir);
|
|
206
212
|
await resourceLoader.reload();
|
package/dist/loader.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import { dirname, resolve, join } from 'path';
|
|
4
|
-
import { readFileSync } from 'fs';
|
|
5
|
-
import { agentDir } from './app-paths.js';
|
|
4
|
+
import { existsSync, readFileSync } from 'fs';
|
|
5
|
+
import { agentDir, appRoot } from './app-paths.js';
|
|
6
|
+
import { renderLogo } from './logo.js';
|
|
6
7
|
// pkg/ is a shim directory: contains gsd's piConfig (package.json) and pi's
|
|
7
8
|
// theme assets (dist/modes/interactive/theme/) without a src/ directory.
|
|
8
9
|
// This allows config.js to:
|
|
@@ -14,7 +15,24 @@ const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'pkg');
|
|
|
14
15
|
process.env.PI_PACKAGE_DIR = pkgDir;
|
|
15
16
|
process.env.PI_SKIP_VERSION_CHECK = '1'; // GSD ships its own update check — suppress pi's
|
|
16
17
|
process.title = 'gsd';
|
|
17
|
-
//
|
|
18
|
+
// Print branded banner on first launch (before ~/.gsd/ exists)
|
|
19
|
+
if (!existsSync(appRoot)) {
|
|
20
|
+
const cyan = '\x1b[36m';
|
|
21
|
+
const green = '\x1b[32m';
|
|
22
|
+
const dim = '\x1b[2m';
|
|
23
|
+
const reset = '\x1b[0m';
|
|
24
|
+
const colorCyan = (s) => `${cyan}${s}${reset}`;
|
|
25
|
+
let version = '';
|
|
26
|
+
try {
|
|
27
|
+
const pkgJson = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
|
|
28
|
+
version = pkgJson.version ?? '';
|
|
29
|
+
}
|
|
30
|
+
catch { /* ignore */ }
|
|
31
|
+
process.stderr.write(renderLogo(colorCyan) +
|
|
32
|
+
'\n' +
|
|
33
|
+
` Get Shit Done ${dim}v${version}${reset}\n` +
|
|
34
|
+
` ${green}Welcome.${reset} Setting up your environment...\n\n`);
|
|
35
|
+
}
|
|
18
36
|
// GSD_CODING_AGENT_DIR — tells pi's getAgentDir() to return ~/.gsd/agent/ instead of ~/.gsd/agent/
|
|
19
37
|
process.env.GSD_CODING_AGENT_DIR = agentDir;
|
|
20
38
|
// NODE_PATH — make gsd's own node_modules available to extensions loaded via jiti.
|
package/dist/logo.d.ts
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Single source of truth — imported by:
|
|
5
5
|
* - scripts/postinstall.js (via dist/logo.js)
|
|
6
|
-
* - src/
|
|
6
|
+
* - src/loader.ts (via ./logo.js)
|
|
7
7
|
*/
|
|
8
8
|
/** Raw logo lines — no ANSI codes, no leading newline. */
|
|
9
|
-
export declare const GSD_LOGO: string[];
|
|
9
|
+
export declare const GSD_LOGO: readonly string[];
|
|
10
10
|
/**
|
|
11
11
|
* Render the logo block with a color function applied to each line.
|
|
12
12
|
*
|
|
13
|
-
* @param color — e.g.
|
|
13
|
+
* @param color — e.g. `(s) => `\x1b[36m${s}\x1b[0m`` or picocolors.cyan
|
|
14
14
|
* @returns Ready-to-write string with leading/trailing newlines.
|
|
15
15
|
*/
|
|
16
16
|
export declare function renderLogo(color: (s: string) => string): string;
|
package/dist/logo.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Single source of truth — imported by:
|
|
5
5
|
* - scripts/postinstall.js (via dist/logo.js)
|
|
6
|
-
* - src/
|
|
6
|
+
* - src/loader.ts (via ./logo.js)
|
|
7
7
|
*/
|
|
8
8
|
/** Raw logo lines — no ANSI codes, no leading newline. */
|
|
9
9
|
export const GSD_LOGO = [
|
|
@@ -17,7 +17,7 @@ export const GSD_LOGO = [
|
|
|
17
17
|
/**
|
|
18
18
|
* Render the logo block with a color function applied to each line.
|
|
19
19
|
*
|
|
20
|
-
* @param color — e.g.
|
|
20
|
+
* @param color — e.g. `(s) => `\x1b[36m${s}\x1b[0m`` or picocolors.cyan
|
|
21
21
|
* @returns Ready-to-write string with leading/trailing newlines.
|
|
22
22
|
*/
|
|
23
23
|
export function renderLogo(color) {
|
package/package.json
CHANGED
|
@@ -7,11 +7,16 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import { existsSync, statSync } from "node:fs";
|
|
10
11
|
import { resolve } from "node:path";
|
|
11
12
|
|
|
12
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
13
|
-
import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
13
|
+
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
14
15
|
import { Type } from "@sinclair/typebox";
|
|
16
|
+
import { makeUI, type ProgressStatus } from "./shared/ui.js";
|
|
17
|
+
import { parseSecretsManifest, formatSecretsManifest } from "./gsd/files.js";
|
|
18
|
+
import { resolveMilestoneFile } from "./gsd/paths.js";
|
|
19
|
+
import type { SecretsManifestEntry } from "./gsd/types.js";
|
|
15
20
|
|
|
16
21
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
17
22
|
|
|
@@ -25,6 +30,8 @@ interface ToolResultDetails {
|
|
|
25
30
|
environment?: string;
|
|
26
31
|
applied: string[];
|
|
27
32
|
skipped: string[];
|
|
33
|
+
existingSkipped?: string[];
|
|
34
|
+
detectedDestination?: string;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -91,6 +98,52 @@ async function writeEnvKey(filePath: string, key: string, value: string): Promis
|
|
|
91
98
|
await writeFile(filePath, content, "utf8");
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
// ─── Exported utilities ───────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check which keys already exist in the .env file or process.env.
|
|
105
|
+
* Returns the subset of `keys` that are already set.
|
|
106
|
+
* Handles ENOENT gracefully (still checks process.env).
|
|
107
|
+
* Empty-string values count as existing.
|
|
108
|
+
*/
|
|
109
|
+
export async function checkExistingEnvKeys(keys: string[], envFilePath: string): Promise<string[]> {
|
|
110
|
+
let fileContent = "";
|
|
111
|
+
try {
|
|
112
|
+
fileContent = await readFile(envFilePath, "utf8");
|
|
113
|
+
} catch {
|
|
114
|
+
// ENOENT or other read error — proceed with empty content
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const existing: string[] = [];
|
|
118
|
+
for (const key of keys) {
|
|
119
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
120
|
+
const regex = new RegExp(`^${escaped}\\s*=`, "m");
|
|
121
|
+
if (regex.test(fileContent) || key in process.env) {
|
|
122
|
+
existing.push(key);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return existing;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Detect the write destination based on project files in basePath.
|
|
130
|
+
* Priority: vercel.json → convex/ dir → fallback "dotenv".
|
|
131
|
+
*/
|
|
132
|
+
export function detectDestination(basePath: string): "dotenv" | "vercel" | "convex" {
|
|
133
|
+
if (existsSync(resolve(basePath, "vercel.json"))) {
|
|
134
|
+
return "vercel";
|
|
135
|
+
}
|
|
136
|
+
const convexPath = resolve(basePath, "convex");
|
|
137
|
+
try {
|
|
138
|
+
if (existsSync(convexPath) && statSync(convexPath).isDirectory()) {
|
|
139
|
+
return "convex";
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// stat error — treat as not found
|
|
143
|
+
}
|
|
144
|
+
return "dotenv";
|
|
145
|
+
}
|
|
146
|
+
|
|
94
147
|
// ─── Paged secure input UI ────────────────────────────────────────────────────
|
|
95
148
|
|
|
96
149
|
/**
|
|
@@ -103,6 +156,7 @@ async function collectOneSecret(
|
|
|
103
156
|
totalPages: number,
|
|
104
157
|
keyName: string,
|
|
105
158
|
hint: string | undefined,
|
|
159
|
+
guidance?: string[],
|
|
106
160
|
): Promise<string | null> {
|
|
107
161
|
if (!ctx.hasUI) return null;
|
|
108
162
|
|
|
@@ -160,6 +214,21 @@ async function collectOneSecret(
|
|
|
160
214
|
if (hint) {
|
|
161
215
|
add(theme.fg("muted", ` ${hint}`));
|
|
162
216
|
}
|
|
217
|
+
|
|
218
|
+
// Guidance steps (numbered, dim, wrapped for long URLs)
|
|
219
|
+
if (guidance && guidance.length > 0) {
|
|
220
|
+
lines.push("");
|
|
221
|
+
for (let g = 0; g < guidance.length; g++) {
|
|
222
|
+
const prefix = ` ${g + 1}. `;
|
|
223
|
+
const step = guidance[g] as string;
|
|
224
|
+
const wrappedLines = wrapTextWithAnsi(step, width - 4);
|
|
225
|
+
for (let w = 0; w < wrappedLines.length; w++) {
|
|
226
|
+
const indent = w === 0 ? prefix : " ".repeat(prefix.length);
|
|
227
|
+
lines.push(theme.fg("dim", `${indent}${wrappedLines[w]}`));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
163
232
|
lines.push("");
|
|
164
233
|
|
|
165
234
|
// Masked preview
|
|
@@ -190,6 +259,248 @@ async function collectOneSecret(
|
|
|
190
259
|
});
|
|
191
260
|
}
|
|
192
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Exported wrapper around collectOneSecret for testing.
|
|
264
|
+
* Exposes the same interface with guidance parameter for test verification.
|
|
265
|
+
*/
|
|
266
|
+
export const collectOneSecretWithGuidance = collectOneSecret;
|
|
267
|
+
|
|
268
|
+
// ─── Summary Screen ───────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Read-only summary screen showing all manifest entries with status indicators.
|
|
272
|
+
* Follows the confirm-ui.ts pattern: render → any key → done.
|
|
273
|
+
*
|
|
274
|
+
* Status mapping:
|
|
275
|
+
* - collected → done
|
|
276
|
+
* - pending → pending
|
|
277
|
+
* - skipped → skipped
|
|
278
|
+
* - existing keys (in existingKeys) → done with "already set" annotation
|
|
279
|
+
*/
|
|
280
|
+
export async function showSecretsSummary(
|
|
281
|
+
ctx: { ui: any; hasUI: boolean },
|
|
282
|
+
entries: SecretsManifestEntry[],
|
|
283
|
+
existingKeys: string[],
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
if (!ctx.hasUI) return;
|
|
286
|
+
|
|
287
|
+
const existingSet = new Set(existingKeys);
|
|
288
|
+
|
|
289
|
+
await ctx.ui.custom<void>((tui: any, theme: Theme, _kb: any, done: () => void) => {
|
|
290
|
+
let cachedLines: string[] | undefined;
|
|
291
|
+
|
|
292
|
+
function handleInput(_data: string) {
|
|
293
|
+
// Any key dismisses
|
|
294
|
+
done();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function render(width: number): string[] {
|
|
298
|
+
if (cachedLines) return cachedLines;
|
|
299
|
+
|
|
300
|
+
const ui = makeUI(theme, width);
|
|
301
|
+
const lines: string[] = [];
|
|
302
|
+
const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
|
|
303
|
+
|
|
304
|
+
push(ui.bar());
|
|
305
|
+
push(ui.blank());
|
|
306
|
+
push(ui.header(" Secrets Summary"));
|
|
307
|
+
push(ui.blank());
|
|
308
|
+
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
let status: ProgressStatus;
|
|
311
|
+
let detail: string | undefined;
|
|
312
|
+
|
|
313
|
+
if (existingSet.has(entry.key)) {
|
|
314
|
+
status = "done";
|
|
315
|
+
detail = "already set";
|
|
316
|
+
} else if (entry.status === "collected") {
|
|
317
|
+
status = "done";
|
|
318
|
+
} else if (entry.status === "skipped") {
|
|
319
|
+
status = "skipped";
|
|
320
|
+
} else {
|
|
321
|
+
status = "pending";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
push(ui.progressItem(entry.key, status, { detail }));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
push(ui.blank());
|
|
328
|
+
push(ui.hints(["any key to continue"]));
|
|
329
|
+
push(ui.bar());
|
|
330
|
+
|
|
331
|
+
cachedLines = lines;
|
|
332
|
+
return lines;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
render,
|
|
337
|
+
invalidate: () => { cachedLines = undefined; },
|
|
338
|
+
handleInput,
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─── Destination Write Helper ─────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Apply collected secrets to the target destination.
|
|
347
|
+
* Dotenv writes are handled directly; vercel/convex require pi.exec.
|
|
348
|
+
*/
|
|
349
|
+
async function applySecrets(
|
|
350
|
+
provided: Array<{ key: string; value: string }>,
|
|
351
|
+
destination: "dotenv" | "vercel" | "convex",
|
|
352
|
+
opts: {
|
|
353
|
+
envFilePath: string;
|
|
354
|
+
environment?: string;
|
|
355
|
+
exec?: (cmd: string, args: string[]) => Promise<{ code: number; stderr: string }>;
|
|
356
|
+
},
|
|
357
|
+
): Promise<{ applied: string[]; errors: string[] }> {
|
|
358
|
+
const applied: string[] = [];
|
|
359
|
+
const errors: string[] = [];
|
|
360
|
+
|
|
361
|
+
if (destination === "dotenv") {
|
|
362
|
+
for (const { key, value } of provided) {
|
|
363
|
+
try {
|
|
364
|
+
await writeEnvKey(opts.envFilePath, key, value);
|
|
365
|
+
applied.push(key);
|
|
366
|
+
} catch (err: any) {
|
|
367
|
+
errors.push(`${key}: ${err.message}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (destination === "vercel" && opts.exec) {
|
|
373
|
+
const env = opts.environment ?? "development";
|
|
374
|
+
for (const { key, value } of provided) {
|
|
375
|
+
try {
|
|
376
|
+
const result = await opts.exec("sh", [
|
|
377
|
+
"-c",
|
|
378
|
+
`printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}`,
|
|
379
|
+
]);
|
|
380
|
+
if (result.code !== 0) {
|
|
381
|
+
errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
|
|
382
|
+
} else {
|
|
383
|
+
applied.push(key);
|
|
384
|
+
}
|
|
385
|
+
} catch (err: any) {
|
|
386
|
+
errors.push(`${key}: ${err.message}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (destination === "convex" && opts.exec) {
|
|
392
|
+
for (const { key, value } of provided) {
|
|
393
|
+
try {
|
|
394
|
+
const result = await opts.exec("sh", [
|
|
395
|
+
"-c",
|
|
396
|
+
`npx convex env set ${key} ${shellEscapeSingle(value)}`,
|
|
397
|
+
]);
|
|
398
|
+
if (result.code !== 0) {
|
|
399
|
+
errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
|
|
400
|
+
} else {
|
|
401
|
+
applied.push(key);
|
|
402
|
+
}
|
|
403
|
+
} catch (err: any) {
|
|
404
|
+
errors.push(`${key}: ${err.message}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return { applied, errors };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ─── Manifest Orchestrator ────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Full orchestrator: reads manifest, checks env, shows summary, collects
|
|
416
|
+
* only pending keys (with guidance + hint), updates manifest statuses,
|
|
417
|
+
* writes back, and applies collected values to the destination.
|
|
418
|
+
*
|
|
419
|
+
* Returns a structured result matching the tool result shape.
|
|
420
|
+
*/
|
|
421
|
+
export async function collectSecretsFromManifest(
|
|
422
|
+
base: string,
|
|
423
|
+
milestoneId: string,
|
|
424
|
+
ctx: { ui: any; hasUI: boolean; cwd: string },
|
|
425
|
+
): Promise<{ applied: string[]; skipped: string[]; existingSkipped: string[] }> {
|
|
426
|
+
// (a) Resolve manifest path
|
|
427
|
+
const manifestPath = resolveMilestoneFile(base, milestoneId, "SECRETS");
|
|
428
|
+
if (!manifestPath) {
|
|
429
|
+
throw new Error(`Secrets manifest not found for milestone ${milestoneId} in ${base}`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// (b) Read and parse manifest
|
|
433
|
+
const content = await readFile(manifestPath, "utf8");
|
|
434
|
+
const manifest = parseSecretsManifest(content);
|
|
435
|
+
|
|
436
|
+
// (c) Check existing keys
|
|
437
|
+
const envPath = resolve(base, ".env");
|
|
438
|
+
const allKeys = manifest.entries.map((e) => e.key);
|
|
439
|
+
const existingKeys = await checkExistingEnvKeys(allKeys, envPath);
|
|
440
|
+
const existingSet = new Set(existingKeys);
|
|
441
|
+
|
|
442
|
+
// (d) Build categorization
|
|
443
|
+
const existingSkipped: string[] = [];
|
|
444
|
+
const alreadySkipped: string[] = [];
|
|
445
|
+
const pendingEntries: SecretsManifestEntry[] = [];
|
|
446
|
+
|
|
447
|
+
for (const entry of manifest.entries) {
|
|
448
|
+
if (existingSet.has(entry.key)) {
|
|
449
|
+
existingSkipped.push(entry.key);
|
|
450
|
+
} else if (entry.status === "skipped") {
|
|
451
|
+
alreadySkipped.push(entry.key);
|
|
452
|
+
} else if (entry.status === "pending") {
|
|
453
|
+
pendingEntries.push(entry);
|
|
454
|
+
}
|
|
455
|
+
// collected entries that are not in env are left as-is
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// (e) Show summary screen
|
|
459
|
+
await showSecretsSummary(ctx, manifest.entries, existingKeys);
|
|
460
|
+
|
|
461
|
+
// (f) Detect destination
|
|
462
|
+
const destination = detectDestination(ctx.cwd);
|
|
463
|
+
|
|
464
|
+
// (g) Collect only pending keys that are not already existing
|
|
465
|
+
const collected: CollectedSecret[] = [];
|
|
466
|
+
for (let i = 0; i < pendingEntries.length; i++) {
|
|
467
|
+
const entry = pendingEntries[i] as SecretsManifestEntry;
|
|
468
|
+
const value = await collectOneSecret(
|
|
469
|
+
ctx,
|
|
470
|
+
i,
|
|
471
|
+
pendingEntries.length,
|
|
472
|
+
entry.key,
|
|
473
|
+
entry.formatHint || undefined,
|
|
474
|
+
entry.guidance.length > 0 ? entry.guidance : undefined,
|
|
475
|
+
);
|
|
476
|
+
collected.push({ key: entry.key, value });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// (h) Update manifest entry statuses
|
|
480
|
+
for (const { key, value } of collected) {
|
|
481
|
+
const entry = manifest.entries.find((e) => e.key === key);
|
|
482
|
+
if (entry) {
|
|
483
|
+
entry.status = value !== null ? "collected" : "skipped";
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// (i) Write manifest back to disk
|
|
488
|
+
await writeFile(manifestPath, formatSecretsManifest(manifest), "utf8");
|
|
489
|
+
|
|
490
|
+
// (j) Apply collected values to destination
|
|
491
|
+
const provided = collected.filter((c) => c.value !== null) as Array<{ key: string; value: string }>;
|
|
492
|
+
const { applied } = await applySecrets(provided, destination, {
|
|
493
|
+
envFilePath: resolve(ctx.cwd, ".env"),
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const skipped = [
|
|
497
|
+
...alreadySkipped,
|
|
498
|
+
...collected.filter((c) => c.value === null).map((c) => c.key),
|
|
499
|
+
];
|
|
500
|
+
|
|
501
|
+
return { applied, skipped, existingSkipped };
|
|
502
|
+
}
|
|
503
|
+
|
|
193
504
|
// ─── Extension ────────────────────────────────────────────────────────────────
|
|
194
505
|
|
|
195
506
|
export default function secureEnv(pi: ExtensionAPI) {
|
|
@@ -209,16 +520,17 @@ export default function secureEnv(pi: ExtensionAPI) {
|
|
|
209
520
|
"Never echo, log, or repeat secret values in your responses. Only report key names and applied/skipped status.",
|
|
210
521
|
],
|
|
211
522
|
parameters: Type.Object({
|
|
212
|
-
destination: Type.Union([
|
|
523
|
+
destination: Type.Optional(Type.Union([
|
|
213
524
|
Type.Literal("dotenv"),
|
|
214
525
|
Type.Literal("vercel"),
|
|
215
526
|
Type.Literal("convex"),
|
|
216
|
-
], { description: "Where to write the collected secrets" }),
|
|
527
|
+
], { description: "Where to write the collected secrets" })),
|
|
217
528
|
keys: Type.Array(
|
|
218
529
|
Type.Object({
|
|
219
530
|
key: Type.String({ description: "Env var name, e.g. OPENAI_API_KEY" }),
|
|
220
531
|
hint: Type.Optional(Type.String({ description: "Format hint shown to user, e.g. 'starts with sk-'" })),
|
|
221
532
|
required: Type.Optional(Type.Boolean()),
|
|
533
|
+
guidance: Type.Optional(Type.Array(Type.String(), { description: "Step-by-step guidance for finding this key" })),
|
|
222
534
|
}),
|
|
223
535
|
{ minItems: 1 },
|
|
224
536
|
),
|
|
@@ -240,79 +552,39 @@ export default function secureEnv(pi: ExtensionAPI) {
|
|
|
240
552
|
};
|
|
241
553
|
}
|
|
242
554
|
|
|
555
|
+
// Auto-detect destination when not provided
|
|
556
|
+
const destinationAutoDetected = params.destination == null;
|
|
557
|
+
const destination = params.destination ?? detectDestination(ctx.cwd);
|
|
558
|
+
|
|
243
559
|
const collected: CollectedSecret[] = [];
|
|
244
560
|
|
|
245
561
|
// Collect one key per page
|
|
246
562
|
for (let i = 0; i < params.keys.length; i++) {
|
|
247
563
|
const item = params.keys[i];
|
|
248
|
-
const value = await collectOneSecret(ctx, i, params.keys.length, item.key, item.hint);
|
|
564
|
+
const value = await collectOneSecret(ctx, i, params.keys.length, item.key, item.hint, item.guidance);
|
|
249
565
|
collected.push({ key: item.key, value });
|
|
250
566
|
}
|
|
251
567
|
|
|
252
568
|
const provided = collected.filter((c) => c.value !== null) as Array<{ key: string; value: string }>;
|
|
253
569
|
const skipped = collected.filter((c) => c.value === null).map((c) => c.key);
|
|
254
|
-
const applied: string[] = [];
|
|
255
|
-
const errors: string[] = [];
|
|
256
|
-
|
|
257
|
-
// Apply to destination
|
|
258
|
-
if (params.destination === "dotenv") {
|
|
259
|
-
const filePath = resolve(ctx.cwd, params.envFilePath ?? ".env");
|
|
260
|
-
for (const { key, value } of provided) {
|
|
261
|
-
try {
|
|
262
|
-
await writeEnvKey(filePath, key, value);
|
|
263
|
-
applied.push(key);
|
|
264
|
-
} catch (err: any) {
|
|
265
|
-
errors.push(`${key}: ${err.message}`);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
570
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
`printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}`,
|
|
277
|
-
]);
|
|
278
|
-
if (result.code !== 0) {
|
|
279
|
-
errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
|
|
280
|
-
} else {
|
|
281
|
-
applied.push(key);
|
|
282
|
-
}
|
|
283
|
-
} catch (err: any) {
|
|
284
|
-
errors.push(`${key}: ${err.message}`);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (params.destination === "convex") {
|
|
290
|
-
for (const { key, value } of provided) {
|
|
291
|
-
try {
|
|
292
|
-
const result = await pi.exec("sh", [
|
|
293
|
-
"-c",
|
|
294
|
-
`npx convex env set ${key} ${shellEscapeSingle(value)}`,
|
|
295
|
-
]);
|
|
296
|
-
if (result.code !== 0) {
|
|
297
|
-
errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
|
|
298
|
-
} else {
|
|
299
|
-
applied.push(key);
|
|
300
|
-
}
|
|
301
|
-
} catch (err: any) {
|
|
302
|
-
errors.push(`${key}: ${err.message}`);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
571
|
+
// Apply to destination via shared helper
|
|
572
|
+
const { applied, errors } = await applySecrets(provided, destination, {
|
|
573
|
+
envFilePath: resolve(ctx.cwd, params.envFilePath ?? ".env"),
|
|
574
|
+
environment: params.environment,
|
|
575
|
+
exec: (cmd, args) => pi.exec(cmd, args),
|
|
576
|
+
});
|
|
306
577
|
|
|
307
578
|
const details: ToolResultDetails = {
|
|
308
|
-
destination
|
|
579
|
+
destination,
|
|
309
580
|
environment: params.environment,
|
|
310
581
|
applied,
|
|
311
582
|
skipped,
|
|
583
|
+
...(destinationAutoDetected ? { detectedDestination: destination } : {}),
|
|
312
584
|
};
|
|
313
585
|
|
|
314
586
|
const lines = [
|
|
315
|
-
`destination: ${
|
|
587
|
+
`destination: ${destination}${destinationAutoDetected ? " (auto-detected)" : ""}${params.environment ? ` (${params.environment})` : ""}`,
|
|
316
588
|
...applied.map((k) => `✓ ${k}: applied`),
|
|
317
589
|
...skipped.map((k) => `• ${k}: skipped`),
|
|
318
590
|
...errors.map((e) => `✗ ${e}`),
|
|
@@ -329,7 +601,7 @@ export default function secureEnv(pi: ExtensionAPI) {
|
|
|
329
601
|
const count = Array.isArray(args.keys) ? args.keys.length : 0;
|
|
330
602
|
return new Text(
|
|
331
603
|
theme.fg("toolTitle", theme.bold("secure_env_collect ")) +
|
|
332
|
-
theme.fg("muted", `→ ${args.destination}`) +
|
|
604
|
+
theme.fg("muted", `→ ${args.destination ?? "auto"}`) +
|
|
333
605
|
theme.fg("dim", ` ${count} key${count !== 1 ? "s" : ""}`),
|
|
334
606
|
0, 0,
|
|
335
607
|
);
|