@celilo/cli 0.3.24 → 0.3.26
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/package.json +1 -1
- package/src/cli/commands/system-update.ts +68 -19
- package/src/cli/tui/icons.ts +7 -1
- package/src/manifest/schema.ts +11 -0
- package/src/services/audit/types.test.ts +26 -0
- package/src/services/audit/types.ts +15 -5
- package/src/services/audit/unconfigured-modules.test.ts +1 -1
- package/src/services/audit/unconfigured-modules.ts +5 -1
- package/src/services/audit/undeployed-modules.test.ts +1 -1
- package/src/services/audit/undeployed-modules.ts +7 -1
- package/src/services/bus-interview.ts +10 -0
- package/src/services/config-interview.ts +22 -0
- package/src/services/deploy-validation.test.ts +32 -0
- package/src/services/deploy-validation.ts +16 -0
- package/src/services/terminal-responder.ts +45 -2
package/package.json
CHANGED
|
@@ -159,9 +159,13 @@ async function buildSnapshots(
|
|
|
159
159
|
* - `health` calls `runModuleHealthCheck`.
|
|
160
160
|
* - `snapshotCeliloDb` calls `createSystemStateBackup`.
|
|
161
161
|
*/
|
|
162
|
-
function buildOps(registry: RegistryClient): OrchestratorOps {
|
|
162
|
+
function buildOps(registry: RegistryClient, wasDeployed: Set<string>): OrchestratorOps {
|
|
163
163
|
return {
|
|
164
164
|
backup: async (moduleId, _updateId) => {
|
|
165
|
+
// IMPORTED modules don't have running state to back up; skip
|
|
166
|
+
// silently. The orchestrator already gates on `!noBackup`, so
|
|
167
|
+
// this is the second layer (per-module rather than system-wide).
|
|
168
|
+
if (!wasDeployed.has(moduleId)) return { ok: true };
|
|
165
169
|
const db = getDb();
|
|
166
170
|
const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
167
171
|
if (!mod) return { ok: false, error: `module ${moduleId} not found` };
|
|
@@ -202,6 +206,12 @@ function buildOps(registry: RegistryClient): OrchestratorOps {
|
|
|
202
206
|
}
|
|
203
207
|
},
|
|
204
208
|
deploy: async (id) => {
|
|
209
|
+
// IMPORTED modules weren't deployed before this run; we just
|
|
210
|
+
// refreshed their source files via `upgrade`. Don't try to
|
|
211
|
+
// deploy them here — that would surprise the operator who
|
|
212
|
+
// hasn't asked for a deploy. They'll get a `module deploy <id>`
|
|
213
|
+
// todo finding from the audit on the next run.
|
|
214
|
+
if (!wasDeployed.has(id)) return { ok: true };
|
|
205
215
|
const db = getDb();
|
|
206
216
|
// The deploy interview runs through the bus; if config is
|
|
207
217
|
// missing the deploy hangs waiting for a responder. system-update
|
|
@@ -211,6 +221,19 @@ function buildOps(registry: RegistryClient): OrchestratorOps {
|
|
|
211
221
|
return result.success ? { ok: true } : { ok: false, error: result.error };
|
|
212
222
|
},
|
|
213
223
|
health: async (id) => {
|
|
224
|
+
// Same gating as deploy: there's no live deployment to health-
|
|
225
|
+
// check for an IMPORTED module that we just refreshed. Return
|
|
226
|
+
// pass with a "not deployed" detail so the orchestrator's
|
|
227
|
+
// module-step record doesn't say "health: skipped" (which would
|
|
228
|
+
// imply a check ran and skipped its assertions).
|
|
229
|
+
if (!wasDeployed.has(id)) {
|
|
230
|
+
// The orchestrator's HealthStatus type uses 'healthy' /
|
|
231
|
+
// 'degraded' / 'unhealthy' / 'error' / 'no-checks'. There's
|
|
232
|
+
// no "skipped" — closest meaningful value for "we didn't
|
|
233
|
+
// run the check on purpose" is 'no-checks' (semantically:
|
|
234
|
+
// "the module didn't have any health checks to run").
|
|
235
|
+
return { status: 'no-checks', detail: 'not deployed; health check skipped' };
|
|
236
|
+
}
|
|
214
237
|
// We can call the existing health-runner directly today — that's
|
|
215
238
|
// already a clean service.
|
|
216
239
|
const db = getDb();
|
|
@@ -249,17 +272,20 @@ function formatResult(result: SystemUpdateResult): string {
|
|
|
249
272
|
);
|
|
250
273
|
lines.push(` backups: ${result.backupsCreated ? 'created' : 'skipped'}`);
|
|
251
274
|
|
|
252
|
-
// Surface audit findings inline.
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
275
|
+
// Surface audit findings inline. Three severity tiers:
|
|
276
|
+
// blocked → ✗ gates the run (orchestrator short-circuits)
|
|
277
|
+
// drift → ▸ informational; the system moved away from a
|
|
278
|
+
// desired state, but the run still proceeded
|
|
279
|
+
// todo → ➤ next-step reminders (e.g. "you imported X but
|
|
280
|
+
// haven't deployed it yet"); never escalates the
|
|
281
|
+
// verdict.
|
|
282
|
+
// Each tier renders separately so operators can scan for what
|
|
283
|
+
// needs attention vs. what's just a friendly nudge.
|
|
259
284
|
if (result.audit.findings.length > 0) {
|
|
260
285
|
lines.push('');
|
|
261
|
-
const drift = result.audit.findings.filter((f) => f.severity === 'drift');
|
|
262
286
|
const blocked = result.audit.findings.filter((f) => f.severity === 'blocked');
|
|
287
|
+
const drift = result.audit.findings.filter((f) => f.severity === 'drift');
|
|
288
|
+
const todo = result.audit.findings.filter((f) => f.severity === 'todo');
|
|
263
289
|
if (blocked.length > 0) {
|
|
264
290
|
lines.push(` BLOCKED (${blocked.length}):`);
|
|
265
291
|
for (const f of blocked) {
|
|
@@ -274,6 +300,13 @@ function formatResult(result: SystemUpdateResult): string {
|
|
|
274
300
|
if (f.remediation) lines.push(` → ${f.remediation}`);
|
|
275
301
|
}
|
|
276
302
|
}
|
|
303
|
+
if (todo.length > 0) {
|
|
304
|
+
lines.push(` Todos (${todo.length}, next-step reminders):`);
|
|
305
|
+
for (const f of todo) {
|
|
306
|
+
lines.push(` ➤ [${f.category}] ${f.subject}: ${f.message}`);
|
|
307
|
+
if (f.remediation) lines.push(` → ${f.remediation}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
277
310
|
}
|
|
278
311
|
|
|
279
312
|
if (result.modules.length > 0) {
|
|
@@ -303,12 +336,28 @@ export async function handleSystemUpdate(
|
|
|
303
336
|
|
|
304
337
|
const db = getDb();
|
|
305
338
|
const installed = db.select().from(modules).all();
|
|
306
|
-
|
|
339
|
+
// Include IMPORTED modules in the upgrade scope so a fresh
|
|
340
|
+
// `module import <name>` followed (later, possibly weeks later) by
|
|
341
|
+
// `system update` actually picks up registry-side updates for
|
|
342
|
+
// not-yet-deployed modules. The deploy and health steps are gated
|
|
343
|
+
// on prior state in buildOps so we don't deploy something the
|
|
344
|
+
// operator hasn't asked us to deploy.
|
|
345
|
+
// INSTALLED + VERIFIED + IMPORTED is the upgrade-eligible set;
|
|
346
|
+
// ERROR / DEPLOYING / GENERATING / UNINSTALLING are skipped (the
|
|
347
|
+
// operator needs to handle those manually first).
|
|
348
|
+
const upgradeEligibleStates = new Set(['INSTALLED', 'VERIFIED', 'IMPORTED']);
|
|
349
|
+
const upgradableModules = installed.filter((m) => upgradeEligibleStates.has(m.state));
|
|
350
|
+
// Track which modules were already deployed BEFORE this run.
|
|
351
|
+
// Anything else gets the upgrade only — no deploy / health / backup
|
|
352
|
+
// attempts (those would be no-ops at best, surprising at worst).
|
|
353
|
+
const wasDeployed = new Set(
|
|
354
|
+
installed.filter((m) => ['INSTALLED', 'VERIFIED'].includes(m.state)).map((m) => m.id),
|
|
355
|
+
);
|
|
307
356
|
const registry = new RegistryClient();
|
|
308
357
|
|
|
309
358
|
// Build the dep graph from installed manifests.
|
|
310
|
-
const graph = buildModuleGraph(
|
|
311
|
-
const snapshots = await buildSnapshots(db,
|
|
359
|
+
const graph = buildModuleGraph(upgradableModules.map((m) => m.manifestData as ModuleManifest));
|
|
360
|
+
const snapshots = await buildSnapshots(db, upgradableModules, registry);
|
|
312
361
|
|
|
313
362
|
// Build audit deps. (Mostly mirrors system-audit.ts; could be refactored
|
|
314
363
|
// into a shared helper in Phase 5.)
|
|
@@ -351,13 +400,13 @@ export async function handleSystemUpdate(
|
|
|
351
400
|
db,
|
|
352
401
|
},
|
|
353
402
|
capabilityAbi: {
|
|
354
|
-
modules:
|
|
403
|
+
modules: upgradableModules.map((m) => ({
|
|
355
404
|
id: m.id,
|
|
356
405
|
manifest: m.manifestData as ModuleManifest,
|
|
357
406
|
})),
|
|
358
407
|
},
|
|
359
408
|
terraformPlan: {
|
|
360
|
-
modules:
|
|
409
|
+
modules: upgradableModules.map((m) => ({
|
|
361
410
|
id: m.id,
|
|
362
411
|
terraformDir: existsSync(join(m.sourcePath, 'generated', 'terraform'))
|
|
363
412
|
? join(m.sourcePath, 'generated', 'terraform')
|
|
@@ -366,7 +415,7 @@ export async function handleSystemUpdate(
|
|
|
366
415
|
run: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
|
|
367
416
|
},
|
|
368
417
|
moduleVersions: {
|
|
369
|
-
installed:
|
|
418
|
+
installed: upgradableModules.map((m) => ({ id: m.id, version: m.version })),
|
|
370
419
|
fetcher: async (id: string) => {
|
|
371
420
|
const entries = await registry.getIndex(id);
|
|
372
421
|
const top = registry.latestVersion(entries);
|
|
@@ -374,7 +423,7 @@ export async function handleSystemUpdate(
|
|
|
374
423
|
},
|
|
375
424
|
},
|
|
376
425
|
moduleConfigs: {
|
|
377
|
-
modules:
|
|
426
|
+
modules: upgradableModules.map((m) => ({
|
|
378
427
|
id: m.id,
|
|
379
428
|
manifest: m.manifestData as ModuleManifest,
|
|
380
429
|
configs: configsByModule.get(m.id) ?? {},
|
|
@@ -382,7 +431,7 @@ export async function handleSystemUpdate(
|
|
|
382
431
|
},
|
|
383
432
|
health: { results: healthResults },
|
|
384
433
|
backups: {
|
|
385
|
-
modules:
|
|
434
|
+
modules: upgradableModules.map((m) => ({
|
|
386
435
|
id: m.id,
|
|
387
436
|
manifest: m.manifestData as ModuleManifest,
|
|
388
437
|
lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
|
|
@@ -415,7 +464,7 @@ export async function handleSystemUpdate(
|
|
|
415
464
|
updateId: crypto.randomUUID(),
|
|
416
465
|
audit,
|
|
417
466
|
willSelfUpdate: false, // computed at run time; the dry-run preview is best-effort
|
|
418
|
-
modules:
|
|
467
|
+
modules: upgradableModules
|
|
419
468
|
.filter((m) => {
|
|
420
469
|
const snap = snapshots.get(m.id);
|
|
421
470
|
return snap?.latestVersion && snap.latestVersion !== snap.installedVersion;
|
|
@@ -482,7 +531,7 @@ Then re-run system update.`,
|
|
|
482
531
|
}
|
|
483
532
|
}
|
|
484
533
|
|
|
485
|
-
const ops = buildOps(registry);
|
|
534
|
+
const ops = buildOps(registry, wasDeployed);
|
|
486
535
|
|
|
487
536
|
const result = await runSystemUpdate({
|
|
488
537
|
audit: auditDeps,
|
package/src/cli/tui/icons.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface SeverityVisual {
|
|
|
9
9
|
export const SEVERITY_VISUALS: Record<DriftSeverity, SeverityVisual> = {
|
|
10
10
|
blocked: { icon: '×', color: 'red', label: 'BLOCKED' },
|
|
11
11
|
drift: { icon: '▲', color: 'yellow', label: 'DRIFT' },
|
|
12
|
+
todo: { icon: '➤', color: 'gray', label: 'TODO' },
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
export const VERDICT_VISUALS: Record<AuditVerdict, SeverityVisual> = {
|
|
@@ -17,6 +18,11 @@ export const VERDICT_VISUALS: Record<AuditVerdict, SeverityVisual> = {
|
|
|
17
18
|
READY: { icon: '✓', color: 'green', label: 'READY' },
|
|
18
19
|
};
|
|
19
20
|
|
|
21
|
+
// Lower rank = higher priority (sorts first when listing). todo is
|
|
22
|
+
// the lowest priority — operators can scan past them when triaging
|
|
23
|
+
// what actually needs attention.
|
|
20
24
|
export function severityRank(s: DriftSeverity): number {
|
|
21
|
-
|
|
25
|
+
if (s === 'blocked') return 0;
|
|
26
|
+
if (s === 'drift') return 1;
|
|
27
|
+
return 2; // todo
|
|
22
28
|
}
|
package/src/manifest/schema.ts
CHANGED
|
@@ -112,6 +112,17 @@ export const SecretDeclareSchema = z.object({
|
|
|
112
112
|
// generic; namecheap wants 'Domain' / 'Password'.
|
|
113
113
|
key_label: z.string().optional(),
|
|
114
114
|
value_label: z.string().optional(),
|
|
115
|
+
// For `type: string-map` only: optional regex applied to each entered
|
|
116
|
+
// key. The responder rejects mismatches and re-prompts. Use for
|
|
117
|
+
// domain-shape validation (apex-only), hostname/email shape, etc.
|
|
118
|
+
// Pair with key_pattern_message to give the operator the rule in
|
|
119
|
+
// plain English when they hit it.
|
|
120
|
+
key_pattern: z.string().optional(),
|
|
121
|
+
key_pattern_message: z.string().optional(),
|
|
122
|
+
// Same idea for value validation. Less commonly useful for secrets
|
|
123
|
+
// (passwords vary), but provided for symmetry.
|
|
124
|
+
value_pattern: z.string().optional(),
|
|
125
|
+
value_pattern_message: z.string().optional(),
|
|
115
126
|
});
|
|
116
127
|
|
|
117
128
|
/**
|
|
@@ -17,6 +17,14 @@ const blocked: DriftFinding = {
|
|
|
17
17
|
subject: 'lunacycle',
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
const todo: DriftFinding = {
|
|
21
|
+
category: 'undeployed_modules',
|
|
22
|
+
severity: 'todo',
|
|
23
|
+
code: 'module_undeployed',
|
|
24
|
+
message: 'namecheap: imported but not deployed (state: IMPORTED)',
|
|
25
|
+
subject: 'namecheap',
|
|
26
|
+
};
|
|
27
|
+
|
|
20
28
|
describe('computeVerdict', () => {
|
|
21
29
|
test('READY when no findings', () => {
|
|
22
30
|
expect(computeVerdict([])).toBe('READY');
|
|
@@ -33,4 +41,22 @@ describe('computeVerdict', () => {
|
|
|
33
41
|
test('BLOCKED takes precedence regardless of order', () => {
|
|
34
42
|
expect(computeVerdict([blocked, drift])).toBe('BLOCKED');
|
|
35
43
|
});
|
|
44
|
+
|
|
45
|
+
// Pinning down the new severity tier: todo findings are
|
|
46
|
+
// informational reminders and never escalate the verdict beyond
|
|
47
|
+
// READY. The whole point of this severity level is to let
|
|
48
|
+
// categories like undeployed_modules and unconfigured_modules
|
|
49
|
+
// surface a TODO list without making `system update` look like
|
|
50
|
+
// it has unfinished work.
|
|
51
|
+
test('READY when only todo-severity findings', () => {
|
|
52
|
+
expect(computeVerdict([todo, { ...todo, subject: 'caddy' }])).toBe('READY');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('DRIFT when both drift and todo findings exist (drift wins)', () => {
|
|
56
|
+
expect(computeVerdict([todo, drift])).toBe('DRIFT');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('BLOCKED still wins over todos', () => {
|
|
60
|
+
expect(computeVerdict([todo, drift, blocked])).toBe('BLOCKED');
|
|
61
|
+
});
|
|
36
62
|
});
|
|
@@ -3,14 +3,22 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `runAudit` returns a `SystemAuditReport` aggregating findings from
|
|
5
5
|
* each drift category. Each finding has a category (one of
|
|
6
|
-
* `DriftCategory`), a severity (`drift
|
|
6
|
+
* `DriftCategory`), a severity (`todo`, `drift`, or `blocked`), a stable
|
|
7
7
|
* machine-readable code, and a human-readable message + suggested
|
|
8
8
|
* remediation.
|
|
9
9
|
*
|
|
10
10
|
* The overall verdict is computed from the findings:
|
|
11
11
|
* - any `blocked` finding → BLOCKED
|
|
12
12
|
* - any `drift` finding (no blocked) → DRIFT
|
|
13
|
-
* -
|
|
13
|
+
* - only `todo` findings (or none) → READY
|
|
14
|
+
*
|
|
15
|
+
* `todo` exists because some categories surface "next-step reminders"
|
|
16
|
+
* rather than actual divergence — `unconfigured_modules` and
|
|
17
|
+
* `undeployed_modules` are the canonical examples. The operator
|
|
18
|
+
* imported a module and hasn't gotten around to configuring/deploying;
|
|
19
|
+
* that's a TODO list, not a drifted system. Calling it DRIFT
|
|
20
|
+
* overloaded the term and made `system update` look like it had
|
|
21
|
+
* unfinished work even when it didn't.
|
|
14
22
|
*/
|
|
15
23
|
|
|
16
24
|
export type DriftCategory =
|
|
@@ -29,7 +37,7 @@ export type DriftCategory =
|
|
|
29
37
|
| 'services_reachable'
|
|
30
38
|
| 'machines_reachable';
|
|
31
39
|
|
|
32
|
-
export type DriftSeverity = 'drift' | 'blocked';
|
|
40
|
+
export type DriftSeverity = 'todo' | 'drift' | 'blocked';
|
|
33
41
|
|
|
34
42
|
export type AuditVerdict = 'READY' | 'DRIFT' | 'BLOCKED';
|
|
35
43
|
|
|
@@ -81,10 +89,12 @@ export interface SystemAuditReport {
|
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
/**
|
|
84
|
-
* Compute the overall verdict from a set of findings.
|
|
92
|
+
* Compute the overall verdict from a set of findings. `todo` findings
|
|
93
|
+
* never escalate beyond READY — they're informational reminders, not
|
|
94
|
+
* a signal that the system has moved away from a desired state.
|
|
85
95
|
*/
|
|
86
96
|
export function computeVerdict(findings: DriftFinding[]): AuditVerdict {
|
|
87
97
|
if (findings.some((f) => f.severity === 'blocked')) return 'BLOCKED';
|
|
88
|
-
if (findings.
|
|
98
|
+
if (findings.some((f) => f.severity === 'drift')) return 'DRIFT';
|
|
89
99
|
return 'READY';
|
|
90
100
|
}
|
|
@@ -9,7 +9,7 @@ describe('auditUnconfiguredModules', () => {
|
|
|
9
9
|
expect(result).toHaveLength(1);
|
|
10
10
|
expect(result[0]).toMatchObject({
|
|
11
11
|
category: 'unconfigured_modules',
|
|
12
|
-
severity: '
|
|
12
|
+
severity: 'todo',
|
|
13
13
|
code: 'module_unconfigured',
|
|
14
14
|
actionable: false,
|
|
15
15
|
subject: 'authentik',
|
|
@@ -41,9 +41,13 @@ export async function auditUnconfiguredModules(
|
|
|
41
41
|
if (ALREADY_DEPLOYED.has(m.state)) continue;
|
|
42
42
|
if (m.configCount > 0) continue;
|
|
43
43
|
|
|
44
|
+
// todo: same reasoning as undeployed_modules — never running the
|
|
45
|
+
// configuration interview is a next-step reminder, not divergence
|
|
46
|
+
// from a desired state. Won't escalate the audit verdict beyond
|
|
47
|
+
// READY.
|
|
44
48
|
findings.push({
|
|
45
49
|
category: 'unconfigured_modules',
|
|
46
|
-
severity: '
|
|
50
|
+
severity: 'todo',
|
|
47
51
|
code: 'module_unconfigured',
|
|
48
52
|
message: `${m.id}: never configured (no config rows in DB)`,
|
|
49
53
|
details: [
|
|
@@ -19,7 +19,7 @@ describe('auditUndeployedModules', () => {
|
|
|
19
19
|
expect(result).toHaveLength(1);
|
|
20
20
|
expect(result[0]).toMatchObject({
|
|
21
21
|
category: 'undeployed_modules',
|
|
22
|
-
severity: '
|
|
22
|
+
severity: 'todo',
|
|
23
23
|
code: 'module_undeployed',
|
|
24
24
|
remediation: 'celilo module deploy authentik',
|
|
25
25
|
actionable: true,
|
|
@@ -57,9 +57,15 @@ export async function auditUndeployedModules(
|
|
|
57
57
|
continue;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// todo: this is a next-step reminder, not a divergence from a
|
|
61
|
+
// desired state. The operator imported the module and hasn't
|
|
62
|
+
// deployed yet — that's a TODO list, not "the system has drifted".
|
|
63
|
+
// ERROR-state modules (handled above) DO stay severity=blocked
|
|
64
|
+
// because the operator's prior intent was to deploy, the deploy
|
|
65
|
+
// failed, and the system is now stuck pending intervention.
|
|
60
66
|
findings.push({
|
|
61
67
|
category: 'undeployed_modules',
|
|
62
|
-
severity: '
|
|
68
|
+
severity: 'todo',
|
|
63
69
|
code: 'module_undeployed',
|
|
64
70
|
message: `${m.id}: imported but not deployed (state: ${m.state})`,
|
|
65
71
|
remediation: `celilo module deploy ${m.id}`,
|
|
@@ -87,6 +87,16 @@ export interface SecretRequiredPayload {
|
|
|
87
87
|
*/
|
|
88
88
|
key_label?: string;
|
|
89
89
|
value_label?: string;
|
|
90
|
+
/**
|
|
91
|
+
* For `type: string-map` only — optional regex applied to each
|
|
92
|
+
* entered key/value. Mismatches are rejected at input time and the
|
|
93
|
+
* prompt re-fires. Pair with the `_message` variants for a
|
|
94
|
+
* plain-English explanation when the operator hits one.
|
|
95
|
+
*/
|
|
96
|
+
key_pattern?: string;
|
|
97
|
+
key_pattern_message?: string;
|
|
98
|
+
value_pattern?: string;
|
|
99
|
+
value_pattern_message?: string;
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
export interface SecretAck {
|
|
@@ -70,6 +70,10 @@ interface SecretDeclareLike {
|
|
|
70
70
|
description?: string;
|
|
71
71
|
key_label?: string;
|
|
72
72
|
value_label?: string;
|
|
73
|
+
key_pattern?: string;
|
|
74
|
+
key_pattern_message?: string;
|
|
75
|
+
value_pattern?: string;
|
|
76
|
+
value_pattern_message?: string;
|
|
73
77
|
generate?: { method: string; length: number; encoding: string };
|
|
74
78
|
}
|
|
75
79
|
|
|
@@ -89,6 +93,11 @@ function coerceSecretDeclare(entry: unknown): SecretDeclareLike | null {
|
|
|
89
93
|
if (typeof e.description === 'string') out.description = e.description;
|
|
90
94
|
if (typeof e.key_label === 'string') out.key_label = e.key_label;
|
|
91
95
|
if (typeof e.value_label === 'string') out.value_label = e.value_label;
|
|
96
|
+
if (typeof e.key_pattern === 'string') out.key_pattern = e.key_pattern;
|
|
97
|
+
if (typeof e.key_pattern_message === 'string') out.key_pattern_message = e.key_pattern_message;
|
|
98
|
+
if (typeof e.value_pattern === 'string') out.value_pattern = e.value_pattern;
|
|
99
|
+
if (typeof e.value_pattern_message === 'string')
|
|
100
|
+
out.value_pattern_message = e.value_pattern_message;
|
|
92
101
|
if (typeof e.generate === 'object' && e.generate !== null) {
|
|
93
102
|
const g = e.generate as Record<string, unknown>;
|
|
94
103
|
if (
|
|
@@ -129,6 +138,10 @@ export async function findMissingSecrets(
|
|
|
129
138
|
type: secret.type,
|
|
130
139
|
key_label: secret.key_label,
|
|
131
140
|
value_label: secret.value_label,
|
|
141
|
+
key_pattern: secret.key_pattern,
|
|
142
|
+
key_pattern_message: secret.key_pattern_message,
|
|
143
|
+
value_pattern: secret.value_pattern,
|
|
144
|
+
value_pattern_message: secret.value_pattern_message,
|
|
132
145
|
generate: secret.generate,
|
|
133
146
|
});
|
|
134
147
|
}
|
|
@@ -161,6 +174,11 @@ export interface MissingVariable {
|
|
|
161
174
|
/** For `type: string-map` only — labels shown in the add-loop prompt. */
|
|
162
175
|
key_label?: string;
|
|
163
176
|
value_label?: string;
|
|
177
|
+
/** For `type: string-map` only — optional regex validation per entry. */
|
|
178
|
+
key_pattern?: string;
|
|
179
|
+
key_pattern_message?: string;
|
|
180
|
+
value_pattern?: string;
|
|
181
|
+
value_pattern_message?: string;
|
|
164
182
|
}
|
|
165
183
|
|
|
166
184
|
export interface InterviewResult {
|
|
@@ -735,6 +753,10 @@ export async function interviewForMissingSecrets(
|
|
|
735
753
|
: undefined,
|
|
736
754
|
key_label: variable.key_label,
|
|
737
755
|
value_label: variable.value_label,
|
|
756
|
+
key_pattern: variable.key_pattern,
|
|
757
|
+
key_pattern_message: variable.key_pattern_message,
|
|
758
|
+
value_pattern: variable.value_pattern,
|
|
759
|
+
value_pattern_message: variable.value_pattern_message,
|
|
738
760
|
};
|
|
739
761
|
await busInterview<SecretAck>(EVENT_TYPES.secretRequired(moduleId, variable.name), payload);
|
|
740
762
|
log.success(`Saved ${variable.name}`);
|
|
@@ -260,4 +260,36 @@ describe('findMissingSecrets (shared)', () => {
|
|
|
260
260
|
);
|
|
261
261
|
expect(missing.map((m) => m.name)).toEqual(['still_missing']);
|
|
262
262
|
});
|
|
263
|
+
|
|
264
|
+
test('passes through key_pattern / value_pattern + their messages', async () => {
|
|
265
|
+
// The terminal-responder reads these off the bus payload to apply
|
|
266
|
+
// input-time regex validation. Without manifest → MissingVariable
|
|
267
|
+
// propagation, the responder never sees them and operators end
|
|
268
|
+
// up entering invalid keys (e.g. 'www.lunacycle.net' instead of
|
|
269
|
+
// the apex 'lunacycle.net').
|
|
270
|
+
const missing = await findMissingSecrets(
|
|
271
|
+
'testmod',
|
|
272
|
+
{
|
|
273
|
+
secrets: {
|
|
274
|
+
declares: [
|
|
275
|
+
{
|
|
276
|
+
name: 'ddns_passwords',
|
|
277
|
+
type: 'string-map',
|
|
278
|
+
required: true,
|
|
279
|
+
key_pattern: '^[a-z0-9-]+\\.[a-z]{2,}$',
|
|
280
|
+
key_pattern_message: 'apex domain only — drop the www.',
|
|
281
|
+
value_pattern: '^.{8,}$',
|
|
282
|
+
value_pattern_message: 'min 8 chars',
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
db,
|
|
288
|
+
);
|
|
289
|
+
expect(missing).toHaveLength(1);
|
|
290
|
+
expect(missing[0].key_pattern).toBe('^[a-z0-9-]+\\.[a-z]{2,}$');
|
|
291
|
+
expect(missing[0].key_pattern_message).toBe('apex domain only — drop the www.');
|
|
292
|
+
expect(missing[0].value_pattern).toBe('^.{8,}$');
|
|
293
|
+
expect(missing[0].value_pattern_message).toBe('min 8 chars');
|
|
294
|
+
});
|
|
263
295
|
});
|
|
@@ -33,6 +33,10 @@ export interface ValidationResult {
|
|
|
33
33
|
/** For `type: string-map` only — labels shown in the add-loop prompt. */
|
|
34
34
|
key_label?: string;
|
|
35
35
|
value_label?: string;
|
|
36
|
+
key_pattern?: string;
|
|
37
|
+
key_pattern_message?: string;
|
|
38
|
+
value_pattern?: string;
|
|
39
|
+
value_pattern_message?: string;
|
|
36
40
|
}>;
|
|
37
41
|
}
|
|
38
42
|
|
|
@@ -390,6 +394,10 @@ export async function findMissingRequiredVariables(
|
|
|
390
394
|
generate?: { method: string; length: number; encoding: string };
|
|
391
395
|
key_label?: string;
|
|
392
396
|
value_label?: string;
|
|
397
|
+
key_pattern?: string;
|
|
398
|
+
key_pattern_message?: string;
|
|
399
|
+
value_pattern?: string;
|
|
400
|
+
value_pattern_message?: string;
|
|
393
401
|
}>
|
|
394
402
|
> {
|
|
395
403
|
const missing: Array<{
|
|
@@ -403,6 +411,10 @@ export async function findMissingRequiredVariables(
|
|
|
403
411
|
generate?: { method: string; length: number; encoding: string };
|
|
404
412
|
key_label?: string;
|
|
405
413
|
value_label?: string;
|
|
414
|
+
key_pattern?: string;
|
|
415
|
+
key_pattern_message?: string;
|
|
416
|
+
value_pattern?: string;
|
|
417
|
+
value_pattern_message?: string;
|
|
406
418
|
}> = [];
|
|
407
419
|
|
|
408
420
|
// Check declared variables (user config, capability, system, infrastructure)
|
|
@@ -466,6 +478,10 @@ export async function findMissingRequiredVariables(
|
|
|
466
478
|
generate: s.generate,
|
|
467
479
|
key_label: s.key_label,
|
|
468
480
|
value_label: s.value_label,
|
|
481
|
+
key_pattern: s.key_pattern,
|
|
482
|
+
key_pattern_message: s.key_pattern_message,
|
|
483
|
+
value_pattern: s.value_pattern,
|
|
484
|
+
value_pattern_message: s.value_pattern_message,
|
|
469
485
|
});
|
|
470
486
|
}
|
|
471
487
|
|
|
@@ -397,6 +397,21 @@ async function promptForStringMap(payload: SecretRequiredPayload): Promise<strin
|
|
|
397
397
|
`Add ${keyLabel.toLowerCase()} → ${valueLabel.toLowerCase()} entries one at a time. Press Enter on an empty ${keyLabel.toLowerCase()} when done.`,
|
|
398
398
|
);
|
|
399
399
|
|
|
400
|
+
// Compile the optional regex validators once. An invalid regex (i.e.
|
|
401
|
+
// a typo in the manifest) gets surfaced once here; we treat it as
|
|
402
|
+
// "no validation" rather than wedging the whole interview.
|
|
403
|
+
const keyRegex = compileMaybeRegex(
|
|
404
|
+
payload.key_pattern,
|
|
405
|
+
`${payload.module}.${payload.key} key_pattern`,
|
|
406
|
+
);
|
|
407
|
+
const valueRegex = compileMaybeRegex(
|
|
408
|
+
payload.value_pattern,
|
|
409
|
+
`${payload.module}.${payload.key} value_pattern`,
|
|
410
|
+
);
|
|
411
|
+
const keyPatternMessage = payload.key_pattern_message ?? `must match: ${payload.key_pattern}`;
|
|
412
|
+
const valuePatternMessage =
|
|
413
|
+
payload.value_pattern_message ?? `must match: ${payload.value_pattern}`;
|
|
414
|
+
|
|
400
415
|
const collected: Record<string, string> = {};
|
|
401
416
|
|
|
402
417
|
while (true) {
|
|
@@ -405,7 +420,14 @@ async function promptForStringMap(payload: SecretRequiredPayload): Promise<strin
|
|
|
405
420
|
|
|
406
421
|
const key = await promptText({
|
|
407
422
|
message: keyMessage,
|
|
408
|
-
|
|
423
|
+
// promptText empties + falsy returns terminate the loop, so
|
|
424
|
+
// validate has to allow empty input. Apply the key regex only
|
|
425
|
+
// to non-empty values so the operator can still finish the loop.
|
|
426
|
+
validate: (val) => {
|
|
427
|
+
if (!val || val.trim() === '') return undefined;
|
|
428
|
+
if (keyRegex && !keyRegex.test(val.trim())) return keyPatternMessage;
|
|
429
|
+
return undefined;
|
|
430
|
+
},
|
|
409
431
|
});
|
|
410
432
|
if (key === undefined) {
|
|
411
433
|
// User cancelled (Ctrl-C). Return undefined so the responder
|
|
@@ -429,7 +451,11 @@ async function promptForStringMap(payload: SecretRequiredPayload): Promise<strin
|
|
|
429
451
|
|
|
430
452
|
const value = await promptPassword({
|
|
431
453
|
message: `${valueLabel} for '${trimmedKey}':`,
|
|
432
|
-
validate: (val) =>
|
|
454
|
+
validate: (val) => {
|
|
455
|
+
if (!val || val.trim() === '') return 'Required';
|
|
456
|
+
if (valueRegex && !valueRegex.test(val)) return valuePatternMessage;
|
|
457
|
+
return undefined;
|
|
458
|
+
},
|
|
433
459
|
});
|
|
434
460
|
if (value === undefined) {
|
|
435
461
|
// User cancelled mid-entry; abort the whole interview.
|
|
@@ -443,6 +469,23 @@ async function promptForStringMap(payload: SecretRequiredPayload): Promise<strin
|
|
|
443
469
|
return JSON.stringify(collected);
|
|
444
470
|
}
|
|
445
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Compile a regex pattern from manifest config, returning null on
|
|
474
|
+
* empty or invalid input. A bad regex emits a one-time warning and
|
|
475
|
+
* disables that validation rather than crashing the interview.
|
|
476
|
+
*/
|
|
477
|
+
function compileMaybeRegex(pattern: string | undefined, label: string): RegExp | null {
|
|
478
|
+
if (!pattern) return null;
|
|
479
|
+
try {
|
|
480
|
+
return new RegExp(pattern);
|
|
481
|
+
} catch (err) {
|
|
482
|
+
log.warn(
|
|
483
|
+
`${label}: invalid regex '${pattern}' — ${err instanceof Error ? err.message : String(err)}. Skipping validation.`,
|
|
484
|
+
);
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
446
489
|
/**
|
|
447
490
|
* Short hint shown in the prompt for non-string types so the operator
|
|
448
491
|
* isn't guessing what shape we want. Returns null for plain strings —
|