@celilo/cli 0.22.0 → 0.24.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 (104) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +61 -9
  3. package/drizzle/0024_module_pause.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +7 -7
  6. package/src/__integration__/container-services-cli.integration.test.ts +8 -2
  7. package/src/api/remote-client.test.ts +6 -5
  8. package/src/api/serve.ts +41 -7
  9. package/src/api-clients/proxmox.ts +34 -0
  10. package/src/cli/commands/alerts-act.ts +1 -1
  11. package/src/cli/commands/alerts-sweep.ts +2 -0
  12. package/src/cli/commands/backup-create.ts +26 -11
  13. package/src/cli/commands/backup-list.test.ts +83 -0
  14. package/src/cli/commands/backup-list.ts +67 -3
  15. package/src/cli/commands/backup-prune.ts +17 -17
  16. package/src/cli/commands/backup-sweep.ts +20 -8
  17. package/src/cli/commands/events.ts +34 -3
  18. package/src/cli/commands/firewall-interface-list.test.ts +85 -0
  19. package/src/cli/commands/firewall-interface-list.ts +123 -0
  20. package/src/cli/commands/machine-add.ts +30 -2
  21. package/src/cli/commands/module-config.test.ts +64 -2
  22. package/src/cli/commands/module-config.ts +159 -8
  23. package/src/cli/commands/module-deploy.ts +2 -2
  24. package/src/cli/commands/module-health.ts +1 -0
  25. package/src/cli/commands/module-import.ts +3 -3
  26. package/src/cli/commands/module-list.ts +12 -1
  27. package/src/cli/commands/module-pause.ts +317 -0
  28. package/src/cli/commands/module-remove.ts +78 -40
  29. package/src/cli/commands/module-status.ts +127 -4
  30. package/src/cli/commands/module-update.test.ts +1 -1
  31. package/src/cli/commands/monitor.ts +116 -19
  32. package/src/cli/commands/proxmox-template-selection.ts +1 -1
  33. package/src/cli/commands/status.ts +25 -3
  34. package/src/cli/commands/system-migrate.ts +14 -0
  35. package/src/cli/commands/system-update.ts +4 -1
  36. package/src/cli/completion.ts +39 -9
  37. package/src/cli/fuel-gauge.ts +4 -4
  38. package/src/cli/index.ts +104 -22
  39. package/src/cli/json-output.test.ts +162 -0
  40. package/src/cli/prompts.ts +53 -74
  41. package/src/cli/service-credential.ts +3 -3
  42. package/src/cli/stdout-is-undecorated.test.ts +94 -0
  43. package/src/cli/tui/audit-state.ts +2 -0
  44. package/src/cli/types.ts +7 -2
  45. package/src/db/schema.ts +73 -15
  46. package/src/hooks/capability-loader.ts +130 -4
  47. package/src/hooks/run-named-hook.ts +28 -0
  48. package/src/hooks/types.ts +2 -1
  49. package/src/manifest/contracts/v1.ts +16 -0
  50. package/src/manifest/schema.ts +40 -65
  51. package/src/services/alerting/builtin-monitors.test.ts +18 -10
  52. package/src/services/alerting/cadence-migration.test.ts +155 -0
  53. package/src/services/alerting/cadence-migration.ts +90 -0
  54. package/src/services/alerting/coverage-source.ts +8 -11
  55. package/src/services/alerting/deploy-hooks.test.ts +16 -7
  56. package/src/services/alerting/deploy-hooks.ts +11 -5
  57. package/src/services/alerting/health-cadence.test.ts +58 -0
  58. package/src/services/alerting/health-cadence.ts +128 -0
  59. package/src/services/alerting/health-coverage.ts +18 -8
  60. package/src/services/alerting/monitors.ts +50 -15
  61. package/src/services/alerting/suppression.test.ts +5 -0
  62. package/src/services/alerting/suppression.ts +18 -1
  63. package/src/services/alerting/sweep-runner.test.ts +52 -3
  64. package/src/services/alerting/sweep-runner.ts +41 -8
  65. package/src/services/audit/backup-source.ts +24 -1
  66. package/src/services/audit/backups.test.ts +95 -10
  67. package/src/services/audit/backups.ts +40 -37
  68. package/src/services/audit/interface-classification.test.ts +220 -0
  69. package/src/services/audit/interface-classification.ts +167 -0
  70. package/src/services/audit/types.ts +2 -1
  71. package/src/services/backup-age-agreement.test.ts +118 -0
  72. package/src/services/backup-create.ts +36 -30
  73. package/src/services/backup-metadata.ts +52 -1
  74. package/src/services/backup-retention.test.ts +123 -0
  75. package/src/services/backup-retention.ts +66 -5
  76. package/src/services/backup-schedule.test.ts +166 -0
  77. package/src/services/backup-schedule.ts +105 -15
  78. package/src/services/backup-staging.ts +14 -1
  79. package/src/services/backup-sweep.test.ts +22 -3
  80. package/src/services/backup-sweep.ts +15 -5
  81. package/src/services/bus-interview.ts +2 -2
  82. package/src/services/bus-secret-flow.test.ts +1 -1
  83. package/src/services/cadence.test.ts +97 -0
  84. package/src/services/cadence.ts +165 -0
  85. package/src/services/fleet-checks.ts +48 -0
  86. package/src/services/machine-detector.ts +23 -1
  87. package/src/services/module-config.ts +33 -0
  88. package/src/services/module-deploy.ts +1 -1
  89. package/src/services/module-pause-observability.test.ts +224 -0
  90. package/src/services/module-pause-quiescence.test.ts +163 -0
  91. package/src/services/module-pause.test.ts +573 -0
  92. package/src/services/module-pause.ts +544 -0
  93. package/src/services/remove-guard.test.ts +175 -0
  94. package/src/services/remove-guard.ts +109 -0
  95. package/src/services/storage-providers/s3.test.ts +96 -13
  96. package/src/services/storage-providers/s3.ts +48 -15
  97. package/src/services/terminal-responder.ts +16 -16
  98. package/src/services/update/dep-graph.test.ts +33 -4
  99. package/src/services/update/dep-graph.ts +39 -17
  100. package/src/services/zone-detector.test.ts +34 -3
  101. package/src/services/zone-detector.ts +32 -49
  102. package/src/test-utils/cli.ts +15 -14
  103. package/src/test-utils/integration-guard.ts +26 -0
  104. package/src/test-utils/setup-test-db.ts +13 -23
