@celilo/cli 0.22.0 → 0.23.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 (51) hide show
  1. package/CELILO_SUBSYSTEMS.md +34 -2
  2. package/drizzle/0024_module_pause.sql +20 -0
  3. package/drizzle/meta/_journal.json +8 -1
  4. package/package.json +4 -5
  5. package/src/__integration__/container-services-cli.integration.test.ts +8 -2
  6. package/src/api/remote-client.test.ts +6 -5
  7. package/src/api/serve.ts +41 -7
  8. package/src/api-clients/proxmox.ts +34 -0
  9. package/src/cli/commands/alerts-sweep.ts +2 -0
  10. package/src/cli/commands/events.ts +34 -3
  11. package/src/cli/commands/module-deploy.ts +2 -2
  12. package/src/cli/commands/module-health.ts +1 -0
  13. package/src/cli/commands/module-import.ts +3 -3
  14. package/src/cli/commands/module-list.ts +12 -1
  15. package/src/cli/commands/module-pause.ts +317 -0
  16. package/src/cli/commands/module-remove.ts +78 -40
  17. package/src/cli/commands/module-status.ts +3 -4
  18. package/src/cli/commands/module-update.test.ts +1 -1
  19. package/src/cli/commands/proxmox-template-selection.ts +1 -1
  20. package/src/cli/commands/status.ts +25 -3
  21. package/src/cli/completion.ts +4 -0
  22. package/src/cli/fuel-gauge.ts +4 -4
  23. package/src/cli/index.ts +45 -20
  24. package/src/cli/json-output.test.ts +162 -0
  25. package/src/cli/prompts.ts +53 -74
  26. package/src/cli/service-credential.ts +3 -3
  27. package/src/cli/stdout-is-undecorated.test.ts +94 -0
  28. package/src/cli/types.ts +7 -2
  29. package/src/db/schema.ts +73 -15
  30. package/src/hooks/run-named-hook.ts +28 -0
  31. package/src/services/alerting/suppression.test.ts +5 -0
  32. package/src/services/alerting/suppression.ts +18 -1
  33. package/src/services/alerting/sweep-runner.test.ts +1 -0
  34. package/src/services/alerting/sweep-runner.ts +11 -1
  35. package/src/services/bus-interview.ts +2 -2
  36. package/src/services/bus-secret-flow.test.ts +1 -1
  37. package/src/services/fleet-checks.ts +48 -0
  38. package/src/services/module-deploy.ts +1 -1
  39. package/src/services/module-pause-observability.test.ts +224 -0
  40. package/src/services/module-pause-quiescence.test.ts +163 -0
  41. package/src/services/module-pause.test.ts +573 -0
  42. package/src/services/module-pause.ts +544 -0
  43. package/src/services/remove-guard.test.ts +175 -0
  44. package/src/services/remove-guard.ts +109 -0
  45. package/src/services/terminal-responder.ts +16 -16
  46. package/src/services/update/dep-graph.test.ts +33 -4
  47. package/src/services/update/dep-graph.ts +39 -17
  48. package/src/services/zone-detector.ts +2 -39
  49. package/src/test-utils/cli.ts +15 -14
  50. package/src/test-utils/integration-guard.ts +26 -0
  51. package/src/test-utils/setup-test-db.ts +13 -23
