@celilo/cli 0.3.27 → 0.3.29

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.27",
3
+ "version": "0.3.29",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Tests for `storage add local`'s pre-save write probe.
3
+ *
4
+ * Background: an operator on celilo-mgmt typed `/var/backups/celilo`
5
+ * for the path. The CLI accepted it, saved a storage row, then the
6
+ * heavyweight verify step failed with EACCES — leaving an unverified
7
+ * row that confused subsequent `system update` runs. The probe
8
+ * catches unwriteable paths before any persistent state is created.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
12
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { probePathWriteable } from './storage-add-local';
16
+
17
+ describe('probePathWriteable', () => {
18
+ let tempRoot: string;
19
+
20
+ beforeEach(() => {
21
+ tempRoot = mkdtempSync(join(tmpdir(), 'celilo-probe-test-'));
22
+ });
23
+
24
+ afterEach(() => {
25
+ rmSync(tempRoot, { recursive: true, force: true });
26
+ });
27
+
28
+ test('returns null for a writeable existing directory', () => {
29
+ expect(probePathWriteable(tempRoot)).toBeNull();
30
+ });
31
+
32
+ test("creates parent dirs that don't exist yet (mkdir -p semantics)", () => {
33
+ // The probe's mkdirSync uses `recursive: true`, so a path several
34
+ // levels below the temp root works on the first try. This is the
35
+ // common case for the user's typed path under their data dir.
36
+ const deep = join(tempRoot, 'a', 'b', 'c', 'backups');
37
+ expect(probePathWriteable(deep)).toBeNull();
38
+ });
39
+
40
+ test('returns an error message for a path under a read-only ancestor', () => {
41
+ const readOnly = join(tempRoot, 'readonly');
42
+ mkdirSync(readOnly);
43
+ // 0o500 = read+execute for owner, no write. mkdirSync into it
44
+ // should EACCES.
45
+ require('node:fs').chmodSync(readOnly, 0o500);
46
+ try {
47
+ const target = join(readOnly, 'celilo-backups');
48
+ const err = probePathWriteable(target);
49
+ expect(err).not.toBeNull();
50
+ expect(err).toContain('EACCES');
51
+ } finally {
52
+ // Restore permissions so afterEach can clean up.
53
+ require('node:fs').chmodSync(readOnly, 0o700);
54
+ }
55
+ });
56
+
57
+ test('cleans up the probe directory on success (no leftover state)', () => {
58
+ const dir = join(tempRoot, 'check');
59
+ expect(probePathWriteable(dir)).toBeNull();
60
+ // The probe creates `<dir>/.celilo-write-probe` then removes it.
61
+ // The directory itself stays (mkdir -p), but the probe sentinel
62
+ // doesn't.
63
+ const { existsSync } = require('node:fs');
64
+ expect(existsSync(join(dir, '.celilo-write-probe'))).toBe(false);
65
+ });
66
+ });
@@ -3,6 +3,7 @@
3
3
  * Configure a local filesystem backup storage destination
4
4
  */
5
5
 
6
+ import { mkdirSync, rmSync } from 'node:fs';
6
7
  import { homedir } from 'node:os';
7
8
  import { join, resolve } from 'node:path';
8
9
  import { getDataDir } from '../../config/paths';
@@ -15,6 +16,27 @@ import { celiloIntro, celiloOutro, promptConfirm, promptText } from '../prompts'
15
16
  import type { CommandResult } from '../types';
16
17
  import { validateRequired } from '../validators';
17
18
 
