@celilo/cli 0.1.4 → 0.1.6

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 (161) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +9 -8
  5. package/src/ansible/inventory.ts +9 -7
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +45 -12
  8. package/src/capabilities/registration.test.ts +6 -6
  9. package/src/capabilities/well-known.test.ts +2 -2
  10. package/src/capabilities/well-known.ts +5 -5
  11. package/src/cli/cli.test.ts +2 -2
  12. package/src/cli/command-registry.ts +146 -3
  13. package/src/cli/command-tree-parser.test.ts +1 -1
  14. package/src/cli/command-tree-parser.ts +9 -8
  15. package/src/cli/commands/hook-run.ts +15 -66
  16. package/src/cli/commands/module-audit.ts +14 -44
  17. package/src/cli/commands/module-deploy.ts +4 -1
  18. package/src/cli/commands/module-import-registry.test.ts +115 -0
  19. package/src/cli/commands/module-import.ts +106 -22
  20. package/src/cli/commands/module-publish.test.ts +235 -0
  21. package/src/cli/commands/module-publish.ts +234 -0
  22. package/src/cli/commands/module-remove.ts +82 -2
  23. package/src/cli/commands/module-search.ts +57 -0
  24. package/src/cli/commands/module-secret-get.ts +59 -0
  25. package/src/cli/commands/module-show.ts +1 -1
  26. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  27. package/src/cli/commands/module-verify.test.ts +59 -0
  28. package/src/cli/commands/module-verify.ts +53 -0
  29. package/src/cli/commands/status.ts +30 -20
  30. package/src/cli/commands/system-audit.test.ts +138 -0
  31. package/src/cli/commands/system-audit.ts +571 -0
  32. package/src/cli/commands/system-update.ts +391 -0
  33. package/src/cli/completion.ts +15 -1
  34. package/src/cli/fuel-gauge.ts +68 -3
  35. package/src/cli/generate-zsh-completion.ts +13 -3
  36. package/src/cli/index.ts +112 -5
  37. package/src/cli/parser.ts +11 -0
  38. package/src/cli/prompts.ts +36 -5
  39. package/src/cli/tui/audit-state.test.ts +246 -0
  40. package/src/cli/tui/audit-state.ts +525 -0
  41. package/src/cli/tui/audit-tui.test.tsx +135 -0
  42. package/src/cli/tui/audit-tui.tsx +624 -0
  43. package/src/cli/tui/celebration.tsx +29 -0
  44. package/src/cli/tui/clipboard.test.ts +94 -0
  45. package/src/cli/tui/clipboard.ts +101 -0
  46. package/src/cli/tui/icons.ts +22 -0
  47. package/src/cli/tui/keybar.tsx +65 -0
  48. package/src/cli/tui/keymap.test.ts +105 -0
  49. package/src/cli/tui/keymap.ts +70 -0
  50. package/src/cli/tui/modals/analyzing.tsx +75 -0
  51. package/src/cli/tui/modals/celebration.tsx +44 -0
  52. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  53. package/src/cli/tui/modals/remediate.tsx +44 -0
  54. package/src/cli/tui/modals.test.ts +137 -0
  55. package/src/cli/tui/mouse.test.ts +78 -0
  56. package/src/cli/tui/mouse.ts +114 -0
  57. package/src/cli/tui/panes/categories.tsx +62 -0
  58. package/src/cli/tui/panes/command-log.tsx +87 -0
  59. package/src/cli/tui/panes/detail.tsx +175 -0
  60. package/src/cli/tui/panes/findings.tsx +97 -0
  61. package/src/cli/tui/panes/summary.tsx +64 -0
  62. package/src/cli/tui/spawn.ts +130 -0
  63. package/src/cli/tui/theme.ts +42 -0
  64. package/src/cli/tui/wrap.test.ts +43 -0
  65. package/src/cli/tui/wrap.ts +45 -0
  66. package/src/cli/types.ts +5 -0
  67. package/src/db/client.ts +55 -2
  68. package/src/db/schema.test.ts +3 -3
  69. package/src/db/schema.ts +26 -17
  70. package/src/hooks/capability-loader.ts +135 -72
  71. package/src/hooks/define-hook.test.ts +11 -3
  72. package/src/hooks/executor.ts +22 -1
  73. package/src/hooks/load-hook-config.test.ts +165 -0
  74. package/src/hooks/load-hook-config.ts +60 -0
  75. package/src/hooks/logger.ts +42 -12
  76. package/src/hooks/run-named-hook.ts +128 -0
  77. package/src/hooks/types.ts +19 -0
  78. package/src/manifest/ensure-schema.test.ts +115 -0
  79. package/src/manifest/schema.ts +76 -0
  80. package/src/manifest/template-validator.test.ts +1 -1
  81. package/src/manifest/template-validator.ts +1 -1
  82. package/src/manifest/validate.test.ts +1 -1
  83. package/src/module/import.ts +20 -12
  84. package/src/module/packaging/build.ts +121 -25
  85. package/src/module/packaging/release-metadata.test.ts +103 -0
  86. package/src/module/packaging/release-metadata.ts +145 -0
  87. package/src/registry/client.test.ts +228 -0
  88. package/src/registry/client.ts +157 -0
  89. package/src/services/audit/backups.test.ts +233 -0
  90. package/src/services/audit/backups.ts +128 -0
  91. package/src/services/audit/capability-abi.test.ts +153 -0
  92. package/src/services/audit/capability-abi.ts +204 -0
  93. package/src/services/audit/cli-version.test.ts +60 -0
  94. package/src/services/audit/cli-version.ts +87 -0
  95. package/src/services/audit/health.test.ts +84 -0
  96. package/src/services/audit/health.ts +43 -0
  97. package/src/services/audit/index.test.ts +99 -0
  98. package/src/services/audit/index.ts +118 -0
  99. package/src/services/audit/machines-reachable.test.ts +87 -0
  100. package/src/services/audit/machines-reachable.ts +87 -0
  101. package/src/services/audit/module-configs.test.ts +131 -0
  102. package/src/services/audit/module-configs.ts +80 -0
  103. package/src/services/audit/module-versions.test.ts +99 -0
  104. package/src/services/audit/module-versions.ts +154 -0
  105. package/src/services/audit/schema.test.ts +68 -0
  106. package/src/services/audit/schema.ts +115 -0
  107. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  108. package/src/services/audit/secrets-decryptable.ts +97 -0
  109. package/src/services/audit/services-credentials.test.ts +54 -0
  110. package/src/services/audit/services-credentials.ts +64 -0
  111. package/src/services/audit/services-reachable.test.ts +60 -0
  112. package/src/services/audit/services-reachable.ts +64 -0
  113. package/src/services/audit/terraform-plan.test.ts +127 -0
  114. package/src/services/audit/terraform-plan.ts +153 -0
  115. package/src/services/audit/types.test.ts +36 -0
  116. package/src/services/audit/types.ts +90 -0
  117. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  118. package/src/services/audit/unconfigured-modules.ts +71 -0
  119. package/src/services/audit/undeployed-modules.test.ts +66 -0
  120. package/src/services/audit/undeployed-modules.ts +72 -0
  121. package/src/services/build-stream.ts +122 -122
  122. package/src/services/config-interview.ts +407 -2
  123. package/src/services/deploy-ansible.ts +73 -7
  124. package/src/services/deploy-planner.ts +5 -5
  125. package/src/services/deploy-preflight.ts +45 -4
  126. package/src/services/deploy-terraform.ts +31 -24
  127. package/src/services/deploy-validation.ts +167 -23
  128. package/src/services/dns-auto-register.ts +4 -4
  129. package/src/services/ensure-interview.test.ts +245 -0
  130. package/src/services/health-runner.ts +110 -38
  131. package/src/services/infrastructure-variable-resolver.test.ts +1 -1
  132. package/src/services/infrastructure-variable-resolver.ts +3 -3
  133. package/src/services/module-build.ts +11 -13
  134. package/src/services/module-deploy.ts +372 -61
  135. package/src/services/proxmox-state-recovery.ts +6 -6
  136. package/src/services/ssh-key-manager.test.ts +1 -1
  137. package/src/services/ssh-key-manager.ts +3 -2
  138. package/src/services/terraform-env.ts +62 -0
  139. package/src/services/update/dep-graph.test.ts +214 -0
  140. package/src/services/update/dep-graph.ts +215 -0
  141. package/src/services/update/orchestrator.test.ts +463 -0
  142. package/src/services/update/orchestrator.ts +359 -0
  143. package/src/services/update/progress.ts +49 -0
  144. package/src/services/update/self-update.test.ts +68 -0
  145. package/src/services/update/self-update.ts +57 -0
  146. package/src/services/update/types.ts +94 -0
  147. package/src/templates/generator.test.ts +3 -3
  148. package/src/templates/generator.ts +43 -2
  149. package/src/test-utils/completion-harness.test.ts +1 -1
  150. package/src/test-utils/completion-harness.ts +4 -4
  151. package/src/variables/capability-self-ref.test.ts +203 -0
  152. package/src/variables/context.test.ts +31 -31
  153. package/src/variables/context.ts +65 -17
  154. package/src/variables/declarative-derivation.test.ts +306 -0
  155. package/src/variables/declarative-derivation.ts +4 -2
  156. package/src/variables/parser.test.ts +64 -9
  157. package/src/variables/parser.ts +47 -6
  158. package/src/variables/resolver.test.ts +14 -14
  159. package/src/variables/resolver.ts +27 -9
  160. package/src/variables/types.ts +1 -1
  161. package/tsconfig.json +1 -0
