gsd-pi 2.4.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/README.md +4 -3
- package/dist/loader.js +21 -3
- package/dist/logo.d.ts +3 -3
- package/dist/logo.js +2 -2
- package/package.json +2 -2
- package/src/resources/GSD-WORKFLOW.md +7 -7
- package/src/resources/extensions/get-secrets-from-user.ts +63 -8
- package/src/resources/extensions/gsd/auto.ts +123 -34
- package/src/resources/extensions/gsd/docs/preferences-reference.md +28 -0
- package/src/resources/extensions/gsd/files.ts +70 -0
- package/src/resources/extensions/gsd/git-service.ts +151 -11
- 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 +59 -0
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/complete-slice.md +8 -6
- package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
- package/src/resources/extensions/gsd/prompts/execute-task.md +3 -7
- 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/prompts/replan-slice.md +1 -1
- package/src/resources/extensions/gsd/templates/plan.md +8 -10
- package/src/resources/extensions/gsd/templates/preferences.md +7 -0
- 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 +421 -0
- package/src/resources/extensions/gsd/tests/parsers.test.ts +211 -65
- package/src/resources/extensions/gsd/tests/replan-slice.test.ts +0 -2
- package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
- package/src/resources/extensions/gsd/types.ts +20 -0
- package/src/resources/extensions/gsd/worktree-command.ts +48 -6
- package/src/resources/extensions/gsd/worktree.ts +40 -147
- package/src/resources/extensions/search-the-web/index.ts +16 -25
- package/src/resources/extensions/search-the-web/native-search.ts +157 -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
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -385,7 +385,6 @@ console.log('\n=== prompt: replan-slice template loads and substitutes variables
|
|
|
385
385
|
sliceTitle: 'Test Slice',
|
|
386
386
|
slicePath: '.gsd/milestones/M001/slices/S01',
|
|
387
387
|
planPath: '.gsd/milestones/M001/slices/S01/S01-PLAN.md',
|
|
388
|
-
blockerTaskId: 'T02',
|
|
389
388
|
inlinedContext: '## Inlined Context\n\nTest context here.',
|
|
390
389
|
});
|
|
391
390
|
|
|
@@ -393,7 +392,6 @@ console.log('\n=== prompt: replan-slice template loads and substitutes variables
|
|
|
393
392
|
assert(prompt.includes('S01'), 'prompt contains sliceId');
|
|
394
393
|
assert(prompt.includes('Test Slice'), 'prompt contains sliceTitle');
|
|
395
394
|
assert(prompt.includes('.gsd/milestones/M001/slices/S01/S01-PLAN.md'), 'prompt contains planPath');
|
|
396
|
-
assert(prompt.includes('T02'), 'prompt contains blockerTaskId');
|
|
397
395
|
assert(prompt.includes('Test context here'), 'prompt contains inlined context');
|
|
398
396
|
}
|
|
399
397
|
|
|
@@ -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 {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
createWorktree,
|
|
20
20
|
listWorktrees,
|
|
21
21
|
removeWorktree,
|
|
22
|
+
mergeWorktreeToMain,
|
|
22
23
|
diffWorktreeAll,
|
|
23
24
|
diffWorktreeNumstat,
|
|
24
25
|
getMainBranch,
|
|
@@ -28,7 +29,9 @@ import {
|
|
|
28
29
|
worktreeBranchName,
|
|
29
30
|
worktreePath,
|
|
30
31
|
} from "./worktree-manager.js";
|
|
32
|
+
import { inferCommitType } from "./git-service.js";
|
|
31
33
|
import type { FileLineStat } from "./worktree-manager.js";
|
|
34
|
+
import { execSync } from "node:child_process";
|
|
32
35
|
import { existsSync, realpathSync, readFileSync, readdirSync, rmSync, unlinkSync, utimesSync } from "node:fs";
|
|
33
36
|
import { join, resolve, sep } from "node:path";
|
|
34
37
|
|
|
@@ -349,13 +352,14 @@ async function handleCreate(
|
|
|
349
352
|
ctx: ExtensionCommandContext,
|
|
350
353
|
): Promise<void> {
|
|
351
354
|
try {
|
|
355
|
+
// Auto-commit dirty files before leaving current workspace (must happen
|
|
356
|
+
// before createWorktree so the new worktree forks from committed HEAD)
|
|
357
|
+
const commitMsg = autoCommitCurrentBranch(basePath, "worktree-switch", name);
|
|
358
|
+
|
|
352
359
|
// Create from the main tree, not from inside another worktree
|
|
353
360
|
const mainBase = originalCwd ?? basePath;
|
|
354
361
|
const info = createWorktree(mainBase, name);
|
|
355
362
|
|
|
356
|
-
// Auto-commit dirty files before leaving current workspace
|
|
357
|
-
const commitMsg = autoCommitCurrentBranch(basePath, "worktree-switch", name);
|
|
358
|
-
|
|
359
363
|
// Track original cwd before switching
|
|
360
364
|
if (!originalCwd) originalCwd = basePath;
|
|
361
365
|
|
|
@@ -655,9 +659,8 @@ async function handleMerge(
|
|
|
655
659
|
return;
|
|
656
660
|
}
|
|
657
661
|
|
|
658
|
-
// Switch to the main tree before
|
|
659
|
-
//
|
|
660
|
-
// it later removes the worktree, the agent's CWD must not be inside it.
|
|
662
|
+
// Switch to the main tree before merging.
|
|
663
|
+
// Must be on the main branch to run git merge --squash.
|
|
661
664
|
if (originalCwd) {
|
|
662
665
|
const prevCwd = process.cwd();
|
|
663
666
|
process.chdir(basePath);
|
|
@@ -665,6 +668,45 @@ async function handleMerge(
|
|
|
665
668
|
originalCwd = null;
|
|
666
669
|
}
|
|
667
670
|
|
|
671
|
+
// --- Deterministic merge path (preferred) ---
|
|
672
|
+
// Try a direct squash-merge first. Only fall back to LLM on conflict.
|
|
673
|
+
const commitType = inferCommitType(name);
|
|
674
|
+
const commitMessage = `${commitType}(${name}): merge worktree ${name}`;
|
|
675
|
+
try {
|
|
676
|
+
mergeWorktreeToMain(basePath, name, commitMessage);
|
|
677
|
+
ctx.ui.notify(
|
|
678
|
+
[
|
|
679
|
+
`${CLR.ok("✓")} Merged ${CLR.name(name)} → ${CLR.branch(mainBranch)} ${CLR.muted("(deterministic squash)")}`,
|
|
680
|
+
"",
|
|
681
|
+
` ${totalChanges} file${totalChanges === 1 ? "" : "s"} changed, ${CLR.ok(`+${totalAdded}`)} ${RED}-${totalRemoved}${RESET} lines`,
|
|
682
|
+
` ${CLR.muted("commit:")} ${commitMessage}`,
|
|
683
|
+
].join("\n"),
|
|
684
|
+
"info",
|
|
685
|
+
);
|
|
686
|
+
return;
|
|
687
|
+
} catch (mergeErr) {
|
|
688
|
+
const mergeMsg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
|
|
689
|
+
const isConflict = /conflict/i.test(mergeMsg);
|
|
690
|
+
|
|
691
|
+
if (isConflict) {
|
|
692
|
+
// Abort the failed merge so the working tree is clean for LLM retry
|
|
693
|
+
try {
|
|
694
|
+
execSync("git merge --abort", { cwd: basePath, stdio: "pipe" });
|
|
695
|
+
} catch { /* already clean */ }
|
|
696
|
+
|
|
697
|
+
ctx.ui.notify(
|
|
698
|
+
`${CLR.muted("Deterministic merge hit conflicts — falling back to LLM-guided merge.")}`,
|
|
699
|
+
"warning",
|
|
700
|
+
);
|
|
701
|
+
// Fall through to LLM dispatch below
|
|
702
|
+
} else {
|
|
703
|
+
// Non-conflict error — surface it directly, don't fall back
|
|
704
|
+
ctx.ui.notify(`Failed to merge: ${mergeMsg}`, "error");
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// --- LLM fallback path (conflict resolution) ---
|
|
668
710
|
// Format file lists for the prompt
|
|
669
711
|
const formatFiles = (files: string[]) =>
|
|
670
712
|
files.length > 0 ? files.map(f => `- \`${f}\``).join("\n") : "_(none)_";
|