gsd-pi 2.5.0 → 2.5.1
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/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 +63 -8
- package/src/resources/extensions/gsd/auto.ts +45 -11
- package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -0
- package/src/resources/extensions/gsd/files.ts +70 -0
- package/src/resources/extensions/gsd/git-service.ts +27 -106
- 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/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/git-service.test.ts +63 -0
- package/src/resources/extensions/gsd/tests/parsers.test.ts +211 -65
- package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
- package/src/resources/extensions/gsd/types.ts +20 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseRoadmap, parsePlan, parseSummary, parseContinue, parseRequirementCounts } from '../files.ts';
|
|
1
|
+
import { parseRoadmap, parsePlan, parseSummary, parseContinue, parseRequirementCounts, parseSecretsManifest, formatSecretsManifest } from '../files.ts';
|
|
2
2
|
|
|
3
3
|
let passed = 0;
|
|
4
4
|
let failed = 0;
|
|
@@ -1249,97 +1249,243 @@ console.log('\n=== parseRequirementCounts: total is sum of all section counts ==
|
|
|
1249
1249
|
}
|
|
1250
1250
|
|
|
1251
1251
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1252
|
-
//
|
|
1252
|
+
// parseSecretsManifest / formatSecretsManifest tests
|
|
1253
1253
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1254
1254
|
|
|
1255
|
-
console.log('\n===
|
|
1255
|
+
console.log('\n=== parseSecretsManifest: full manifest with 3 keys ===');
|
|
1256
1256
|
{
|
|
1257
|
-
const content =
|
|
1258
|
-
id: T04
|
|
1259
|
-
parent: S03
|
|
1260
|
-
milestone: M001
|
|
1261
|
-
provides:
|
|
1262
|
-
- iOS rules
|
|
1263
|
-
key_files:
|
|
1264
|
-
- .claude/rules/swift-style.md
|
|
1265
|
-
key_decisions: none
|
|
1266
|
-
patterns_established: none
|
|
1267
|
-
drill_down_paths: none
|
|
1268
|
-
observability_surfaces: none — static reference files
|
|
1269
|
-
affects: single-value
|
|
1270
|
-
---
|
|
1257
|
+
const content = `# Secrets Manifest
|
|
1271
1258
|
|
|
1272
|
-
|
|
1259
|
+
**Milestone:** M003
|
|
1260
|
+
**Generated:** 2025-06-15T10:00:00Z
|
|
1273
1261
|
|
|
1274
|
-
|
|
1262
|
+
### OPENAI_API_KEY
|
|
1275
1263
|
|
|
1276
|
-
|
|
1264
|
+
**Service:** OpenAI
|
|
1265
|
+
**Dashboard:** https://platform.openai.com/api-keys
|
|
1266
|
+
**Format hint:** starts with sk-
|
|
1267
|
+
**Status:** pending
|
|
1268
|
+
**Destination:** dotenv
|
|
1277
1269
|
|
|
1278
|
-
|
|
1270
|
+
1. Go to https://platform.openai.com/api-keys
|
|
1271
|
+
2. Click "Create new secret key"
|
|
1272
|
+
3. Copy the key immediately — it won't be shown again
|
|
1279
1273
|
|
|
1280
|
-
|
|
1274
|
+
### STRIPE_SECRET_KEY
|
|
1281
1275
|
|
|
1282
|
-
|
|
1276
|
+
**Service:** Stripe
|
|
1277
|
+
**Dashboard:** https://dashboard.stripe.com/apikeys
|
|
1278
|
+
**Format hint:** starts with sk_test_ or sk_live_
|
|
1279
|
+
**Status:** collected
|
|
1280
|
+
**Destination:** dotenv
|
|
1281
|
+
|
|
1282
|
+
1. Go to https://dashboard.stripe.com/apikeys
|
|
1283
|
+
2. Reveal the secret key
|
|
1284
|
+
3. Copy it
|
|
1285
|
+
|
|
1286
|
+
### SUPABASE_URL
|
|
1287
|
+
|
|
1288
|
+
**Service:** Supabase
|
|
1289
|
+
**Dashboard:** https://app.supabase.com/project/settings/api
|
|
1290
|
+
**Format hint:** https://<project-ref>.supabase.co
|
|
1291
|
+
**Status:** skipped
|
|
1292
|
+
**Destination:** vercel
|
|
1293
|
+
|
|
1294
|
+
1. Go to project settings in Supabase
|
|
1295
|
+
2. Copy the URL from the API section
|
|
1283
1296
|
`;
|
|
1284
1297
|
|
|
1285
|
-
const
|
|
1298
|
+
const m = parseSecretsManifest(content);
|
|
1299
|
+
|
|
1300
|
+
assertEq(m.milestone, 'M003', 'manifest milestone');
|
|
1301
|
+
assertEq(m.generatedAt, '2025-06-15T10:00:00Z', 'manifest generatedAt');
|
|
1302
|
+
assertEq(m.entries.length, 3, 'three entries');
|
|
1303
|
+
|
|
1304
|
+
// First entry
|
|
1305
|
+
assertEq(m.entries[0].key, 'OPENAI_API_KEY', 'entry 0 key');
|
|
1306
|
+
assertEq(m.entries[0].service, 'OpenAI', 'entry 0 service');
|
|
1307
|
+
assertEq(m.entries[0].dashboardUrl, 'https://platform.openai.com/api-keys', 'entry 0 dashboardUrl');
|
|
1308
|
+
assertEq(m.entries[0].formatHint, 'starts with sk-', 'entry 0 formatHint');
|
|
1309
|
+
assertEq(m.entries[0].status, 'pending', 'entry 0 status');
|
|
1310
|
+
assertEq(m.entries[0].destination, 'dotenv', 'entry 0 destination');
|
|
1311
|
+
assertEq(m.entries[0].guidance.length, 3, 'entry 0 guidance count');
|
|
1312
|
+
assertEq(m.entries[0].guidance[0], 'Go to https://platform.openai.com/api-keys', 'entry 0 guidance[0]');
|
|
1313
|
+
assertEq(m.entries[0].guidance[2], 'Copy the key immediately — it won\'t be shown again', 'entry 0 guidance[2]');
|
|
1314
|
+
|
|
1315
|
+
// Second entry
|
|
1316
|
+
assertEq(m.entries[1].key, 'STRIPE_SECRET_KEY', 'entry 1 key');
|
|
1317
|
+
assertEq(m.entries[1].service, 'Stripe', 'entry 1 service');
|
|
1318
|
+
assertEq(m.entries[1].status, 'collected', 'entry 1 status');
|
|
1319
|
+
assertEq(m.entries[1].formatHint, 'starts with sk_test_ or sk_live_', 'entry 1 formatHint');
|
|
1320
|
+
assertEq(m.entries[1].guidance.length, 3, 'entry 1 guidance count');
|
|
1321
|
+
|
|
1322
|
+
// Third entry
|
|
1323
|
+
assertEq(m.entries[2].key, 'SUPABASE_URL', 'entry 2 key');
|
|
1324
|
+
assertEq(m.entries[2].status, 'skipped', 'entry 2 status');
|
|
1325
|
+
assertEq(m.entries[2].destination, 'vercel', 'entry 2 destination');
|
|
1326
|
+
assertEq(m.entries[2].guidance.length, 2, 'entry 2 guidance count');
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
console.log('\n=== parseSecretsManifest: single-key manifest ===');
|
|
1330
|
+
{
|
|
1331
|
+
const content = `# Secrets Manifest
|
|
1332
|
+
|
|
1333
|
+
**Milestone:** M001
|
|
1334
|
+
**Generated:** 2025-06-15T12:00:00Z
|
|
1286
1335
|
|
|
1287
|
-
|
|
1288
|
-
assertEq(s.frontmatter.provides.length, 1, '#91: provides array preserved');
|
|
1289
|
-
assertEq(s.frontmatter.provides[0], 'iOS rules', '#91: provides value');
|
|
1290
|
-
assertEq(s.frontmatter.key_files.length, 1, '#91: key_files array preserved');
|
|
1336
|
+
### DATABASE_URL
|
|
1291
1337
|
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1338
|
+
**Service:** PostgreSQL
|
|
1339
|
+
**Dashboard:** https://console.neon.tech
|
|
1340
|
+
**Format hint:** postgresql://...
|
|
1341
|
+
**Status:** pending
|
|
1342
|
+
**Destination:** dotenv
|
|
1296
1343
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1344
|
+
1. Create a database on Neon
|
|
1345
|
+
2. Copy the connection string
|
|
1346
|
+
`;
|
|
1299
1347
|
|
|
1300
|
-
|
|
1301
|
-
assertEq(
|
|
1348
|
+
const m = parseSecretsManifest(content);
|
|
1349
|
+
assertEq(m.milestone, 'M001', 'single-key milestone');
|
|
1350
|
+
assertEq(m.entries.length, 1, 'single entry');
|
|
1351
|
+
assertEq(m.entries[0].key, 'DATABASE_URL', 'single entry key');
|
|
1352
|
+
assertEq(m.entries[0].service, 'PostgreSQL', 'single entry service');
|
|
1353
|
+
assertEq(m.entries[0].guidance.length, 2, 'single entry guidance count');
|
|
1354
|
+
}
|
|
1302
1355
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
assertEq(s.frontmatter.observability_surfaces[0], 'none — static reference files', '#91: full scalar preserved');
|
|
1356
|
+
console.log('\n=== parseSecretsManifest: empty/no-secrets manifest ===');
|
|
1357
|
+
{
|
|
1358
|
+
const content = `# Secrets Manifest
|
|
1307
1359
|
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
assertEq(s.frontmatter.affects[0], 'single-value', '#91: affects value');
|
|
1360
|
+
**Milestone:** M002
|
|
1361
|
+
**Generated:** 2025-06-15T14:00:00Z
|
|
1362
|
+
`;
|
|
1312
1363
|
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
assertEq(
|
|
1364
|
+
const m = parseSecretsManifest(content);
|
|
1365
|
+
assertEq(m.milestone, 'M002', 'empty manifest milestone');
|
|
1366
|
+
assertEq(m.generatedAt, '2025-06-15T14:00:00Z', 'empty manifest generatedAt');
|
|
1367
|
+
assertEq(m.entries.length, 0, 'no entries in empty manifest');
|
|
1316
1368
|
}
|
|
1317
1369
|
|
|
1318
|
-
console.log('\n===
|
|
1370
|
+
console.log('\n=== parseSecretsManifest: missing optional fields default correctly ===');
|
|
1319
1371
|
{
|
|
1320
|
-
const content =
|
|
1321
|
-
id: T05
|
|
1322
|
-
parent: S04
|
|
1323
|
-
milestone: M001
|
|
1324
|
-
---
|
|
1372
|
+
const content = `# Secrets Manifest
|
|
1325
1373
|
|
|
1326
|
-
|
|
1374
|
+
**Milestone:** M004
|
|
1375
|
+
**Generated:** 2025-06-15T16:00:00Z
|
|
1327
1376
|
|
|
1328
|
-
|
|
1377
|
+
### SOME_API_KEY
|
|
1329
1378
|
|
|
1330
|
-
|
|
1379
|
+
**Service:** SomeService
|
|
1331
1380
|
|
|
1332
|
-
|
|
1381
|
+
1. Get the key from the dashboard
|
|
1333
1382
|
`;
|
|
1334
1383
|
|
|
1335
|
-
const
|
|
1336
|
-
assertEq(
|
|
1337
|
-
assertEq(
|
|
1338
|
-
assertEq(
|
|
1339
|
-
assertEq(
|
|
1340
|
-
assertEq(
|
|
1341
|
-
assertEq(
|
|
1342
|
-
assertEq(
|
|
1384
|
+
const m = parseSecretsManifest(content);
|
|
1385
|
+
assertEq(m.entries.length, 1, 'one entry with missing fields');
|
|
1386
|
+
assertEq(m.entries[0].key, 'SOME_API_KEY', 'key parsed');
|
|
1387
|
+
assertEq(m.entries[0].service, 'SomeService', 'service parsed');
|
|
1388
|
+
assertEq(m.entries[0].dashboardUrl, '', 'missing dashboardUrl defaults to empty string');
|
|
1389
|
+
assertEq(m.entries[0].formatHint, '', 'missing formatHint defaults to empty string');
|
|
1390
|
+
assertEq(m.entries[0].status, 'pending', 'missing status defaults to pending');
|
|
1391
|
+
assertEq(m.entries[0].destination, 'dotenv', 'missing destination defaults to dotenv');
|
|
1392
|
+
assertEq(m.entries[0].guidance.length, 1, 'guidance still parsed');
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
console.log('\n=== parseSecretsManifest: all three status values parse ===');
|
|
1396
|
+
{
|
|
1397
|
+
for (const status of ['pending', 'collected', 'skipped'] as const) {
|
|
1398
|
+
const content = `# Secrets Manifest
|
|
1399
|
+
|
|
1400
|
+
**Milestone:** M005
|
|
1401
|
+
**Generated:** 2025-06-15T18:00:00Z
|
|
1402
|
+
|
|
1403
|
+
### TEST_KEY
|
|
1404
|
+
|
|
1405
|
+
**Service:** TestService
|
|
1406
|
+
**Status:** ${status}
|
|
1407
|
+
|
|
1408
|
+
1. Do something
|
|
1409
|
+
`;
|
|
1410
|
+
|
|
1411
|
+
const m = parseSecretsManifest(content);
|
|
1412
|
+
assertEq(m.entries[0].status, status, `status variant: ${status}`);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
console.log('\n=== parseSecretsManifest: invalid status defaults to pending ===');
|
|
1417
|
+
{
|
|
1418
|
+
const content = `# Secrets Manifest
|
|
1419
|
+
|
|
1420
|
+
**Milestone:** M006
|
|
1421
|
+
**Generated:** 2025-06-15T20:00:00Z
|
|
1422
|
+
|
|
1423
|
+
### BAD_STATUS_KEY
|
|
1424
|
+
|
|
1425
|
+
**Service:** TestService
|
|
1426
|
+
**Status:** invalid_value
|
|
1427
|
+
|
|
1428
|
+
1. Some step
|
|
1429
|
+
`;
|
|
1430
|
+
|
|
1431
|
+
const m = parseSecretsManifest(content);
|
|
1432
|
+
assertEq(m.entries[0].status, 'pending', 'invalid status defaults to pending');
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
console.log('\n=== parseSecretsManifest + formatSecretsManifest: round-trip ===');
|
|
1436
|
+
{
|
|
1437
|
+
const original = `# Secrets Manifest
|
|
1438
|
+
|
|
1439
|
+
**Milestone:** M007
|
|
1440
|
+
**Generated:** 2025-06-16T10:00:00Z
|
|
1441
|
+
|
|
1442
|
+
### OPENAI_API_KEY
|
|
1443
|
+
|
|
1444
|
+
**Service:** OpenAI
|
|
1445
|
+
**Dashboard:** https://platform.openai.com/api-keys
|
|
1446
|
+
**Format hint:** starts with sk-
|
|
1447
|
+
**Status:** pending
|
|
1448
|
+
**Destination:** dotenv
|
|
1449
|
+
|
|
1450
|
+
1. Go to the API keys page
|
|
1451
|
+
2. Create a new key
|
|
1452
|
+
3. Copy it
|
|
1453
|
+
|
|
1454
|
+
### REDIS_URL
|
|
1455
|
+
|
|
1456
|
+
**Service:** Upstash
|
|
1457
|
+
**Dashboard:** https://console.upstash.com
|
|
1458
|
+
**Format hint:** redis://...
|
|
1459
|
+
**Status:** collected
|
|
1460
|
+
**Destination:** vercel
|
|
1461
|
+
|
|
1462
|
+
1. Open Upstash console
|
|
1463
|
+
2. Copy the Redis URL
|
|
1464
|
+
`;
|
|
1465
|
+
|
|
1466
|
+
const parsed1 = parseSecretsManifest(original);
|
|
1467
|
+
const formatted = formatSecretsManifest(parsed1);
|
|
1468
|
+
const parsed2 = parseSecretsManifest(formatted);
|
|
1469
|
+
|
|
1470
|
+
// Verify semantic equality after round-trip
|
|
1471
|
+
assertEq(parsed2.milestone, parsed1.milestone, 'round-trip milestone');
|
|
1472
|
+
assertEq(parsed2.generatedAt, parsed1.generatedAt, 'round-trip generatedAt');
|
|
1473
|
+
assertEq(parsed2.entries.length, parsed1.entries.length, 'round-trip entry count');
|
|
1474
|
+
|
|
1475
|
+
for (let i = 0; i < parsed1.entries.length; i++) {
|
|
1476
|
+
const e1 = parsed1.entries[i];
|
|
1477
|
+
const e2 = parsed2.entries[i];
|
|
1478
|
+
assertEq(e2.key, e1.key, `round-trip entry ${i} key`);
|
|
1479
|
+
assertEq(e2.service, e1.service, `round-trip entry ${i} service`);
|
|
1480
|
+
assertEq(e2.dashboardUrl, e1.dashboardUrl, `round-trip entry ${i} dashboardUrl`);
|
|
1481
|
+
assertEq(e2.formatHint, e1.formatHint, `round-trip entry ${i} formatHint`);
|
|
1482
|
+
assertEq(e2.status, e1.status, `round-trip entry ${i} status`);
|
|
1483
|
+
assertEq(e2.destination, e1.destination, `round-trip entry ${i} destination`);
|
|
1484
|
+
assertEq(e2.guidance.length, e1.guidance.length, `round-trip entry ${i} guidance length`);
|
|
1485
|
+
for (let j = 0; j < e1.guidance.length; j++) {
|
|
1486
|
+
assertEq(e2.guidance[j], e1.guidance[j], `round-trip entry ${i} guidance[${j}]`);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1343
1489
|
}
|
|
1344
1490
|
|
|
1345
1491
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for secure_env_collect utility functions:
|
|
3
|
+
* - checkExistingEnvKeys: detects keys already present in .env file or process.env
|
|
4
|
+
* - detectDestination: infers write destination from project files
|
|
5
|
+
*
|
|
6
|
+
* Uses temp directories for filesystem isolation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { checkExistingEnvKeys, detectDestination } from "../../get-secrets-from-user.ts";
|
|
15
|
+
|
|
16
|
+
function makeTempDir(prefix: string): string {
|
|
17
|
+
const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
18
|
+
mkdirSync(dir, { recursive: true });
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── checkExistingEnvKeys ─────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
test("secure_env_collect: checkExistingEnvKeys — key found in .env file", async () => {
|
|
25
|
+
const tmp = makeTempDir("sec-env-test");
|
|
26
|
+
try {
|
|
27
|
+
const envPath = join(tmp, ".env");
|
|
28
|
+
writeFileSync(envPath, "API_KEY=secret123\nOTHER=val\n");
|
|
29
|
+
const result = await checkExistingEnvKeys(["API_KEY"], envPath);
|
|
30
|
+
assert.deepStrictEqual(result, ["API_KEY"]);
|
|
31
|
+
} finally {
|
|
32
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("secure_env_collect: checkExistingEnvKeys — key found in process.env", async () => {
|
|
37
|
+
const tmp = makeTempDir("sec-env-test");
|
|
38
|
+
const savedVal = process.env.GSD_TEST_ENV_KEY_12345;
|
|
39
|
+
try {
|
|
40
|
+
process.env.GSD_TEST_ENV_KEY_12345 = "some-value";
|
|
41
|
+
const envPath = join(tmp, ".env"); // file doesn't exist
|
|
42
|
+
const result = await checkExistingEnvKeys(["GSD_TEST_ENV_KEY_12345"], envPath);
|
|
43
|
+
assert.deepStrictEqual(result, ["GSD_TEST_ENV_KEY_12345"]);
|
|
44
|
+
} finally {
|
|
45
|
+
delete process.env.GSD_TEST_ENV_KEY_12345;
|
|
46
|
+
if (savedVal !== undefined) process.env.GSD_TEST_ENV_KEY_12345 = savedVal;
|
|
47
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("secure_env_collect: checkExistingEnvKeys — key found in both .env and process.env", async () => {
|
|
52
|
+
const tmp = makeTempDir("sec-env-test");
|
|
53
|
+
const savedVal = process.env.GSD_TEST_BOTH_KEY;
|
|
54
|
+
try {
|
|
55
|
+
process.env.GSD_TEST_BOTH_KEY = "from-env";
|
|
56
|
+
const envPath = join(tmp, ".env");
|
|
57
|
+
writeFileSync(envPath, "GSD_TEST_BOTH_KEY=from-file\n");
|
|
58
|
+
const result = await checkExistingEnvKeys(["GSD_TEST_BOTH_KEY"], envPath);
|
|
59
|
+
assert.deepStrictEqual(result, ["GSD_TEST_BOTH_KEY"]);
|
|
60
|
+
} finally {
|
|
61
|
+
delete process.env.GSD_TEST_BOTH_KEY;
|
|
62
|
+
if (savedVal !== undefined) process.env.GSD_TEST_BOTH_KEY = savedVal;
|
|
63
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("secure_env_collect: checkExistingEnvKeys — key not found anywhere", async () => {
|
|
68
|
+
const tmp = makeTempDir("sec-env-test");
|
|
69
|
+
try {
|
|
70
|
+
const envPath = join(tmp, ".env");
|
|
71
|
+
writeFileSync(envPath, "OTHER_KEY=val\n");
|
|
72
|
+
// Ensure it's not in process.env
|
|
73
|
+
delete process.env.DEFINITELY_NOT_SET_KEY_XYZ;
|
|
74
|
+
const result = await checkExistingEnvKeys(["DEFINITELY_NOT_SET_KEY_XYZ"], envPath);
|
|
75
|
+
assert.deepStrictEqual(result, []);
|
|
76
|
+
} finally {
|
|
77
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("secure_env_collect: checkExistingEnvKeys — .env file doesn't exist (ENOENT), still checks process.env", async () => {
|
|
82
|
+
const tmp = makeTempDir("sec-env-test");
|
|
83
|
+
const savedVal = process.env.GSD_TEST_ENOENT_KEY;
|
|
84
|
+
try {
|
|
85
|
+
process.env.GSD_TEST_ENOENT_KEY = "exists-in-process";
|
|
86
|
+
const envPath = join(tmp, "nonexistent.env");
|
|
87
|
+
const result = await checkExistingEnvKeys(["GSD_TEST_ENOENT_KEY", "MISSING_KEY_XYZ"], envPath);
|
|
88
|
+
assert.deepStrictEqual(result, ["GSD_TEST_ENOENT_KEY"]);
|
|
89
|
+
} finally {
|
|
90
|
+
delete process.env.GSD_TEST_ENOENT_KEY;
|
|
91
|
+
if (savedVal !== undefined) process.env.GSD_TEST_ENOENT_KEY = savedVal;
|
|
92
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("secure_env_collect: checkExistingEnvKeys — empty-string value in process.env counts as existing", async () => {
|
|
97
|
+
const tmp = makeTempDir("sec-env-test");
|
|
98
|
+
const savedVal = process.env.GSD_TEST_EMPTY_KEY;
|
|
99
|
+
try {
|
|
100
|
+
process.env.GSD_TEST_EMPTY_KEY = "";
|
|
101
|
+
const envPath = join(tmp, ".env");
|
|
102
|
+
writeFileSync(envPath, "");
|
|
103
|
+
const result = await checkExistingEnvKeys(["GSD_TEST_EMPTY_KEY"], envPath);
|
|
104
|
+
assert.deepStrictEqual(result, ["GSD_TEST_EMPTY_KEY"]);
|
|
105
|
+
} finally {
|
|
106
|
+
delete process.env.GSD_TEST_EMPTY_KEY;
|
|
107
|
+
if (savedVal !== undefined) process.env.GSD_TEST_EMPTY_KEY = savedVal;
|
|
108
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("secure_env_collect: checkExistingEnvKeys — returns only existing keys from input list", async () => {
|
|
113
|
+
const tmp = makeTempDir("sec-env-test");
|
|
114
|
+
const saved1 = process.env.GSD_TEST_EXISTS_A;
|
|
115
|
+
const saved2 = process.env.GSD_TEST_EXISTS_B;
|
|
116
|
+
try {
|
|
117
|
+
process.env.GSD_TEST_EXISTS_A = "val-a";
|
|
118
|
+
delete process.env.GSD_TEST_EXISTS_B;
|
|
119
|
+
const envPath = join(tmp, ".env");
|
|
120
|
+
writeFileSync(envPath, "FILE_KEY=val\n");
|
|
121
|
+
const result = await checkExistingEnvKeys(
|
|
122
|
+
["GSD_TEST_EXISTS_A", "GSD_TEST_EXISTS_B", "FILE_KEY", "NOPE_KEY"],
|
|
123
|
+
envPath,
|
|
124
|
+
);
|
|
125
|
+
assert.deepStrictEqual(result.sort(), ["FILE_KEY", "GSD_TEST_EXISTS_A"]);
|
|
126
|
+
} finally {
|
|
127
|
+
delete process.env.GSD_TEST_EXISTS_A;
|
|
128
|
+
delete process.env.GSD_TEST_EXISTS_B;
|
|
129
|
+
if (saved1 !== undefined) process.env.GSD_TEST_EXISTS_A = saved1;
|
|
130
|
+
if (saved2 !== undefined) process.env.GSD_TEST_EXISTS_B = saved2;
|
|
131
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ─── detectDestination ────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
test("secure_env_collect: detectDestination — returns 'vercel' when vercel.json exists", () => {
|
|
138
|
+
const tmp = makeTempDir("sec-dest-test");
|
|
139
|
+
try {
|
|
140
|
+
writeFileSync(join(tmp, "vercel.json"), "{}");
|
|
141
|
+
assert.equal(detectDestination(tmp), "vercel");
|
|
142
|
+
} finally {
|
|
143
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("secure_env_collect: detectDestination — returns 'convex' when convex/ dir exists", () => {
|
|
148
|
+
const tmp = makeTempDir("sec-dest-test");
|
|
149
|
+
try {
|
|
150
|
+
mkdirSync(join(tmp, "convex"));
|
|
151
|
+
assert.equal(detectDestination(tmp), "convex");
|
|
152
|
+
} finally {
|
|
153
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("secure_env_collect: detectDestination — returns 'dotenv' when neither exists", () => {
|
|
158
|
+
const tmp = makeTempDir("sec-dest-test");
|
|
159
|
+
try {
|
|
160
|
+
assert.equal(detectDestination(tmp), "dotenv");
|
|
161
|
+
} finally {
|
|
162
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("secure_env_collect: detectDestination — vercel takes priority when both exist", () => {
|
|
167
|
+
const tmp = makeTempDir("sec-dest-test");
|
|
168
|
+
try {
|
|
169
|
+
writeFileSync(join(tmp, "vercel.json"), "{}");
|
|
170
|
+
mkdirSync(join(tmp, "convex"));
|
|
171
|
+
assert.equal(detectDestination(tmp), "vercel");
|
|
172
|
+
} finally {
|
|
173
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("secure_env_collect: detectDestination — convex file (not dir) does not trigger convex", () => {
|
|
178
|
+
const tmp = makeTempDir("sec-dest-test");
|
|
179
|
+
try {
|
|
180
|
+
writeFileSync(join(tmp, "convex"), "not a directory");
|
|
181
|
+
assert.equal(detectDestination(tmp), "dotenv");
|
|
182
|
+
} finally {
|
|
183
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
@@ -116,6 +116,26 @@ export interface Continue {
|
|
|
116
116
|
nextAction: string;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// ─── Secrets Manifest ──────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
export type SecretsManifestEntryStatus = 'pending' | 'collected' | 'skipped';
|
|
122
|
+
|
|
123
|
+
export interface SecretsManifestEntry {
|
|
124
|
+
key: string; // e.g. "OPENAI_API_KEY"
|
|
125
|
+
service: string; // e.g. "OpenAI"
|
|
126
|
+
dashboardUrl: string; // e.g. "https://platform.openai.com/api-keys" — empty if unknown
|
|
127
|
+
guidance: string[]; // numbered setup steps
|
|
128
|
+
formatHint: string; // e.g. "starts with sk-" — empty if unknown
|
|
129
|
+
status: SecretsManifestEntryStatus;
|
|
130
|
+
destination: string; // e.g. "dotenv", "vercel", "convex"
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface SecretsManifest {
|
|
134
|
+
milestone: string; // e.g. "M001"
|
|
135
|
+
generatedAt: string; // ISO 8601 timestamp
|
|
136
|
+
entries: SecretsManifestEntry[];
|
|
137
|
+
}
|
|
138
|
+
|
|
119
139
|
// ─── GSD State (Derived Dashboard) ────────────────────────────────────────
|
|
120
140
|
|
|
121
141
|
export interface ActiveRef {
|