@@ -0,0 +1,391 @@
1
+ /**
2
+ * System update command — orchestrates the audit-determined upgrade flow.
3
+ *
4
+ * Usage:
5
+ * celilo system update [--module <id>] [--no-backup] [--allow-destructive]
6
+ * [--dry-run] [--json]
7
+ *
8
+ * Per CELILO_UPDATE Phase 4: runs `system audit` first, refuses on
9
+ * BLOCKED, then walks each drifting module through
10
+ * backup → upgrade → deploy → health, isolating failures to the
11
+ * dependency subtree.
12
+ *
13
+ * This is a thin adapter — per Rule 10.5: parse args, compose deps,
14
+ * call `runSystemUpdate`, format output, return. The real logic
15
+ * lives in `services/update/orchestrator.ts`.
16
+ */
17
+
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { eq } from 'drizzle-orm';
22
+ import { getDb } from '../../db/client';
23
+ import { backups, moduleConfigs as moduleConfigsTbl, modules } from '../../db/schema';
24
+ import type { ModuleManifest } from '../../manifest/schema';
25
+ import { RegistryClient } from '../../registry/client';
26
+ import { runAudit } from '../../services/audit';
27
+ import { fetchLatestCliVersion } from '../../services/audit/cli-version';
28
+ import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
29
+ import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
30
+ import { runAllHealthChecks, runModuleHealthCheck } from '../../services/health-runner';
31
+ import { deployModule } from '../../services/module-deploy';
32
+ import { buildModuleGraph } from '../../services/update/dep-graph';
33
+ import {
34
+ type ModuleSnapshot,
35
+ type OrchestratorOps,
36
+ runSystemUpdate,
37
+ } from '../../services/update/orchestrator';
38
+ import type { SystemUpdateResult } from '../../services/update/types';
39
+ import { getFlag, hasFlag } from '../parser';
40
+ import type { CommandResult } from '../types';
41
+
42
+ function readInstalledCliVersion(): string {
43
+ const here = dirname(fileURLToPath(import.meta.url));
44
+ const candidates = [
45
+ join(here, '..', '..', '..', 'package.json'),
46
+ join(process.cwd(), 'package.json'),
47
+ ];
48
+ for (const path of candidates) {
49
+ if (existsSync(path)) {
50
+ try {
51
+ const pkg = JSON.parse(readFileSync(path, 'utf-8')) as { version?: string };
52
+ if (pkg.version) return pkg.version;
53
+ } catch {
54
+ // try the next candidate
55
+ }
56
+ }
57
+ }
58
+ return '0.0.0';
59
+ }
60
+
61
+ function findMigrationsFolderSafe(): string | null {
62
+ try {
63
+ const here = dirname(fileURLToPath(import.meta.url));
64
+ const candidates = [
65
+ join(here, '..', '..', '..', 'drizzle'),
66
+ join(process.cwd(), 'drizzle'),
67
+ join(process.cwd(), 'apps', 'celilo', 'drizzle'),
68
+ ];
69
+ for (const c of candidates) {
70
+ if (existsSync(join(c, 'meta', '_journal.json'))) return c;
71
+ }
72
+ return null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Build a snapshot map from installed modules + registry lookups.
80
+ *
81
+ * `installedProvides` and `pendingRequires` come from the manifest
82
+ * — `installedProvides` from the currently-deployed manifest in the
83
+ * DB; `pendingRequires` from the manifest of the latest registry
84
+ * version (so the version-aware skip can compare what the new code
85
+ * needs against what the still-running provider has).
86
+ *
87
+ * Phase 4 V1: we don't yet fetch the new manifest from the registry
88
+ * — that needs a registry endpoint that exposes the embedded
89
+ * manifest.yml. Until that lands, `pendingRequires` is populated
90
+ * from the *currently installed* manifest (so consumer compatibility
91
+ * checks behave as "are the running versions still compatible?").
92
+ * That degrades the skip logic to the pre-D3 behavior in mixed
93
+ * cases but doesn't crash anything.
94
+ */
95
+ async function buildSnapshots(
96
+ db: ReturnType<typeof getDb>,
97
+ installed: Array<typeof modules.$inferSelect>,
98
+ registry: RegistryClient,
99
+ ): Promise<Map<string, ModuleSnapshot>> {
100
+ const snapshots = new Map<string, ModuleSnapshot>();
101
+ for (const m of installed) {
102
+ const manifest = m.manifestData as ModuleManifest;
103
+ const provides: Record<string, string> = {};
104
+ for (const p of manifest.provides?.capabilities ?? []) {
105
+ provides[p.name] = p.version;
106
+ }
107
+ const requires: Record<string, string> = {};
108
+ for (const r of manifest.requires?.capabilities ?? []) {
109
+ requires[r.name] = r.version;
110
+ }
111
+ for (const r of manifest.optional?.capabilities ?? []) {
112
+ requires[r.name] = r.version;
113
+ }
114
+
115
+ let latest: string | null = null;
116
+ try {
117
+ const entries = await registry.getIndex(m.id);
118
+ const top = registry.latestVersion(entries);
119
+ latest = top ? top.vers : null;
120
+ } catch {
121
+ // network error → leave null
122
+ }
123
+
124
+ snapshots.set(m.id, {
125
+ id: m.id,
126
+ installedVersion: m.version,
127
+ latestVersion: latest,
128
+ installedProvides: provides,
129
+ pendingRequires: requires,
130
+ });
131
+ }
132
+ // Suppress the unused-db warning while we don't yet need the parameter.
133
+ // (Future revs read pending manifests from the DB cache.)
134
+ void db;
135
+ return snapshots;
136
+ }
137
+
138
+ /**
139
+ * Live ops — wraps the existing per-module services as the
140
+ * orchestrator's hook callbacks.
141
+ *
142
+ * - `backup` calls `createModuleBackup`. Modules without an
143
+ * `on_backup` hook return ok (nothing to back up; not an error).
144
+ * - `upgrade` is a no-op for now — the in-place upgrade path lives
145
+ * in `module install <name>` and needs a refactor before the
146
+ * orchestrator can drive it cleanly. Deploy will re-converge
147
+ * against whatever's installed, which is the right behavior for
148
+ * "redeploy what's there."
149
+ * - `deploy` calls `deployModule`.
150
+ * - `health` calls `runModuleHealthCheck`.
151
+ * - `snapshotCeliloDb` calls `createSystemStateBackup`.
152
+ */
153
+ function buildOps(): OrchestratorOps {
154
+ return {
155
+ backup: async (moduleId, _updateId) => {
156
+ const db = getDb();
157
+ const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get();
158
+ if (!mod) return { ok: false, error: `module ${moduleId} not found` };
159
+ const manifest = mod.manifestData as ModuleManifest;
160
+ if (!manifest.hooks?.on_backup) {
161
+ return { ok: true };
162
+ }
163
+ const result = await createModuleBackup(moduleId);
164
+ return result.success ? { ok: true } : { ok: false, error: result.error };
165
+ },
166
+ upgrade: async (_id) => ({ ok: true }),
167
+ deploy: async (id) => {
168
+ const db = getDb();
169
+ const result = await deployModule(id, db, { noInteractive: true });
170
+ return result.success ? { ok: true } : { ok: false, error: result.error };
171
+ },
172
+ health: async (id) => {
173
+ // We can call the existing health-runner directly today — that's
174
+ // already a clean service.
175
+ const db = getDb();
176
+ const r = await runModuleHealthCheck(id, db);
177
+ return { status: r.status, detail: r.error };
178
+ },
179
+ snapshotCeliloDb: async (_updateId) => {
180
+ const result = await createSystemStateBackup();
181
+ return result.success ? { ok: true } : { ok: false, error: result.error };
182
+ },
183
+ };
184
+ }
185
+
186
+ function formatResult(result: SystemUpdateResult): string {
187
+ const lines: string[] = [];
188
+ lines.push('');
189
+ lines.push(`update ${result.updateId} → ${result.ok ? 'OK' : 'FAILED'}`);
190
+ lines.push(` audit verdict: ${result.audit.verdict}`);
191
+ lines.push(
192
+ ` self-update: ${result.selfUpdate.performed ? `${result.selfUpdate.from} → ${result.selfUpdate.to}` : `(${result.selfUpdate.reason})`}`,
193
+ );
194
+ lines.push(` backups: ${result.backupsCreated ? 'created' : 'skipped'}`);
195
+ lines.push('');
196
+ for (const m of result.modules) {
197
+ const tag =
198
+ m.step === 'done' ? '✓' : m.step === 'failed' ? '✗' : m.step === 'skipped' ? '↳' : '?';
199
+ const change =
200
+ m.fromVersion === m.toVersion ? m.fromVersion : `${m.fromVersion} → ${m.toVersion}`;
201
+ lines.push(` ${tag} ${m.moduleId} (${change}) — ${m.step}`);
202
+ if (m.error) lines.push(` ${m.error}`);
203
+ if (m.skipReason) lines.push(` ${m.skipReason}`);
204
+ }
205
+ return lines.join('\n');
206
+ }
207
+
208
+ export async function handleSystemUpdate(
209
+ _args: string[],
210
+ flags: Record<string, string | boolean>,
211
+ ): Promise<CommandResult> {
212
+ const json = hasFlag(flags, 'json');
213
+ const dryRun = hasFlag(flags, 'dry-run');
214
+ const noBackup = hasFlag(flags, 'no-backup');
215
+ const allowDestructive = hasFlag(flags, 'allow-destructive');
216
+ const onlyModule = getFlag(flags, 'module', '') || undefined;
217
+
218
+ const db = getDb();
219
+ const installed = db.select().from(modules).all();
220
+ const deployedModules = installed.filter((m) => ['INSTALLED', 'VERIFIED'].includes(m.state));
221
+ const registry = new RegistryClient();
222
+
223
+ // Build the dep graph from installed manifests.
224
+ const graph = buildModuleGraph(deployedModules.map((m) => m.manifestData as ModuleManifest));
225
+ const snapshots = await buildSnapshots(db, deployedModules, registry);
226
+
227
+ // Build audit deps. (Mostly mirrors system-audit.ts; could be refactored
228
+ // into a shared helper in Phase 5.)
229
+ const migrationsFolder = findMigrationsFolderSafe();
230
+ const healthResults = await runAllHealthChecks(db);
231
+
232
+ const latestBackupByModule = new Map<string, number>();
233
+ try {
234
+ const successfulBackups = db
235
+ .select()
236
+ .from(backups)
237
+ .where(eq(backups.status, 'completed'))
238
+ .all();
239
+ for (const b of successfulBackups) {
240
+ if (!b.moduleId || !b.completedAt) continue;
241
+ const prev = latestBackupByModule.get(b.moduleId);
242
+ const ts = b.completedAt.getTime();
243
+ if (prev === undefined || ts > prev) latestBackupByModule.set(b.moduleId, ts);
244
+ }
245
+ } catch {
246
+ // backups table missing on a fresh DB — fine.
247
+ }
248
+
249
+ const allConfigs = db.select().from(moduleConfigsTbl).all();
250
+ const configsByModule = new Map<string, Record<string, unknown>>();
251
+ for (const c of allConfigs) {
252
+ const m = configsByModule.get(c.moduleId) ?? {};
253
+ m[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
254
+ configsByModule.set(c.moduleId, m);
255
+ }
256
+
257
+ const auditDeps = {
258
+ cliVersion: {
259
+ installedVersion: readInstalledCliVersion(),
260
+ fetcher: fetchLatestCliVersion,
261
+ },
262
+ schema: {
263
+ journal: migrationsFolder ? makeJournalReader(migrationsFolder) : () => null,
264
+ applied: readAppliedMigrations,
265
+ db,
266
+ },
267
+ capabilityAbi: {
268
+ modules: deployedModules.map((m) => ({
269
+ id: m.id,
270
+ manifest: m.manifestData as ModuleManifest,
271
+ })),
272
+ },
273
+ terraformPlan: {
274
+ modules: deployedModules.map((m) => ({
275
+ id: m.id,
276
+ terraformDir: existsSync(join(m.sourcePath, 'generated', 'terraform'))
277
+ ? join(m.sourcePath, 'generated', 'terraform')
278
+ : null,
279
+ })),
280
+ run: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
281
+ },
282
+ moduleVersions: {
283
+ installed: deployedModules.map((m) => ({ id: m.id, version: m.version })),
284
+ fetcher: async (id: string) => {
285
+ const entries = await registry.getIndex(id);
286
+ const top = registry.latestVersion(entries);
287
+ return top ? { latest: top.vers } : { latest: null };
288
+ },
289
+ },
290
+ moduleConfigs: {
291
+ modules: deployedModules.map((m) => ({
292
+ id: m.id,
293
+ manifest: m.manifestData as ModuleManifest,
294
+ configs: configsByModule.get(m.id) ?? {},
295
+ })),
296
+ },
297
+ health: { results: healthResults },
298
+ backups: {
299
+ modules: deployedModules.map((m) => ({
300
+ id: m.id,
301
+ manifest: m.manifestData as ModuleManifest,
302
+ lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
303
+ })),
304
+ },
305
+ undeployedModules: {
306
+ modules: installed.map((m) => ({ id: m.id, state: m.state })),
307
+ },
308
+ unconfiguredModules: {
309
+ modules: installed.map((m) => ({
310
+ id: m.id,
311
+ state: m.state,
312
+ configCount: Object.keys(configsByModule.get(m.id) ?? {}).length,
313
+ })),
314
+ },
315
+ // The update flow doesn't run these checks itself — pass empty
316
+ // results so the typed AuditDeps shape is satisfied. The full
317
+ // audit (with credential + secret decryption checks) runs in
318
+ // `system audit`; pre-update we just consume what we have.
319
+ servicesCredentials: { results: [] },
320
+ secretsDecryptable: { results: [] },
321
+ servicesReachable: { results: [] },
322
+ machinesReachable: { results: [] },
323
+ };
324
+
325
+ if (dryRun) {
326
+ const audit = await runAudit(auditDeps);
327
+ const plan = {
328
+ version: 1 as const,
329
+ updateId: crypto.randomUUID(),
330
+ audit,
331
+ willSelfUpdate: false, // computed at run time; the dry-run preview is best-effort
332
+ modules: deployedModules
333
+ .filter((m) => {
334
+ const snap = snapshots.get(m.id);
335
+ return snap?.latestVersion && snap.latestVersion !== snap.installedVersion;
336
+ })
337
+ .map((m) => ({
338
+ moduleId: m.id,
339
+ fromVersion: m.version,
340
+ toVersion: snapshots.get(m.id)?.latestVersion ?? m.version,
341
+ dependsOn: [...(graph.edges.get(m.id) ?? [])],
342
+ })),
343
+ willBackup: !noBackup,
344
+ destructiveTerraformBlocked: !allowDestructive,
345
+ };
346
+ return json
347
+ ? { success: true, message: JSON.stringify(plan, null, 2), rawOutput: true }
348
+ : {
349
+ success: true,
350
+ message: `dry-run: ${plan.modules.length} module(s) would be updated, backups=${plan.willBackup ? 'on' : 'off'}, allow-destructive=${allowDestructive}\n${plan.modules
351
+ .map((m) => ` ▸ ${m.moduleId}: ${m.fromVersion} → ${m.toVersion}`)
352
+ .join('\n')}`,
353
+ };
354
+ }
355
+
356
+ const ops = buildOps();
357
+
358
+ const result = await runSystemUpdate({
359
+ audit: auditDeps,
360
+ graph,
361
+ snapshots,
362
+ ops,
363
+ selfUpdate: {
364
+ installedVersion: readInstalledCliVersion(),
365
+ fetcher: fetchLatestCliVersion,
366
+ updater: async () => ({
367
+ ok: false,
368
+ stderr: 'self-update wiring lands in Phase 5',
369
+ }),
370
+ },
371
+ progress: { emit() {} },
372
+ noBackup,
373
+ allowDestructive,
374
+ onlyModule,
375
+ });
376
+
377
+ if (json) {
378
+ if (result.ok) {
379
+ return { success: true, message: JSON.stringify(result, null, 2), rawOutput: true };
380
+ }
381
+ return { success: false, error: JSON.stringify(result, null, 2) };
382
+ }
383
+
384
+ if (result.ok) {
385
+ return { success: true, message: formatResult(result) };
386
+ }
387
+ return {
388
+ success: false,
389
+ error: `${formatResult(result)}\n\none or more modules failed; see output above`,
390
+ };
391
+ }
@@ -41,6 +41,7 @@ export async function getCompletions(words: string[], current: number): Promise<
41
41
  'ipam',
42
42
  'completion',
43
43
  'status',
44
+ 'audit',
44
45
  'version',
45
46
  ];
46
47
  return filterSuggestions(commands, args[0] || '');
@@ -98,9 +99,13 @@ export async function getCompletions(words: string[], current: number): Promise<
98
99
  if (command === 'module' && currentIndex === 1) {
99
100
  const subcommands = [
100
101
  'import',
102
+ 'install',
101
103
  'list',
104
+ 'publish',
102
105
  'remove',
106
+ 'search',
103
107
  'update',
108
+ 'verify',
104
109
  'audit',
105
110
  'config',
106
111
  'show-config',
@@ -114,12 +119,18 @@ export async function getCompletions(words: string[], current: number): Promise<
114
119
  'run-hook',
115
120
  'secret',
116
121
  'status',
122
+ 'terraform-unlock',
117
123
  'types',
118
124
  'validate',
119
125
  ];
120
126
  return filterSuggestions(subcommands, args[1] || '');
121
127
  }
122
128
 
129
+ // Module import subcommands (celilo module import file|public-registry)
130
+ if (command === 'module' && args[1] === 'import' && currentIndex === 2) {
131
+ return filterSuggestions(['file', 'public-registry'], args[2] || '');
132
+ }
133
+
123
134
  // Module config subcommands (celilo module config set/get)
124
135
  if (command === 'module' && args[1] === 'config' && currentIndex === 2) {
125
136
  const subcommands = ['set', 'get'];
@@ -306,6 +317,9 @@ export async function getCompletions(words: string[], current: number): Promise<
306
317
  'build',
307
318
  'run-hook',
308
319
  'status',
320
+ 'terraform-unlock',
321
+ 'verify',
322
+ 'audit', // deprecation alias for `verify`
309
323
  ];
310
324
  if (moduleCommands.includes(args[1] || '')) {
311
325
  const db = getDb();
@@ -399,7 +413,7 @@ export async function getCompletions(words: string[], current: number): Promise<
399
413
 
400
414
  // System subcommands
401
415
  if (command === 'system' && currentIndex === 1) {
402
- const subcommands = ['init', 'config', 'secret', 'vault-password'];
416
+ const subcommands = ['init', 'config', 'secret', 'vault-password', 'audit', 'update'];
403
417
  return filterSuggestions(subcommands, args[1] || '');
404
418
  }
405
419
 
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { stdout } from 'node:process';
13
13
  import * as p from '@clack/prompts';
14
+ import { getActiveDisplay } from './prompts';
14
15
 
15
16
  /**
16
17
  * ANSI color codes for terminal output
@@ -53,6 +54,8 @@ export class FuelGauge {
53
54
  private sigintHandler?: () => void;
54
55
  private alreadyCleanedUp = false; // Track if we've already cleaned up terminal
55
56
  private linesDrawn = 0; // Track exactly how many lines were drawn last frame
57
+ private startTime = Date.now();
58
+ private lastOutputTime = Date.now();
56
59
 
57
60
  constructor(title: string, options: FuelGaugeOptions = {}) {
58
61
  this.title = title;
@@ -71,6 +74,16 @@ export class FuelGauge {
71
74
  return;
72
75
  }
73
76
 
77
+ // When a ProgressDisplay is active (e.g. inside `module deploy`),
78
+ // delegate to it instead of running the cursor-redraw animation —
79
+ // the two would otherwise stomp on each other's output.
80
+ const display = getActiveDisplay();
81
+ if (display) {
82
+ this.running = true;
83
+ display.startStep(this.title, this.title);
84
+ return;
85
+ }
86
+
74
87
  this.running = true;
75
88
 
76
89
  // Hide cursor
@@ -140,7 +153,17 @@ export class FuelGauge {
140
153
  this.outputLines.shift();
141
154
  }
142
155
 
156
+ const display = getActiveDisplay();
157
+ if (display) {
158
+ // Pass the original line (ANSI preserved) so colour/dim codes from
159
+ // the hook logger render in the display. Skip blank lines that the
160
+ // child process may emit between output chunks.
161
+ if (line.trim()) display.subEvent(line);
162
+ return;
163
+ }
164
+
143
165
  this.hasNewOutput = true;
166
+ this.lastOutputTime = Date.now();
144
167
  }
145
168
 
146
169
  /**
@@ -169,6 +192,21 @@ export class FuelGauge {
169
192
  return;
170
193
  }
171
194
 
195
+ const display = getActiveDisplay();
196
+ if (display) {
197
+ if (success) {
198
+ display.doneStep();
199
+ } else {
200
+ display.failStep(this.title);
201
+ // Surface the last few output lines so the user can see what broke.
202
+ const errorLines = this.outputLines.slice(-this.errorDisplayLines);
203
+ for (const line of errorLines) {
204
+ if (line.trim()) display.subEvent(line);
205
+ }
206
+ }
207
+ return;
208
+ }
209
+
172
210
  this.cleanup();
173
211
 
174
212
  if (success) {
@@ -187,6 +225,25 @@ export class FuelGauge {
187
225
  }
188
226
  }
189
227
 
228
+ /**
229
+ * Tear down the animation without printing a success or failure stamp.
230
+ * Used when the gauge needs to step out of the way temporarily — e.g.
231
+ * for the cross-module ensure interview, where the next prompt would
232
+ * otherwise collide with the running animation. Pair with a fresh
233
+ * FuelGauge for any subsequent work.
234
+ */
235
+ stopSilent(): void {
236
+ if (!this.running) return;
237
+ this.running = false;
238
+ if (this.skipAnimation) return;
239
+ const display = getActiveDisplay();
240
+ if (display) {
241
+ display.abandon();
242
+ return;
243
+ }
244
+ this.cleanup();
245
+ }
246
+
190
247
  /**
191
248
  * Clean up resources (keyboard listener, animation, cursor)
192
249
  */
@@ -351,13 +408,21 @@ export class FuelGauge {
351
408
  return s + ' '.repeat(remaining);
352
409
  };
353
410
 
354
- // Title line
355
- const titleText = `▸ ${this.title}`;
411
+ // Title line with elapsed time
412
+ const elapsedSecs = Math.floor((Date.now() - this.startTime) / 1000);
413
+ const elapsedStr = elapsedSecs > 0 ? ` (${elapsedSecs}s)` : '';
414
+ const titleText = `▸ ${this.title}${elapsedStr}`;
356
415
  frame += `${pad(colors.cyan(titleText), titleText.length + 2)}\n`;
357
416
  lineCount++;
358
417
 
418
+ // If process has been silent for >5s, inject a status line so user knows it's alive
419
+ const silentSecs = Math.floor((Date.now() - this.lastOutputTime) / 1000);
420
+ const silentLines = silentSecs >= 5 ? [' Status: running'] : [];
421
+
359
422
  // Output preview lines
360
- const displayLines = this.formatOutputLines(this.outputLines, this.maxDisplayLines);
423
+ const sourceLines =
424
+ silentLines.length > 0 ? [...this.outputLines, ...silentLines] : this.outputLines;
425
+ const displayLines = this.formatOutputLines(sourceLines, this.maxDisplayLines);
361
426
  for (const line of displayLines) {
362
427
  frame += `${pad(colors.dim(line), line.length)}\n`;
363
428
  lineCount++;
@@ -132,9 +132,19 @@ function generateCommandFunction(cmd: CommandDef, path: string[], lines: string[
132
132
  lines.push(' local curcontext="$curcontext" state line');
133
133
  lines.push(' typeset -A opt_args');
134
134
  lines.push('');
135
- lines.push(' _arguments -C \\');
136
- lines.push(` '1: :${fnName}_commands' \\`);
137
- lines.push(" '*::arg:->args'");
135
+ const flagArgs = cmd.flags && cmd.flags.length > 0 ? generateFlagArgs(cmd.flags) : [];
136
+ if (flagArgs.length > 0) {
137
+ lines.push(' _arguments -C \\');
138
+ for (const flag of flagArgs) {
139
+ lines.push(` ${flag} \\`);
140
+ }
141
+ lines.push(` '1: :${fnName}_commands' \\`);
142
+ lines.push(" '*::arg:->args'");
143
+ } else {
144
+ lines.push(' _arguments -C \\');
145
+ lines.push(` '1: :${fnName}_commands' \\`);
146
+ lines.push(" '*::arg:->args'");
147
+ }
138
148
  lines.push('');
139
149
  lines.push(' case $state in');
140
150
  lines.push(' args)');