@celilo/cli 0.1.5 → 0.1.7

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 (145) 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 +3 -2
  5. package/src/ansible/inventory.ts +5 -1
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +34 -1
  8. package/src/cli/cli.test.ts +2 -2
  9. package/src/cli/command-registry.ts +146 -3
  10. package/src/cli/command-tree-parser.test.ts +1 -1
  11. package/src/cli/command-tree-parser.ts +9 -8
  12. package/src/cli/commands/hook-run.ts +15 -66
  13. package/src/cli/commands/module-audit.ts +14 -44
  14. package/src/cli/commands/module-deploy.ts +4 -1
  15. package/src/cli/commands/module-import-registry.test.ts +115 -0
  16. package/src/cli/commands/module-import.ts +106 -22
  17. package/src/cli/commands/module-publish.test.ts +235 -0
  18. package/src/cli/commands/module-publish.ts +234 -0
  19. package/src/cli/commands/module-remove.ts +82 -2
  20. package/src/cli/commands/module-search.ts +57 -0
  21. package/src/cli/commands/module-secret-get.ts +59 -0
  22. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  23. package/src/cli/commands/module-verify.test.ts +59 -0
  24. package/src/cli/commands/module-verify.ts +53 -0
  25. package/src/cli/commands/status.ts +30 -20
  26. package/src/cli/commands/system-audit.test.ts +138 -0
  27. package/src/cli/commands/system-audit.ts +571 -0
  28. package/src/cli/commands/system-update.ts +391 -0
  29. package/src/cli/completion.ts +15 -1
  30. package/src/cli/fuel-gauge.ts +68 -3
  31. package/src/cli/generate-zsh-completion.ts +13 -3
  32. package/src/cli/index.ts +112 -5
  33. package/src/cli/parser.ts +11 -0
  34. package/src/cli/prompts.ts +36 -5
  35. package/src/cli/tui/audit-state.test.ts +246 -0
  36. package/src/cli/tui/audit-state.ts +525 -0
  37. package/src/cli/tui/audit-tui.test.tsx +135 -0
  38. package/src/cli/tui/audit-tui.tsx +624 -0
  39. package/src/cli/tui/celebration.tsx +29 -0
  40. package/src/cli/tui/clipboard.test.ts +94 -0
  41. package/src/cli/tui/clipboard.ts +101 -0
  42. package/src/cli/tui/icons.ts +22 -0
  43. package/src/cli/tui/keybar.tsx +65 -0
  44. package/src/cli/tui/keymap.test.ts +105 -0
  45. package/src/cli/tui/keymap.ts +70 -0
  46. package/src/cli/tui/modals/analyzing.tsx +75 -0
  47. package/src/cli/tui/modals/celebration.tsx +44 -0
  48. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  49. package/src/cli/tui/modals/remediate.tsx +44 -0
  50. package/src/cli/tui/modals.test.ts +137 -0
  51. package/src/cli/tui/mouse.test.ts +78 -0
  52. package/src/cli/tui/mouse.ts +114 -0
  53. package/src/cli/tui/panes/categories.tsx +62 -0
  54. package/src/cli/tui/panes/command-log.tsx +87 -0
  55. package/src/cli/tui/panes/detail.tsx +175 -0
  56. package/src/cli/tui/panes/findings.tsx +97 -0
  57. package/src/cli/tui/panes/summary.tsx +64 -0
  58. package/src/cli/tui/spawn.ts +130 -0
  59. package/src/cli/tui/theme.ts +42 -0
  60. package/src/cli/tui/wrap.test.ts +43 -0
  61. package/src/cli/tui/wrap.ts +45 -0
  62. package/src/cli/types.ts +5 -0
  63. package/src/db/client.ts +55 -2
  64. package/src/db/schema.ts +26 -17
  65. package/src/hooks/capability-loader.ts +133 -73
  66. package/src/hooks/define-hook.test.ts +9 -1
  67. package/src/hooks/executor.ts +22 -1
  68. package/src/hooks/load-hook-config.test.ts +165 -0
  69. package/src/hooks/load-hook-config.ts +60 -0
  70. package/src/hooks/logger.ts +42 -12
  71. package/src/hooks/run-named-hook.ts +128 -0
  72. package/src/hooks/types.ts +19 -0
  73. package/src/manifest/ensure-schema.test.ts +115 -0
  74. package/src/manifest/schema.ts +76 -0
  75. package/src/module/import.ts +20 -12
  76. package/src/module/packaging/build.ts +85 -16
  77. package/src/module/packaging/release-metadata.test.ts +103 -0
  78. package/src/module/packaging/release-metadata.ts +145 -0
  79. package/src/registry/client.test.ts +228 -0
  80. package/src/registry/client.ts +157 -0
  81. package/src/services/audit/backups.test.ts +233 -0
  82. package/src/services/audit/backups.ts +128 -0
  83. package/src/services/audit/capability-abi.test.ts +153 -0
  84. package/src/services/audit/capability-abi.ts +204 -0
  85. package/src/services/audit/cli-version.test.ts +60 -0
  86. package/src/services/audit/cli-version.ts +87 -0
  87. package/src/services/audit/health.test.ts +84 -0
  88. package/src/services/audit/health.ts +43 -0
  89. package/src/services/audit/index.test.ts +99 -0
  90. package/src/services/audit/index.ts +118 -0
  91. package/src/services/audit/machines-reachable.test.ts +87 -0
  92. package/src/services/audit/machines-reachable.ts +87 -0
  93. package/src/services/audit/module-configs.test.ts +131 -0
  94. package/src/services/audit/module-configs.ts +80 -0
  95. package/src/services/audit/module-versions.test.ts +99 -0
  96. package/src/services/audit/module-versions.ts +154 -0
  97. package/src/services/audit/schema.test.ts +68 -0
  98. package/src/services/audit/schema.ts +115 -0
  99. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  100. package/src/services/audit/secrets-decryptable.ts +97 -0
  101. package/src/services/audit/services-credentials.test.ts +54 -0
  102. package/src/services/audit/services-credentials.ts +64 -0
  103. package/src/services/audit/services-reachable.test.ts +60 -0
  104. package/src/services/audit/services-reachable.ts +64 -0
  105. package/src/services/audit/terraform-plan.test.ts +127 -0
  106. package/src/services/audit/terraform-plan.ts +153 -0
  107. package/src/services/audit/types.test.ts +36 -0
  108. package/src/services/audit/types.ts +90 -0
  109. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  110. package/src/services/audit/unconfigured-modules.ts +71 -0
  111. package/src/services/audit/undeployed-modules.test.ts +66 -0
  112. package/src/services/audit/undeployed-modules.ts +72 -0
  113. package/src/services/build-stream.ts +122 -122
  114. package/src/services/config-interview.ts +407 -2
  115. package/src/services/deploy-ansible.ts +73 -7
  116. package/src/services/deploy-preflight.ts +45 -4
  117. package/src/services/deploy-terraform.ts +31 -24
  118. package/src/services/deploy-validation.ts +167 -23
  119. package/src/services/ensure-interview.test.ts +245 -0
  120. package/src/services/health-runner.ts +110 -38
  121. package/src/services/module-build.ts +11 -13
  122. package/src/services/module-deploy.ts +370 -59
  123. package/src/services/ssh-key-manager.test.ts +1 -1
  124. package/src/services/ssh-key-manager.ts +3 -2
  125. package/src/services/terraform-env.ts +62 -0
  126. package/src/services/update/dep-graph.test.ts +214 -0
  127. package/src/services/update/dep-graph.ts +215 -0
  128. package/src/services/update/orchestrator.test.ts +463 -0
  129. package/src/services/update/orchestrator.ts +359 -0
  130. package/src/services/update/progress.ts +49 -0
  131. package/src/services/update/self-update.test.ts +68 -0
  132. package/src/services/update/self-update.ts +57 -0
  133. package/src/services/update/types.ts +94 -0
  134. package/src/templates/generator.test.ts +1 -1
  135. package/src/templates/generator.ts +42 -1
  136. package/src/test-utils/completion-harness.test.ts +1 -1
  137. package/src/test-utils/completion-harness.ts +4 -4
  138. package/src/variables/capability-self-ref.test.ts +203 -0
  139. package/src/variables/context.ts +49 -1
  140. package/src/variables/declarative-derivation.test.ts +306 -0
  141. package/src/variables/declarative-derivation.ts +4 -2
  142. package/src/variables/parser.test.ts +56 -1
  143. package/src/variables/parser.ts +47 -6
  144. package/src/variables/resolver.ts +27 -9
  145. package/tsconfig.json +1 -0
