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.
Files changed (33) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +7 -1
  3. package/dist/loader.js +21 -3
  4. package/dist/logo.d.ts +3 -3
  5. package/dist/logo.js +2 -2
  6. package/package.json +1 -1
  7. package/src/resources/extensions/get-secrets-from-user.ts +331 -59
  8. package/src/resources/extensions/gsd/auto.ts +80 -18
  9. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -0
  10. package/src/resources/extensions/gsd/doctor.ts +23 -4
  11. package/src/resources/extensions/gsd/files.ts +115 -1
  12. package/src/resources/extensions/gsd/git-service.ts +67 -105
  13. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  14. package/src/resources/extensions/gsd/guided-flow.ts +6 -3
  15. package/src/resources/extensions/gsd/preferences.ts +8 -0
  16. package/src/resources/extensions/gsd/prompts/complete-slice.md +7 -5
  17. package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
  18. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -6
  19. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -0
  20. package/src/resources/extensions/gsd/prompts/plan-milestone.md +33 -1
  21. package/src/resources/extensions/gsd/prompts/plan-slice.md +24 -32
  22. package/src/resources/extensions/gsd/session-forensics.ts +19 -6
  23. package/src/resources/extensions/gsd/templates/plan.md +8 -10
  24. package/src/resources/extensions/gsd/templates/secrets-manifest.md +22 -0
  25. package/src/resources/extensions/gsd/templates/task-plan.md +6 -6
  26. package/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +196 -0
  27. package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +469 -0
  28. package/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts +170 -0
  29. package/src/resources/extensions/gsd/tests/git-service.test.ts +106 -0
  30. package/src/resources/extensions/gsd/tests/manifest-status.test.ts +283 -0
  31. package/src/resources/extensions/gsd/tests/parsers.test.ts +401 -65
  32. package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
  33. package/src/resources/extensions/gsd/types.ts +27 -0