19
+ /**
20
+ * Best-effort write probe. Attempts to mkdir -p a `.celilo-write-probe`
21
+ * subdir, then deletes it. Returns null on success, or a one-line
22
+ * error message on failure (suitable for surfacing to the operator).
23
+ *
24
+ * Exists so we can catch unwriteable paths AT INTERVIEW TIME instead
25
+ * of saving a storage row, failing the heavyweight verify step, and
26
+ * leaving a dangling unverified row that confuses subsequent
27
+ * `system update` runs (the regression that prompted this work).
28
+ */
29
+ export function probePathWriteable(path: string): string | null {
30
+ const probe = join(path, '.celilo-write-probe');
31
+ try {
32
+ mkdirSync(probe, { recursive: true });
33
+ rmSync(probe, { recursive: true, force: true });
34
+ return null;
35
+ } catch (err) {
36
+ return err instanceof Error ? err.message : String(err);
37
+ }
38
+ }
39
+
18
40
  /**
19
41
  * Expand leading tilde to the user's home directory.
20
42
  * Node.js fs functions don't expand ~ like the shell does.
@@ -55,16 +77,46 @@ export async function handleStorageAddLocal(
55
77
  // looked authoritative but required root to write.
56
78
  const defaultPath = join(getDataDir(), 'backups');
57
79
 
58
- const path =
59
- flagPath ??
60
- (await promptText({
61
- message: 'Storage directory path:',
62
- defaultValue: defaultPath,
63
- placeholder: defaultPath,
64
- validate: validateRequired('Storage path'),
65
- }));
66
-
67
- const resolvedPath = resolve(expandTilde(path));
80
+ // Probe writability BEFORE saving the storage row. The previous
81
+ // flow (save → verify → fail with EACCES → leave dangling
82
+ // unverified row) was confusing and required the operator to know
83
+ // about `storage verify` to recover. Probing here means an
84
+ // unwriteable path never produces persistent state.
85
+ //
86
+ // Non-interactive (--name + --path): probe once, fail loud.
87
+ // Interactive: re-prompt until a writeable path is supplied.
88
+ let resolvedPath: string;
89
+ if (flagPath !== undefined) {
90
+ resolvedPath = resolve(expandTilde(flagPath));
91
+ const writeError = probePathWriteable(resolvedPath);
92
+ if (writeError !== null) {
93
+ return {
94
+ success: false,
95
+ error: `Path '${resolvedPath}' is not writeable: ${writeError}\n\nTry a path under your home directory (e.g. '${defaultPath}') or run with elevated permissions.`,
96
+ };
97
+ }
98
+ } else {
99
+ let candidate: string | undefined;
100
+ while (candidate === undefined) {
101
+ const typed = await promptText({
102
+ message: 'Storage directory path:',
103
+ defaultValue: defaultPath,
104
+ placeholder: defaultPath,
105
+ validate: validateRequired('Storage path'),
106
+ });
107
+ const resolved = resolve(expandTilde(typed));
108
+ const writeError = probePathWriteable(resolved);
109
+ if (writeError === null) {
110
+ candidate = resolved;
111
+ break;
112
+ }
113
+ console.log(
114
+ `\n✗ Path '${resolved}' is not writeable: ${writeError}\nTry a path under your home directory (default '${defaultPath}' works without sudo).\n`,
115
+ );
116
+ // Loop continues — re-prompt with the same default suggestion.
117
+ }
118
+ resolvedPath = candidate;
119
+ }
68
120
 
69
121
  const storage = await addBackupStorage({
70
122
  name,
@@ -78,6 +130,10 @@ export async function handleStorageAddLocal(
78
130
  const { result } = await verifyBackupStorage(storage.id);
79
131
 
80
132
  if (!result.success) {
133
+ // Should be unreachable in interactive mode (the pre-save probe
134
+ // covers the EACCES path); could still fire for less-common
135
+ // verify failures (full disk, etc.). Keep the dangling-row
136
+ // recovery hint as a safety net.
81
137
  console.log(`\n✗ Verification failed: ${result.message}`);
82
138
  celiloOutro(
83
139
  `Storage '${storage.storageId}' added but not verified.\n\nFix the path and re-verify: celilo storage verify ${storage.storageId}`,
@@ -189,12 +189,14 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
189
189
 
190
190
  const installedConfigs = deployedModules.map((m) => ({
191
191
  id: m.id,
192
+ state: m.state,
192
193
  manifest: m.manifestData as ModuleManifest,
193
194
  configs: configsByModule.get(m.id) ?? {},
194
195
  }));
195
196
 
196
197
  const installedBackupInfo = deployedModules.map((m) => ({
197
198
  id: m.id,
199
+ state: m.state,
198
200
  manifest: m.manifestData as ModuleManifest,
199
201
  lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
200
202
  }));
@@ -402,6 +404,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
402
404
  capabilityAbi: {
403
405
  modules: deployedModules.map((m) => ({
404
406
  id: m.id,
407
+ state: m.state,
405
408
  manifest: m.manifestData as ModuleManifest,
406
409
  })),
407
410
  },
@@ -12,7 +12,12 @@
12
12
  */
