@celilo/cli 0.3.25 → 0.3.27

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.25",
3
+ "version": "0.3.27",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,7 +4,8 @@
4
4
  */
5
5
 
6
6
  import { homedir } from 'node:os';
7
- import { resolve } from 'node:path';
7
+ import { join, resolve } from 'node:path';
8
+ import { getDataDir } from '../../config/paths';
8
9
  import {
9
10
  addBackupStorage,
10
11
  setDefaultBackupStorage,
@@ -45,11 +46,21 @@ export async function handleStorageAddLocal(
45
46
  validate: validateRequired('Storage name'),
46
47
  }));
47
48
 
49
+ // Default path lives under celilo's own data dir
50
+ // (~/.local/share/celilo/backups on Linux, ~/Library/Application
51
+ // Support/celilo/backups on macOS). The directory is owned by the
52
+ // running user, mkdir -p succeeds without sudo, and operators who
53
+ // want NAS / external paths can override at the prompt. This
54
+ // replaces the prior placeholder of "/var/backups/celilo" which
55
+ // looked authoritative but required root to write.
56
+ const defaultPath = join(getDataDir(), 'backups');
57
+
48
58
  const path =
49
59
  flagPath ??
50
60
  (await promptText({
51
61
  message: 'Storage directory path:',
52
- placeholder: 'e.g., /mnt/nas/backups or /var/backups/celilo',
62
+ defaultValue: defaultPath,
63
+ placeholder: defaultPath,
53
64
  validate: validateRequired('Storage path'),
54
65
  }));