@@ -7,6 +7,7 @@ import {
7
7
  inferCommitType,
8
8
  GitServiceImpl,
9
9
  RUNTIME_EXCLUSION_PATHS,
10
+ VALID_BRANCH_NAME,
10
11
  runGit,
11
12
  type GitPreferences,
12
13
  type CommitOptions,
@@ -452,6 +453,49 @@ async function main(): Promise<void> {
452
453
  rmSync(repo, { recursive: true, force: true });
453
454
  }
454
455
 
456
+ // ─── GitServiceImpl: autoCommit with extraExclusions ───────────────────
457
+
458
+ console.log("\n=== GitServiceImpl: autoCommit with extraExclusions ===");
459
+
460
+ {
461
+ const repo = initTempRepo();
462
+ const svc = new GitServiceImpl(repo);
463
+
464
+ // Create both a .gsd/ planning file and a regular source file
465
+ createFile(repo, ".gsd/milestones/M001/M001-ROADMAP.md", "- [x] S01");
466
+ createFile(repo, "src/feature.ts", "export const y = 2;");
467
+
468
+ // Auto-commit with .gsd/ excluded (simulates pre-switch)
469
+ const msg = svc.autoCommit("pre-switch", "main", [".gsd/"]);
470
+ assertEq(msg, "chore(main): auto-commit after pre-switch", "pre-switch autoCommit with .gsd/ exclusion commits");
471
+
472
+ // Verify .gsd/ file was NOT committed
473
+ const show = run("git show --stat HEAD", repo);
474
+ assert(!show.includes("ROADMAP"), ".gsd/ files excluded from pre-switch auto-commit");
475
+ assert(show.includes("feature.ts"), "non-.gsd/ files included in pre-switch auto-commit");
476
+
477
+ rmSync(repo, { recursive: true, force: true });
478
+ }
479
+
480
+ // ─── GitServiceImpl: autoCommit extraExclusions — only .gsd/ dirty ────
481
+
482
+ console.log("\n=== GitServiceImpl: autoCommit extraExclusions — only .gsd/ dirty ===");
483
+
484
+ {
485
+ const repo = initTempRepo();
486
+ const svc = new GitServiceImpl(repo);
487
+
488
+ // Create only .gsd/ planning files
489
+ createFile(repo, ".gsd/milestones/M001/M001-ROADMAP.md", "- [x] S01");
490
+ createFile(repo, ".gsd/STATE.md", "state content");
491
+
492
+ // Auto-commit with .gsd/ excluded — nothing else to commit
493
+ const result = svc.autoCommit("pre-switch", "main", [".gsd/"]);
494
+ assertEq(result, null, "autoCommit returns null when only .gsd/ files are dirty and excluded");
495
+
496
+ rmSync(repo, { recursive: true, force: true });
497
+ }
498
+
455
499
  // ─── GitServiceImpl: commit returns null when nothing staged ───────────
456
500
 
457
501
  console.log("\n=== GitServiceImpl: commit empty ===");
@@ -1230,6 +1274,68 @@ async function main(): Promise<void> {
1230
1274
  rmSync(repo, { recursive: true, force: true });
1231
1275
  }
1232
1276
 
1277
+ // ─── VALID_BRANCH_NAME regex ──────────────────────────────────────────
1278
+
1279
+ console.log("\n=== VALID_BRANCH_NAME regex ===");
1280
+
1281
+ {
1282
+ // Valid branch names
1283
+ assert(VALID_BRANCH_NAME.test("main"), "VALID_BRANCH_NAME accepts 'main'");
1284
+ assert(VALID_BRANCH_NAME.test("master"), "VALID_BRANCH_NAME accepts 'master'");
1285
+ assert(VALID_BRANCH_NAME.test("develop"), "VALID_BRANCH_NAME accepts 'develop'");
1286
+ assert(VALID_BRANCH_NAME.test("feature/foo"), "VALID_BRANCH_NAME accepts 'feature/foo'");
1287
+ assert(VALID_BRANCH_NAME.test("release-1.0"), "VALID_BRANCH_NAME accepts 'release-1.0'");
1288
+ assert(VALID_BRANCH_NAME.test("my_branch"), "VALID_BRANCH_NAME accepts 'my_branch'");
1289
+ assert(VALID_BRANCH_NAME.test("v2.0.1"), "VALID_BRANCH_NAME accepts 'v2.0.1'");
1290
+
1291
+ // Invalid / injection attempts
1292
+ assert(!VALID_BRANCH_NAME.test("main; rm -rf /"), "VALID_BRANCH_NAME rejects shell injection");
1293
+ assert(!VALID_BRANCH_NAME.test("main && echo pwned"), "VALID_BRANCH_NAME rejects && injection");
1294
+ assert(!VALID_BRANCH_NAME.test(""), "VALID_BRANCH_NAME rejects empty string");
1295
+ assert(!VALID_BRANCH_NAME.test("branch name"), "VALID_BRANCH_NAME rejects spaces");
1296
+ assert(!VALID_BRANCH_NAME.test("branch`cmd`"), "VALID_BRANCH_NAME rejects backticks");
1297
+ assert(!VALID_BRANCH_NAME.test("branch$(cmd)"), "VALID_BRANCH_NAME rejects $() subshell");
1298
+ }
1299
+
1300
+ // ─── getMainBranch: configured main_branch preference ──────────────────
1301
+
1302
+ console.log("\n=== getMainBranch: configured main_branch ===");
1303
+
1304
+ {
1305
+ const repo = initBranchTestRepo();
1306
+ const svc = new GitServiceImpl(repo, { main_branch: "trunk" });
1307
+
1308
+ assertEq(svc.getMainBranch(), "trunk", "getMainBranch returns configured main_branch preference");
1309
+
1310
+ rmSync(repo, { recursive: true, force: true });
1311
+ }
1312
+
1313
+ // ─── getMainBranch: falls back to auto-detection when not set ──────────
1314
+
1315
+ console.log("\n=== getMainBranch: fallback to auto-detection ===");
1316
+
1317
+ {
1318
+ const repo = initBranchTestRepo();
1319
+ const svc = new GitServiceImpl(repo, {});
1320
+
1321
+ assertEq(svc.getMainBranch(), "main", "getMainBranch falls back to auto-detection when main_branch not set");
1322
+
1323
+ rmSync(repo, { recursive: true, force: true });
1324
+ }
1325
+
1326
+ // ─── getMainBranch: ignores invalid branch names ───────────────────────
1327
+
1328
+ console.log("\n=== getMainBranch: ignores invalid branch name ===");
1329
+
1330
+ {
1331
+ const repo = initBranchTestRepo();
1332
+ const svc = new GitServiceImpl(repo, { main_branch: "main; rm -rf /" });
1333
+
1334
+ assertEq(svc.getMainBranch(), "main", "getMainBranch ignores invalid branch name and falls back to auto-detection");
1335
+
1336
+ rmSync(repo, { recursive: true, force: true });
1337
+ }
1338
+
1233
1339
  // ─── PreMergeCheckResult type export compile check ─────────────────────
1234
1340
 
1235
1341
  console.log("\n=== PreMergeCheckResult type export ===");
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Tests for getManifestStatus() — the S01→S02 boundary contract.
3
+ *
4
+ * Verifies that manifest entries are correctly categorized into
5
+ * pending, collected, skipped, and existing arrays based on
6
+ * manifest status and environment presence.
7
+ *
8
+ * Uses temp directories with real .gsd/milestones/M001/ structure.
9
+ */
10
+
11
+ import test from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { tmpdir } from 'node:os';
16
+ import { getManifestStatus } from '../files.ts';
17
+
18
+ function makeTempDir(prefix: string): string {
19
+ const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
20
+ mkdirSync(dir, { recursive: true });
21
+ return dir;
22
+ }
23
+
24
+ /** Create the .gsd/milestones/M001/ directory structure and write a secrets manifest. */
25
+ function writeManifest(base: string, content: string): void {
26
+ const mDir = join(base, '.gsd', 'milestones', 'M001');
27
+ mkdirSync(mDir, { recursive: true });
28
+ writeFileSync(join(mDir, 'M001-SECRETS.md'), content);
29
+ }
30
+
31
+ // ─── Mixed statuses ──────────────────────────────────────────────────────────
32
+
33
+ test('getManifestStatus: mixed statuses — categorizes entries correctly', async () => {
34
+ const tmp = makeTempDir('manifest-mixed');
35
+ const savedVal = process.env.GSD_TEST_EXISTING_KEY_001;
36
+ try {
37
+ process.env.GSD_TEST_EXISTING_KEY_001 = 'some-value';
38
+
39
+ writeManifest(tmp, `# Secrets Manifest
40
+
41
+ **Milestone:** M001
42
+ **Generated:** 2025-06-20T10:00:00Z
43
+
44
+ ### PENDING_KEY
45
+
46
+ **Service:** SomeService
47
+ **Status:** pending
48
+ **Destination:** dotenv
49
+
50
+ 1. Get the key
51
+
52
+ ### COLLECTED_KEY
53
+
54
+ **Service:** AnotherService
55
+ **Status:** collected
56
+ **Destination:** dotenv
57
+
58
+ 1. Already collected
59
+
60
+ ### SKIPPED_KEY
61
+
62
+ **Service:** OptionalService
63
+ **Status:** skipped
64
+ **Destination:** dotenv
65
+
66
+ 1. Not needed
67
+
68
+ ### GSD_TEST_EXISTING_KEY_001
69
+
70
+ **Service:** EnvService
71
+ **Status:** pending
72
+ **Destination:** dotenv
73
+
74
+ 1. Already in env
75
+ `);
76
+
77
+ const result = await getManifestStatus(tmp, 'M001');
78
+ assert.notStrictEqual(result, null, 'should not be null');
79
+ assert.deepStrictEqual(result!.pending, ['PENDING_KEY']);
80
+ assert.deepStrictEqual(result!.collected, ['COLLECTED_KEY']);
81
+ assert.deepStrictEqual(result!.skipped, ['SKIPPED_KEY']);
82
+ assert.deepStrictEqual(result!.existing, ['GSD_TEST_EXISTING_KEY_001']);
83
+ } finally {
84
+ delete process.env.GSD_TEST_EXISTING_KEY_001;
85
+ if (savedVal !== undefined) process.env.GSD_TEST_EXISTING_KEY_001 = savedVal;
86
+ rmSync(tmp, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ // ─── All pending ─────────────────────────────────────────────────────────────
91
+
92
+ test('getManifestStatus: all pending — 3 pending entries, none in env', async () => {
93
+ const tmp = makeTempDir('manifest-pending');
94
+ try {
95
+ // Ensure none of these are in process.env
96
+ delete process.env.PEND_A;
97
+ delete process.env.PEND_B;
98
+ delete process.env.PEND_C;
99
+
100
+ writeManifest(tmp, `# Secrets Manifest
101
+
102
+ **Milestone:** M001
103
+ **Generated:** 2025-06-20T10:00:00Z
104
+
105
+ ### PEND_A
106
+
107
+ **Service:** A
108
+ **Status:** pending
109
+ **Destination:** dotenv
110
+
111
+ 1. Step one
112
+
113
+ ### PEND_B
114
+
115
+ **Service:** B
116
+ **Status:** pending
117
+ **Destination:** dotenv
118
+
119
+ 1. Step one
120
+
121
+ ### PEND_C
122
+
123
+ **Service:** C
124
+ **Status:** pending
125
+ **Destination:** dotenv
126
+
127
+ 1. Step one
128
+ `);
129
+
130
+ const result = await getManifestStatus(tmp, 'M001');
131
+ assert.notStrictEqual(result, null);
132
+ assert.deepStrictEqual(result!.pending, ['PEND_A', 'PEND_B', 'PEND_C']);
133
+ assert.deepStrictEqual(result!.collected, []);
134
+ assert.deepStrictEqual(result!.skipped, []);
135
+ assert.deepStrictEqual(result!.existing, []);
136
+ } finally {
137
+ rmSync(tmp, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ // ─── All collected ───────────────────────────────────────────────────────────
142
+
143
+ test('getManifestStatus: all collected — 2 collected entries, none in env', async () => {
144
+ const tmp = makeTempDir('manifest-collected');
145
+ try {
146
+ delete process.env.COLL_X;
147
+ delete process.env.COLL_Y;
148
+
149
+ writeManifest(tmp, `# Secrets Manifest
150
+
151
+ **Milestone:** M001
152
+ **Generated:** 2025-06-20T10:00:00Z
153
+
154
+ ### COLL_X
155
+
156
+ **Service:** X
157
+ **Status:** collected
158
+ **Destination:** dotenv
159
+
160
+ 1. Done
161
+
162
+ ### COLL_Y
163
+
164
+ **Service:** Y
165
+ **Status:** collected
166
+ **Destination:** dotenv
167
+
168
+ 1. Done
169
+ `);
170
+
171
+ const result = await getManifestStatus(tmp, 'M001');
172
+ assert.notStrictEqual(result, null);
173
+ assert.deepStrictEqual(result!.pending, []);
174
+ assert.deepStrictEqual(result!.collected, ['COLL_X', 'COLL_Y']);
175
+ assert.deepStrictEqual(result!.skipped, []);
176
+ assert.deepStrictEqual(result!.existing, []);
177
+ } finally {
178
+ rmSync(tmp, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ // ─── Key in env overrides manifest status ────────────────────────────────────
183
+
184
+ test('getManifestStatus: key in env overrides manifest status — collected key in env goes to existing', async () => {
185
+ const tmp = makeTempDir('manifest-override');
186
+ const savedVal = process.env.GSD_TEST_OVERRIDE_KEY;
187
+ try {
188
+ process.env.GSD_TEST_OVERRIDE_KEY = 'already-here';
189
+
190
+ writeManifest(tmp, `# Secrets Manifest
191
+
192
+ **Milestone:** M001
193
+ **Generated:** 2025-06-20T10:00:00Z
194
+
195
+ ### GSD_TEST_OVERRIDE_KEY
196
+
197
+ **Service:** Override
198
+ **Status:** collected
199
+ **Destination:** dotenv
200
+
201
+ 1. Was collected but now in env
202
+ `);
203
+
204
+ const result = await getManifestStatus(tmp, 'M001');
205
+ assert.notStrictEqual(result, null);
206
+ assert.deepStrictEqual(result!.pending, []);
207
+ assert.deepStrictEqual(result!.collected, []);
208
+ assert.deepStrictEqual(result!.skipped, []);
209
+ assert.deepStrictEqual(result!.existing, ['GSD_TEST_OVERRIDE_KEY']);
210
+ } finally {
211
+ delete process.env.GSD_TEST_OVERRIDE_KEY;
212
+ if (savedVal !== undefined) process.env.GSD_TEST_OVERRIDE_KEY = savedVal;
213
+ rmSync(tmp, { recursive: true, force: true });
214
+ }
215
+ });
216
+
217
+ // ─── Missing manifest ────────────────────────────────────────────────────────
218
+
219
+ test('getManifestStatus: missing manifest — returns null', async () => {
220
+ const tmp = makeTempDir('manifest-missing');
221
+ try {
222
+ // No .gsd directory at all
223
+ const result = await getManifestStatus(tmp, 'M001');
224
+ assert.strictEqual(result, null);
225
+ } finally {
226
+ rmSync(tmp, { recursive: true, force: true });
227
+ }
228
+ });
229
+
230
+ // ─── Empty manifest (no entries) ─────────────────────────────────────────────
231
+
232
+ test('getManifestStatus: empty manifest — exists but no H3 sections', async () => {
233
+ const tmp = makeTempDir('manifest-empty');
234
+ try {
235
+ writeManifest(tmp, `# Secrets Manifest
236
+
237
+ **Milestone:** M001
238
+ **Generated:** 2025-06-20T10:00:00Z
239
+ `);
240
+
241
+ const result = await getManifestStatus(tmp, 'M001');
242
+ assert.notStrictEqual(result, null);
243
+ assert.deepStrictEqual(result!.pending, []);
244
+ assert.deepStrictEqual(result!.collected, []);
245
+ assert.deepStrictEqual(result!.skipped, []);
246
+ assert.deepStrictEqual(result!.existing, []);
247
+ } finally {
248
+ rmSync(tmp, { recursive: true, force: true });
249
+ }
250
+ });
251
+
252
+ // ─── Env via .env file (not just process.env) ────────────────────────────────
253
+
254
+ test('getManifestStatus: key in .env file counts as existing', async () => {
255
+ const tmp = makeTempDir('manifest-dotenv');
256
+ try {
257
+ delete process.env.DOTENV_ONLY_KEY;
258
+
259
+ writeManifest(tmp, `# Secrets Manifest
260
+
261
+ **Milestone:** M001
262
+ **Generated:** 2025-06-20T10:00:00Z
263
+
264
+ ### DOTENV_ONLY_KEY
265
+
266
+ **Service:** DotenvService
267
+ **Status:** pending
268
+ **Destination:** dotenv
269
+
270
+ 1. Get key
271
+ `);
272
+
273
+ // Write a .env file at the project root with the key
274
+ writeFileSync(join(tmp, '.env'), 'DOTENV_ONLY_KEY=from-dotenv-file\n');
275
+
276
+ const result = await getManifestStatus(tmp, 'M001');
277
+ assert.notStrictEqual(result, null);
278
+ assert.deepStrictEqual(result!.existing, ['DOTENV_ONLY_KEY']);
279
+ assert.deepStrictEqual(result!.pending, []);
280
+ } finally {
281
+ rmSync(tmp, { recursive: true, force: true });
282
+ }
283
+ });