@celilo/cli 0.3.23 → 0.3.25

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -248,15 +248,45 @@ function formatResult(result: SystemUpdateResult): string {
248
248
  ` self-update: ${result.selfUpdate.performed ? `${result.selfUpdate.from} → ${result.selfUpdate.to}` : `(${result.selfUpdate.reason})`}`,
249
249
  );
250
250
  lines.push(` backups: ${result.backupsCreated ? 'created' : 'skipped'}`);
251
- lines.push('');
252
- for (const m of result.modules) {
253
- const tag =
254
- m.step === 'done' ? '✓' : m.step === 'failed' ? '✗' : m.step === 'skipped' ? '↳' : '?';
255
- const change =
256
- m.fromVersion === m.toVersion ? m.fromVersion : `${m.fromVersion} ${m.toVersion}`;
257
- lines.push(` ${tag} ${m.moduleId} (${change}) ${m.step}`);
258
- if (m.error) lines.push(` ${m.error}`);
259
- if (m.skipReason) lines.push(` ${m.skipReason}`);
251
+
252
+ // Surface audit findings inline. DRIFT means "something is drifting
253
+ // but it didn't block the update" — without listing the findings the
254
+ // operator has no idea what; they'd have to run `celilo system audit`
255
+ // as a follow-up. Show them here so it's a single round-trip.
256
+ // BLOCKED also gets the listing (the orchestrator already short-
257
+ // circuited to skip the run, but the operator still needs to know
258
+ // why — formatResult is the friendly path for that too).
259
+ if (result.audit.findings.length > 0) {
260
+ lines.push('');
261
+ const drift = result.audit.findings.filter((f) => f.severity === 'drift');
262
+ const blocked = result.audit.findings.filter((f) => f.severity === 'blocked');
263
+ if (blocked.length > 0) {
264
+ lines.push(` BLOCKED (${blocked.length}):`);
265
+ for (const f of blocked) {
266
+ lines.push(` ✗ [${f.category}] ${f.subject}: ${f.message}`);
267
+ if (f.remediation) lines.push(` → ${f.remediation}`);
268
+ }
269
+ }
270
+ if (drift.length > 0) {
271
+ lines.push(` Drift findings (${drift.length}, informational):`);
272
+ for (const f of drift) {
273
+ lines.push(` ▸ [${f.category}] ${f.subject}: ${f.message}`);
274
+ if (f.remediation) lines.push(` → ${f.remediation}`);
275
+ }
276
+ }
277
+ }
278
+
279
+ if (result.modules.length > 0) {
280
+ lines.push('');
281
+ for (const m of result.modules) {
282
+ const tag =
283
+ m.step === 'done' ? '✓' : m.step === 'failed' ? '✗' : m.step === 'skipped' ? '↳' : '?';
284
+ const change =
285
+ m.fromVersion === m.toVersion ? m.fromVersion : `${m.fromVersion} → ${m.toVersion}`;
286
+ lines.push(` ${tag} ${m.moduleId} (${change}) — ${m.step}`);
287
+ if (m.error) lines.push(` ${m.error}`);
288
+ if (m.skipReason) lines.push(` ${m.skipReason}`);
289
+ }
260
290
  }
261
291
  return lines.join('\n');
262
292
  }
@@ -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
  /**
@@ -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
- validate: () => undefined, // empty = finish
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) => (!val || val.trim() === '' ? 'Required' : undefined),
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 —