55
66
 
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Tests for the `system update` storage pre-flight.
3
+ *
4
+ * Background: an operator on celilo-mgmt hit a confusing chain when
5
+ * they ran `celilo storage add local /var/backups/celilo` (a system
6
+ * path that needs root). The verify step failed with EACCES, leaving
7
+ * an unverified storage row on disk. The next `celilo system update`
8
+ * said "No default backup storage configured" — technically true
9
+ * (unverified storage never auto-defaults), but misleading: there
10
+ * IS a storage, it just needs fixing. The pre-flight now distinguishes
11
+ * the cases and points at the right next command for each.
12
+ */
13
+
14
+ import { describe, expect, test } from 'bun:test';
15
+ import { type BackupStorageLike, checkBackupStoragePreflight } from './system-update';
16
+
17
+ const verified = (id: string): BackupStorageLike => ({ storageId: id, verified: true });
18
+ const unverified = (id: string): BackupStorageLike => ({ storageId: id, verified: false });
19
+
20
+ describe('checkBackupStoragePreflight', () => {
21
+ test('verified default → ok', () => {
22
+ const result = checkBackupStoragePreflight({
23
+ defaultStorage: verified('home-backups'),
24
+ allStorages: [verified('home-backups')],
25
+ });
26
+ expect(result.kind).toBe('ok');
27
+ });
28
+
29
+ test('default exists but unverified → suggests `storage verify`', () => {
30
+ const result = checkBackupStoragePreflight({
31
+ defaultStorage: unverified('home-backups'),
32
+ allStorages: [unverified('home-backups')],
33
+ });
34
+ if (result.kind !== 'error') throw new Error('expected error');
35
+ expect(result.message).toContain("Default backup storage 'home-backups' is not verified");
36
+ expect(result.message).toContain('celilo storage verify home-backups');
37
+ });
38
+
39
+ test('no storage at all → suggests `storage add local`', () => {
40
+ const result = checkBackupStoragePreflight({
41
+ defaultStorage: null,
42
+ allStorages: [],
43
+ });
44
+ if (result.kind !== 'error') throw new Error('expected error');
45
+ expect(result.message).toContain('No backup storage configured');
46
+ expect(result.message).toContain('celilo storage add local');
47
+ // Always offer the --no-backup escape so the operator knows the
48
+ // CLI self-update can still run on a system without storage.
49
+ expect(result.message).toContain('--no-backup');
50
+ });
51
+
52
+ // The exact case the operator hit on celilo-mgmt: ran storage add,
53
+ // path was unwriteable (/var/backups/celilo without sudo), verify
54
+ // failed, the unverified row stayed in the DB and isn't default.
55
+ test('only unverified storage → suggests `storage verify`, not `storage add`', () => {
56
+ const result = checkBackupStoragePreflight({
57
+ defaultStorage: null,
58
+ allStorages: [unverified('local-backups')],
59
+ });
60
+ if (result.kind !== 'error') throw new Error('expected error');
61
+ expect(result.message).toContain('none is verified yet');
62
+ expect(result.message).toContain('Storages: local-backups');
63
+ expect(result.message).toContain('celilo storage verify <storage-id>');
64
+ // Critically, this case does NOT direct the operator to run
65
+ // `storage add local` — they already did, the row exists.
66
+ expect(result.message).not.toContain('celilo storage add local');
67
+ // Surface the most common cause inline rather than burying it.
68
+ expect(result.message).toContain('elevated permissions');
69
+ expect(result.message).toContain('--no-backup');
70
+ });
71
+
72
+ test('multiple unverified storages → lists all storage IDs', () => {
73
+ const result = checkBackupStoragePreflight({
74
+ defaultStorage: null,
75
+ allStorages: [unverified('local-a'), unverified('local-b'), unverified('local-c')],
76
+ });
77
+ if (result.kind !== 'error') throw new Error('expected error');
78
+ expect(result.message).toContain('Storages: local-a, local-b, local-c');
79
+ });
80
+
81
+ test('verified storage exists but none is default → suggests `storage set-default`', () => {
82
+ const result = checkBackupStoragePreflight({
83
+ defaultStorage: null,
84
+ allStorages: [verified('home-backups'), unverified('s3-cold')],
85
+ });
86
+ if (result.kind !== 'error') throw new Error('expected error');
87
+ expect(result.message).toContain('verified but no default is set');
88
+ expect(result.message).toContain('Verified: home-backups');
89
+ // Doesn't mention the unverified one in the "Verified:" listing
90
+ // (that would mislead).
91
+ expect(result.message).not.toContain('Verified: home-backups, s3-cold');
92
+ expect(result.message).toContain('celilo storage set-default <storage-id>');
93
+ });
94
+
95
+ test('multiple verified, no default → lists all verified IDs', () => {
96
+ const result = checkBackupStoragePreflight({
97
+ defaultStorage: null,
98
+ allStorages: [verified('home-backups'), verified('s3-cold'), unverified('nas')],
99
+ });
100
+ if (result.kind !== 'error') throw new Error('expected error');
101
+ expect(result.message).toContain('Verified: home-backups, s3-cold');
102
+ });
103
+ });
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { spawnSync } from 'node:child_process';
19
19
  import { existsSync, readFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
20
21
  import { dirname, join } from 'node:path';
21
22
  import { fileURLToPath } from 'node:url';
22
23
  import { eq } from 'drizzle-orm';
@@ -50,6 +51,103 @@ import { fetchAndUpgrade } from './module-upgrade';
50
51
  */
51
52
  const MANAGED_PACKAGES = ['@celilo/cli', '@celilo/event-bus', '@celilo/e2e'] as const;
52
53
 
54
+ /**
55
+ * Result of inspecting backup-storage configuration before letting
56
+ * `runSystemUpdate` take a celilo-DB snapshot. Three failure shapes
57
+ * the operator might hit, each with a distinct friendly message:
58
+ *
59
+ * 1. No storage rows at all → run `storage add local`
60
+ * 2. Storage rows exist, none verified → run `storage verify <id>`
61
+ * (the case the operator hits when an unverified storage was
62
+ * left behind by a failed verify)
63
+ * 3. Verified storage exists, none is default → run
64
+ * `storage set-default <id>`
65
+ * 4. Default exists but isn't verified → run `storage verify <id>`
66
+ *
67
+ * Pure data in / data out so it's testable without setting up an
68
+ * audit, registry, or DB.
69
+ */
70
+ export interface BackupStorageLike {
71
+ storageId: string;
72
+ verified: boolean;
73
+ }
74
+
75
+ export function checkBackupStoragePreflight(input: {
76
+ defaultStorage: BackupStorageLike | null;
77
+ allStorages: BackupStorageLike[];
78
+ }): { kind: 'ok' } | { kind: 'error'; message: string } {
79
+ const { defaultStorage, allStorages } = input;
80
+
81
+ if (defaultStorage) {
82
+ if (defaultStorage.verified) return { kind: 'ok' };
83
+ return {
84
+ kind: 'error',
85
+ message: `Default backup storage '${defaultStorage.storageId}' is not verified.
86
+
87
+ Run: celilo storage verify ${defaultStorage.storageId}
88
+
89
+ Then re-run system update.`,
90
+ };
91
+ }
92
+
93
+ if (allStorages.length === 0) {
94
+ return {
95
+ kind: 'error',
96
+ message: `No backup storage configured.
97
+
98
+ celilo system update snapshots the celilo DB before applying module
99
+ updates as a safety net. Configure backup storage first:
100
+
101
+ celilo storage add local
102
+
103
+ Or skip the safety net entirely (the CLI self-update still runs):
104
+
105
+ celilo system update --no-backup`,
106
+ };
107
+ }
108
+
109
+ const verified = allStorages.filter((s) => s.verified);
110
+ if (verified.length === 0) {
111
+ const ids = allStorages.map((s) => s.storageId).join(', ');
112
+ return {
113
+ kind: 'error',
114
+ message: `Backup storage exists but none is verified yet.
115
+
116
+ Storages: ${ids}
117
+
118
+ Verify one before re-running system update:
119
+
120
+ celilo storage verify <storage-id>
121
+
122
+ Common causes of failed verification:
123
+ - The path requires elevated permissions (try a path under your
124
+ home directory; the default '${join(homedir(), '.local/share/celilo/backups')}'
125
+ works without sudo).
126
+ - The disk is full or the mount point isn't writeable.
127
+
128
+ Or skip the safety net entirely (the CLI self-update still runs):
129
+
130
+ celilo system update --no-backup`,
131
+ };
132
+ }
133
+
134
+ const ids = verified.map((s) => s.storageId).join(', ');
135
+ return {
136
+ kind: 'error',
137
+ message: `Backup storage is verified but no default is set.
138
+
139
+ Verified: ${ids}
140
+
141
+ Set a default before re-running system update:
142
+
143
+ celilo storage set-default <storage-id>
144
+
145
+ Or skip the safety net entirely (the CLI self-update still runs):
146
+
147
+ celilo system update --no-backup`,
148
+ };
149
+ }
150
+
53
151
  function readInstalledCliVersion(): string {
54
152
  const here = dirname(fileURLToPath(import.meta.url));
55
153
  const candidates = [
@@ -159,9 +257,13 @@ async function buildSnapshots(
159
257
  * - `health` calls `runModuleHealthCheck`.
160
258
  * - `snapshotCeliloDb` calls `createSystemStateBackup`.
161
259
  */
162
- function buildOps(registry: RegistryClient): OrchestratorOps {
260
+ function buildOps(registry: RegistryClient, wasDeployed: Set<string>): OrchestratorOps {
163
261
  return {
164
262
  backup: async (moduleId, _updateId) => {
263
+ // IMPORTED modules don't have running state to back up; skip
264
+ // silently. The orchestrator already gates on `!noBackup`, so
265
+ // this is the second layer (per-module rather than system-wide).
266
+ if (!wasDeployed.has(moduleId)) return { ok: true };
165
267
  const db = getDb();
166
268
  const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get();
167
269
  if (!mod) return { ok: false, error: `module ${moduleId} not found` };
@@ -202,6 +304,12 @@ function buildOps(registry: RegistryClient): OrchestratorOps {
202
304
  }
203
305
  },
204
306
  deploy: async (id) => {
307
+ // IMPORTED modules weren't deployed before this run; we just
308
+ // refreshed their source files via `upgrade`. Don't try to
309
+ // deploy them here — that would surprise the operator who
310
+ // hasn't asked for a deploy. They'll get a `module deploy <id>`
311
+ // todo finding from the audit on the next run.
312
+ if (!wasDeployed.has(id)) return { ok: true };
205
313
  const db = getDb();
206
314
  // The deploy interview runs through the bus; if config is
207
315
  // missing the deploy hangs waiting for a responder. system-update
@@ -211,6 +319,19 @@ function buildOps(registry: RegistryClient): OrchestratorOps {
211
319
  return result.success ? { ok: true } : { ok: false, error: result.error };
212
320
  },
213
321
  health: async (id) => {
322
+ // Same gating as deploy: there's no live deployment to health-
323
+ // check for an IMPORTED module that we just refreshed. Return
324
+ // pass with a "not deployed" detail so the orchestrator's
325
+ // module-step record doesn't say "health: skipped" (which would
326
+ // imply a check ran and skipped its assertions).
327
+ if (!wasDeployed.has(id)) {
328
+ // The orchestrator's HealthStatus type uses 'healthy' /
329
+ // 'degraded' / 'unhealthy' / 'error' / 'no-checks'. There's
330
+ // no "skipped" — closest meaningful value for "we didn't
331
+ // run the check on purpose" is 'no-checks' (semantically:
332
+ // "the module didn't have any health checks to run").
333
+ return { status: 'no-checks', detail: 'not deployed; health check skipped' };
334
+ }
214
335
  // We can call the existing health-runner directly today — that's
215
336
  // already a clean service.
216
337
  const db = getDb();
@@ -249,17 +370,20 @@ function formatResult(result: SystemUpdateResult): string {
249
370
  );
250
371
  lines.push(` backups: ${result.backupsCreated ? 'created' : 'skipped'}`);
251
372
 
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).
373
+ // Surface audit findings inline. Three severity tiers:
374
+ // blocked ✗ gates the run (orchestrator short-circuits)
375
+ // drift → ▸ informational; the system moved away from a
376
+ // desired state, but the run still proceeded
377
+ // todo → ➤ next-step reminders (e.g. "you imported X but
378
+ // haven't deployed it yet"); never escalates the
379
+ // verdict.
380
+ // Each tier renders separately so operators can scan for what
381
+ // needs attention vs. what's just a friendly nudge.
259
382
  if (result.audit.findings.length > 0) {
260
383
  lines.push('');
261
- const drift = result.audit.findings.filter((f) => f.severity === 'drift');
262
384
  const blocked = result.audit.findings.filter((f) => f.severity === 'blocked');
385
+ const drift = result.audit.findings.filter((f) => f.severity === 'drift');
386
+ const todo = result.audit.findings.filter((f) => f.severity === 'todo');
263
387
  if (blocked.length > 0) {
264
388
  lines.push(` BLOCKED (${blocked.length}):`);
265
389
  for (const f of blocked) {
@@ -274,6 +398,13 @@ function formatResult(result: SystemUpdateResult): string {
274
398
  if (f.remediation) lines.push(` → ${f.remediation}`);
275
399
  }
276
400
  }
401
+ if (todo.length > 0) {
402
+ lines.push(` Todos (${todo.length}, next-step reminders):`);
403
+ for (const f of todo) {
404
+ lines.push(` ➤ [${f.category}] ${f.subject}: ${f.message}`);
405
+ if (f.remediation) lines.push(` → ${f.remediation}`);
406
+ }
407
+ }
277
408
  }
278
409
 
279
410
  if (result.modules.length > 0) {
@@ -303,12 +434,28 @@ export async function handleSystemUpdate(
303
434
 
304
435
  const db = getDb();
305
436
  const installed = db.select().from(modules).all();
306
- const deployedModules = installed.filter((m) => ['INSTALLED', 'VERIFIED'].includes(m.state));
437
+ // Include IMPORTED modules in the upgrade scope so a fresh
438
+ // `module import <name>` followed (later, possibly weeks later) by
439
+ // `system update` actually picks up registry-side updates for
440
+ // not-yet-deployed modules. The deploy and health steps are gated
441
+ // on prior state in buildOps so we don't deploy something the
442
+ // operator hasn't asked us to deploy.
443
+ // INSTALLED + VERIFIED + IMPORTED is the upgrade-eligible set;
444
+ // ERROR / DEPLOYING / GENERATING / UNINSTALLING are skipped (the
445
+ // operator needs to handle those manually first).
446
+ const upgradeEligibleStates = new Set(['INSTALLED', 'VERIFIED', 'IMPORTED']);
447
+ const upgradableModules = installed.filter((m) => upgradeEligibleStates.has(m.state));
448
+ // Track which modules were already deployed BEFORE this run.
449
+ // Anything else gets the upgrade only — no deploy / health / backup
450
+ // attempts (those would be no-ops at best, surprising at worst).
451
+ const wasDeployed = new Set(
452
+ installed.filter((m) => ['INSTALLED', 'VERIFIED'].includes(m.state)).map((m) => m.id),
453
+ );
307
454
  const registry = new RegistryClient();
308
455
 
309
456
  // Build the dep graph from installed manifests.
310
- const graph = buildModuleGraph(deployedModules.map((m) => m.manifestData as ModuleManifest));
311
- const snapshots = await buildSnapshots(db, deployedModules, registry);
457
+ const graph = buildModuleGraph(upgradableModules.map((m) => m.manifestData as ModuleManifest));
458
+ const snapshots = await buildSnapshots(db, upgradableModules, registry);
312
459
 
313
460
  // Build audit deps. (Mostly mirrors system-audit.ts; could be refactored
314
461
  // into a shared helper in Phase 5.)
@@ -351,13 +498,13 @@ export async function handleSystemUpdate(
351
498
  db,
352
499
  },
353
500
  capabilityAbi: {
354
- modules: deployedModules.map((m) => ({
501
+ modules: upgradableModules.map((m) => ({
355
502
  id: m.id,
356
503
  manifest: m.manifestData as ModuleManifest,
357
504
  })),
358
505
  },
359
506
  terraformPlan: {
360
- modules: deployedModules.map((m) => ({
507
+ modules: upgradableModules.map((m) => ({
361
508
  id: m.id,
362
509
  terraformDir: existsSync(join(m.sourcePath, 'generated', 'terraform'))
363
510
  ? join(m.sourcePath, 'generated', 'terraform')
@@ -366,7 +513,7 @@ export async function handleSystemUpdate(
366
513
  run: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
367
514
  },
368
515
  moduleVersions: {
369
- installed: deployedModules.map((m) => ({ id: m.id, version: m.version })),
516
+ installed: upgradableModules.map((m) => ({ id: m.id, version: m.version })),
370
517
  fetcher: async (id: string) => {
371
518
  const entries = await registry.getIndex(id);
372
519
  const top = registry.latestVersion(entries);
@@ -374,7 +521,7 @@ export async function handleSystemUpdate(
374
521
  },
375
522
  },
376
523
  moduleConfigs: {
377
- modules: deployedModules.map((m) => ({
524
+ modules: upgradableModules.map((m) => ({
378
525
  id: m.id,
379
526
  manifest: m.manifestData as ModuleManifest,
380
527
  configs: configsByModule.get(m.id) ?? {},
@@ -382,7 +529,7 @@ export async function handleSystemUpdate(
382
529
  },
383
530
  health: { results: healthResults },
384
531
  backups: {
385
- modules: deployedModules.map((m) => ({
532
+ modules: upgradableModules.map((m) => ({
386
533
  id: m.id,
387
534
  manifest: m.manifestData as ModuleManifest,
388
535
  lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
@@ -415,7 +562,7 @@ export async function handleSystemUpdate(
415
562
  updateId: crypto.randomUUID(),
416
563
  audit,
417
564
  willSelfUpdate: false, // computed at run time; the dry-run preview is best-effort
418
- modules: deployedModules
565
+ modules: upgradableModules
419
566
  .filter((m) => {
420
567
  const snap = snapshots.get(m.id);
421
568
  return snap?.latestVersion && snap.latestVersion !== snap.installedVersion;
@@ -449,40 +596,24 @@ export async function handleSystemUpdate(
449
596
  );
450
597
  const effectiveNoBackup = noBackup || !hasModuleUpdates;
451
598
 
452
- // Pre-flight the storage check so a missing default doesn't reach the
453
- // orchestrator's snapshot hook (where the throw would surface as a
454
- // hostile stack trace). Skip when we're already not going to backup.
599
+ // Pre-flight the storage check so a missing/unusable default doesn't
600
+ // reach the orchestrator's snapshot hook (where the throw would
601
+ // surface as a hostile stack trace). Skip when we're already not
602
+ // going to backup.
455
603
  if (!effectiveNoBackup) {
456
- const { getDefaultBackupStorage } = await import('../../services/backup-storage');
457
- const storage = getDefaultBackupStorage();
458
- if (!storage) {
459
- return {
460
- success: false,
461
- error: `No default backup storage configured.
462
-
463
- celilo system update snapshots the celilo DB before applying module
464
- updates as a safety net. Configure backup storage first:
465
-
466
- celilo storage add local
467
-
468
- Or skip the safety net entirely (the CLI self-update still runs):
469
-
470
- celilo system update --no-backup`,
471
- };
472
- }
473
- if (!storage.verified) {
474
- return {
475
- success: false,
476
- error: `Default backup storage '${storage.storageId}' is not verified.
477
-
478
- Run: celilo storage verify ${storage.storageId}
479
-
480
- Then re-run system update.`,
481
- };
604
+ const { getDefaultBackupStorage, listBackupStorages } = await import(
605
+ '../../services/backup-storage'
606
+ );
607
+ const preflight = checkBackupStoragePreflight({
608
+ defaultStorage: getDefaultBackupStorage(),
609
+ allStorages: listBackupStorages(),
610
+ });
611
+ if (preflight.kind === 'error') {
612
+ return { success: false, error: preflight.message };
482
613
  }
483
614
  }
484
615
 
485
- const ops = buildOps(registry);
616
+ const ops = buildOps(registry, wasDeployed);
486
617
 
487
618
  const result = await runSystemUpdate({
488
619
  audit: auditDeps,
@@ -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
- return s === 'blocked' ? 0 : 1;
25
+ if (s === 'blocked') return 0;
26
+ if (s === 'drift') return 1;
27
+ return 2; // todo
22
28
  }
@@ -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` or `blocked`), a stable
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
- * - no findings → READY
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.length > 0) return 'DRIFT';
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: 'drift',
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: 'drift',
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: 'drift',
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: 'drift',
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}`,