@@ -10,12 +10,12 @@ import { defineEvents, openBus } from '@celilo/event-bus';
10
10
  import { getEventBusPath } from '../../config/paths';
11
11
  import { getDb } from '../../db/client';
12
12
  import type { MonitorKind } from '../../db/schema';
13
- import { parseIntervalMinutes } from '../../manifest/schema';
14
13
  import {
15
14
  isSchedulableBuiltin,
16
15
  runBuiltinCheckForMonitor,
17
16
  } from '../../services/alerting/builtin-source';
18
17
  import { loadModuleCoverage } from '../../services/alerting/coverage-source';
18
+ import { loadModuleHealthCadences } from '../../services/alerting/health-cadence';
19
19
  import { HEALTH_COVERAGE_CHECK } from '../../services/alerting/health-coverage';
20
20
  import {
21
21
  createMonitor,
@@ -23,11 +23,18 @@ import {
23
23
  findMonitorByTarget,
24
24
  listMonitors,
25
25
  setMonitorEnabled,
26
+ updateMonitorInterval,
26
27
  } from '../../services/alerting/monitors';
27
28
  import { listPolicies } from '../../services/alerting/people';
28
29
  import { runOneMonitor } from '../../services/alerting/run-monitor';
29
30
  import { promoteReadyAlerts } from '../../services/alerting/store';
30
31
  import type { DriftCategory } from '../../services/audit/types';
32
+ import {
33
+ MONITOR_INTERVAL_FLOOR_MINUTES,
34
+ cadenceSchema,
35
+ formatCadence,
36
+ parseCadence,
37
+ } from '../../services/cadence';
31
38
  import { runModuleHealthCheck } from '../../services/health-runner';
32
39
  import type { CommandResult } from '../types';
33
40
 
@@ -65,18 +72,34 @@ function handleList(): CommandResult {
65
72
  const policyOf = (id: string | null) =>
66
73
  id ? (policies.get(id) ?? '(deleted policy)') : '— pages nobody';
67
74
 
75
+ // A `module_hook` row's stored interval is not what the sweep uses, so
76
+ // printing it would be a confident lie. Resolve the same way the sweep does.
77
+ const cadences = loadModuleHealthCadences(getDb());
78
+ const everyOf = (monitor: (typeof rows)[number]): string => {
79
+ if (monitor.kind !== 'module_hook') return `${monitor.intervalMinutes}m`;
80
+ const cadence = cadences.get(monitor.target)?.cadence ?? null;
81
+ return cadence === null ? '—' : formatCadence(cadence);
82
+ };
83
+
68
84
  const width = Math.max(6, ...rows.map((r) => r.target.length));
69
85
  const policyWidth = Math.max(6, ...rows.map((r) => policyOf(r.escalationPolicyId).length));
70
86
  console.log('');
71
87
  console.log(
72
- `${'TARGET'.padEnd(width)} ${'KIND'.padEnd(14)} ${'EVERY'.padEnd(6)} ${'POLICY'.padEnd(policyWidth)} STATE`,
88
+ `${'TARGET'.padEnd(width)} ${'KIND'.padEnd(14)} ${'EVERY'.padEnd(8)} ${'POLICY'.padEnd(policyWidth)} STATE`,
73
89
  );
74
90
  for (const monitor of rows) {
75
- const state = monitor.enabled ? 'enabled' : 'disabled';
91
+ const every = everyOf(monitor);
92
+ const state =
93
+ monitor.kind === 'module_hook'
94
+ ? every === 'manual' || every === '—'
95
+ ? 'not watched'
96
+ : 'watched'
97
+ : monitor.enabled
98
+ ? 'enabled'
99
+ : 'disabled';
76
100
  const suffix = monitor.lastRunAt ? '' : ' (never run)';
77
- const every = `${monitor.intervalMinutes}m`;
78
101
  console.log(
79
- `${monitor.target.padEnd(width)} ${monitor.kind.padEnd(14)} ${every.padEnd(6)} ${policyOf(monitor.escalationPolicyId).padEnd(policyWidth)} ${state}${suffix}`,
102
+ `${monitor.target.padEnd(width)} ${monitor.kind.padEnd(14)} ${every.padEnd(8)} ${policyOf(monitor.escalationPolicyId).padEnd(policyWidth)} ${state}${suffix}`,
80
103
  );
81
104
  }
82
105
  console.log('');
@@ -90,18 +113,6 @@ function handleAdd(args: string[], flags: Record<string, boolean | string>): Com
90
113
  }
91
114
 
92
115
  const db = getDb();
93
- if (findMonitorByTarget(db, target)) {
94
- return { success: false, error: `A monitor for "${target}" already exists.` };
95
- }
96
-
97
- const interval = typeof flags.interval === 'string' ? flags.interval : '15m';
98
- const intervalMinutes = parseIntervalMinutes(interval);
99
- if (intervalMinutes === null) {
100
- return {
101
- success: false,
102
- error: `Invalid --interval "${interval}". Use a duration like "15m", "1h", or "1d".`,
103
- };
104
- }
105
116
 
106
117
  // A target naming an audit category is a built-in check; anything else is a
107
118
  // module's health_check hook. `isSchedulableBuiltin` is checked explicitly
@@ -113,6 +124,30 @@ function handleAdd(args: string[], flags: Record<string, boolean | string>): Com
113
124
  ? 'builtin_check'
114
125
  : 'module_hook';
115
126
 
127
+ // Checked BEFORE the already-exists check, so an operator reaching for the
128
+ // cadence knob is told where it moved rather than told the row exists. A
129
+ // module's cadence does not live on its row, so accepting `--interval` here
130
+ // would store a number nothing reads — refuse rather than ignore.
131
+ if (kind === 'module_hook' && typeof flags.interval === 'string') {
132
+ return { success: false, error: moduleCadenceRedirect(target, '--interval') };
133
+ }
134
+
135
+ if (findMonitorByTarget(db, target)) {
136
+ return {
137
+ success: false,
138
+ error:
139
+ kind === 'module_hook'
140
+ ? `A monitor for "${target}" already exists — a deploy creates one for every module with a health_check hook.\n\nTo change how often it runs: celilo module config set ${target} health_check_interval <cadence>`
141
+ : `A monitor for "${target}" already exists.\n\nTo change how often it runs: celilo monitor set-interval ${target} <cadence>`,
142
+ };
143
+ }
144
+
145
+ const interval = typeof flags.interval === 'string' ? flags.interval : '15m';
146
+ const invalid = validateMonitorCadence(interval);
147
+ if (invalid) return { success: false, error: invalid };
148
+ const cadence = parseCadence(interval);
149
+ const intervalMinutes = cadence !== null && cadence !== 'manual' ? cadence.minutes : 0;
150
+
116
151
  createMonitor(db, { kind, target, intervalMinutes });
117
152
 
118
153
  // Creating the first monitor is also what switches the sweep on. Registering
@@ -127,7 +162,10 @@ function handleAdd(args: string[], flags: Record<string, boolean | string>): Com
127
162
 
128
163
  return {
129
164
  success: true,
130
- message: `Monitoring ${target} every ${interval} (sweep runs on timer.tick.5m)`,
165
+ message:
166
+ kind === 'module_hook'
167
+ ? `Watching ${target}. How often is per-module policy: celilo module config set ${target} health_check_interval <cadence>`
168
+ : `Monitoring ${target} every ${interval} (sweep runs on timer.tick.5m)`,
131
169
  };
132
170
  }
