@celilo/cli 2.2.0 → 2.3.0

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.
Files changed (180) hide show
  1. package/CELILO_CORE_MODULES.md +1 -0
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/schemas/system_config.json +5 -0
  9. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  10. package/src/ansible/inventory.test.ts +2 -1
  11. package/src/api/sessions.test.ts +5 -4
  12. package/src/api-clients/proxmox.ts +10 -7
  13. package/src/cli/backup-rename.test.ts +5 -4
  14. package/src/cli/cli.test.ts +3 -2
  15. package/src/cli/commands/console-get-chain.test.ts +2 -1
  16. package/src/cli/commands/events.test.ts +2 -2
  17. package/src/cli/commands/firewall-interface-list.test.ts +4 -3
  18. package/src/cli/commands/machine-list.test.ts +57 -0
  19. package/src/cli/commands/machine-list.ts +35 -4
  20. package/src/cli/commands/module-config.test.ts +2 -1
  21. package/src/cli/commands/module-deploy.ts +8 -2
  22. package/src/cli/commands/module-generate.test.ts +53 -0
  23. package/src/cli/commands/module-generate.ts +31 -26
  24. package/src/cli/commands/module-health.ts +1 -0
  25. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  26. package/src/cli/commands/module-import-registry.test.ts +3 -2
  27. package/src/cli/commands/module-jail.test.ts +242 -0
  28. package/src/cli/commands/module-jail.ts +227 -0
  29. package/src/cli/commands/module-list-jail.test.ts +136 -0
  30. package/src/cli/commands/module-list.ts +30 -3
  31. package/src/cli/commands/module-operations.test.ts +2 -1
  32. package/src/cli/commands/module-publish.test.ts +11 -18
  33. package/src/cli/commands/module-update.test.ts +28 -12
  34. package/src/cli/commands/module-update.ts +4 -1
  35. package/src/cli/commands/module-upgrade.test.ts +130 -1
  36. package/src/cli/commands/module-upgrade.ts +77 -3
  37. package/src/cli/commands/module-verify.test.ts +3 -4
  38. package/src/cli/commands/module-verify.ts +0 -1
  39. package/src/cli/commands/notify-config.test.ts +5 -3
  40. package/src/cli/commands/publish/execute.ts +4 -1
  41. package/src/cli/commands/publish/index.ts +11 -1
  42. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  43. package/src/cli/commands/publish/module-registry.ts +23 -4
  44. package/src/cli/commands/publish/plan.ts +1 -1
  45. package/src/cli/commands/publish/types.ts +7 -0
  46. package/src/cli/commands/registry-owner.test.ts +3 -2
  47. package/src/cli/commands/registry-token.test.ts +3 -2
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  49. package/src/cli/commands/system-audit.ts +20 -6
  50. package/src/cli/commands/system-config.test.ts +148 -0
  51. package/src/cli/commands/system-config.ts +26 -1
  52. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  53. package/src/cli/commands/system-doctor.test.ts +143 -6
  54. package/src/cli/commands/system-doctor.ts +142 -25
  55. package/src/cli/commands/system-init-deprecation.test.ts +7 -4
  56. package/src/cli/commands/system-migrate.test.ts +2 -1
  57. package/src/cli/commands/system-update.ts +5 -0
  58. package/src/cli/completion.ts +8 -2
  59. package/src/cli/flag-surface-gate.test.ts +279 -0
  60. package/src/cli/index.ts +70 -3
  61. package/src/cli/parser.test.ts +37 -1
  62. package/src/cli/restore-command.test.ts +5 -4
  63. package/src/cli/restore-migration-failure.test.ts +4 -3
  64. package/src/cli/tui/audit-state.ts +2 -0
  65. package/src/config/paths.test.ts +22 -22
  66. package/src/db/client.test.ts +46 -1
  67. package/src/db/client.ts +26 -0
  68. package/src/db/migrate.test.ts +2 -1
  69. package/src/db/migrate.ts +16 -16
  70. package/src/db/schema.ts +63 -0
  71. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  72. package/src/hooks/capability-loader.test.ts +78 -0
  73. package/src/hooks/capability-loader.ts +45 -2
  74. package/src/hooks/executor.test.ts +161 -1
  75. package/src/hooks/executor.ts +87 -21
  76. package/src/hooks/hook-jail-unreachability.test.ts +17 -18
  77. package/src/hooks/hook-store.test.ts +6 -0
  78. package/src/hooks/hook-trespass.test.ts +26 -9
  79. package/src/hooks/jail.test.ts +152 -5
  80. package/src/hooks/jail.ts +93 -4
  81. package/src/manifest/contracts/v1.ts +13 -0
  82. package/src/module/packaging/audit.ts +9 -26
  83. package/src/module/packaging/build-paths.test.ts +127 -0
  84. package/src/module/packaging/build-paths.ts +175 -0
  85. package/src/module/packaging/build.test.ts +71 -1
  86. package/src/module/packaging/build.ts +60 -0
  87. package/src/module/packaging/extract.ts +1 -5
  88. package/src/policy/module-script-scan.ts +51 -107
  89. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +14 -15
  91. package/src/registry/client.test.ts +2 -2
  92. package/src/secrets/storage.test.ts +1 -1
  93. package/src/services/alerting/ack.test.ts +2 -1
  94. package/src/services/alerting/cadence-migration.test.ts +3 -2
  95. package/src/services/alerting/coverage-source.test.ts +2 -1
  96. package/src/services/alerting/deferral.test.ts +2 -1
  97. package/src/services/alerting/delivery-loop.test.ts +2 -1
  98. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  99. package/src/services/alerting/inbound-poller.test.ts +2 -1
  100. package/src/services/alerting/inbound.test.ts +2 -1
  101. package/src/services/alerting/keys.test.ts +4 -0
  102. package/src/services/alerting/keys.ts +12 -2
  103. package/src/services/alerting/notification-responder.test.ts +2 -1
  104. package/src/services/alerting/run-monitor.test.ts +2 -1
  105. package/src/services/alerting/store.test.ts +2 -1
  106. package/src/services/alerting/sweep-runner.test.ts +2 -1
  107. package/src/services/alerting/tokens.test.ts +2 -1
  108. package/src/services/aspect-approvals.test.ts +2 -1
  109. package/src/services/aspect-reconcile.test.ts +4 -3
  110. package/src/services/aspect-runner.test.ts +2 -1
  111. package/src/services/audit/health.test.ts +97 -2
  112. package/src/services/audit/health.ts +64 -2
  113. package/src/services/audit/index.test.ts +22 -0
  114. package/src/services/audit/index.ts +8 -2
  115. package/src/services/audit/jail-exemptions.test.ts +42 -0
  116. package/src/services/audit/jail-exemptions.ts +44 -0
  117. package/src/services/audit/module-integrity.test.ts +23 -22
  118. package/src/services/audit/module-integrity.ts +7 -16
  119. package/src/services/audit/types.ts +2 -1
  120. package/src/services/backup-age-agreement.test.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +3 -2
  123. package/src/services/backup-in-flight-refusal.test.ts +3 -2
  124. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
  125. package/src/services/build-bus/hook-dispatcher.ts +49 -1
  126. package/src/services/bus-ensure-flow.test.ts +3 -2
  127. package/src/services/bus-interview-park.test.ts +4 -3
  128. package/src/services/bus-interview.test.ts +2 -2
  129. package/src/services/bus-secret-flow.test.ts +3 -2
  130. package/src/services/capability-table-rows.test.ts +2 -1
  131. package/src/services/celilo-events.test.ts +1 -1
  132. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  133. package/src/services/consumer-cleanup.test.ts +3 -2
  134. package/src/services/container-service.test.ts +3 -2
  135. package/src/services/control-plane-bootstrap.test.ts +2 -1
  136. package/src/services/cross-module-read.test.ts +4 -3
  137. package/src/services/deploy-preflight.ts +7 -0
  138. package/src/services/deploy-validation.test.ts +3 -2
  139. package/src/services/deployed-systems.ts +1 -1
  140. package/src/services/dns-internal-records.test.ts +3 -2
  141. package/src/services/dns-provider-backfill.test.ts +2 -1
  142. package/src/services/dns-registrations.test.ts +2 -1
  143. package/src/services/ensure-interview.test.ts +3 -2
  144. package/src/services/fleet-checks.test.ts +126 -6
  145. package/src/services/fleet-checks.ts +176 -3
  146. package/src/services/fleet-key.test.ts +3 -2
  147. package/src/services/health-runner.test.ts +87 -2
  148. package/src/services/health-runner.ts +40 -11
  149. package/src/services/infrastructure-selector.test.ts +3 -2
  150. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  151. package/src/services/jail-exemptions.test.ts +125 -0
  152. package/src/services/jail-exemptions.ts +81 -0
  153. package/src/services/machine-pool.test.ts +3 -2
  154. package/src/services/module-config.test.ts +2 -1
  155. package/src/services/module-deploy.dns-repoint.test.ts +2 -1
  156. package/src/services/module-deploy.ts +48 -24
  157. package/src/services/module-operations.test.ts +2 -1
  158. package/src/services/module-subscriptions.test.ts +4 -3
  159. package/src/services/network-discovery.test.ts +64 -1
  160. package/src/services/network-discovery.ts +30 -5
  161. package/src/services/port-forwards.test.ts +2 -1
  162. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  163. package/src/services/proxmox-reconcile.test.ts +2 -1
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +7 -6
  166. package/src/services/restore-preflight.test.ts +3 -2
  167. package/src/services/secret-schema-loader.test.ts +2 -1
  168. package/src/services/ssh-key-manager.test.ts +4 -3
  169. package/src/services/static-content-converge.test.ts +5 -4
  170. package/src/services/static-content-converge.ts +33 -10
  171. package/src/services/system-state-stage.test.ts +4 -3
  172. package/src/services/trusted-sources.test.ts +3 -2
  173. package/src/services/update/orchestrator.test.ts +2 -0
  174. package/src/services/zone-detector.test.ts +2 -1
  175. package/src/test-utils/bus-responder.ts +6 -4
  176. package/src/test-utils/db-path.ts +25 -0
  177. package/src/test-utils/integration.ts +7 -0
  178. package/src/test-utils/module-fixtures.ts +5 -6
  179. package/src/module/packaging/generated-plane.test.ts +0 -79
  180. package/src/module/packaging/generated-plane.ts +0 -134