@@ -0,0 +1,317 @@
1
+ /**
2
+ * `celilo module pause <id>` / `celilo module unpause <id>`.
3
+ *
4
+ * A thin adapter (Rule 10.5): parse flags, build the plan, confirm, execute,
5
+ * render. All the decisions live in `services/module-pause.ts` (pure planning)
6
+ * and the injected deps below (the side effects).
7
+ *
8
+ * See openspec/changes/module-pause-lifecycle/.
9
+ */
10
+
11
+ import { join } from 'node:path';
12
+ import { eq } from 'drizzle-orm';
13
+ import { ProxmoxClient } from '../../api-clients/proxmox';
14
+ import { getModuleStoragePath } from '../../config/paths';
15
+ import { type DbClient, getDb } from '../../db/client';
16
+ import { moduleSystems, modules } from '../../db/schema';
17
+ import type { ModuleManifest } from '../../manifest/schema';
18
+ import { ModuleManifestSchema } from '../../manifest/schema';
19
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
20
+ import { getServiceCredentials } from '../../services/container-service';
21
+ import { deployModule } from '../../services/module-deploy';
22
+ import { checkInFlight } from '../../services/module-operations';
23
+ import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
24
+ import {
25
+ type ExecutionReport,
26
+ type InfraStopOutcome,
27
+ type ModuleSnapshot,
28
+ type PauseDeps,
29
+ type PausePlan,
30
+ PauseRefusedError,
31
+ actedOn,
32
+ describeMachineStopInfra,
33
+ executePause,
34
+ executeUnpause,
35
+ planPause,
36
+ planUnpause,
37
+ } from '../../services/module-pause';
38
+ import {
39
+ registerModuleSubscriptions,
40
+ unregisterModuleSubscriptions,
41
+ } from '../../services/module-subscriptions';
42
+ import { getArg, hasFlag } from '../parser';
43
+ import { log } from '../prompts';
44
+ import type { CommandResult } from '../types';
45
+
46
+ /**
47
+ * Every module celilo knows about, in the shape the planner wants. The planner
48
+ * is pure, so the whole fleet is read once here rather than queried per step.
49
+ *
50
+ * A module whose stored manifest no longer parses is dropped rather than
51
+ * failing the command: it cannot be a graph node, and refusing to pause the
52
+ * fleet because one unrelated manifest went stale would be the wrong trade.
53
+ */
54
+ function loadFleet(db: DbClient): ModuleSnapshot[] {
55
+ const snapshots: ModuleSnapshot[] = [];
56
+ for (const row of db.select().from(modules).all()) {
57
+ const parsed = ModuleManifestSchema.safeParse(row.manifestData);
58
+ if (!parsed.success) continue;
59
+ snapshots.push({
60
+ id: row.id,
61
+ state: row.state,
62
+ pausedAt: row.pausedAt,
63
+ pauseReason: row.pauseReason,
64
+ manifest: parsed.data,
65
+ });
66
+ }
67
+ return snapshots;
68
+ }
69
+
70
+ /** moduleId → description, for the in-flight refusal (task 3.8). */
71
+ function inFlightByModule(): Map<string, string> {
72
+ const map = new Map<string, string>();
73
+ for (const conflict of checkInFlight()) {
74
+ map.set(conflict.operation.moduleId, conflict.describe);
75
+ }
76
+ return map;
77
+ }
78
+
79
+ /**
80
+ * `--stop-infra` (design D2, revised). Opt-in, and deliberately NOT what a pause
81
+ * means: pausing `caddy` to swap the *firewall* must not take every website
82
+ * down.
83
+ *
84
+ * It acts ONLY on infrastructure celilo provisioned for this module:
85
+ * - celilo-provisioned LXC/VM -> stopped; celilo created it, so it is celilo's
86
+ * - machine-pool system -> not applicable, by design (see below)
87
+ * - systemless driver -> nothing to stop
88
+ *
89
+ * The flag is a convenience for "I actually want the box off". Pause's real job
90
+ * is control-plane quiescence, and the provider swap this feature exists for
91
+ * never needs the flag at all.
92
+ */
93
+ async function stopModuleInfrastructure(db: DbClient, moduleId: string): Promise<InfraStopOutcome> {
94
+ const systems = db.select().from(moduleSystems).where(eq(moduleSystems.moduleId, moduleId)).all();
95
+
96
+ if (systems.length === 0) {
97
+ // A driver module (`greenwave`, `axon`, `namecheap`) declares no
98
+ // `requires.system` — it talks to a device over HTTP. Nothing to stop, and
99
+ // that is a normal outcome to report, not an error (spec scenario
100
+ // "Shutdown on a systemless driver module reports nothing to stop").
101
+ return { stopped: false, detail: 'no infrastructure to stop' };
102
+ }
103
+
104
+ const detail: string[] = [];
105
+ for (const system of systems) {
106
+ if (system.infraType === 'machine') {
107
+ // NOT APPLICABLE, by design — not a missing feature (design D2, revised).
108
+ //
109
+ // `--stop-infra` acts only on infrastructure celilo PROVISIONED for the
110
+ // module. A machine-pool system is operator-pre-provisioned: it may
111
+ // predate celilo, and it may run things celilo has never heard of — not
112
+ // merely other celilo modules, but arbitrary operator work. Powering it
113
+ // off, or stopping services on it, reaches outside what celilo owns.
114
+ //
115
+ // Same principle as the sizing rule: a module must not own a host-level
116
+ // fact, because the host outlives any one module's config. Framing this
117
+ // as "celilo cannot identify the service unit" would be wrong — it
118
+ // implies a capability gap, when the answer is that this is not celilo's
119
+ // to stop.
120
+ detail.push(describeMachineStopInfra(system.hostname, moduleId));
121
+ continue;
122
+ }
123
+
124
+ if (system.vmid == null || !system.serviceId) {
125
+ detail.push(`${system.hostname}: no container recorded, nothing to stop`);
126
+ continue;
127
+ }
128
+
129
+ const credentials = await getServiceCredentials(system.serviceId);
130
+ if (!('api_url' in credentials)) {
131
+ detail.push(`${system.hostname}: container service is not Proxmox, nothing to stop`);
132
+ continue;
133
+ }
134
+
135
+ const client = new ProxmoxClient(credentials);
136
+ const result = await client.setGuestPower(system.vmid, 'lxc', 'shutdown');
137
+ detail.push(
138
+ result.success
139
+ ? `stopped ${system.hostname} (vmid ${system.vmid})`
140
+ : `could not stop ${system.hostname} (vmid ${system.vmid}): ${result.message}`,
141
+ );
142
+ }
143
+
144
+ return { stopped: true, detail: detail.join('; ') };
145
+ }
146
+
147
+ function buildDeps(db: DbClient): PauseDeps {
148
+ return {
149
+ db,
150
+ unsubscribe: (moduleId) => {
151
+ // The primary quiescence mechanism: with no subscriber rows the
152
+ // dispatcher has nothing to deliver to. `run-named-hook` guards the
153
+ // paths that do not go through the bus.
154
+ unregisterModuleSubscriptions(moduleId);
155
+ },
156
+ resubscribe: (moduleId) => {
157
+ // A deploy does NOT re-register subscriptions (only import and
158
+ // `module update` do), so unpause has to — otherwise the module comes
159
+ // back deployed but permanently deaf.
160
+ const row = db.select().from(modules).where(eq(modules.id, moduleId)).get();
161
+ if (!row) return;
162
+ const manifest = row.manifestData as ModuleManifest;
163
+ registerModuleSubscriptions(manifest, join(getModuleStoragePath(), moduleId));
164
+ },
165
+ redeploy: async (moduleId) => {
166
+ try {
167
+ const result = await deployModule(moduleId, db, {});
168
+ return { success: result.success, error: result.error };
169
+ } catch (err) {
170
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
171
+ }
172
+ },
173
+ stopInfrastructure: (moduleId) => stopModuleInfrastructure(db, moduleId),
174
+ startOperation,
175
+ completeOperation,
176
+ failOperation,
177
+ now: () => new Date(),
178
+ log: (message) => log.info(message),
179
+ };
180
+ }
181
+
182
+ /** The ordered plan, rendered for `--dry-run` and for the confirmation. */
183
+ function renderPlan(plan: PausePlan): string {
184
+ const verb = plan.action === 'pause' ? 'Pause' : 'Unpause';
185
+ const order = plan.action === 'pause' ? 'consumers first' : 'providers first';
186
+ const lines = [
187
+ plan.cascade
188
+ ? `${verb} ${plan.requested} and its transitive consumers (${order}):`
189
+ : `${verb} ${plan.requested}:`,
190
+ ];
191
+ plan.steps.forEach((step, index) => {
192
+ const suffix = step.disposition === 'act' ? '' : ` — skip (${step.note})`;
193
+ lines.push(` ${index + 1}. ${step.moduleId}${suffix}`);
194
+ });
195
+ if (plan.stopInfra) {
196
+ lines.push('', 'Infrastructure will also be stopped (--stop-infra).');
197
+ }
198
+ return lines.join('\n');
199
+ }
200
+
201
+ function renderReport(report: ExecutionReport): string {
202
+ const lines = report.outcomes.map((o) => {
203
+ const mark = o.result === 'acted' ? '✓' : o.result === 'skipped' ? '·' : '✗';
204
+ return ` ${mark} ${o.moduleId}${o.detail ? ` — ${o.detail}` : ''}`;
205
+ });
206
+ const acted = report.outcomes.filter((o) => o.result === 'acted').length;
207
+ const skipped = report.outcomes.filter((o) => o.result === 'skipped').length;
208
+ const failed = report.outcomes.filter((o) => o.result === 'failed').length;
209
+ lines.push('', `${acted} changed, ${skipped} already done, ${failed} failed.`);
210
+ return lines.join('\n');
211
+ }
212
+
213
+ /**
214
+ * Confirmation for a cascade. An event-bus interview question, never a stdin
215
+ * prompt (design D6), so the operation is drivable headlessly — by CI, by the
216
+ * MCP, by a remote responder. `--yes` satisfies it without asking.
217
+ */
218
+ async function confirmCascade(plan: PausePlan, yes: boolean): Promise<boolean> {
219
+ if (!plan.cascade || yes) return true;
220
+ const affected = actedOn(plan);
221
+ if (affected.length === 0) return true;
222
+
223
+ return withInterviewSession(() =>
224
+ askConfirm({
225
+ scope: `module-${plan.action}:${plan.requested}`,
226
+ key: 'cascade',
227
+ message: `${plan.action === 'pause' ? 'Pause' : 'Unpause'} ${affected.length} module(s): ${affected.join(', ')}?`,
228
+ description: renderPlan(plan),
229
+ defaultValue: false,
230
+ }),
231
+ );
232
+ }
233
+
234
+ export async function handleModulePause(
235
+ args: string[],
236
+ flags: Record<string, string | boolean> = {},
237
+ ): Promise<CommandResult> {
238
+ const moduleId = getArg(args, 0);
239
+ if (!moduleId) {
240
+ return {
241
+ success: false,
242
+ error:
243
+ 'Module ID is required\n\nUsage: celilo module pause <id> [--cascade] [--stop-infra] [--dry-run] [--yes] [--reason "..."]',
244
+ };
245
+ }
246
+
247
+ const db = getDb();
248
+ const reason = typeof flags.reason === 'string' ? flags.reason : null;
249
+
250
+ let plan: PausePlan;
251
+ try {
252
+ plan = planPause({
253
+ moduleId,
254
+ fleet: loadFleet(db),
255
+ cascade: hasFlag(flags, 'cascade'),
256
+ stopInfra: hasFlag(flags, 'stop-infra'),
257
+ inFlight: inFlightByModule(),
258
+ });
259
+ } catch (err) {
260
+ if (err instanceof PauseRefusedError) return { success: false, error: err.message };
261
+ throw err;
262
+ }
263
+
264
+ if (hasFlag(flags, 'dry-run')) {
265
+ return { success: true, message: `${renderPlan(plan)}\n\n(dry run — nothing was changed)` };
266
+ }
267
+
268
+ if (!(await confirmCascade(plan, hasFlag(flags, 'yes')))) {
269
+ return { success: false, error: 'Cancelled — nothing was paused' };
270
+ }
271
+
272
+ const report = await executePause(plan, buildDeps(db), reason);
273
+ return report.success
274
+ ? { success: true, message: renderReport(report) }
275
+ : { success: false, error: renderReport(report) };
276
+ }
277
+
278
+ export async function handleModuleUnpause(
279
+ args: string[],
280
+ flags: Record<string, string | boolean> = {},
281
+ ): Promise<CommandResult> {
282
+ const moduleId = getArg(args, 0);
283
+ if (!moduleId) {
284
+ return {
285
+ success: false,
286
+ error:
287
+ 'Module ID is required\n\nUsage: celilo module unpause <id> [--cascade] [--dry-run] [--yes]',
288
+ };
289
+ }
290
+
291
+ const db = getDb();
292
+
293
+ let plan: PausePlan;
294
+ try {
295
+ plan = planUnpause({
296
+ moduleId,
297
+ fleet: loadFleet(db),
298
+ cascade: hasFlag(flags, 'cascade'),
299
+ });
300
+ } catch (err) {
301
+ if (err instanceof PauseRefusedError) return { success: false, error: err.message };
302
+ throw err;
303
+ }
304
+
305
+ if (hasFlag(flags, 'dry-run')) {
306
+ return { success: true, message: `${renderPlan(plan)}\n\n(dry run — nothing was changed)` };
307
+ }
308
+
309
+ if (!(await confirmCascade(plan, hasFlag(flags, 'yes')))) {
310
+ return { success: false, error: 'Cancelled — nothing was unpaused' };
311
+ }
312
+
313
+ const report = await executeUnpause(plan, buildDeps(db));
314
+ return report.success
315
+ ? { success: true, message: renderReport(report) }
316
+ : { success: false, error: renderReport(report) };
317
+ }
@@ -24,6 +24,11 @@ import {
24
24
  } from '../../services/celilo-events';