13
13
 
14
14
  import { describe, expect, test } from 'bun:test';
15
- import { type BackupStorageLike, checkBackupStoragePreflight } from './system-update';
15
+ import type { ModuleSnapshot } from '../../services/update/orchestrator';
16
+ import {
17
+ type BackupStorageLike,
18
+ checkBackupStoragePreflight,
19
+ shouldTakeBackup,
20
+ } from './system-update';
16
21
 
17
22
  const verified = (id: string): BackupStorageLike => ({ storageId: id, verified: true });
18
23
  const unverified = (id: string): BackupStorageLike => ({ storageId: id, verified: false });
@@ -101,3 +106,83 @@ describe('checkBackupStoragePreflight', () => {
101
106
  expect(result.message).toContain('Verified: home-backups, s3-cold');
102
107
  });
103
108
  });
109
+
110
+ const snap = (
111
+ id: string,
112
+ installedVersion: string,
113
+ latestVersion: string | null,
114
+ ): ModuleSnapshot => ({
115
+ id,
116
+ installedVersion,
117
+ latestVersion,
118
+ installedProvides: {},
119
+ pendingRequires: {},
120
+ });
121
+
122
+ const snapshotsOf = (...entries: ModuleSnapshot[]): Map<string, ModuleSnapshot> =>
123
+ new Map(entries.map((s) => [s.id, s]));
124
+
125
+ describe('shouldTakeBackup', () => {
126
+ test('false when no module updates at all', () => {
127
+ expect(
128
+ shouldTakeBackup({
129
+ snapshots: snapshotsOf(snap('caddy', '2.0.0+5', '2.0.0+5')),
130
+ wasDeployed: new Set(['caddy']),
131
+ }),
132
+ ).toBe(false);
133
+ });
134
+
135
+ test('true when a deployed module has a registry-newer version', () => {
136
+ expect(
137
+ shouldTakeBackup({
138
+ snapshots: snapshotsOf(snap('caddy', '2.0.0+5', '2.0.0+6')),
139
+ wasDeployed: new Set(['caddy']),
140
+ }),
141
+ ).toBe(true);
142
+ });
143
+
144
+ // The exact case the operator hit on celilo-mgmt: namecheap was
145
+ // imported but never deployed; system update wanted to back up the
146
+ // celilo DB even though no live state would be touched.
147
+ test('false when only IMPORTED modules have updates (live state untouched)', () => {
148
+ expect(
149
+ shouldTakeBackup({
150
+ snapshots: snapshotsOf(snap('namecheap', '3.1.0+10', '3.1.1+4')),
151
+ wasDeployed: new Set(), // nothing deployed
152
+ }),
153
+ ).toBe(false);
154
+ });
155
+
156
+ test('true when any deployed module needs updating, regardless of imported ones', () => {
157
+ expect(
158
+ shouldTakeBackup({
159
+ snapshots: snapshotsOf(
160
+ snap('namecheap', '3.1.0+10', '3.1.1+4'), // imported, has update
161
+ snap('caddy', '2.0.0+5', '2.0.0+6'), // deployed, has update
162
+ ),
163
+ wasDeployed: new Set(['caddy']),
164
+ }),
165
+ ).toBe(true);
166
+ });
167
+
168
+ test('false when registry has no info (latestVersion null)', () => {
169
+ // Network failure — we don't know if updates exist. Default to
170
+ // "no backup needed" rather than blocking the run; the per-module
171
+ // upgrade step will report individual failures if any.
172
+ expect(
173
+ shouldTakeBackup({
174
+ snapshots: snapshotsOf(snap('caddy', '2.0.0+5', null)),
175
+ wasDeployed: new Set(['caddy']),
176
+ }),
177
+ ).toBe(false);
178
+ });
179
+
180
+ test('false on empty snapshots', () => {
181
+ expect(
182
+ shouldTakeBackup({
183
+ snapshots: new Map(),
184
+ wasDeployed: new Set(),
185
+ }),
186
+ ).toBe(false);
187
+ });
188
+ });
@@ -148,6 +148,29 @@ Or skip the safety net entirely (the CLI self-update still runs):
148
148
  };