@@ -20,7 +20,8 @@ import { type DbClient, getDb } from '../../db/client';
20
20
  import { modules } from '../../db/schema';
21
21
  import type { ModuleManifest } from '../../manifest/schema';
22
22
  import { RegistryClient } from '../../registry/client';
23
- import { createModuleBackup } from '../../services/backup-create';
23
+ import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
24
+ import { getDefaultBackupStorage } from '../../services/backup-storage';
24
25
  import {
25
26
  type DeployPosture,
26
27
  type UpgradePolicy,
@@ -79,12 +80,55 @@ export function needsPreUpgradeBackup(
79
80
  return posture === 'safe' && Boolean(targetManifest.hooks?.on_backup);
80
81
  }
81
82
 
83
+ /**
84
+ * Pure (Rule 10): does this upgrade snapshot the celilo DB first? Safe posture
85
+ * means a minor/major bump, and a minor bump can migrate the DB schema — the
86
+ * snapshot is what makes a later package downgrade a real rollback instead of
87
+ * a gamble on an older binary reading a newer-migrated database.
88
+ */
89
+ export function needsSystemStateSnapshot(posture: DeployPosture): boolean {
90
+ return posture === 'safe';
91
+ }
92
+
93
+ /**
94
+ * Take the system_state snapshot a safe-posture upgrade requires.
95
+ *
96
+ * A host with no default backup storage keeps today's behavior (warn and
97
+ * proceed): refusing every safe upgrade there would break CD for a config
98
+ * gap, and the module-level backup below still runs when the target manifest
99
+ * declares on_backup. A configured-but-failing snapshot refuses the upgrade,
100
+ * same rule `system update` applies to its own snapshot (D7): the DB snapshot
101
+ * is the safety net, and an upgrade that cannot take it should not mutate.
102
+ */
103
+ async function snapshotSystemStateBeforeUpgrade(
104
+ moduleId: string,
105
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
106
+ try {
107
+ if (!getDefaultBackupStorage()) {
108
+ log.warn(
109
+ `${moduleId}: no default backup storage configured — skipping pre-upgrade system_state snapshot`,
110
+ );
111
+ return { ok: true };
112
+ }
113
+ const snapshot = await createSystemStateBackup();
114
+ if (!snapshot.success) {
115
+ return { ok: false, error: snapshot.error ?? 'system_state backup failed' };
116
+ }
117
+ log.success(`System state snapshot taken before upgrading ${moduleId}`);
118
+ return { ok: true };
119
+ } catch (err) {
120
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
121
+ }
122
+ }
123
+
82
124
  export interface PollCandidate {
83
125
  moduleId: string;
84
126
  installed: string;
85
127
  /** Latest registry version, or null when the module isn't on the registry. */
86
128
  latest: string | null;
87
129
  autoUpgrade: boolean;
130
+ /** True when the module row carries state ERROR (a failed upgrade, celilo#1363). */
131
+ inError: boolean;
88
132
  }
89
133
 
90
134
  export interface PollTarget {
@@ -102,6 +146,10 @@ export function selectPollTargets(candidates: PollCandidate[]): PollTarget[] {
102
146
  const targets: PollTarget[] = [];
103
147
  for (const c of candidates) {
104
148
  if (!c.autoUpgrade || !c.latest) continue;
149
+ // A module in ERROR failed an upgrade already. Retrying it on every poll
150
+ // redeploys the same broken version indefinitely (celilo#1363), so skip it
151
+ // until an attended health check clears the state (healthy → VERIFIED).
152
+ if (c.inError) continue;
105
153
  const change = classifyVersionChange(c.installed, c.latest);
106
154
  if (change === 'up-to-date' || change === 'ahead') continue;
107
155
  targets.push({ moduleId: c.moduleId, from: c.installed, to: c.latest });
@@ -121,8 +169,10 @@ function resolveAutoUpgrade(moduleId: string): boolean {
121
169
  * Upgrade ONE module to a known target version: posture → update → backup →
122
170
  * deploy → verify. The shared core for both the single-module command and the
123
171
  * poll. `mod` is the current DB row; `targetVersion` is the registry latest.
172
+ * Exported for tests: the failure paths here are where the store diverges from
173
+ * reality (celilo#1363).
124
174
  */
125
- async function upgradeOneModule(
175
+ export async function upgradeOneModule(
126
176
  mod: typeof modules.$inferSelect,
127
177
  targetVersion: string,
128
178
  client: RegistryClient,
@@ -168,7 +218,17 @@ async function upgradeOneModule(
168
218
  });
169
219
  log.info(`Upgrading ${moduleId} ${mod.version} → ${targetVersion} (${posture} — ${reason})`);
170
220
 
171
- // Safe → back up first, gated on the TARGET manifest's on_backup hook.
221
+ // Safe → snapshot the celilo DB first (the package rollback pair), then
222
+ // back up the module itself, gated on the TARGET manifest's on_backup hook.
223
+ if (needsSystemStateSnapshot(posture)) {
224
+ const snapshot = await snapshotSystemStateBeforeUpgrade(moduleId);
225
+ if (!snapshot.ok) {
226
+ return {
227
+ success: false,
228
+ error: `Pre-upgrade system_state snapshot failed for ${moduleId}: ${snapshot.error}`,
229
+ };
230
+ }
231
+ }
172
232
  if (needsPreUpgradeBackup(posture, targetManifest)) {
173
233
  const backup = await createModuleBackup(moduleId);
174
234
  if (!backup.success) {
@@ -185,6 +245,19 @@ async function upgradeOneModule(
185
245
  // Deploy (idempotent).
186
246
  const deployed = await deployModule(moduleId, db, {});
187
247
  if (!deployed.success) {
248
+ // fetchAndUpdate already swapped the files and wrote the new version to
249
+ // the store, so the recorded version no longer matches anything verified.
250
+ // Record the failure in the state rather than leaving a stale VERIFIED
251
+ // beside the new version (celilo#1363, option A). An attended health check
252
+ // that passes moves ERROR → VERIFIED and clears the record.
253
+ db.update(modules)
254
+ .set({
255
+ state: 'ERROR',
256
+ errorMessage: `Upgrade to ${targetVersion} failed to deploy: ${deployed.error}`,
257
+ updatedAt: new Date(),
258
+ })
259
+ .where(eq(modules.id, moduleId))
260
+ .run();
188
261
  return {
189
262
  success: false,
190
263
  error: `Deploy failed for ${moduleId} (now at ${targetVersion}): ${deployed.error}`,
@@ -236,6 +309,7 @@ async function runRegistryPoll(
236
309
  installed: mod.version,
237
310
  latest: await latestRegistryVersion(client, mod.id),
238
311
  autoUpgrade: resolveAutoUpgrade(mod.id),
312
+ inError: mod.state === 'ERROR',
239
313
  });
240
314
  }
241
315
 
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
2
  import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { resetTestDbPath } from '../../test-utils/db-path';
5
6
  import { moduleAudit } from './module-audit';
6
7
  import { moduleVerify } from './module-verify';
7
8
 
@@ -19,7 +20,6 @@ describe('moduleVerify', () => {
19
20
  describe('moduleAudit (deprecation alias)', () => {
20
21
  let dataDir: string;
21
22
  const originalDataDir = process.env.CELILO_DATA_DIR;
22
- const originalDbPath = process.env.CELILO_DB_PATH;
23
23
 
24
24
  beforeEach(() => {
25
25
  dataDir = mkdtempSync(join(tmpdir(), 'celilo-audit-alias-'));
@@ -29,10 +29,9 @@ describe('moduleAudit (deprecation alias)', () => {
29
29
 
30
30
  afterEach(() => {
31
31
  rmSync(dataDir, { recursive: true, force: true });
32
- if (originalDataDir === undefined) process.env.CELILO_DATA_DIR = undefined;
32
+ resetTestDbPath();
33
+ if (originalDataDir === undefined) delete process.env.CELILO_DATA_DIR;
33
34
  else process.env.CELILO_DATA_DIR = originalDataDir;
34
- if (originalDbPath === undefined) process.env.CELILO_DB_PATH = undefined;
35
- else process.env.CELILO_DB_PATH = originalDbPath;
36
35
  });
37
36
 
38
37
  test('writes a deprecation warning to stderr and delegates to verify', async () => {
@@ -102,7 +102,6 @@ export async function moduleVerify(
102
102
  modified: '✗',
103
103
  extra: '!',
104
104
  'stale-baseline': '⚠',
105
- 'stale-generated': '✗',
106
105
  };
107
106
 
108
107
  return {
@@ -22,10 +22,10 @@ import { join } from 'node:path';
22
22
  import { closeDb, getDb } from '../../db/client';
23
23
  import { createPerson } from '../../services/alerting/people';
24
24
  import { setupTestDatabaseAt } from '../../test-utils/database';
25
+ import { resetTestDbPath } from '../../test-utils/db-path';
25
26
  import { handlePerson } from './notify-config';
26
27
 
27
28
  let dir: string;
28
- const previousDbPath = process.env.CELILO_DB_PATH;
29
29
 
30
30
  beforeEach(async () => {
31
31
  dir = mkdtempSync(join(tmpdir(), 'celilo-person-'));
@@ -39,8 +39,10 @@ beforeEach(async () => {
39
39
 
40
40
  afterEach(() => {
41
41
  closeDb();
42
- if (previousDbPath === undefined) delete process.env.CELILO_DB_PATH;
43
- else process.env.CELILO_DB_PATH = previousDbPath;
42
+ // Reset to the scratch path, never restore: the saved value may be another
43
+ // suite's temp database or unset — which sends the next var-less reader to
44
+ // the operator's real celilo.db (celilo#1315).
45
+ resetTestDbPath();
44
46
  rmSync(dir, { recursive: true, force: true });
45
47
  });
46
48
 
@@ -63,7 +63,10 @@ export async function executePlan(input: ExecutePlanInput): Promise<PublishResul
63
63
  }
64
64
 
65
65
  if (plan.modulePublish.length > 0) {
66
- executeModulePublish(plan.modulePublish, { allowStale: plan.options.allowStale });
66
+ executeModulePublish(plan.modulePublish, {
67
+ allowStale: plan.options.allowStale,
68
+ skippedModules: plan.options.skippedModules,
69
+ });
67
70
  }
68
71
 
69
72
  return result;
@@ -20,6 +20,7 @@
20
20
  * celilo publish --release-touch # auto-fix module stale-drift
21
21
  * celilo publish --allow-stale # skip stale-version safeguards
22
22
  * celilo publish -y / --yes # auto-confirm prompts
23
+ * celilo publish --skip-module <id> # leave a module out of the registry sweep
23
24
  * celilo publish --alpha # X.Y.Z-alpha.N to @alpha
24
25
  * celilo publish --alpha --track-alpha # also force-pin alphas globally
25
26
  * celilo publish --alpha --alpha-modules # also publish module +N
@@ -120,6 +121,15 @@ export function parseOptions(argv: string[]): ParsedFlags {
120
121
  const alphaModulesFlag = argv.includes('--alpha-modules');
121
122
  const skipChangesets = argv.includes('--skip-changesets');
122
123
  const promoteArg = takeFlagValue('--promote');
124
+ const skippedModules: string[] = [];
125
+ for (let i = argv.indexOf('--skip-module'); i >= 0; i = argv.indexOf('--skip-module', i + 1)) {
126
+ const id = argv[i + 1];
127
+ if (id === undefined || id.startsWith('-')) {
128
+ console.error('✗ --skip-module requires a module id (e.g. --skip-module homebridge)');
129
+ process.exit(1);
130
+ }
131
+ skippedModules.push(id);
132
+ }
123
133
 
124
134
  if (alphaFlag && promoteArg) {
125
135
  console.error('✗ --alpha and --promote are mutually exclusive.');
@@ -152,7 +162,7 @@ export function parseOptions(argv: string[]): ParsedFlags {
152
162
  }
153
163
 
154
164
  return {
155
- options: { allowStale, autoYes: yes, mode },
165
+ options: { allowStale, autoYes: yes, mode, skippedModules },
156
166
  dryRun,
157
167
  releaseTouch,
158
168
  skipChangesets,
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, test } from 'bun:test';
11
- import { mapModuleDirsToItems } from './module-registry';
11
+ import { applyModuleSkips, mapModuleDirsToItems } from './module-registry';
12
12
 
13
13
  describe('mapModuleDirsToItems', () => {
14
14
  test('empty input → empty plan', () => {
@@ -38,3 +38,26 @@ describe('mapModuleDirsToItems', () => {
38
38
  ]);
39
39
  });
40
40
  });
41
+
42
+ describe('applyModuleSkips', () => {
43
+ const items = mapModuleDirsToItems([
44
+ '/repo/modules/caddy',
45
+ '/repo/modules/burner',
46
+ '/repo/modules/homebridge',
47
+ ]);
48
+
49
+ test('no skips leaves the plan unchanged', () => {
50
+ expect(applyModuleSkips(items, [])).toEqual(items);
51
+ });
52
+
53
+ test('drops each named module and keeps the order of the rest', () => {
54
+ expect(applyModuleSkips(items, ['burner']).map((m) => m.moduleDir)).toEqual([
55
+ '/repo/modules/caddy',
56
+ '/repo/modules/homebridge',
57
+ ]);
58
+ });
59
+
60
+ test('a name that matches no module directory throws and names it', () => {
61
+ expect(() => applyModuleSkips(items, ['burnr'])).toThrow('burnr');
62
+ });
63
+ });
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { spawnSync } from 'node:child_process';
13
13
  import { existsSync } from 'node:fs';
14
- import { join, relative } from 'node:path';
14
+ import { basename, join, relative } from 'node:path';
15
15
  import { REPO_ROOT, listModuleDirs } from './helpers';
16
16
  import type { ModuleItem } from './types';
17
17
 
@@ -28,13 +28,31 @@ export function mapModuleDirsToItems(dirs: string[]): ModuleItem[] {
28
28
  return dirs.map((moduleDir) => ({ moduleDir }));
29
29
  }
30
30
 
31
- export function planModulePublish(): ModuleItem[] {
32
- return mapModuleDirsToItems(listModuleDirs());
31
+ /**
32
+ * Leave the modules named by `--skip-module` out of the sweep. A name
33
+ * that matches no module directory throws, so a typo cannot skip
34
+ * nothing and publish the module it meant to hold back.
35
+ */
36
+ export function applyModuleSkips(items: ModuleItem[], skippedModules: string[]): ModuleItem[] {
37
+ const known = new Set(items.map((m) => basename(m.moduleDir)));
38
+ const unknown = skippedModules.filter((id) => !known.has(id));
39
+ if (unknown.length > 0) {
40
+ throw new Error(`--skip-module names no module directory: ${unknown.join(', ')}`);
41
+ }
42
+ const skipped = new Set(skippedModules);
43
+ return items.filter((m) => !skipped.has(basename(m.moduleDir)));
44
+ }
45
+
46
+ export function planModulePublish(skippedModules: string[]): ModuleItem[] {
47
+ return applyModuleSkips(mapModuleDirsToItems(listModuleDirs()), skippedModules);
33
48
  }
34
49
 
35
50
  // ─── Executor ──────────────────────────────────────────────────────
36
51
 
37
- export function executeModulePublish(items: ModuleItem[], opts: { allowStale: boolean }): void {
52
+ export function executeModulePublish(
53
+ items: ModuleItem[],
54
+ opts: { allowStale: boolean; skippedModules: string[] },
55
+ ): void {
38
56
  if (items.length === 0) {
39
57
  console.log('\nNo modules with manifest.yml found — skipping module-publish phase.');
40
58
  return;
@@ -49,6 +67,7 @@ export function executeModulePublish(items: ModuleItem[], opts: { allowStale: bo
49
67
  console.log('\n──────────────────────────────────────────────');
50
68
  console.log(' Publishing modules to celilo registry');
51
69
  console.log('──────────────────────────────────────────────');
70
+ for (const id of opts.skippedModules) console.log(` modules/${id} (skipped: --skip-module)`);
52
71
  for (const m of items) console.log(` ${relative(REPO_ROOT, m.moduleDir)}`);
53
72
  console.log();
54
73
 
@@ -76,7 +76,7 @@ export function planPublish(opts: PublishOptions): PublishPlan {
76
76
  trackAlpha: opts.mode.kind === 'alpha' && opts.mode.trackAlpha,
77
77
  })
78
78
  : [],
79
- modulePublish: runPhase4 ? planModulePublish() : [],
79
+ modulePublish: runPhase4 ? planModulePublish(opts.skippedModules) : [],
80
80
  };
81
81
  }
82
82
 
@@ -61,6 +61,13 @@ export interface PublishOptions {
61
61
  allowStale: boolean;
62
62
  autoYes: boolean;
63
63
  mode: PublishMode;
64
+ /**
65
+ * Module ids to leave out of the module-registry sweep (`--skip-module
66
+ * <id>`, repeatable). For a module that cannot publish yet. The sweep
67
+ * stops at its first failed module, so one such module would otherwise
68
+ * take every module after it, and the release's .deb, down with it.
69
+ */
70
+ skippedModules: string[];
64
71
  }
65
72
 
66
73
  /**
@@ -11,6 +11,7 @@ import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import { closeDb, getDb } from '../../db/client';
13
13
  import { modules } from '../../db/schema';
14
+ import { resetTestDbPath } from '../../test-utils/db-path';
14
15
  import {
15
16
  handleRegistryOwnerList,
16
17
  handleRegistryOwnerSet,
@@ -44,8 +45,8 @@ describe('registry owner commands', () => {
44
45
  afterEach(async () => {
45
46
  fetchSpy.mockRestore();
46
47
  closeDb();
47
- process.env.CELILO_DATA_DIR = undefined;
48
- process.env.CELILO_DB_PATH = undefined;
48
+ delete process.env.CELILO_DATA_DIR;
49
+ resetTestDbPath();
49
50
  if (existsSync(dataDir)) await rm(dataDir, { recursive: true, force: true });
50
51
  });
51
52
 
@@ -12,6 +12,7 @@ import { join } from 'node:path';
12
12
  import { closeDb, getDb } from '../../db/client';
13
13
  import { modules } from '../../db/schema';
14
14
  import { CrossModuleDataManager } from '../../services/cross-module-data-manager';
15
+ import { resetTestDbPath } from '../../test-utils/db-path';
15
16
  import { handleRegistryTokenAdd, handleRegistryTokenRm } from './registry-token';
16
17
 
17
18
  /** Read back the stored publish_tokens list via the production read path. */
@@ -49,8 +50,8 @@ describe('registry token add/rm (F2 clobber fix)', () => {
49
50
 
50
51
  afterEach(async () => {
51
52
  closeDb();
52
- process.env.CELILO_DATA_DIR = undefined;
53
- process.env.CELILO_DB_PATH = undefined;
53
+ delete process.env.CELILO_DATA_DIR;
54
+ resetTestDbPath();
54
55
  if (existsSync(dataDir)) {
55
56
  await rm(dataDir, { recursive: true, force: true });
56
57
  }
@@ -30,24 +30,22 @@ import { tmpdir } from 'node:os';
30
30
  import { join } from 'node:path';
31
31
  import { getDb } from '../../db/client';
32
32
  import { loadExistingConfiguration } from '../../services/system-init';
33
+ import { resetTestDbPath } from '../../test-utils/db-path';
33
34
  import { handleSystemApplyConfig } from './system-apply-config';
34
35
  import { handleSystemInit } from './system-init';
35
36
 
36
37
  describe('system apply-config equivalence with system init --accept-defaults', () => {
37
38
  let tmpDir: string;
38
- let savedDbPath: string | undefined;
39
39
 
40
40
  beforeEach(() => {
41
41
  tmpDir = mkdtempSync(join(tmpdir(), 'celilo-apply-config-test-'));
42
- savedDbPath = process.env.CELILO_DB_PATH;
43
42
  });
44
43
 
45
44
  afterEach(() => {
46
- if (savedDbPath !== undefined) {
47
- process.env.CELILO_DB_PATH = savedDbPath;
48
- } else {
49
- process.env.CELILO_DB_PATH = undefined;
50
- }
45
+ // Reset to the scratch path, never restore: the saved value may be
46
+ // another suite's temp database, or unset — which sends the next
47
+ // var-less reader to the operator's real celilo.db (celilo#1315).
48
+ resetTestDbPath();
51
49
  rmSync(tmpDir, { recursive: true, force: true });
52
50
  });
53
51
 
@@ -45,7 +45,6 @@ import {
45
45
  type LatestCliVersionFetcher,
46
46
  fetchLatestCliVersion,
47
47
  } from '../../services/audit/cli-version';
48
- import type { MachineReachableResult } from '../../services/audit/machines-reachable';
49
48
  import type { ModuleVersionFetcher } from '../../services/audit/module-versions';
50
49
  import {
51
50
  loadPublicDnsEvidence,
@@ -57,8 +56,10 @@ import type { ServiceCredentialsResult } from '../../services/audit/services-cre
57
56
  import type { ServiceReachableResult } from '../../services/audit/services-reachable';
58
57
  import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
59
58
  import { getServiceCredentials, listContainerServices } from '../../services/container-service';
59
+ import { probeDiskUsage } from '../../services/disk-probe';
60
60
  import { collectFirewallReach } from '../../services/firewall-reach';
61
61
  import { runAllHealthChecks } from '../../services/health-runner';
62
+ import { collectJailExemptions } from '../../services/jail-exemptions';
62
63
  import { probeMachines } from '../../services/machine-probe';
63
64
  import { parseStoredConfigValue } from '../../services/module-config';
64
65
  import { createPublicDnsProbe, loadPublicDnsProbeSettings } from '../../services/public-dns-probe';
@@ -341,11 +342,22 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
341
342
  }),
342
343
  );
343
344
 
344
- // Machines-reachable: probe each pool machine. Shared with the monitor sweep
345
- // (services/machine-probe.ts) — this block used to be a second copy of the
346
- // same SSH logic, and both copies reported the local management box as
347
- // unreachable because celilo does not hold an SSH key for itself.
348
- const machineReachableResults: MachineReachableResult[] = await probeMachines();
345
+ // Machines-reachable and disk-space both SSH every system, so they run
346
+ // concurrently here rather than paying each other's latency. The machine
347
+ // probe is shared with the monitor sweep (services/machine-probe.ts) —
348
+ // this block used to be a second copy of the same SSH logic, and both
349
+ // copies reported the local management box as unreachable because celilo
350
+ // does not hold an SSH key for itself.
351
+ //
352
+ // The disk probe measures every system's root filesystem — the machine
353
+ // pool AND every provisioned instance, the systems celilo#1133 filled —
354
+ // in the same pre-computed measurement shape the disk_space monitor
355
+ // sweep consumes, so an audit finding and an alert page carry the same
356
+ // numbers.
357
+ const [machineReachableResults, diskUsageResults] = await Promise.all([
358
+ probeMachines(),
359
+ probeDiskUsage(db),
360
+ ]);
349
361
 
350
362
  const migrationsFolder = findMigrationsFolderSafe();
351
363
 
@@ -384,6 +396,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
384
396
  manifest: m.manifestData as ModuleManifest,
385
397
  })),
386
398
  },
399
+ jailExemptions: { exemptions: collectJailExemptions(db) },
387
400
  health: { results: healthResults },
388
401
  backups: { modules: installedBackupInfo },
389
402
  abandonedOperations: { records: loadAbandonedOperations(db) },
@@ -417,6 +430,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
417
430
  probe: createPublicDnsProbe(loadPublicDnsProbeSettings(db)),
418
431
  evidence: loadPublicDnsEvidence(db),
419
432
  },
433
+ diskSpace: { results: diskUsageResults },
420
434
  // Reads the record the poller already writes — this check never performs a
421
435
  // read of its own. One that did would drain the queue and eat the
422
436
  // acknowledgement it exists to protect (#541).
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Tests for the set-time interview gate on `system config set`
3
+ * (slice 3 of hook-jail-config-surface, design D5 + peba's --force addition).
4
+ *
5
+ * Writing `hooks.jail_policy off` is a fleet-wide security posture change, so
6
+ * the write asks first over the bus interview and only proceeds on an explicit
7
+ * confirm. `auto` and `required` write directly. `--force` skips the ask per
8
+ * the house flags.force convention.
9
+ *
10
+ * Isolation: CELILO_DB_PATH / CELILO_DATA_DIR / EVENT_BUS_DB are set before the
11
+ * SUT is imported, so nothing touches the production database or event bus.
12
+ * bun test runs non-TTY, so `withInterviewSession` starts no terminal responder
13
+ * and the interview guard probes the isolated bus — a successful direct write
14
+ * with no responder running is itself proof the command never asked.
15
+ */
16
+
17
+ import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
18
+ import { mkdtempSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { eq } from 'drizzle-orm';
22
+
23
+ const testRoot = mkdtempSync(join(tmpdir(), 'celilo-jailgate-'));
24
+ const busDbPath = join(testRoot, 'bus.db');
25
+ process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db');
26
+ process.env.CELILO_DATA_DIR = join(testRoot, 'data');
27
+ process.env.EVENT_BUS_DB = busDbPath;
28
+
29
+ const { getDb, closeDb } = await import('../../db/client');
30
+ const { systemConfig } = await import('../../db/schema');
31
+ const { handleSystemConfigSet } = await import('./system-config');
32
+ const { startProgrammaticResponder } = await import('../../services/programmatic-responder');
33
+
34
+ const KEY = 'hooks.jail_policy';
35
+ const CONFIRM_KEY = 'hooks-jail-policy.set-off';
36
+
37
+ function storedJailPolicy(): string | undefined {
38
+ return getDb().select().from(systemConfig).where(eq(systemConfig.key, KEY)).get()?.value;
39
+ }
40
+
41
+ afterAll(() => {
42
+ closeDb();
43
+ rmSync(testRoot, { recursive: true, force: true });
44
+ });
45
+
46
+ describe('system config set — jail_policy value validation', () => {
47
+ beforeEach(() => {
48
+ getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run();
49
+ });
50
+
51
+ test('a value outside the enum is refused and names the allowed values', async () => {
52
+ const result = await handleSystemConfigSet([KEY, 'alwayssafe']);
53
+
54
+ expect(result.success).toBe(false);
55
+ if (result.success) throw new Error('expected a refusal');
56
+ expect(result.error).toContain('auto');
57
+ expect(result.error).toContain('required');
58
+ expect(storedJailPolicy()).toBeUndefined();
59
+ });
60
+ });
61
+
62
+ describe('system config set — jail_policy values that never ask', () => {
63
+ beforeEach(() => {
64
+ getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run();
65
+ });
66
+
67
+ test('auto writes directly with no responder listening', async () => {
68
+ const result = await handleSystemConfigSet([KEY, 'auto']);
69
+
70
+ expect(result.success).toBe(true);
71
+ expect(storedJailPolicy()).toBe('auto');
72
+ });
73
+
74
+ test('required writes directly with no responder listening', async () => {
75
+ const result = await handleSystemConfigSet([KEY, 'required']);
76
+
77
+ expect(result.success).toBe(true);
78
+ expect(storedJailPolicy()).toBe('required');
79
+ });
80
+ });
81
+
82
+ describe('system config set — the off interview gate', () => {
83
+ beforeEach(() => {
84
+ getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run();
85
+ });
86
+
87
+ test('off fails fast with no responder listening and writes nothing', async () => {
88
+ let threw: unknown;
89
+ try {
90
+ await handleSystemConfigSet([KEY, 'off']);
91
+ } catch (error) {
92
+ threw = error;
93
+ }
94
+
95
+ expect(threw).toBeInstanceOf(Error);
96
+ expect(String((threw as Error).message)).toContain('No responder is listening');
97
+ expect(storedJailPolicy()).toBeUndefined();
98
+ });
99
+
100
+ test('off writes after a confirming reply over non-TTY, and the question was asked', async () => {
101
+ const responder = startProgrammaticResponder({
102
+ busDbPath,
103
+ db: getDb(),
104
+ onMissing: 'skip',
105
+ values: { interview: { [CONFIRM_KEY]: true } },
106
+ });
107
+ try {
108
+ const result = await handleSystemConfigSet([KEY, 'off']);
109
+
110
+ expect(result.success).toBe(true);
111
+ expect(storedJailPolicy()).toBe('off');
112
+
113
+ const asked = responder.seenInterviewPayloads();
114
+ expect(asked.length).toBe(1);
115
+ expect(asked[0].scope).toBe('hooks-jail-policy');
116
+ expect(asked[0].key).toBe('set-off');
117
+ expect(asked[0].message).toContain('unjailed');
118
+ } finally {
119
+ responder.close();
120
+ }
121
+ });
122
+
123
+ test('off is cancelled when the reply declines, and writes nothing', async () => {
124
+ const responder = startProgrammaticResponder({
125
+ busDbPath,
126
+ db: getDb(),
127
+ onMissing: 'skip',
128
+ values: { interview: { [CONFIRM_KEY]: false } },
129
+ });
130
+ try {
131
+ const result = await handleSystemConfigSet([KEY, 'off']);
132
+
133
+ expect(result.success).toBe(false);
134
+ if (result.success) throw new Error('expected cancellation');
135
+ expect(result.error).toBe('Cancelled by user');
136
+ expect(storedJailPolicy()).toBeUndefined();
137
+ } finally {
138
+ responder.close();
139
+ }
140
+ });
141
+
142
+ test('--force skips the ask and writes off with no responder listening', async () => {
143
+ const result = await handleSystemConfigSet([KEY, 'off'], { force: true });
144
+
145
+ expect(result.success).toBe(true);
146
+ expect(storedJailPolicy()).toBe('off');
147
+ });
148
+ });