@@ -0,0 +1,359 @@
1
+ /**
2
+ * System update orchestrator (CELILO_UPDATE Phase 4).
3
+ *
4
+ * Walks the dependency graph in topological order (providers first)
5
+ * and drives each drifting module through:
6
+ *
7
+ * pending → backup → upgrade → deploy → health → done | failed | skipped
8
+ *
9
+ * Failure handling per D3: when a provider fails, downstream consumers
10
+ * are checked against the *running* provider's `provides[X].version`
11
+ * — if the consumer's new manifest can be satisfied by the running
12
+ * version, the consumer proceeds; otherwise it's skipped with a
13
+ * clear "blocked by old provider X@<v>" reason.
14
+ *
15
+ * Every operation that can fail or take time is dependency-injected
16
+ * (backup, upgrade, deploy, health). The orchestrator stays a pure
17
+ * state machine the unit tests can drive without touching disk or
18
+ * the network.
19
+ */
20
+
21
+ import { runAudit } from '../audit';
22
+ import type { AuditDeps, SystemAuditReport } from '../audit';
23
+ import {
24
+ type ModuleGraph,
25
+ type ModuleId,
26
+ topologicalOrder,
27
+ transitiveConsumers,
28
+ } from './dep-graph';
29
+ import type { ProgressEmitter } from './progress';
30
+ import type { SelfUpdateDeps } from './self-update';
31
+ import { performSelfUpdate } from './self-update';
32
+ import type { ModuleUpdateState, SelfUpdateResult, SystemUpdateResult, UpdateStep } from './types';
33
+
34
+ export interface ModuleSnapshot {
35
+ id: string;
36
+ /** Currently installed version (from DB). */
37
+ installedVersion: string;
38
+ /** Latest version available in the registry (or null if not in registry). */
39
+ latestVersion: string | null;
40
+ /**
41
+ * Capability versions the *currently installed* manifest provides,
42
+ * keyed by capability name. Used by the version-aware skip logic.
43
+ */
44
+ installedProvides: Record<string, string>;
45
+ /**
46
+ * Capability versions the *new* manifest (latest registry version)
47
+ * requires. Used by the version-aware skip logic.
48
+ */
49
+ pendingRequires: Record<string, string>;
50
+ }
51
+
52
+ export interface OrchestratorOps {
53
+ /** Take a pre-update backup for a module. Returns ok/error. */
54
+ backup: (moduleId: string, updateId: string) => Promise<{ ok: boolean; error?: string }>;
55
+ /** Pull a newer version from the registry, write code/manifest, preserve state. */
56
+ upgrade: (moduleId: string) => Promise<{ ok: boolean; error?: string }>;
57
+ /** Re-converge the deployed instance (terraform apply + ansible). */
58
+ deploy: (moduleId: string) => Promise<{ ok: boolean; error?: string }>;
59
+ /** Run the module's health_check hook, report status. */
60
+ health: (moduleId: string) => Promise<{
61
+ status: 'healthy' | 'degraded' | 'unhealthy' | 'no-checks' | 'error';
62
+ detail?: string;
63
+ }>;
64
+ /** Read the celilo DB into a single-file snapshot — the framework-state half of D7. */
65
+ snapshotCeliloDb: (updateId: string) => Promise<{ ok: boolean; error?: string }>;
66
+ }
67
+
68
+ export interface RunUpdateDeps {
69
+ audit: AuditDeps;
70
+ graph: ModuleGraph;
71
+ /** Map of moduleId → ModuleSnapshot for every module the orchestrator might touch. */
72
+ snapshots: Map<ModuleId, ModuleSnapshot>;
73
+ ops: OrchestratorOps;
74
+ selfUpdate: SelfUpdateDeps;
75
+ /** ID generator — defaults to crypto.randomUUID. Tests inject a fixed value. */
76
+ idGen?: () => string;
77
+ /** Clock — defaults to `() => new Date()`. */
78
+ now?: () => Date;
79
+ /** Progress callback for live UI / test capture. */
80
+ progress: ProgressEmitter;
81
+ /** When true, skip backups and the celilo-db snapshot. Confirmed at the CLI layer. */
82
+ noBackup?: boolean;
83
+ /** When true, allow destructive terraform plans through (D6). */
84
+ allowDestructive?: boolean;
85
+ /** Restrict the run to one module + its dependencies. */
86
+ onlyModule?: string;
87
+ }
88
+
89
+ const VERSION_MAJOR_RE = /^v?(\d+)/;
90
+
91
+ function majorOf(version: string): number {
92
+ const m = version.match(VERSION_MAJOR_RE);
93
+ return m ? Number.parseInt(m[1], 10) : 0;
94
+ }
95
+
96
+ /**
97
+ * Decide whether a consumer's pending upgrade can proceed given the
98
+ * provider's current (still-installed) version.
99
+ *
100
+ * Returns null if compatible (proceed); otherwise returns the reason
101
+ * the consumer should be skipped.
102
+ */
103
+ export function consumerSkipReason(
104
+ consumer: ModuleSnapshot,
105
+ failedProvider: ModuleSnapshot,
106
+ graph: ModuleGraph,
107
+ ): string | null {
108
+ // Find which capabilities the consumer requires from the failed provider.
109
+ const sharedCaps: string[] = [];
110
+ for (const [cap, requiredVersion] of Object.entries(consumer.pendingRequires)) {
111
+ const providerCurrentVersion = failedProvider.installedProvides[cap];
112
+ if (providerCurrentVersion === undefined) continue; // not a shared cap
113
+ sharedCaps.push(cap);
114
+
115
+ const requiredMajor = majorOf(requiredVersion);
116
+ const providerMajor = majorOf(providerCurrentVersion);
117
+ if (requiredMajor !== providerMajor) {
118
+ return `blocked by ${failedProvider.id}@${failedProvider.installedVersion}: ${cap}@${providerCurrentVersion} doesn't satisfy ${cap}@${requiredVersion}`;
119
+ }
120
+ }
121
+
122
+ // No shared capability between this consumer and the failed provider —
123
+ // the dep-graph has them connected for some other capability that
124
+ // didn't appear in `pendingRequires`. Be conservative: skip.
125
+ if (sharedCaps.length === 0) {
126
+ // We only got here because the dep-graph said `consumer → provider`,
127
+ // so SOMETHING ties them. If pendingRequires doesn't include that
128
+ // capability, treat as blocked (we can't reason about compatibility).
129
+ void graph;
130
+ return `blocked by ${failedProvider.id}: cannot verify compatibility`;
131
+ }
132
+
133
+ return null; // compatible; consumer can proceed against still-installed provider
134
+ }
135
+
136
+ /**
137
+ * Drive a single module through the state machine. Stops at the first
138
+ * failed step.
139
+ */
140
+ async function runModule(
141
+ moduleId: string,
142
+ snap: ModuleSnapshot,
143
+ deps: RunUpdateDeps,
144
+ updateId: string,
145
+ ): Promise<ModuleUpdateState> {
146
+ const startedAt = (deps.now ?? (() => new Date()))().toISOString();
147
+ const fromVersion = snap.installedVersion;
148
+ const toVersion = snap.latestVersion ?? fromVersion;
149
+
150
+ deps.progress.emit({ kind: 'module-start', moduleId, fromVersion, toVersion });
151
+
152
+ const finishWith = (
153
+ step: UpdateStep,
154
+ extra: { error?: string; skipReason?: string } = {},
155
+ ): ModuleUpdateState => {
156
+ const finishedAt = (deps.now ?? (() => new Date()))().toISOString();
157
+ const state: ModuleUpdateState = {
158
+ moduleId,
159
+ fromVersion,
160
+ toVersion,
161
+ step,
162
+ startedAt,
163
+ finishedAt,
164
+ ...extra,
165
+ };
166
+ if (step === 'failed') deps.progress.emit({ kind: 'module-failed', state });
167
+ else if (step === 'skipped') deps.progress.emit({ kind: 'module-skipped', state });
168
+ else deps.progress.emit({ kind: 'module-done', state });
169
+ return state;
170
+ };
171
+
172
+ // 1. backup (per-module)
173
+ if (!deps.noBackup) {
174
+ deps.progress.emit({ kind: 'module-step', moduleId, step: 'backup' });
175
+ const r = await deps.ops.backup(moduleId, updateId);
176
+ if (!r.ok) {
177
+ return finishWith('failed', { error: `backup: ${r.error ?? 'unknown'}` });
178
+ }
179
+ }
180
+
181
+ // 2. upgrade
182
+ if (snap.latestVersion && snap.latestVersion !== snap.installedVersion) {
183
+ deps.progress.emit({ kind: 'module-step', moduleId, step: 'upgrade' });
184
+ const r = await deps.ops.upgrade(moduleId);
185
+ if (!r.ok) {
186
+ return finishWith('failed', { error: `upgrade: ${r.error ?? 'unknown'}` });
187
+ }
188
+ }
189
+
190
+ // 3. deploy
191
+ deps.progress.emit({ kind: 'module-step', moduleId, step: 'deploy' });
192
+ const dr = await deps.ops.deploy(moduleId);
193
+ if (!dr.ok) {
194
+ return finishWith('failed', { error: `deploy: ${dr.error ?? 'unknown'}` });
195
+ }
196
+
197
+ // 4. health
198
+ deps.progress.emit({ kind: 'module-step', moduleId, step: 'health' });
199
+ const hr = await deps.ops.health(moduleId);
200
+ if (hr.status === 'unhealthy' || hr.status === 'error') {
201
+ return finishWith('failed', {
202
+ error: `health: ${hr.status}${hr.detail ? ` (${hr.detail})` : ''}`,
203
+ });
204
+ }
205
+
206
+ return finishWith('done');
207
+ }
208
+
209
+ /**
210
+ * Top-level update flow.
211
+ */
212
+ export async function runSystemUpdate(deps: RunUpdateDeps): Promise<SystemUpdateResult> {
213
+ const idGen = deps.idGen ?? (() => crypto.randomUUID());
214
+ const now = deps.now ?? (() => new Date());
215
+ const updateId = idGen();
216
+ const startedAt = now().toISOString();
217
+ const states: ModuleUpdateState[] = [];
218
+
219
+ deps.progress.emit({ kind: 'plan', modules: deps.snapshots.size });
220
+
221
+ // Audit first; bail if BLOCKED.
222
+ const audit: SystemAuditReport = await runAudit(deps.audit);
223
+ if (audit.verdict === 'BLOCKED') {
224
+ return {
225
+ version: 1,
226
+ updateId,
227
+ startedAt,
228
+ finishedAt: now().toISOString(),
229
+ audit,
230
+ selfUpdate: { performed: false, reason: 'no-network' },
231
+ backupsCreated: false,
232
+ modules: [],
233
+ ok: false,
234
+ };
235
+ }
236
+
237
+ // Self-update.
238
+ deps.progress.emit({ kind: 'self-update-start' });
239
+ let selfUpdate: SelfUpdateResult;
240
+ try {
241
+ selfUpdate = await performSelfUpdate(deps.selfUpdate);
242
+ } catch (err) {
243
+ deps.progress.emit({
244
+ kind: 'self-update-skipped',
245
+ reason: err instanceof Error ? err.message : String(err),
246
+ });
247
+ return {
248
+ version: 1,
249
+ updateId,
250
+ startedAt,
251
+ finishedAt: now().toISOString(),
252
+ audit,
253
+ selfUpdate: { performed: false, reason: 'no-network' },
254
+ backupsCreated: false,
255
+ modules: [],
256
+ ok: false,
257
+ };
258
+ }
259
+ if (selfUpdate.performed) {
260
+ deps.progress.emit({
261
+ kind: 'self-update-done',
262
+ from: selfUpdate.from,
263
+ to: selfUpdate.to,
264
+ });
265
+ } else {
266
+ deps.progress.emit({ kind: 'self-update-skipped', reason: selfUpdate.reason });
267
+ }
268
+
269
+ // celilo-db snapshot (D7).
270
+ let backupsCreated = false;
271
+ if (!deps.noBackup) {
272
+ const r = await deps.ops.snapshotCeliloDb(updateId);
273
+ if (!r.ok) {
274
+ // DB snapshot is the safety net for this whole run; if we can't
275
+ // take it, refuse to mutate.
276
+ return {
277
+ version: 1,
278
+ updateId,
279
+ startedAt,
280
+ finishedAt: now().toISOString(),
281
+ audit,
282
+ selfUpdate,
283
+ backupsCreated: false,
284
+ modules: [],
285
+ ok: false,
286
+ };
287
+ }
288
+ backupsCreated = true;
289
+ }
290
+
291
+ // Walk modules in topological order.
292
+ const order = topologicalOrder(deps.graph);
293
+ const failed = new Map<ModuleId, ModuleUpdateState>();
294
+
295
+ for (const moduleId of order) {
296
+ if (deps.onlyModule && deps.onlyModule !== moduleId) {
297
+ // Allow dependencies of `onlyModule` to be considered later if needed,
298
+ // but for V1 the simple case is "just this one module".
299
+ continue;
300
+ }
301
+
302
+ const snap = deps.snapshots.get(moduleId);
303
+ if (!snap) continue; // no snapshot → not currently installed → skip
304
+
305
+ // Check upstream failures first.
306
+ const upstreamProviders = [...(deps.graph.edges.get(moduleId) ?? [])];
307
+ const blockingProvider = upstreamProviders
308
+ .map((id) => failed.get(id))
309
+ .find((s): s is ModuleUpdateState => s !== undefined);
310
+
311
+ if (blockingProvider) {
312
+ const failedSnap = deps.snapshots.get(blockingProvider.moduleId);
313
+ const reason = failedSnap
314
+ ? consumerSkipReason(snap, failedSnap, deps.graph)
315
+ : `blocked by ${blockingProvider.moduleId}: snapshot missing`;
316
+ if (reason) {
317
+ const state: ModuleUpdateState = {
318
+ moduleId,
319
+ fromVersion: snap.installedVersion,
320
+ toVersion: snap.latestVersion ?? snap.installedVersion,
321
+ step: 'skipped',
322
+ skipReason: reason,
323
+ startedAt: now().toISOString(),
324
+ finishedAt: now().toISOString(),
325
+ };
326
+ deps.progress.emit({ kind: 'module-skipped', state });
327
+ states.push(state);
328
+ // Also propagate blockage to consumers of *this* skipped module.
329
+ for (const id of transitiveConsumers(deps.graph, moduleId)) {
330
+ if (!failed.has(id)) failed.set(id, state);
331
+ }
332
+ failed.set(moduleId, state);
333
+ continue;
334
+ }
335
+ // version-compatible: proceed.
336
+ }
337
+
338
+ const state = await runModule(moduleId, snap, deps, updateId);
339
+ states.push(state);
340
+ if (state.step === 'failed') {
341
+ failed.set(moduleId, state);
342
+ }
343
+ }
344
+
345
+ const ok = states.every((s) => s.step === 'done' || s.step === 'pending');
346
+ deps.progress.emit({ kind: 'run-done', ok });
347
+
348
+ return {
349
+ version: 1,
350
+ updateId,
351
+ startedAt,
352
+ finishedAt: now().toISOString(),
353
+ audit,
354
+ selfUpdate,
355
+ backupsCreated,
356
+ modules: states,
357
+ ok,
358
+ };
359
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Progress emitter for the orchestrator.
3
+ *
4
+ * The CLI prints live status as the orchestrator walks each module
5
+ * through its state machine; tests use a capturing emitter to assert
6
+ * on exactly which events fired in which order. Same shape as the
7
+ * `[progress:start] / [progress:done]` pattern in
8
+ * `packages/e2e/src/runner.ts`.
9
+ */
10
+
11
+ import type { ModuleUpdateState, UpdateStep } from './types';
12
+
13
+ export type ProgressEvent =
14
+ | { kind: 'plan'; modules: number }
15
+ | { kind: 'self-update-start' }
16
+ | { kind: 'self-update-done'; from: string; to: string }
17
+ | { kind: 'self-update-skipped'; reason: string }
18
+ | { kind: 'backup-start'; moduleId: string }
19
+ | { kind: 'backup-done'; moduleId: string }
20
+ | { kind: 'module-start'; moduleId: string; fromVersion: string; toVersion: string }
21
+ | { kind: 'module-step'; moduleId: string; step: UpdateStep }
22
+ | { kind: 'module-done'; state: ModuleUpdateState }
23
+ | { kind: 'module-failed'; state: ModuleUpdateState }
24
+ | { kind: 'module-skipped'; state: ModuleUpdateState }
25
+ | { kind: 'run-done'; ok: boolean };
26
+
27
+ export interface ProgressEmitter {
28
+ emit(event: ProgressEvent): void;
29
+ }
30
+
31
+ /** Default emitter — silent. Override for live console output. */
32
+ export const silentProgress: ProgressEmitter = {
33
+ emit() {},
34
+ };
35
+
36
+ export interface CapturingEmitter extends ProgressEmitter {
37
+ events: ProgressEvent[];
38
+ }
39
+
40
+ /** Test helper: collect every event in `events` for later assertion. */
41
+ export function capturingProgress(): CapturingEmitter {
42
+ const events: ProgressEvent[] = [];
43
+ return {
44
+ events,
45
+ emit(event) {
46
+ events.push(event);
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { performSelfUpdate } from './self-update';
3
+
4
+ const okUpdater = async () => ({ ok: true, stderr: '' });
5
+ const failUpdater = async () => ({ ok: false, stderr: 'EACCES on /usr/local' });
6
+
7
+ describe('performSelfUpdate', () => {
8
+ test('skips when devMode flag is set', async () => {
9
+ const result = await performSelfUpdate({
10
+ installedVersion: '0.0.1',
11
+ fetcher: async () => '99.0.0',
12
+ updater: okUpdater,
13
+ devMode: true,
14
+ });
15
+ expect(result).toEqual({ performed: false, reason: 'dev-mode' });
16
+ });
17
+
18
+ test('skips when fetcher returns null (no network)', async () => {
19
+ const result = await performSelfUpdate({
20
+ installedVersion: '0.1.5',
21
+ fetcher: async () => null,
22
+ updater: okUpdater,
23
+ });
24
+ expect(result).toEqual({ performed: false, reason: 'no-network' });
25
+ });
26
+
27
+ test('skips when running version >= latest', async () => {
28
+ const result = await performSelfUpdate({
29
+ installedVersion: '0.1.5',
30
+ fetcher: async () => '0.1.5',
31
+ updater: okUpdater,
32
+ });
33
+ expect(result).toEqual({ performed: false, reason: 'already-current' });
34
+ });
35
+
36
+ test('skips when running version is ahead of latest (dev build)', async () => {
37
+ const result = await performSelfUpdate({
38
+ installedVersion: '0.2.0',
39
+ fetcher: async () => '0.1.7',
40
+ updater: okUpdater,
41
+ });
42
+ expect(result).toEqual({ performed: false, reason: 'already-current' });
43
+ });
44
+
45
+ test('runs the updater and returns from/to when newer is available', async () => {
46
+ let updaterCalled = false;
47
+ const result = await performSelfUpdate({
48
+ installedVersion: '0.1.5',
49
+ fetcher: async () => '0.1.7',
50
+ updater: async () => {
51
+ updaterCalled = true;
52
+ return { ok: true, stderr: '' };
53
+ },
54
+ });
55
+ expect(updaterCalled).toBe(true);
56
+ expect(result).toEqual({ performed: true, from: '0.1.5', to: '0.1.7' });
57
+ });
58
+
59
+ test('throws with stderr when the updater fails', async () => {
60
+ await expect(
61
+ performSelfUpdate({
62
+ installedVersion: '0.1.5',
63
+ fetcher: async () => '0.1.7',
64
+ updater: failUpdater,
65
+ }),
66
+ ).rejects.toThrow(/bun update failed: EACCES on \/usr\/local/);
67
+ });
68
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * CLI self-update — the first step of `celilo system update` per
3
+ * CELILO_UPDATE D2.
4
+ *
5
+ * Compares the running CLI version against the latest published
6
+ * `@celilo/cli` on npm and runs `bun update -g @celilo/cli` if newer.
7
+ * On success the caller re-execs with the new binary so subsequent
8
+ * module work uses the new code.
9
+ *
10
+ * Both the version-check and the bun-update invocation are
11
+ * dependency-injected so the orchestrator can be tested against
12
+ * canned responses without touching npm or the running shell.
13
+ */
14
+
15
+ import { compareSemver } from '../audit/cli-version';
16
+ import type { SelfUpdateResult } from './types';
17
+
18
+ export type CliVersionFetcher = () => Promise<string | null>;
19
+ export type CliUpdater = () => Promise<{ ok: boolean; stderr: string }>;
20
+
21
+ export interface SelfUpdateDeps {
22
+ /** Currently running CLI version (read from package.json). */
23
+ installedVersion: string;
24
+ /** Resolves the latest @celilo/cli on npm; returns null on transport failure. */
25
+ fetcher: CliVersionFetcher;
26
+ /** Runs `bun update -g @celilo/cli` (or equivalent). */
27
+ updater: CliUpdater;
28
+ /**
29
+ * If true, skip self-update entirely. Set when `system update`
30
+ * detects the CLI is running from local source (cele2e dev loop)
31
+ * — there's nothing to update against and any npm install would
32
+ * step on the mounted source tree.
33
+ */
34
+ devMode?: boolean;
35
+ }
36
+
37
+ export async function performSelfUpdate(deps: SelfUpdateDeps): Promise<SelfUpdateResult> {
38
+ if (deps.devMode) {
39
+ return { performed: false, reason: 'dev-mode' };
40
+ }
41
+
42
+ const latest = await deps.fetcher();
43
+ if (latest === null) {
44
+ return { performed: false, reason: 'no-network' };
45
+ }
46
+
47
+ if (compareSemver(deps.installedVersion, latest) >= 0) {
48
+ return { performed: false, reason: 'already-current' };
49
+ }
50
+
51
+ const result = await deps.updater();
52
+ if (!result.ok) {
53
+ throw new Error(`bun update failed: ${result.stderr || 'unknown error'}`);
54
+ }
55
+
56
+ return { performed: true, from: deps.installedVersion, to: latest };
57
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Types for the system update orchestrator (CELILO_UPDATE Phase 4).
3
+ *
4
+ * The orchestrator's job is to take a `SystemAuditReport` plus a
5
+ * dependency graph and walk every drifting module through a small
6
+ * state machine: backup → upgrade → deploy → health-check. Each
7
+ * module's outcome is recorded as a `ModuleUpdateState`; the
8
+ * aggregate is reported as a `SystemUpdateResult`.
9
+ *
10
+ * Stable JSON-friendly shapes — version-bumped if the schema
11
+ * changes incompatibly.
12
+ */
13
+
14
+ import type { SystemAuditReport } from '../audit';
15
+
16
+ /** Per-module step in the update state machine. */
17
+ export type UpdateStep =
18
+ | 'pending'
19
+ | 'backup'
20
+ | 'upgrade'
21
+ | 'deploy'
22
+ | 'health'
23
+ | 'done'
24
+ | 'failed'
25
+ | 'skipped';
26
+
27
+ export interface ModuleUpdateState {
28
+ moduleId: string;
29
+ /** Version installed at the start of the run. */
30
+ fromVersion: string;
31
+ /** Version we're attempting to upgrade to (may equal `fromVersion` if no version drift, but other drift triggered the update). */
32
+ toVersion: string;
33
+ /** Where the state machine stopped. */
34
+ step: UpdateStep;
35
+ /** Step-specific error message (if `step === 'failed'`). */
36
+ error?: string;
37
+ /** Reason for skip (if `step === 'skipped'`), e.g., "blocked by old provider X@Y". */
38
+ skipReason?: string;
39
+ /** ISO timestamp when this module's run started. */
40
+ startedAt?: string;
41
+ /** ISO timestamp when this module's run finished. */
42
+ finishedAt?: string;
43
+ }
44
+
45
+ export type SelfUpdateResult =
46
+ | { performed: false; reason: 'already-current' | 'dev-mode' | 'no-network' }
47
+ | { performed: true; from: string; to: string };
48
+
49
+ export interface SystemUpdateResult {
50
+ version: 1;
51
+ /** Run identifier — same uuid that ties together all the pre-update backups. */
52
+ updateId: string;
53
+ startedAt: string;
54
+ finishedAt: string;
55
+ /** The audit that drove this run. Carried through so the JSON output is self-contained. */
56
+ audit: SystemAuditReport;
57
+ /** CLI self-update outcome. */
58
+ selfUpdate: SelfUpdateResult;
59
+ /**
60
+ * Whether per-module backups were created. False on `--no-backup` runs
61
+ * (the user accepted the rollback risk).
62
+ */
63
+ backupsCreated: boolean;
64
+ /** Per-module results, in the order the orchestrator walked them. */
65
+ modules: ModuleUpdateState[];
66
+ /** True iff every module reached `done` (or had nothing to do). */
67
+ ok: boolean;
68
+ }
69
+
70
+ /**
71
+ * Non-destructive preview produced by `system update --dry-run`.
72
+ * Describes what the orchestrator *would* do without running anything.
73
+ */
74
+ export interface UpdatePlan {
75
+ version: 1;
76
+ updateId: string;
77
+ audit: SystemAuditReport;
78
+ /** Whether the CLI would self-update before module work. */
79
+ willSelfUpdate: boolean;
80
+ selfUpdateFromVersion?: string;
81
+ selfUpdateToVersion?: string;
82
+ /** Modules that will be upgraded, in topological order. */
83
+ modules: Array<{
84
+ moduleId: string;
85
+ fromVersion: string;
86
+ toVersion: string;
87
+ /** Capability provider this module depends on, for the tree rendering. */
88
+ dependsOn: string[];
89
+ }>;
90
+ /** Whether backups will be taken before mutations. */
91
+ willBackup: boolean;
92
+ /** Whether the destructive-terraform gate is in effect. */
93
+ destructiveTerraformBlocked: boolean;
94
+ }
@@ -565,7 +565,7 @@ resource "proxmox_lxc" "container" {
565
565
  expect(hostsIni).toContain('ansible_host=192.168.0.100'); // Machine IP
566
566
  expect(hostsIni).toContain('ansible_user=root'); // Machine SSH user
567
567
  expect(hostsIni).toContain('ansible_ssh_private_key_file='); // SSH key path
568
- expect(hostsIni).toContain('/tmp/ansible-keys/machine-machine-1.key'); // Key filename
568
+ expect(hostsIni).toContain('celilo-ansible-keys/machine-machine-1.key'); // Key filename
569
569
  }
570
570
  });
571
571