149
149
  }
150
150
 
151
+ /**
152
+ * Should `system update` take a celilo-DB snapshot for this run?
153
+ * True iff at least one module that's currently DEPLOYED has a
154
+ * registry-newer version waiting. Pure data in / data out.
155
+ *
156
+ * Excludes IMPORTED-but-not-yet-deployed modules — refreshing their
157
+ * on-disk source has no impact on live state, so the safety net
158
+ * isn't needed (and an operator who hasn't configured storage yet
159
+ * shouldn't be blocked by a snapshot they don't need).
160
+ */
161
+ export function shouldTakeBackup(input: {
162
+ snapshots: Map<string, ModuleSnapshot>;
163
+ wasDeployed: Set<string>;
164
+ }): boolean {
165
+ for (const [id, s] of input.snapshots) {
166
+ if (!input.wasDeployed.has(id)) continue;
167
+ if (!s.latestVersion) continue;
168
+ if (s.latestVersion === s.installedVersion) continue;
169
+ return true;
170
+ }
171
+ return false;
172
+ }
173
+
151
174
  function readInstalledCliVersion(): string {
152
175
  const here = dirname(fileURLToPath(import.meta.url));
153
176
  const candidates = [
@@ -363,7 +386,12 @@ function buildOps(registry: RegistryClient, wasDeployed: Set<string>): Orchestra
363
386
  function formatResult(result: SystemUpdateResult): string {
364
387
  const lines: string[] = [];
365
388
  lines.push('');
366
- lines.push(`update ${result.updateId} ${result.ok ? 'OK' : 'FAILED'}`);
389
+ // The updateId is for audit / journal correlation (see the
390
+ // `backups` table's updateId FK); operators don't read it during
391
+ // normal use, only when debugging a specific run via --json or
392
+ // `celilo backup list`. Surfacing it in human output added noise
393
+ // without paying for itself.
394
+ lines.push(`System update ${result.ok ? 'completed' : 'FAILED'}`);
367
395
  lines.push(` audit verdict: ${result.audit.verdict}`);
368
396
  lines.push(
369
397
  ` self-update: ${result.selfUpdate.performed ? `${result.selfUpdate.from} → ${result.selfUpdate.to}` : `(${result.selfUpdate.reason})`}`,
@@ -500,6 +528,7 @@ export async function handleSystemUpdate(
500
528
  capabilityAbi: {
501
529
  modules: upgradableModules.map((m) => ({
502
530
  id: m.id,
531
+ state: m.state,
503
532
  manifest: m.manifestData as ModuleManifest,
504
533
  })),
505
534
  },
@@ -523,6 +552,7 @@ export async function handleSystemUpdate(
523
552
  moduleConfigs: {
524
553
  modules: upgradableModules.map((m) => ({
525
554
  id: m.id,
555
+ state: m.state,
526
556
  manifest: m.manifestData as ModuleManifest,
527
557
  configs: configsByModule.get(m.id) ?? {},
528
558
  })),
@@ -531,6 +561,7 @@ export async function handleSystemUpdate(
531
561
  backups: {
532
562
  modules: upgradableModules.map((m) => ({
533
563
  id: m.id,
564
+ state: m.state,
534
565
  manifest: m.manifestData as ModuleManifest,
535
566
  lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
536
567
  })),
@@ -587,14 +618,21 @@ export async function handleSystemUpdate(
587
618
  }
588
619
 
589
620
  // Decide whether the celilo-db snapshot is even needed for this run.
590
- // If nothing's changing at the module level, there's nothing to roll
591
- // back to taking a snapshot would be pointless work, and on a fresh
592
- // box (no storage configured yet) it would actively fail. Treat the
593
- // "nothing-to-update" case as implicit --no-backup.
594
- const hasModuleUpdates = [...snapshots.values()].some(
595
- (s) => s.latestVersion && s.latestVersion !== s.installedVersion,
596
- );
597
- const effectiveNoBackup = noBackup || !hasModuleUpdates;
621
+ //
622
+ // The snapshot is a safety net its purpose is to let `system update`
623
+ // roll back if a deploy / health step bricks a running module. So
624
+ // we ONLY need it when the run will actually upgrade-and-redeploy
625
+ // a currently-deployed module. Two cases that don't qualify:
626
+ //
627
+ // 1. Nothing has new code waiting at all (everyone's at latest).
628
+ // 2. The only modules with new code are IMPORTED-but-not-deployed.
629
+ // Their upgrade is a pure source-files-on-disk refresh; no
630
+ // live state to roll back, no risk to mitigate. Forcing a
631
+ // backup here means an operator on a fresh celilo-mgmt with
632
+ // nothing deployed yet has to configure backup storage just
633
+ // to refresh the on-disk modules they imported — which is
634
+ // exactly the friction that prompted this code path.
635
+ const effectiveNoBackup = noBackup || !shouldTakeBackup({ snapshots, wasDeployed });
598
636
 
599
637
  // Pre-flight the storage check so a missing/unusable default doesn't
600
638
  // reach the orchestrator's snapshot hook (where the throw would
@@ -24,7 +24,10 @@ function makeModule(
24
24
  hooks: opts.hasBackupHook ? { on_backup: { script: './backup.ts' } } : {},
25
25
  backup: opts.schedule ? { schedule: opts.schedule } : undefined,
26
26
  } as unknown as ModuleManifest;
27
- return { id, manifest, lastSuccessfulBackupAt: opts.lastSuccessfulBackupAt };
27
+ // Default to INSTALLED existing tests assert backup findings
28
+ // fire, which is the deployed-module behavior. Tests for the
29
+ // non-deployed-skip behavior override this explicitly.
30
+ return { id, state: 'INSTALLED', manifest, lastSuccessfulBackupAt: opts.lastSuccessfulBackupAt };
28
31
  }
29
32
 
30
33
  describe('auditBackups', () => {
@@ -230,4 +233,24 @@ describe('auditBackups', () => {
230
233
  expect(result).toHaveLength(2);
231
234
  expect(result.map((f) => f.subject).sort()).toEqual(['authentik', 'homebridge']);
232
235
  });
236
+
237
+ // The regression: backups were complaining about IMPORTED modules
238
+ // having no successful backup. There's no live state on an
239
+ // IMPORTED module, so there's nothing to back up — skip entirely
240
+ // rather than emit a misleading finding.
241
+ test('skips non-deployed (IMPORTED) modules entirely', async () => {
242
+ const importedModule = {
243
+ ...makeModule('authentik', {
244
+ hasBackupHook: true,
245
+ lastSuccessfulBackupAt: null,
246
+ schedule: 'daily',
247
+ }),
248
+ state: 'IMPORTED',
249
+ };
250
+ const result = await auditBackups({
251
+ modules: [importedModule],
252
+ now: () => NOW,
253
+ });
254
+ expect(result).toEqual([]);
255
+ });
233
256
  });
@@ -20,6 +20,8 @@ import type { DriftFinding } from './types';
20
20
 
21
21
  export interface InstalledModuleBackupInfo {
22
22
  id: string;
23
+ /** Lifecycle state — non-deployed modules have nothing to back up. */
24
+ state: string;
23
25
  manifest: ModuleManifest;
24
26
  /** Most recent successful backup timestamp (ms since epoch), or null. */
25
27
  lastSuccessfulBackupAt: number | null;
@@ -87,12 +89,20 @@ function formatAge(ms: number): string {
87
89
  return `${mins}m`;
88
90
  }
89
91
 
92
+ const DEPLOYED_STATES = new Set(['INSTALLED', 'VERIFIED']);
93
+
90
94
  export async function auditBackups(deps: BackupsAuditDeps): Promise<DriftFinding[]> {
91
95
  const now = (deps.now ?? Date.now)();
92
96
  const findings: DriftFinding[] = [];
93
97
 
94
98
  for (const m of deps.modules) {
95
99
  if (!moduleHasBackupHook(m.manifest)) continue;
100
+ // Non-deployed modules have no live state to back up. Surfacing
101
+ // a "no successful backup recorded" finding for an IMPORTED
102
+ // module is just noise — the operator hasn't deployed yet, so
103
+ // there's nothing to lose. Skip them entirely from the backup
104
+ // audit.
105
+ if (!DEPLOYED_STATES.has(m.state)) continue;
96
106
 
97
107
  if (m.lastSuccessfulBackupAt === null) {
98
108
  findings.push({
@@ -23,7 +23,10 @@ function makeModule(
23
23
  requires: opts.requires ? { capabilities: opts.requires } : { capabilities: [] },
24
24
  optional: opts.optional ? { capabilities: opts.optional } : undefined,
25
25
  } as unknown as ModuleManifest;
26
- return { id, manifest };
26
+ // Default INSTALLED so existing assertions keep their `blocked`
27
+ // semantics. Tests that exercise the IMPORTED-demotion path
28
+ // override the state explicitly.
29
+ return { id, state: 'INSTALLED', manifest };
27
30
  }
28
31
 
29
32
  const RUNTIME = {
@@ -26,9 +26,19 @@ import type { DriftFinding } from './types';
26
26
 
27
27
  export interface InstalledCapabilityModule {
28
28
  id: string;
29
+ /**
30
+ * Lifecycle state — used to demote ABI mismatches on IMPORTED-but-
31
+ * not-deployed modules from `blocked` to `todo`. The mismatch is
32
+ * still real (the operator will hit it at deploy time) but it
33
+ * doesn't block other module work, and gating system update on it
34
+ * makes refreshing unrelated modules harder than it should be.
35
+ */
36
+ state: string;
29
37
  manifest: ModuleManifest;
30
38
  }
31
39
 
40
+ const DEPLOYED_STATES = new Set(['INSTALLED', 'VERIFIED']);
41
+
32
42
  export interface CapabilityAbiAuditDeps {
33
43
  modules: InstalledCapabilityModule[];
34
44
  /** Override the framework registry — for tests. Defaults to `CAPABILITY_CONTRACT_VERSIONS`. */
@@ -120,7 +130,11 @@ export async function auditCapabilityAbi(deps: CapabilityAbiAuditDeps): Promise<
120
130
 
121
131
  findings.push({
122
132
  category: 'capability_abi',
123
- severity: 'blocked',
133
+ // Demoted to `todo` for non-deployed modules: the mismatch
134
+ // matters at deploy time, but blocking system update on it
135
+ // means the operator can't refresh unrelated modules until
136
+ // they fix the ABI for a module they haven't deployed yet.
137
+ severity: DEPLOYED_STATES.has(m.state) ? 'blocked' : 'todo',
124
138
  code: 'capability_abi_provider_mismatch',
125
139
  message: `${m.id} provides ${p.name}@${p.version} but framework runtime expects ${runtimeVersion}`,
126
140
  details,
@@ -190,7 +204,9 @@ export async function auditCapabilityAbi(deps: CapabilityAbiAuditDeps): Promise<
190
204
 
191
205
  findings.push({
192
206
  category: 'capability_abi',
193
- severity: 'blocked',
207
+ // Same demotion as the provider check — non-deployed
208
+ // modules surface as todos rather than blockers.
209
+ severity: DEPLOYED_STATES.has(m.state) ? 'blocked' : 'todo',
194
210
  code: 'capability_abi_consumer_mismatch',
195
211
  message: `${m.id} requires ${need.name}@${need.version} but ${provider.moduleId} provides ${provider.version}`,
196
212
  details,
@@ -24,7 +24,10 @@ function makeModule(
24
24
  celilo_contract: '1.0',
25
25
  variables: { owns: variables, imports: [] },
26
26
  } as unknown as ModuleManifest;
27
- return { id, manifest, configs };
27
+ // Default state INSTALLED so existing tests continue to expect
28
+ // `blocked` severity. Tests that exercise the IMPORTED path
29
+ // override the state explicitly.
30
+ return { id, state: 'INSTALLED', manifest, configs };
28
31
  }
29
32
 
30
33
  describe('auditModuleConfigs', () => {
@@ -128,4 +131,47 @@ describe('auditModuleConfigs', () => {
128
131
  expect(result).toHaveLength(1);
129
132
  expect(result[0].subject).toBe('caddy');
130
133
  });
134
+
135
+ // The exact regression the operator hit on celilo-mgmt: required
136
+ // configs missing on IMPORTED-but-not-yet-deployed modules used to
137
+ // surface as `blocked`, gating system update entirely. The deploy
138
+ // interview collects them at deploy time, so they're todos here.
139
+ test('IMPORTED module with missing required config → todo, not blocked', async () => {
140
+ const moduleWithImportedState = {
141
+ ...makeModule('namecheap', [makeVariable({ name: 'domains', required: true })], {}),
142
+ state: 'IMPORTED',
143
+ };
144
+ const result = await auditModuleConfigs({
145
+ modules: [moduleWithImportedState],
146
+ });
147
+ expect(result).toHaveLength(1);
148
+ expect(result[0].severity).toBe('todo');
149
+ // Remediation also flips: don't tell the operator to manually
150
+ // `module config set` (the deploy interview is the right path).
151
+ expect(result[0].remediation).toBe('celilo module deploy namecheap');
152
+ });
153
+
154
+ test('VALIDATED / GENERATING / other pre-deploy states also demote to todo', async () => {
155
+ const validated = {
156
+ ...makeModule('foo', [makeVariable({ name: 'x', required: true })], {}),
157
+ state: 'VALIDATED',
158
+ };
159
+ const generating = {
160
+ ...makeModule('bar', [makeVariable({ name: 'x', required: true })], {}),
161
+ state: 'GENERATING',
162
+ };
163
+ const result = await auditModuleConfigs({ modules: [validated, generating] });
164
+ expect(result).toHaveLength(2);
165
+ expect(result.every((f) => f.severity === 'todo')).toBe(true);
166
+ });
167
+
168
+ test('VERIFIED state stays blocked (deployed module)', async () => {
169
+ const verified = {
170
+ ...makeModule('caddy', [makeVariable({ name: 'acme_email', required: true })], {}),
171
+ state: 'VERIFIED',
172
+ };
173
+ const result = await auditModuleConfigs({ modules: [verified] });
174
+ expect(result).toHaveLength(1);
175
+ expect(result[0].severity).toBe('blocked');
176
+ });
131
177
  });
@@ -8,20 +8,30 @@
8
8
  * the user.
9
9
  *
10
10
  * Severity rules:
11
- * - `required: true` AND no current value AND no `default:` → BLOCKED
12
- * (the user has to set it; `system update` would otherwise fail at
13
- * deploy time anyway).
14
- * - All other unset cases no finding. An optional variable with a
15
- * default is by definition fine when unset (the default applies);
16
- * a required variable with a default is also fine (the default
17
- * resolves the value). Either case as drift would just be noise.
11
+ * - DEPLOYED module (state=INSTALLED|VERIFIED) with `required: true`
12
+ * AND no current value AND no `default:` BLOCKED. The module is
13
+ * live and a config gap is a real divergence: the next deploy
14
+ * would fail, capability consumers may be reading the unset value,
15
+ * etc.
16
+ * - NON-DEPLOYED module (IMPORTED, etc.) TODO. The deploy
17
+ * interview will collect required values when the operator
18
+ * eventually runs `module deploy`. Telling them to manually
19
+ * `module config set` is bad UX — the interview is the canonical
20
+ * path. Surfacing as TODO keeps the visibility without escalating
21
+ * the verdict.
22
+ * - Variable has a `default:` (any state) → no finding. The default
23
+ * resolves the value.
18
24
  */
19
25
 
20
26
  import type { ModuleManifest, VariableDeclare } from '../../manifest/schema';
21
27
  import type { DriftFinding } from './types';
22
28
 
29
+ const DEPLOYED_STATES = new Set(['INSTALLED', 'VERIFIED']);
30
+
23
31
  export interface InstalledModuleConfig {
24
32
  id: string;
33
+ /** Lifecycle state from the modules table — drives severity. */
34
+ state: string;
25
35
  manifest: ModuleManifest;
26
36
  /** Map of config key → current value (string for primitives, parsed object for complex). */
27
37
  configs: Record<string, unknown>;
@@ -37,6 +47,7 @@ function isUnset(value: unknown): boolean {
37
47
 
38
48
  function ownVariableFindings(
39
49
  moduleId: string,
50
+ state: string,
40
51
  variable: VariableDeclare,
41
52
  currentValue: unknown,
42
53
  ): DriftFinding[] {
@@ -47,23 +58,30 @@ function ownVariableFindings(
47
58
  if (!isUnset(currentValue)) return [];
48
59
 
49
60
  const hasDefault = variable.default !== undefined && variable.default !== null;
61
+ if (!variable.required || hasDefault) return [];
50
62
 
51
- if (variable.required && !hasDefault) {
52
- return [
53
- {
54
- category: 'module_configs',
55
- severity: 'blocked',
56
- code: 'module_config_required_unset',
57
- message: `${moduleId}: required config "${variable.name}" is not set`,
58
- details: variable.description,
59
- remediation: `celilo module config set ${moduleId} ${variable.name} <value>`,
60
- actionable: true,
61
- subject: moduleId,
62
- },
63
- ];
64
- }
63
+ // BLOCKED only when the module is currently deployed. For
64
+ // IMPORTED-and-similar pre-deploy states, the deploy interview
65
+ // collects this value automatically — surfacing as a blocker
66
+ // wrongly directs the operator at `module config set` (a manual
67
+ // workaround) when the right command is `module deploy <id>`.
68
+ const severity = DEPLOYED_STATES.has(state) ? 'blocked' : 'todo';
69
+ const remediation = DEPLOYED_STATES.has(state)
70
+ ? `celilo module config set ${moduleId} ${variable.name} <value>`
71
+ : `celilo module deploy ${moduleId}`;
65
72
 
66
- return [];
73
+ return [
74
+ {
75
+ category: 'module_configs',
76
+ severity,
77
+ code: 'module_config_required_unset',
78
+ message: `${moduleId}: required config "${variable.name}" is not set`,
79
+ details: variable.description,
80
+ remediation,
81
+ actionable: true,
82
+ subject: moduleId,
83
+ },
84
+ ];
67
85
  }
68
86
 
69
87
  export async function auditModuleConfigs(deps: ModuleConfigsAuditDeps): Promise<DriftFinding[]> {
@@ -72,7 +90,7 @@ export async function auditModuleConfigs(deps: ModuleConfigsAuditDeps): Promise<
72
90
  for (const m of deps.modules) {
73
91
  const owned = m.manifest.variables?.owns ?? [];
74
92
  for (const variable of owned) {
75
- findings.push(...ownVariableFindings(m.id, variable, m.configs[variable.name]));
93
+ findings.push(...ownVariableFindings(m.id, m.state, variable, m.configs[variable.name]));
76
94
  }
77
95
  }
78
96