133
171
 
@@ -167,11 +205,68 @@ function handleToggle(args: string[], enabled: boolean): CommandResult {
167
205
  const db = getDb();
168
206
  const monitor = findMonitorByTarget(db, target);
169
207
  if (!monitor) return { success: false, error: `No monitor for "${target}".` };
208
+ if (monitor.kind === 'module_hook') {
209
+ return { success: false, error: moduleCadenceRedirect(target, `monitor ${verb}`) };
210
+ }
170
211
 
171
212
  setMonitorEnabled(db, monitor.id, enabled, new Date());
172
213
  return { success: true, message: `Monitor for ${target} ${enabled ? 'enabled' : 'disabled'}` };
173
214
  }
174
215
 
216
+ /**
217
+ * Two ways to change one module's cadence would disagree about what `module
218
+ * status` shows, so the fleet-level commands refuse a module target and name
219
+ * the per-module one. The targets are disjoint in practice — an operator has
220
+ * either a module or an audit check in hand — and an explicit error teaches
221
+ * better than silence.
222
+ */
223
+ function moduleCadenceRedirect(target: string, what: string): string {
224
+ return (
225
+ `${what} does not apply to "${target}" — it is a module, and a module's cadence is per-module policy rather than a monitor setting. Its monitor row is created by the deploy.\n\n` +
226
+ ` celilo module config set ${target} health_check_interval 15m # watch it every 15 minutes\n` +
227
+ ` celilo module config set ${target} health_check_interval manual # stop watching it\n` +
228
+ ` celilo module config unset ${target} health_check_interval # follow the manifest again`
229
+ );
230
+ }
231
+
232
+ /** The floor comes from the alerting sweep's own tick, never a written-down number. */
233
+ function validateMonitorCadence(value: string): string | null {
234
+ const result = cadenceSchema({ floorMinutes: MONITOR_INTERVAL_FLOOR_MINUTES }).safeParse(value);
235
+ if (result.success) return null;
236
+ return `Invalid interval "${value}".\n\n${result.error.issues.map((i) => i.message).join('\n')}`;
237
+ }
238
+
239
+ /**
240
+ * `celilo monitor set-interval <check> <cadence>` — re-cadence a built-in check
241
+ * without removing and recreating it, which would orphan its alert history.
242
+ */
243
+ function handleSetInterval(args: string[]): CommandResult {
244
+ const [target, cadence] = args;
245
+ if (!target || !cadence) {
246
+ return { success: false, error: 'Usage: celilo monitor set-interval <check> <cadence>' };
247
+ }
248
+
249
+ const db = getDb();
250
+ const monitor = findMonitorByTarget(db, target);
251
+ if (!monitor) return { success: false, error: `No monitor for "${target}".` };
252
+ if (monitor.kind === 'module_hook') {
253
+ return { success: false, error: moduleCadenceRedirect(target, 'monitor set-interval') };
254
+ }
255
+
256
+ const invalid = validateMonitorCadence(cadence);
257
+ if (invalid) return { success: false, error: invalid };
258
+ const parsed = parseCadence(cadence);
259
+ if (parsed === null || parsed === 'manual') {
260
+ return {
261
+ success: false,
262
+ error: `A built-in check has no "manual" — disable it instead: celilo monitor disable ${target}`,
263
+ };
264
+ }
265
+
266
+ updateMonitorInterval(db, monitor.id, parsed.minutes);
267
+ return { success: true, message: `${target} now runs every ${formatCadence(parsed)}` };
268
+ }
269
+
175
270
  export async function handleMonitor(
176
271
  subcommand: string | undefined,
177
272
  args: string[],
@@ -189,10 +284,12 @@ export async function handleMonitor(
189
284
  return handleToggle(args, true);
190
285
  case 'disable':
191
286
  return handleToggle(args, false);
287
+ case 'set-interval':
288
+ return handleSetInterval(args);
192
289
  default:
193
290
  return {
194
291
  success: false,
195
- error: `Unknown monitor subcommand: ${subcommand}\n\nUse: list, add, run, enable, disable`,
292
+ error: `Unknown monitor subcommand: ${subcommand}\n\nUse: list, add, run, set-interval, enable, disable`,
196
293
  };
197
294
  }
198
295
  }
