gsd-pi 2.5.1 → 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/package.json +1 -1
- package/src/resources/extensions/get-secrets-from-user.ts +271 -54
- package/src/resources/extensions/gsd/auto.ts +35 -7
- package/src/resources/extensions/gsd/doctor.ts +23 -4
- package/src/resources/extensions/gsd/files.ts +45 -1
- package/src/resources/extensions/gsd/git-service.ts +50 -9
- package/src/resources/extensions/gsd/session-forensics.ts +19 -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 +43 -0
- package/src/resources/extensions/gsd/tests/manifest-status.test.ts +283 -0
- package/src/resources/extensions/gsd/tests/parsers.test.ts +190 -0
- package/src/resources/extensions/gsd/types.ts +7 -0
|
@@ -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
|
+
});
|
|
@@ -1488,6 +1488,196 @@ console.log('\n=== parseSecretsManifest + formatSecretsManifest: round-trip ==='
|
|
|
1488
1488
|
}
|
|
1489
1489
|
}
|
|
1490
1490
|
|
|
1491
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1492
|
+
// LLM-style round-trip tests — realistic manifest variations
|
|
1493
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1494
|
+
|
|
1495
|
+
console.log('\n=== LLM round-trip: extra whitespace ===');
|
|
1496
|
+
{
|
|
1497
|
+
// LLMs often produce inconsistent indentation and trailing spaces
|
|
1498
|
+
const messy = `# Secrets Manifest
|
|
1499
|
+
|
|
1500
|
+
**Milestone:** M010
|
|
1501
|
+
**Generated:** 2025-07-01T12:00:00Z
|
|
1502
|
+
|
|
1503
|
+
### OPENAI_API_KEY
|
|
1504
|
+
|
|
1505
|
+
**Service:** OpenAI
|
|
1506
|
+
**Dashboard:** https://platform.openai.com/api-keys
|
|
1507
|
+
**Format hint:** starts with sk-
|
|
1508
|
+
**Status:** pending
|
|
1509
|
+
**Destination:** dotenv
|
|
1510
|
+
|
|
1511
|
+
1. Go to the API keys page
|
|
1512
|
+
2. Create a new key
|
|
1513
|
+
|
|
1514
|
+
### REDIS_URL
|
|
1515
|
+
|
|
1516
|
+
**Service:** Upstash
|
|
1517
|
+
**Status:** collected
|
|
1518
|
+
**Destination:** vercel
|
|
1519
|
+
|
|
1520
|
+
1. Open console
|
|
1521
|
+
`;
|
|
1522
|
+
|
|
1523
|
+
const parsed1 = parseSecretsManifest(messy);
|
|
1524
|
+
const formatted = formatSecretsManifest(parsed1);
|
|
1525
|
+
const parsed2 = parseSecretsManifest(formatted);
|
|
1526
|
+
|
|
1527
|
+
assertEq(parsed2.milestone, parsed1.milestone, 'whitespace round-trip milestone');
|
|
1528
|
+
assertEq(parsed2.generatedAt, parsed1.generatedAt, 'whitespace round-trip generatedAt');
|
|
1529
|
+
assertEq(parsed2.entries.length, parsed1.entries.length, 'whitespace round-trip entry count');
|
|
1530
|
+
assertEq(parsed2.entries.length, 2, 'whitespace: two entries parsed');
|
|
1531
|
+
|
|
1532
|
+
for (let i = 0; i < parsed1.entries.length; i++) {
|
|
1533
|
+
const e1 = parsed1.entries[i];
|
|
1534
|
+
const e2 = parsed2.entries[i];
|
|
1535
|
+
assertEq(e2.key, e1.key, `whitespace round-trip entry ${i} key`);
|
|
1536
|
+
assertEq(e2.service, e1.service, `whitespace round-trip entry ${i} service`);
|
|
1537
|
+
assertEq(e2.dashboardUrl, e1.dashboardUrl, `whitespace round-trip entry ${i} dashboardUrl`);
|
|
1538
|
+
assertEq(e2.formatHint, e1.formatHint, `whitespace round-trip entry ${i} formatHint`);
|
|
1539
|
+
assertEq(e2.status, e1.status, `whitespace round-trip entry ${i} status`);
|
|
1540
|
+
assertEq(e2.destination, e1.destination, `whitespace round-trip entry ${i} destination`);
|
|
1541
|
+
assertEq(e2.guidance.length, e1.guidance.length, `whitespace round-trip entry ${i} guidance length`);
|
|
1542
|
+
for (let j = 0; j < e1.guidance.length; j++) {
|
|
1543
|
+
assertEq(e2.guidance[j], e1.guidance[j], `whitespace round-trip entry ${i} guidance[${j}]`);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// Verify the parser correctly stripped trailing whitespace
|
|
1548
|
+
assertEq(parsed1.milestone, 'M010', 'whitespace: milestone trimmed');
|
|
1549
|
+
assertEq(parsed1.entries[0].key, 'OPENAI_API_KEY', 'whitespace: key trimmed');
|
|
1550
|
+
assertEq(parsed1.entries[0].service, 'OpenAI', 'whitespace: service trimmed');
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
console.log('\n=== LLM round-trip: missing optional fields ===');
|
|
1554
|
+
{
|
|
1555
|
+
// LLMs may omit Dashboard and Format hint lines entirely
|
|
1556
|
+
const minimal = `# Secrets Manifest
|
|
1557
|
+
|
|
1558
|
+
**Milestone:** M011
|
|
1559
|
+
**Generated:** 2025-07-02T08:00:00Z
|
|
1560
|
+
|
|
1561
|
+
### DATABASE_URL
|
|
1562
|
+
|
|
1563
|
+
**Service:** Neon
|
|
1564
|
+
**Status:** pending
|
|
1565
|
+
**Destination:** dotenv
|
|
1566
|
+
|
|
1567
|
+
1. Create a Neon project
|
|
1568
|
+
2. Copy connection string
|
|
1569
|
+
|
|
1570
|
+
### WEBHOOK_SECRET
|
|
1571
|
+
|
|
1572
|
+
**Service:** Stripe
|
|
1573
|
+
**Status:** collected
|
|
1574
|
+
**Destination:** dotenv
|
|
1575
|
+
|
|
1576
|
+
1. Go to webhooks
|
|
1577
|
+
`;
|
|
1578
|
+
|
|
1579
|
+
const parsed1 = parseSecretsManifest(minimal);
|
|
1580
|
+
|
|
1581
|
+
// Verify missing optional fields get defaults
|
|
1582
|
+
assertEq(parsed1.entries[0].dashboardUrl, '', 'missing-optional: no dashboard → empty string');
|
|
1583
|
+
assertEq(parsed1.entries[0].formatHint, '', 'missing-optional: no format hint → empty string');
|
|
1584
|
+
assertEq(parsed1.entries[1].dashboardUrl, '', 'missing-optional: entry 2 no dashboard → empty string');
|
|
1585
|
+
assertEq(parsed1.entries[1].formatHint, '', 'missing-optional: entry 2 no format hint → empty string');
|
|
1586
|
+
|
|
1587
|
+
// Round-trip: formatter omits empty optional fields, re-parse preserves defaults
|
|
1588
|
+
const formatted = formatSecretsManifest(parsed1);
|
|
1589
|
+
const parsed2 = parseSecretsManifest(formatted);
|
|
1590
|
+
|
|
1591
|
+
assertEq(parsed2.entries.length, parsed1.entries.length, 'missing-optional round-trip entry count');
|
|
1592
|
+
|
|
1593
|
+
for (let i = 0; i < parsed1.entries.length; i++) {
|
|
1594
|
+
const e1 = parsed1.entries[i];
|
|
1595
|
+
const e2 = parsed2.entries[i];
|
|
1596
|
+
assertEq(e2.key, e1.key, `missing-optional round-trip entry ${i} key`);
|
|
1597
|
+
assertEq(e2.service, e1.service, `missing-optional round-trip entry ${i} service`);
|
|
1598
|
+
assertEq(e2.dashboardUrl, e1.dashboardUrl, `missing-optional round-trip entry ${i} dashboardUrl`);
|
|
1599
|
+
assertEq(e2.formatHint, e1.formatHint, `missing-optional round-trip entry ${i} formatHint`);
|
|
1600
|
+
assertEq(e2.status, e1.status, `missing-optional round-trip entry ${i} status`);
|
|
1601
|
+
assertEq(e2.destination, e1.destination, `missing-optional round-trip entry ${i} destination`);
|
|
1602
|
+
assertEq(e2.guidance.length, e1.guidance.length, `missing-optional round-trip entry ${i} guidance length`);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
console.log('\n=== LLM round-trip: extra blank lines ===');
|
|
1607
|
+
{
|
|
1608
|
+
// LLMs sometimes insert excessive blank lines between sections
|
|
1609
|
+
const blanky = `# Secrets Manifest
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
**Milestone:** M012
|
|
1613
|
+
**Generated:** 2025-07-03T14:00:00Z
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
### API_KEY_ONE
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
**Service:** ServiceOne
|
|
1621
|
+
**Dashboard:** https://one.example.com
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
**Format hint:** key_...
|
|
1625
|
+
**Status:** pending
|
|
1626
|
+
**Destination:** dotenv
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
1. Go to settings
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
2. Generate key
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
### API_KEY_TWO
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
**Service:** ServiceTwo
|
|
1642
|
+
**Status:** skipped
|
|
1643
|
+
**Destination:** dotenv
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
1. Not needed
|
|
1647
|
+
`;
|
|
1648
|
+
|
|
1649
|
+
const parsed1 = parseSecretsManifest(blanky);
|
|
1650
|
+
|
|
1651
|
+
assertEq(parsed1.entries.length, 2, 'blank-lines: two entries parsed');
|
|
1652
|
+
assertEq(parsed1.milestone, 'M012', 'blank-lines: milestone parsed');
|
|
1653
|
+
assertEq(parsed1.entries[0].key, 'API_KEY_ONE', 'blank-lines: first key');
|
|
1654
|
+
assertEq(parsed1.entries[0].guidance.length, 2, 'blank-lines: first entry guidance count');
|
|
1655
|
+
assertEq(parsed1.entries[1].key, 'API_KEY_TWO', 'blank-lines: second key');
|
|
1656
|
+
assertEq(parsed1.entries[1].status, 'skipped', 'blank-lines: second entry status');
|
|
1657
|
+
|
|
1658
|
+
// Round-trip produces clean output
|
|
1659
|
+
const formatted = formatSecretsManifest(parsed1);
|
|
1660
|
+
const parsed2 = parseSecretsManifest(formatted);
|
|
1661
|
+
|
|
1662
|
+
assertEq(parsed2.entries.length, parsed1.entries.length, 'blank-lines round-trip entry count');
|
|
1663
|
+
|
|
1664
|
+
for (let i = 0; i < parsed1.entries.length; i++) {
|
|
1665
|
+
const e1 = parsed1.entries[i];
|
|
1666
|
+
const e2 = parsed2.entries[i];
|
|
1667
|
+
assertEq(e2.key, e1.key, `blank-lines round-trip entry ${i} key`);
|
|
1668
|
+
assertEq(e2.service, e1.service, `blank-lines round-trip entry ${i} service`);
|
|
1669
|
+
assertEq(e2.dashboardUrl, e1.dashboardUrl, `blank-lines round-trip entry ${i} dashboardUrl`);
|
|
1670
|
+
assertEq(e2.formatHint, e1.formatHint, `blank-lines round-trip entry ${i} formatHint`);
|
|
1671
|
+
assertEq(e2.status, e1.status, `blank-lines round-trip entry ${i} status`);
|
|
1672
|
+
assertEq(e2.destination, e1.destination, `blank-lines round-trip entry ${i} destination`);
|
|
1673
|
+
assertEq(e2.guidance.length, e1.guidance.length, `blank-lines round-trip entry ${i} guidance length`);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// Verify the formatted output is cleaner (fewer consecutive blank lines)
|
|
1677
|
+
const consecutiveBlanks = formatted.match(/\n{4,}/g);
|
|
1678
|
+
assert(consecutiveBlanks === null, 'blank-lines: formatted output has no 4+ consecutive newlines');
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1491
1681
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1492
1682
|
// Results
|
|
1493
1683
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -136,6 +136,13 @@ export interface SecretsManifest {
|
|
|
136
136
|
entries: SecretsManifestEntry[];
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
export interface ManifestStatus {
|
|
140
|
+
pending: string[]; // manifest status = pending AND not in env
|
|
141
|
+
collected: string[]; // manifest status = collected AND not in env
|
|
142
|
+
skipped: string[]; // manifest status = skipped
|
|
143
|
+
existing: string[]; // key present in .env or process.env (regardless of manifest status)
|
|
144
|
+
}
|
|
145
|
+
|
|
139
146
|
// ─── GSD State (Derived Dashboard) ────────────────────────────────────────
|
|
140
147
|
|
|
141
148
|
export interface ActiveRef {
|