@celilo/cli 0.3.25 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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. 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).
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
- const deployedModules = installed.filter((m) => ['INSTALLED', 'VERIFIED'].includes(m.state));
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(deployedModules.map((m) => m.manifestData as ModuleManifest));
311
- const snapshots = await buildSnapshots(db, deployedModules, registry);
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: deployedModules.map((m) => ({
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: deployedModules.map((m) => ({
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: deployedModules.map((m) => ({ id: m.id, version: m.version })),
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: deployedModules.map((m) => ({
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: deployedModules.map((m) => ({
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: deployedModules
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,
@@ -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}`,