25
25
  import { getContainerService, getServiceCredentials } from '../../services/container-service';
26
26
  import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
27
+ import {
28
+ type DependentCandidate,
29
+ describeRemovalRefusal,
30
+ findRemovalBlockers,
31
+ } from '../../services/remove-guard';
27
32
  import { cleanupWebRoutesForModule } from '../../services/web-route-cleanup';
28
33
  import { getArg, hasFlag, validateRequiredArgs } from '../parser';
29
34
  import { log } from '../prompts';
@@ -81,23 +86,26 @@ export async function handleModuleRemove(
81
86
  .all();
82
87
 
83
88
  if (providedCapabilities.length > 0) {
84
- const providedNames = new Set(providedCapabilities.map((c) => c.capabilityName));
89
+ const providedNames = providedCapabilities.map((c) => c.capabilityName);
85
90
  const otherModules = db.select().from(modules).where(ne(modules.id, moduleId)).all();
86
91
 
87
- const dependents = otherModules
88
- .filter((m) => {
89
- const parsed = ModuleManifestSchema.safeParse(m.manifestData);
90
- if (!parsed.success) return false;
91
- return (parsed.data.requires?.capabilities ?? []).some((req) =>
92
- providedNames.has(req.name),
93
- );
94
- })
95
- .map((m) => m.id);
96
-
97
- if (dependents.length > 0) {
92
+ const candidates: DependentCandidate[] = [];
93
+ for (const m of otherModules) {
94
+ const parsed = ModuleManifestSchema.safeParse(m.manifestData);
95
+ if (!parsed.success) continue;
96
+ candidates.push({
97
+ id: m.id,
98
+ manifest: parsed.data,
99
+ paused: m.state === 'PAUSED',
100
+ deployed: !(['IMPORTED', 'VALIDATED', 'CONFIGURED'] as string[]).includes(m.state),
101
+ });
102
+ }
103
+
104
+ const blockers = findRemovalBlockers(providedNames, candidates);
105
+ if (blockers.length > 0) {
98
106
  return {
99
107
  success: false,
100
- error: `Cannot remove '${moduleId}': the following installed modules depend on its capabilities: ${dependents.join(', ')}`,
108
+ error: describeRemovalRefusal(moduleId, blockers),
101
109
  };
102
110
  }
103
111
  }
@@ -163,9 +171,9 @@ async function performModuleRemove(
163
171
  // prompt on hook failure.
164
172
  const manifestForHook = module.manifestData as { hooks?: { on_uninstall?: unknown } } | undefined;
165
173
  if (manifestForHook?.hooks?.on_uninstall) {
166
- // Match build-stream's TTY detection: in non-TTY contexts (tests,
167
- // pipes, CI) skipAnimation prevents the gauge's setInterval and
168
- // raw-stdin handlers from blocking process exit.
174
+ // Match build-stream's TTY detection: in non-TTY contexts (tests, pipes,
175
+ // CI) skipAnimation prevents the gauge's setInterval and raw-stdin
176
+ // handlers from blocking process exit.
169
177
  const isInteractive = process.stdout.isTTY && process.stdin.isTTY;
170
178
  const gauge = new FuelGauge(`${moduleId}: on_uninstall`, {
171
179
  skipAnimation: !isInteractive,
@@ -173,35 +181,65 @@ async function performModuleRemove(
173
181
  gauge.start();
174
182
  const logger = createGaugeLogger(gauge, moduleId, 'on_uninstall');
175
183
  const hookResult = await runNamedHook(moduleId, 'on_uninstall', db, logger, {});
184
+
176
185
  if (hookResult.success) {
177
186
  gauge.stop(true);
178
187
  } else {
179
188
  gauge.stop(false);
180
- log.warn(`on_uninstall failed: ${hookResult.error ?? 'unknown error'}`);
181
- if (force) {
182
- log.info('--force set, continuing removal despite hook failure');
183
- } else if (!isInteractive) {
184
- // Non-TTY (tests, scripts, piped input). Don't try to prompt —
185
- // @clack/prompts hangs on stdin in that context. Default
186
- // behaviour: continue with removal so a hook crash doesn't
187
- // wedge automation. Operators who want strict failure can
188
- // re-run `celilo module run-hook <id> on_uninstall` manually.
189
- log.warn('Non-interactive context detected; continuing removal despite hook failure');
190
- } else {
191
- const proceed = await withInterviewSession(() =>
192
- askConfirm({
193
- scope: `module-remove:${moduleId}`,
194
- key: 'continue_after_hook_failure',
195
- message:
196
- 'on_uninstall hook failed. Continue removing the module anyway? ' +
197
- '(some external state may not be cleaned up)',
198
- defaultValue: false,
199
- }),
200
- );
201
- if (!proceed) {
202
- return { success: false, error: 'Removal cancelled after on_uninstall failure' };
203
- }
189
+ const reason = hookResult.error ?? 'unknown error';
190
+
191
+ if (!force) {
192
+ // STOP, and leave the module ERRORED. Do not ask.
193
+ //
194
+ // `on_uninstall` is what withdraws a module's cross-module state —
195
+ // caddy unexposes its ports and clears its Caddyfile, a firewall
196
+ // provider withdraws its forwards. If it failed, that state is still
197
+ // out there and we do not know how much of it the hook managed before
198
+ // dying. Deleting the module now orphans whatever is left, with no
199
+ // record of what to go clean up.
200
+ //
201
+ // This previously asked "continue removing anyway?" in a TTY and
202
+ // silently continued when not one. Both were wrong. The prompt offers
203
+ // the orphaning decision at the least informed possible moment — inside
204
+ // a Y/N, before anyone has looked at the machine — and the non-TTY
205
+ // default made orphaning the norm for every scripted removal.
206
+ //
207
+ // The module is marked ERROR rather than left reading INSTALLED,
208
+ // because the previous behaviour recorded the failure only in a
209
+ // `module_operations` row: `module list` and `system doctor` both
210
+ // showed the module as perfectly healthy while its teardown had failed.
211
+ //
212
+ // The way forward is deliberate: go look, clean up by hand, then
213
+ // re-run with --force, which means "I have remediated; drop the
214
+ // record."
215
+ db.update(modules)
216
+ .set({
217
+ state: 'ERROR',
218
+ errorMessage: `on_uninstall failed: ${reason}`,
219
+ updatedAt: new Date(),
220
+ })
221
+ .where(eq(modules.id, moduleId))
222
+ .run();
223
+
224
+ return {
225
+ success: false,
226
+ error: [
227
+ `Cannot remove '${moduleId}': its on_uninstall hook failed, so cross-module state it registered (port forwards, DNS records, web routes) may still exist.`,
228
+ '',
229
+ ` ${reason}`,
230
+ '',
231
+ `'${moduleId}' is now marked ERROR and has NOT been removed. Inspect what the hook left behind, clean it up, then re-run:`,
232
+ ` celilo module remove ${moduleId} --force`,
233
+ '',
234
+ '--force means "I have remediated this by hand; delete the record anyway".',
235
+ ].join('\n'),
236
+ };
204
237
  }
238
+
239
+ log.warn(`on_uninstall failed: ${reason}`);
240
+ log.info(
241
+ '--force set: continuing removal. Any state the hook left behind is yours to clean up.',
242
+ );
205
243
  }
206
244
  }
207
245
 
@@ -62,10 +62,9 @@ export async function handleModuleStatus(args: string[]): Promise<CommandResult>
62
62
  .where(eq(capabilities.moduleId, moduleId))
63
63
  .all();
64
64
 
65
- // Build sections as multi-line blocks joined by \n\n.
66
- // The display code in index.ts splits on \n\n and passes each section
67
- // as a single multi-line string to p.log.message(), avoiding clack's
68
- // per-line extra spacing.
65
+ // Build sections as multi-line blocks joined by \n\n. index.ts writes the
66
+ // whole message to stdout verbatim, so the blank line between sections is
67
+ // exactly what the operator sees.
69
68
  const sections: string[] = [];
70
69
 
71
70
  // Section 1: Module metadata
@@ -143,7 +143,7 @@ description: fixture
143
143
  });
144
144
 
145
145
  test('quiet=true suppresses the per-call log lines (caller renders its own)', async () => {
146
- // We can't easily intercept @clack's log output without adding test
146
+ // We can't easily intercept the log helpers' output without adding test
147
147
  // hooks, so instead we exercise that the call simply succeeds and
148
148
  // returns the structured outcome — the sweep relies on this to
149
149
  // render its own output without duplicates. A non-quiet call
@@ -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;
@@ -194,6 +194,8 @@ export async function getCompletions(words: string[], current: number): Promise<
194
194
  'terraform-unlock',
195
195
  'types',
196
196
  'validate',
197
+ 'pause',
198
+ 'unpause',
197
199
  ];
198
200
  return filterSuggestions(subcommands, args[1] || '');
199
201
  }
@@ -437,6 +439,8 @@ export async function getCompletions(words: string[], current: number): Promise<
437
439
  'terraform-unlock',
438
440
  'verify',
439
441
  'audit', // deprecation alias for `verify`
442
+ 'pause',
443
+ 'unpause',
440
444
  ];
441
445
  if (moduleCommands.includes(args[1] || '')) {
442
446
  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);