@@ -60,7 +60,7 @@ function compareDescending(a: ParsedUbuntu, b: ParsedUbuntu): number {
60
60
 
61
61
  /**
62
62
  * Build the option list used by the select prompt and tests.
63
- * Exported so tests can assert on filtering/sorting without mocking @clack.
63
+ * Exported so tests can assert on filtering/sorting without driving a prompt.
64
64
  */
65
65
  export function buildUbuntuOptions(appliances: ProxmoxAppliance[]): ParsedUbuntu[] {
66
66
  return appliances
@@ -10,6 +10,7 @@ import { eq } from 'drizzle-orm';
10
10
  import { getDb } from '../../db/client';
11
11
  import { capabilities, moduleConfigs, modules, systemConfig, systemSecrets } from '../../db/schema';
12
12
  import { type ModuleManifest, getSingularSystemSpec } from '../../manifest/schema';
13
+ import { formatPausedDuration } from '../../services/module-pause';
13
14
  import type { CommandResult } from '../types';
14
15
 
15
16
  /**
@@ -26,10 +27,21 @@ async function determineModuleStatus(
26
27
  generatedPath: string,
27
28
  dbState: string,
28
29
  ): Promise<{
29
- status: 'IMPORTED' | 'CONFIGURED' | 'GENERATED' | 'DEPLOYED' | 'VERIFIED' | 'NEEDS_UPDATE';
30
+ status:
31
+ | 'IMPORTED'
32
+ | 'CONFIGURED'
33
+ | 'GENERATED'
34
+ | 'DEPLOYED'
35
+ | 'VERIFIED'
36
+ | 'NEEDS_UPDATE'
37
+ | 'PAUSED';
30
38
  missingCount?: number;
31
39
  }> {
32
- // Deployed states come directly from the DB — don't infer from filesystem
40
+ // Deployed states come directly from the DB — don't infer from filesystem.
41
+ // PAUSED is checked FIRST: a paused module still has a generated/ directory
42
+ // and a full config, so every derivation below it would report it as an
43
+ // ordinary deployed module and the pause would be invisible here.
44
+ if (dbState === 'PAUSED') return { status: 'PAUSED' };
33
45
  if (dbState === 'VERIFIED') return { status: 'VERIFIED' };
34
46
  if (dbState === 'INSTALLED') return { status: 'DEPLOYED' };
35
47
 
@@ -120,6 +132,9 @@ export async function handleStatus(): Promise<CommandResult> {
120
132
  );
121
133
 
122
134
  // Status icon
135
+ // A paused module is deliberately out of service, so it must never
136
+ // carry the same ✓ as a healthy one — that is exactly how a pause turns
137
+ // into an outage nobody notices.
123
138
  const icon =
124
139
  statusInfo.status === 'VERIFIED' ||
125
140
  statusInfo.status === 'DEPLOYED' ||
@@ -128,7 +143,14 @@ export async function handleStatus(): Promise<CommandResult> {
128
143
  : '⚠';
129
144
 
130
145
  lines.push(` ${icon} ${module.id} (v${module.version})`);
131
- lines.push(` Status: ${statusInfo.status}`);
146
+ if (statusInfo.status === 'PAUSED') {
147
+ // State plus AGE — a pause with alerting suppressed is only safe if
148
+ // how long it has run is impossible to miss (design D7).
149
+ lines.push(` Status: PAUSED (${formatPausedDuration(module.pausedAt)})`);
150
+ if (module.pauseReason) lines.push(` Paused: ${module.pauseReason}`);
151
+ } else {
152
+ lines.push(` Status: ${statusInfo.status}`);
153
+ }
132
154
 
133
155
  // Type: VPS or Container
134
156
  const zone = getSingularSystemSpec(manifest)?.zone;
@@ -14,6 +14,7 @@ import { createDbClient, findMigrationsFolder, getDb } from '../../db/client';
14
14
  import { runMigrationsOn } from '../../db/migrate';
15
15
  import { getMigrationStatus } from '../../db/migration-status';
16
16
  import { findSchemaDrift } from '../../db/schema-introspection';
17
+ import { migrateMonitorCadences } from '../../services/alerting/cadence-migration';
17
18
  import { ensureBackupSweepSubscriber } from '../../services/backup-sweep';
18
19
  import { ensureOperationsSweepSubscriber } from '../../services/module-operations';
19
20
  import type { CommandResult } from '../types';
@@ -141,6 +142,13 @@ export async function handleSystemMigrate(
141
142
 
142
143
  ensureCoreSubscribers();
143
144
 
145
+ // A health-check cadence used to live on the monitor row and now resolves
146
+ // from `module_configs`. Without carrying the existing rows over, this very
147
+ // upgrade would silently revert every operator's cadence to the manifest's
148
+ // suggestion and resume watching modules they deliberately stopped watching.
149
+ // Idempotent: writes only where no override exists.
150
+ const cadences = migrateMonitorCadences(db);
151
+
144
152
  // Name the latest migration, not just a table count: "35 tables" reads the
145
153
  // same whether a column migration applied or silently did nothing (celilo#604).
146
154
  const status = getMigrationStatus(sqlite, findMigrationsFolder());
@@ -148,6 +156,12 @@ export async function handleSystemMigrate(
148
156
  applied > 0 ? `Applied ${applied} migration(s).` : 'Schema already up to date.',
149
157
  `Applied migrations: ${status.appliedCount} (latest: ${status.latestApplied ?? 'none'})`,
150
158
  `Schema current: ${drift.tableCount} tables, ${drift.columnCount} columns.`,
159
+ ...(cadences.written.size > 0
160
+ ? [
161
+ `Carried ${cadences.written.size} health-check cadence(s) into module config:`,
162
+ ...[...cadences.written].map(([moduleId, value]) => ` ${moduleId}: ${value}`),
163
+ ]
164
+ : []),
151
165
  ];
152
166
  return { success: true, message: lines.join('\n'), data: status };
153
167
  }
@@ -31,8 +31,9 @@ import { fetchLatestCliVersion } from '../../services/audit/cli-version';
31
31
  import { unusedPublicDnsProbe } from '../../services/audit/public-dns';
32
32
  import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
33
33
  import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
34
+ import { BACKUP_SCHEDULE_CONFIG_KEY } from '../../services/backup-schedule';
34
35
  import { runAllHealthChecks, runModuleHealthCheck } from '../../services/health-runner';
35
- import { parseStoredConfigValue } from '../../services/module-config';
36
+ import { configOverride, parseStoredConfigValue } from '../../services/module-config';
36
37
  import { deployModule } from '../../services/module-deploy';
37
38
  import { buildModuleGraph } from '../../services/update/dep-graph';
38
39
  import {
@@ -566,6 +567,7 @@ export async function handleSystemUpdate(
566
567
  id: m.id,
567
568
  state: m.state,
568
569
  manifest: m.manifestData as ModuleManifest,
570
+ scheduleOverride: configOverride(configsByModule.get(m.id), BACKUP_SCHEDULE_CONFIG_KEY),
569
571
  lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
570
572
  })),
571
573
  },
@@ -794,6 +796,7 @@ export function rebuildAuditDepsForRerun(
794
796
  id: m.id,
795
797
  state: m.state,
796
798
  manifest: m.manifestData as ModuleManifest,
799
+ scheduleOverride: configOverride(configsByModule.get(m.id), BACKUP_SCHEDULE_CONFIG_KEY),
797
800
  lastSuccessfulBackupAt: priorBackupByModule.get(m.id) ?? null,
798
801
  })),
799
802
  },
@@ -13,6 +13,7 @@ import { listBackups } from '../services/backup-metadata';
13
13
  import { listBackupStorages } from '../services/backup-storage';
14
14
  import { listContainerServices } from '../services/container-service';
15
15
  import { listMachines } from '../services/machine-pool';
16
+ import { FRAMEWORK_CONFIG_KEYS } from './commands/module-config';
16
17
 
17
18
  /**
18
19
  * Get completion suggestions based on current command context
@@ -41,6 +42,7 @@ export async function getCompletions(words: string[], current: number): Promise<
41
42
  'alerts',
42
43
  'escalation-policy',
43
44
  'events',
45
+ 'firewall',
44
46
  'help',
45
47
  'hook',
46
48
  'ipam',
@@ -194,13 +196,15 @@ export async function getCompletions(words: string[], current: number): Promise<
194
196
  'terraform-unlock',
195
197
  'types',
196
198
  'validate',
199
+ 'pause',
200
+ 'unpause',
197
201
  ];
198
202
  return filterSuggestions(subcommands, args[1] || '');
199
203
  }
200
204
 
201
- // Module config subcommands (celilo module config set/get)
205
+ // Module config subcommands (celilo module config set/get/unset)
202
206
  if (command === 'module' && args[1] === 'config' && currentIndex === 2) {
203
- const subcommands = ['set', 'get'];
207
+ const subcommands = ['set', 'get', 'unset'];
204
208
  return filterSuggestions(subcommands, args[2] || '');
205
209
  }
206
210
 
@@ -216,11 +220,11 @@ export async function getCompletions(words: string[], current: number): Promise<
216
220
  return filterSuggestions(subcommands, args[2] || '');
217
221
  }
218
222
 
219
- // Module config set/get - complete with module IDs
223
+ // Module config set/get/unset - complete with module IDs
220
224
  if (
221
225
  command === 'module' &&
222
226
  args[1] === 'config' &&
223
- (args[2] === 'set' || args[2] === 'get') &&
227
+ (args[2] === 'set' || args[2] === 'get' || args[2] === 'unset') &&
224
228
  currentIndex === 3
225
229
  ) {
226
230
  const db = getDb();
@@ -229,11 +233,16 @@ export async function getCompletions(words: string[], current: number): Promise<
229
233
  return filterSuggestions(moduleIds, args[3] || '');
230
234
  }
231
235
 
232
- // Module config set/get <module-id> - complete with config variable names
236
+ // Module config set/get/unset <module-id> - complete with config key names.
237
+ // Framework keys (backup cadence, upgrade policy…) are offered on EVERY
238
+ // module: they describe how celilo treats a module, so a module never
239
+ // declares them and completion built from the manifest alone could not see
240
+ // them — which is why `auto_upgrade` and `upgrade_policy` were uncompletable
241
+ // for as long as they have existed.
233
242
  if (
234
243
  command === 'module' &&
235
244
  args[1] === 'config' &&
236
- (args[2] === 'set' || args[2] === 'get') &&
245
+ (args[2] === 'set' || args[2] === 'get' || args[2] === 'unset') &&
237
246
  currentIndex === 4
238
247
  ) {
239
248
  const db = getDb();
@@ -242,13 +251,15 @@ export async function getCompletions(words: string[], current: number): Promise<
242
251
  .from(modules)
243
252
  .where(eq(modules.id, args[3] || ''))
244
253
  .get();
254
+ const frameworkKeys = Object.keys(FRAMEWORK_CONFIG_KEYS);
245
255
  if (module?.manifestData) {
246
256
  const manifest = module.manifestData as ModuleManifest;
247
257
  const varNames = (manifest.variables?.owns || [])
248
258
  .filter((v) => v.source === 'user' || !v.source)
249
259
  .map((v) => v.name);
250
- return filterSuggestions(varNames, args[4] || '');
260
+ return filterSuggestions([...varNames, ...frameworkKeys], args[4] || '');
251
261
  }
262
+ return filterSuggestions(frameworkKeys, args[4] || '');
252
263
  }
253
264
 
254
265
  // Module secret subcommands (celilo module secret set/list)
@@ -393,6 +404,16 @@ export async function getCompletions(words: string[], current: number): Promise<
393
404
  }
394
405
 
395
406
  // Machine subcommands
407
+ if (command === 'firewall' && currentIndex === 1) {
408
+ // `interface` is the only group. `acknowledge` and `enforce` were deleted in
409
+ // the design amendments — there is no policy toggle to complete.
410
+ return filterSuggestions(['interface'], args[1] || '');
411
+ }
412
+
413
+ if (command === 'firewall' && args[1] === 'interface' && currentIndex === 2) {
414
+ return filterSuggestions(['list'], args[2] || '');
415
+ }
416
+
396
417
  if (command === 'machine' && currentIndex === 1) {
397
418
  const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'detect'];
398
419
  return filterSuggestions(subcommands, args[1] || '');
@@ -437,6 +458,8 @@ export async function getCompletions(words: string[], current: number): Promise<
437
458
  'terraform-unlock',
438
459
  'verify',
439
460
  'audit', // deprecation alias for `verify`
461
+ 'pause',
462
+ 'unpause',
440
463
  ];
441
464
  if (moduleCommands.includes(args[1] || '')) {
442
465
  const db = getDb();
@@ -480,7 +503,10 @@ export async function getCompletions(words: string[], current: number): Promise<
480
503
 
481
504
  // Monitor subcommands
482
505
  if (command === 'monitor' && currentIndex === 1) {
483
- return filterSuggestions(['list', 'add', 'run', 'enable', 'disable'], args[1] || '');
506
+ return filterSuggestions(
507
+ ['list', 'add', 'run', 'set-interval', 'enable', 'disable'],
508
+ args[1] || '',
509
+ );
484
510
  }
485
511
 
486
512
  // Monitor targets - a module ID or one of celilo's own schedulable checks.
@@ -488,7 +514,11 @@ export async function getCompletions(words: string[], current: number): Promise<
488
514
  // a new built-in check is completable the moment it is schedulable.
489
515
  if (
490
516
  command === 'monitor' &&
491
- (args[1] === 'add' || args[1] === 'run' || args[1] === 'enable' || args[1] === 'disable') &&
517
+ (args[1] === 'add' ||
518
+ args[1] === 'run' ||
519
+ args[1] === 'set-interval' ||
520
+ args[1] === 'enable' ||
521
+ args[1] === 'disable') &&
492
522
  currentIndex === 2
493
523
  ) {
494
524
  const db = getDb();
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { stdout } from 'node:process';
13
- import * as p from '@clack/prompts';
13
+ import { log } from '@celilo/cli-display';
14
14
  import { getActiveDisplay } from './prompts';
15
15
 
16
16
  /**
@@ -173,7 +173,7 @@ export class FuelGauge {
173
173
  if (!this.running) return;
174
174
 
175
175
  this.cleanup();
176
- p.log.info(`${this.title} (backgrounded)`);
176
+ log.info(`${this.title} (backgrounded)`);
177
177
 
178
178
  if (this.onBackground) {
179
179
  this.onBackground();
@@ -208,9 +208,9 @@ export class FuelGauge {
208
208
  this.cleanup();
209
209
 
210
210
  if (success) {
211
- p.log.success(this.title);
211
+ log.success(this.title);
212
212
  } else {
213
- p.log.error(this.title);
213
+ log.error(this.title);
214
214
  console.log('');
215
215
  console.log(colors.dim('Last output:'));
216
216
  const errorLines = this.outputLines.slice(-this.errorDisplayLines);