@celilo/cli 0.3.30 → 0.4.0-alpha.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 (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +5 -4
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. package/src/services/dns-auto-register.ts +0 -211
package/src/cli/index.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  handleEventsRepair,
22
22
  handleEventsRespond,
23
23
  handleEventsRun,
24
+ handleEventsRunHook,
24
25
  handleEventsShowDaemon,
25
26
  handleEventsStatus,
26
27
  handleEventsTail,
@@ -62,6 +63,7 @@ import { handleModuleTypesCheck, handleModuleTypesGenerate } from './commands/mo
62
63
  import { handleModuleUpgrade } from './commands/module-upgrade';
63
64
  import { moduleVerify } from './commands/module-verify';
64
65
  import { handlePackage } from './commands/package';
66
+ import { main as runPublish } from './commands/publish';
65
67
  import { handleSecretList } from './commands/secret-list';
66
68
  import { handleSecretSet } from './commands/secret-set';
67
69
  import { handleServiceAddDigitalOcean } from './commands/service-add-digitalocean';
@@ -73,6 +75,13 @@ import { handleServiceReconfigure } from './commands/service-reconfigure';
73
75
  import { handleServiceRemove } from './commands/service-remove';
74
76
  import { handleServiceVerify } from './commands/service-verify';
75
77
  import { handleStatus } from './commands/status';
78
+ import { handleSubscribersAdd } from './commands/subscribers-add';
79
+ import { handleSubscribersList } from './commands/subscribers-list';
80
+ import { handleSubscribersRemove } from './commands/subscribers-remove';
81
+ import { handleSubscribersServe } from './commands/subscribers-serve';
82
+ import { handleSubscribersStatus } from './commands/subscribers-status';
83
+ import { handleSubscribersTest } from './commands/subscribers-test';
84
+ import { handleSystemApplyConfig } from './commands/system-apply-config';
76
85
  import { handleSystemAudit } from './commands/system-audit';
77
86
  import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
78
87
  import { handleSystemDoctor } from './commands/system-doctor';
@@ -157,9 +166,12 @@ Commands:
157
166
  service Manage container services (Proxmox, Digital Ocean)
158
167
  storage Manage backup storage destinations
159
168
  backup Create and manage backups
169
+ restore Restore a celilo-mgmt backup from a local file (fresh-bootstrap path)
160
170
  machine Manage machine pool (bring-your-own-hardware)
161
171
  system Manage system configuration
162
172
  ipam Manage IP address and VMID allocations and reservations
173
+ publish Publish workspace packages to npm and modules to celilo.computer
174
+ subscribers Manage build-bus subscribers (cross-machine publish-event delivery)
163
175
  completion Generate shell completion scripts (bash/zsh)
164
176
 
165
177
  help, --help, -h Show this help message
@@ -271,6 +283,99 @@ Examples:
271
283
  return { success: true, message: helpText.trim() };
272
284
  }
273
285
 
286
+ function displaySubscribersHelp(): CommandResult {
287
+ const helpText = `
288
+ Celilo - Build-Bus Subscribers
289
+
290
+ Usage:
291
+ celilo subscribers <subcommand> [args...] [options]
292
+
293
+ Subcommands:
294
+ list Show registered subscribers (truncated secret fingerprints)
295
+ add <url> --secret <hmac-secret> Add a subscriber (replaces any with the same URL)
296
+ [--name <label>] [--registry <r>]
297
+ [--tag <t>] [--package-pattern <glob>]
298
+ remove <url> Remove a subscriber
299
+ test <url> Fire a synthetic event at a subscriber; report delivery
300
+ serve --secret <hmac-secret> Run the receiver daemon (verifies + emits to local bus)
301
+ [--port <p>] [--no-dispatch]
302
+ status Per-subscriber delivery history (success rate, recent failures)
303
+ --help, -h Show this help
304
+
305
+ Description:
306
+ Subscribers receive signed webhooks when 'celilo publish' ships a
307
+ new package version. Each subscriber has a per-target HMAC secret,
308
+ a target URL, and optional match filters (registry, dist-tag,
309
+ package name glob). The publisher signs every event with the
310
+ subscriber's secret; the receiver verifies before reacting.
311
+
312
+ This is the static-config first cut of v2/BUILD_BUS.md — operators
313
+ manage subscribers explicitly. Auto-discovery via the celilo
314
+ registry switchboard is a follow-on.
315
+
316
+ Examples:
317
+ celilo subscribers add https://lunacycle.lab/build-bus --secret <hex> \\
318
+ --name "lunacycle build box" --registry npm --package-pattern '@celilo/*'
319
+ celilo subscribers list
320
+ celilo subscribers test https://lunacycle.lab/build-bus
321
+ celilo subscribers remove https://lunacycle.lab/build-bus
322
+ `;
323
+ return { success: true, message: helpText.trim() };
324
+ }
325
+
326
+ function displayPublishHelp(): CommandResult {
327
+ const helpText = `
328
+ Celilo - Publish Workspace Packages and Modules
329
+
330
+ Usage:
331
+ celilo publish [options]
332
+
333
+ Options:
334
+ --dry-run Preflight report only — no publishing, no file changes
335
+ --release-touch Auto-touch drifted module manifests with a fresh release marker
336
+ --allow-stale Bypass workspace and module stale-version safeguards
337
+ -y, --yes Auto-confirm every prompt (doesn't bypass safety gates)
338
+ --alpha Publish X.Y.Z-alpha.N to npm @alpha dist-tag
339
+ --track-alpha Force-pin just-published alphas into bun global (with --alpha)
340
+ --alpha-modules Also publish module +N revisions in alpha mode (with --alpha)
341
+ --promote <spec> Graduate a named alpha to its base X.Y.Z (e.g. @celilo/e2e@0.7.14-alpha.3)
342
+ --skip-changesets Skip the pending-changesets prompt at the top of normal-mode publishes
343
+ --help, -h Show this help message
344
+
345
+ Description:
346
+ Publishes @celilo/* workspace packages to npm, bumps consumer dependency pins,
347
+ updates the bun global install, and ships modules to celilo.computer. Runs four
348
+ phases: preflight + workspace publish, consumer pin bump, global install update,
349
+ module registry publish.
350
+
351
+ In normal mode, if pending changesets exist under .changeset/ (recorded by
352
+ 'bun changeset' during dev), 'celilo publish' applies them via
353
+ 'bunx changeset version' before planning. Skipped in alpha + promote modes.
354
+
355
+ --alpha mode publishes pre-release versions under the @alpha dist-tag so consumers
356
+ can opt into iteration via 'bun add @celilo/<pkg>@alpha' without disturbing @latest.
357
+ Auto-skips packages whose source hasn't changed since the prior alpha.
358
+
359
+ --promote graduates a vetted alpha to its real X.Y.Z and runs all four phases.
360
+
361
+ Examples:
362
+ celilo publish # Full publish flow
363
+ celilo publish --dry-run # Show plan, no side effects
364
+ celilo publish --alpha # Ship pre-release alphas
365
+ celilo publish --alpha --track-alpha # Alpha + pin into global install
366
+ celilo publish --promote @celilo/e2e@0.7.14-alpha.3 # Graduate alpha to real release
367
+ celilo publish --skip-changesets # Publish current versions; ignore .changeset/*.md
368
+
369
+ Related Commands:
370
+ bun changeset Record a pending version bump (read by 'celilo publish')
371
+ `;
372
+
373
+ return {
374
+ success: true,
375
+ message: helpText.trim(),
376
+ };
377
+ }
378
+
274
379
  function displayCapabilityHelp(): CommandResult {
275
380
  const helpText = `
276
381
  Celilo - Capability Management
@@ -499,7 +604,7 @@ Usage:
499
604
  celilo backup <subcommand> [args...] [options]
500
605
 
501
606
  Subcommands:
502
- create [module-id] Create a backup (system state + modules, or specific module)
607
+ create [module-id] (DEPRECATED) Create a backup. Use 'celilo module backup' instead.
503
608
  Options:
504
609
  --force Ignore schedule, back up now
505
610
  --storage <id> Use specific storage destination
@@ -541,11 +646,11 @@ Description:
541
646
  Use "celilo backup name" to assign human-readable names.
542
647
 
543
648
  Examples:
544
- # Create a full backup (system + all due modules)
545
- celilo backup create
649
+ # Create a full backup (system + all due modules) — canonical path:
650
+ celilo module backup
546
651
 
547
- # Back up a specific module
548
- celilo backup create authentik --force
652
+ # Back up a specific module — canonical path:
653
+ celilo module backup authentik --force
549
654
 
550
655
  # List recent backups
551
656
  celilo backup list
@@ -617,7 +722,7 @@ Examples:
617
722
  celilo storage remove local-backups --force
618
723
 
619
724
  Related Commands:
620
- celilo backup create Create a backup
725
+ celilo module backup <module-id> Create a backup
621
726
  celilo backup list List available backups
622
727
  celilo backup restore <id> Restore from a backup
623
728
  `;
@@ -780,6 +885,10 @@ Usage:
780
885
  Subcommands:
781
886
  init [--accept-defaults] [key=val...] Initialize system with guided setup or defaults
782
887
 
888
+ apply-config <key=val>... | --from-stdin
889
+ Headless config write — designed for module
890
+ hooks (no prompts, no guidance, just writes).
891
+
783
892
  config set <key> <value> Set system-wide configuration value
784
893
  config get [key] Get system configuration value(s)
785
894
 
@@ -991,6 +1100,26 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
991
1100
  };
992
1101
  }
993
1102
 
1103
+ if (parsed.command === 'publish') {
1104
+ if (parsed.flags.help || parsed.flags.h) {
1105
+ return displayPublishHelp();
1106
+ }
1107
+ const pubFlagError = checkFlags('publish', undefined, parsed.flags, parsed.args);
1108
+ if (pubFlagError) return pubFlagError;
1109
+ // Slice from `publish` onwards in the raw argv so the publish entry
1110
+ // sees the operator's exact flag spelling (`-y` vs `--yes`, etc.).
1111
+ // Falling back to args+flags reconstruction would lose short-form
1112
+ // flag identity since the parser canonicalises those.
1113
+ const publishIdx = process.argv.indexOf('publish');
1114
+ const publishArgv = publishIdx >= 0 ? process.argv.slice(publishIdx + 1) : [...parsed.args];
1115
+ await runPublish(publishArgv);
1116
+ // runPublish handles its own console output (multi-phase, multi-line);
1117
+ // returning an empty success message tells the outer CLI loop to skip
1118
+ // the clack outro and exit 0 cleanly. Any failure inside runPublish
1119
+ // calls process.exit() directly and never returns here.
1120
+ return { success: true, message: '' };
1121
+ }
1122
+
994
1123
  if (parsed.command === 'events') {
995
1124
  if (parsed.flags.help || parsed.flags.h) {
996
1125
  return displayEventsHelp();
@@ -1017,6 +1146,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1017
1146
  return handleEventsDrain(parsed.args, parsed.flags);
1018
1147
  case 'run':
1019
1148
  return handleEventsRun(parsed.args, parsed.flags);
1149
+ case 'run-hook':
1150
+ return handleEventsRunHook(parsed.args);
1020
1151
  case 'emit':
1021
1152
  return handleEventsEmit(parsed.args, parsed.flags);
1022
1153
  case 'ack':
@@ -1155,6 +1286,14 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1155
1286
  case 'validate':
1156
1287
  // Alias for `module deploy --preflight`
1157
1288
  return handleModuleDeploy(parsed.args, { ...parsed.flags, preflight: true });
1289
+ case 'backup': {
1290
+ // Canonical create-a-module-backup surface, matching the rest of
1291
+ // the module CLI verbs (deploy, health, remove, ...). The legacy
1292
+ // `celilo backup create <module>` still works but prints a
1293
+ // deprecation banner.
1294
+ const { handleBackupCreate } = await import('./commands/backup-create');
1295
+ return handleBackupCreate(parsed.args, parsed.flags);
1296
+ }
1158
1297
  case 'run-hook':
1159
1298
  return handleHookRun(parsed.args, parsed.flags);
1160
1299
  case 'terraform-unlock':
@@ -1267,6 +1406,41 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1267
1406
  };
1268
1407
  }
1269
1408
 
1409
+ if (parsed.command === 'subscribers') {
1410
+ if (parsed.flags.help || parsed.flags.h) {
1411
+ return displaySubscribersHelp();
1412
+ }
1413
+ if (!parsed.subcommand) {
1414
+ return {
1415
+ success: false,
1416
+ error:
1417
+ 'Subscribers subcommand required (list, add, remove, test).\n\nRun "celilo subscribers --help" for usage.',
1418
+ };
1419
+ }
1420
+ const subFlagError = checkFlags('subscribers', parsed.subcommand, parsed.flags, parsed.args);
1421
+ if (subFlagError) return subFlagError;
1422
+
1423
+ switch (parsed.subcommand) {
1424
+ case 'list':
1425
+ return handleSubscribersList();
1426
+ case 'add':
1427
+ return handleSubscribersAdd(parsed.args, parsed.flags);
1428
+ case 'remove':
1429
+ return handleSubscribersRemove(parsed.args);
1430
+ case 'test':
1431
+ return handleSubscribersTest(parsed.args);
1432
+ case 'serve':
1433
+ return handleSubscribersServe(parsed.args, parsed.flags);
1434
+ case 'status':
1435
+ return handleSubscribersStatus();
1436
+ default:
1437
+ return {
1438
+ success: false,
1439
+ error: `Unknown subscribers subcommand: ${parsed.subcommand}\n\nRun "celilo subscribers --help" for usage.`,
1440
+ };
1441
+ }
1442
+ }
1443
+
1270
1444
  if (parsed.command === 'storage') {
1271
1445
  if (parsed.flags.help || parsed.flags.h) {
1272
1446
  return displayStorageHelp();
@@ -1350,6 +1524,17 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1350
1524
  if (backupFlagError) return backupFlagError;
1351
1525
 
1352
1526
  if (parsed.subcommand === 'create') {
1527
+ // Legacy surface — `celilo module backup <id>` is the canonical
1528
+ // create command now (matches the module-namespaced verbs:
1529
+ // deploy, health, remove). Print a one-line deprecation banner
1530
+ // and forward to the same handler. Suppress via
1531
+ // `CELILO_SUPPRESS_DEPRECATION=1` so tests / cron stay quiet.
1532
+ if (process.env.CELILO_SUPPRESS_DEPRECATION !== '1') {
1533
+ const target = parsed.args[0]
1534
+ ? `celilo module backup ${parsed.args[0]}`
1535
+ : 'celilo module backup';
1536
+ console.warn(`⚠ "celilo backup create" is deprecated. Use: ${target}`);
1537
+ }
1353
1538
  const { handleBackupCreate } = await import('./commands/backup-create');
1354
1539
  return handleBackupCreate(parsed.args, parsed.flags);
1355
1540
  }
@@ -1390,6 +1575,25 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1390
1575
  };
1391
1576
  }
1392
1577
 
1578
+ if (parsed.command === 'restore') {
1579
+ // Phase 4 of v2/SYSTEM_BACKUP_TERRAFORM_STATE.md — top-level
1580
+ // fresh-bootstrap restore. Distinct from `celilo backup restore
1581
+ // <id>` (which restores from a configured storage destination
1582
+ // using an existing backup record); this one takes a local file
1583
+ // directly so it works before any storage is configured.
1584
+ //
1585
+ // `restore` has no subcommands but the parser eagerly captures
1586
+ // the second positional as `subcommand`. Re-thread it as the
1587
+ // first arg so `celilo restore <file>` works alongside
1588
+ // `celilo restore --from <file>`.
1589
+ const restoreArgs = parsed.subcommand ? [parsed.subcommand, ...parsed.args] : parsed.args;
1590
+ const restoreFlagError = checkFlags('restore', undefined, parsed.flags, restoreArgs);
1591
+ if (restoreFlagError) return restoreFlagError;
1592
+
1593
+ const { handleRestore } = await import('./commands/restore');
1594
+ return handleRestore(restoreArgs, parsed.flags);
1595
+ }
1596
+
1393
1597
  if (parsed.command === 'machine') {
1394
1598
  // Handle machine --help
1395
1599
  if (parsed.flags.help || parsed.flags.h) {
@@ -1452,6 +1656,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1452
1656
  return handleSystemInit(parsed.args, parsed.flags);
1453
1657
  }
1454
1658
 
1659
+ if (parsed.subcommand === 'apply-config') {
1660
+ return handleSystemApplyConfig(parsed.args, parsed.flags);
1661
+ }
1662
+
1455
1663
  if (parsed.subcommand === 'config') {
1456
1664
  const configSubcommand = parsed.args[0];
1457
1665
  if (!configSubcommand) {
@@ -23,11 +23,30 @@ const celiloIntroMock = mock(() => undefined);
23
23
  // Install module mock BEFORE dynamically importing the SUT.
24
24
  // bun:test's mock.module is a runtime call (no hoisting), so the order
25
25
  // matters: anything that imports './prompts' AFTER this call gets the mock.
26
+ //
27
+ // IMPORTANT: mock.module replaces the module PROCESS-GLOBALLY for the
28
+ // remainder of the test run. Every export from the real module needs a
29
+ // stub here, or test files loaded after this one will fail with
30
+ // `SyntaxError: Export named 'X' not found in module 'prompts.ts'` when
31
+ // they try to import a missing one. The mocks below replace what this
32
+ // test actively asserts on; the no-op stubs satisfy other modules that
33
+ // import from prompts.ts just to use them as side-effects.
26
34
  mock.module('./prompts', () => ({
27
35
  celiloIntro: celiloIntroMock,
36
+ celiloOutro: async () => undefined,
28
37
  promptText: promptTextMock,
29
38
  promptPassword: promptPasswordMock,
30
39
  promptConfirm: promptConfirmMock,
40
+ showNote: () => undefined,
41
+ getActiveDisplay: () => undefined,
42
+ setActiveDisplay: () => undefined,
43
+ log: {
44
+ success: () => undefined,
45
+ error: () => undefined,
46
+ warn: () => undefined,
47
+ info: () => undefined,
48
+ message: () => undefined,
49
+ },
31
50
  }));
32
51
 
33
52
  // Dynamic import after the mock is installed.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Tests for the `celilo restore` top-level command (Phase 4 of
3
+ * v2/SYSTEM_BACKUP_TERRAFORM_STATE.md).
4
+ *
5
+ * Covers the CLI surface: routing, argument parsing, pre-flight
6
+ * gating, error propagation. The actual file-direct restore +
7
+ * system-file swap logic is tested in restore-from-file.test.ts;
8
+ * here we focus on what the user-facing command does (and refuses
9
+ * to do).
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
13
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { closeDb, getDb } from '../db/client';
17
+ import { runMigrations } from '../db/migrate';
18
+ import { modules } from '../db/schema';
19
+ import { runCli } from './index';
20
+
21
+ describe('celilo restore', () => {
22
+ let dir: string;
23
+
24
+ beforeEach(async () => {
25
+ dir = mkdtempSync(join(tmpdir(), 'celilo-restore-cmd-test-'));
26
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
27
+ process.env.CELILO_DATA_DIR = dir;
28
+ process.env.CELILO_MASTER_KEY_PATH = join(dir, 'master.key');
29
+ process.env.CELILO_SUPPRESS_DEPRECATION = '1';
30
+ await runMigrations(process.env.CELILO_DB_PATH);
31
+ });
32
+
33
+ afterEach(() => {
34
+ closeDb();
35
+ process.env.CELILO_DB_PATH = undefined;
36
+ process.env.CELILO_DATA_DIR = undefined;
37
+ process.env.CELILO_MASTER_KEY_PATH = undefined;
38
+ process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
39
+ try {
40
+ rmSync(dir, { recursive: true, force: true });
41
+ } catch {
42
+ /* ignore */
43
+ }
44
+ });
45
+
46
+ test('errors when --from is missing', async () => {
47
+ const result = await runCli(['node', 'celilo', 'restore']);
48
+ expect(result.success).toBe(false);
49
+ const err = result.success ? '' : (result.error ?? '');
50
+ expect(err).toContain('--from');
51
+ });
52
+
53
+ test('errors when --from points at a non-existent file', async () => {
54
+ const result = await runCli([
55
+ 'node',
56
+ 'celilo',
57
+ 'restore',
58
+ '--from',
59
+ '/tmp/does-not-exist-celilo-test.backup',
60
+ ]);
61
+ expect(result.success).toBe(false);
62
+ const err = result.success ? '' : (result.error ?? '');
63
+ expect(err).toContain('not found');
64
+ });
65
+
66
+ test('refuses when target DB has modules (non-empty)', async () => {
67
+ // Plant a fake module to simulate a populated target.
68
+ const db = getDb();
69
+ db.insert(modules)
70
+ .values({
71
+ id: 'fake-existing-mod',
72
+ name: 'fake',
73
+ version: '0.0.1',
74
+ sourcePath: '/tmp/fake',
75
+ manifestData: {} as Record<string, unknown>,
76
+ })
77
+ .run();
78
+
79
+ // Plant a phony artifact file so the path check passes.
80
+ const phonyArtifact = join(dir, 'phony.backup');
81
+ writeFileSync(phonyArtifact, '{}');
82
+
83
+ const result = await runCli(['node', 'celilo', 'restore', '--from', phonyArtifact]);
84
+ expect(result.success).toBe(false);
85
+ const err = result.success ? '' : (result.error ?? '');
86
+ expect(err).toContain('not empty');
87
+ expect(err).toContain('fake-existing-mod');
88
+ expect(err).toContain('--force');
89
+ });
90
+
91
+ test('refuses when target has terraform state on disk (non-empty)', async () => {
92
+ const tfDir = join(dir, 'modules', 'half-deployed', 'generated', 'terraform');
93
+ mkdirSync(tfDir, { recursive: true });
94
+ writeFileSync(join(tfDir, 'terraform.tfstate'), '{}');
95
+
96
+ const phonyArtifact = join(dir, 'phony.backup');
97
+ writeFileSync(phonyArtifact, '{}');
98
+
99
+ const result = await runCli(['node', 'celilo', 'restore', '--from', phonyArtifact]);
100
+ expect(result.success).toBe(false);
101
+ const err = result.success ? '' : (result.error ?? '');
102
+ expect(err).toContain('not empty');
103
+ expect(err).toContain('half-deployed');
104
+ });
105
+
106
+ test('routes to handleRestore when --from is provided (errors at decrypt of non-artifact)', async () => {
107
+ // No state in DB or disk, but the file isn't a real artifact —
108
+ // we expect to get PAST pre-flight and fail at the
109
+ // decrypt/manifest stage. That proves routing is wired up.
110
+ const notAnArtifact = join(dir, 'random.backup');
111
+ writeFileSync(notAnArtifact, 'this is not encrypted JSON');
112
+
113
+ const result = await runCli(['node', 'celilo', 'restore', '--from', notAnArtifact]);
114
+ expect(result.success).toBe(false);
115
+ const err = result.success ? '' : (result.error ?? '');
116
+ // Pre-flight passed (no mention of "not empty"); decryption failed.
117
+ expect(err).not.toContain('not empty');
118
+ expect(err.toLowerCase()).toMatch(/json|decrypt|artifact/);
119
+ });
120
+
121
+ test('accepts artifact path as positional too (not just --from)', async () => {
122
+ const notAnArtifact = join(dir, 'random2.backup');
123
+ writeFileSync(notAnArtifact, 'still not a real artifact');
124
+
125
+ const result = await runCli(['node', 'celilo', 'restore', notAnArtifact]);
126
+ expect(result.success).toBe(false);
127
+ const err = result.success ? '' : (result.error ?? '');
128
+ // Reached the decrypt stage = positional arg was honored.
129
+ expect(err).not.toContain('missing --from');
130
+ });
131
+ });
package/src/db/client.ts CHANGED
@@ -92,6 +92,23 @@ function needsMigration(sqlite: Database): boolean {
92
92
  )`,
93
93
  // Backup naming support
94
94
  'ALTER TABLE backups ADD name text',
95
+ // Module systems (v2/MODULE_SYSTEMS_ADDRESSING.md) — a module's 0..N
96
+ // deployed hosts. Fresh DBs get this via migration 0007; existing installs
97
+ // get it here. Replaces the scalar target_ip/vmid rows in module_configs.
98
+ `CREATE TABLE IF NOT EXISTS module_systems (
99
+ module_id text NOT NULL REFERENCES modules(id) ON DELETE cascade,
100
+ name text NOT NULL,
101
+ hostname text NOT NULL,
102
+ ipv4_address text NOT NULL,
103
+ zone text NOT NULL,
104
+ infra_type text NOT NULL,
105
+ machine_id text REFERENCES machines(id),
106
+ service_id text REFERENCES container_services(id),
107
+ vmid integer,
108
+ created_at integer DEFAULT (unixepoch()) NOT NULL,
109
+ updated_at integer DEFAULT (unixepoch()) NOT NULL,
110
+ PRIMARY KEY (module_id, name)
111
+ )`,
95
112
  ];
96
113
 
97
114
  for (const stmt of alterStatements) {
@@ -200,6 +217,31 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
200
217
  }
201
218
  }
202
219
 
220
+ // One-time upgrade backfill for the target_ip → module_systems refactor
221
+ // (v2/MODULE_SYSTEMS_ADDRESSING.md). Runs for both paths above: needsMigration
222
+ // has already ensured the module_systems table exists (via migration 0007 for
223
+ // a fresh DB, or the imperative CREATE for an existing install). A deployment
224
+ // created before the refactor has its host data only in module_configs /
225
+ // ip_allocations / module_infrastructure and an EMPTY module_systems, so its
226
+ // modules resolve to no system and the migrated hooks throw "No deployed
227
+ // system found". This lifts that state across. Idempotent (skips modules
228
+ // already recorded), so a no-op on a fresh DB and on every steady-state open.
229
+ // Logged so the upgrade is operator-visible, not silent magic.
230
+ if (!readonly) {
231
+ try {
232
+ const { backfillModuleSystems } = require('../services/deployed-systems');
233
+ const filled = backfillModuleSystems(db) as string[];
234
+ if (filled.length > 0) {
235
+ console.log(
236
+ `Backfilled module_systems for ${filled.length} module(s): ${filled.join(', ')}`,
237
+ );
238
+ }
239
+ } catch (error) {
240
+ console.error('Failed to backfill module_systems:', error);
241
+ throw error;
242
+ }
243
+ }
244
+
203
245
  return db;
204
246
  }
205
247
 
@@ -1,10 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { unlink } from 'node:fs/promises';
4
- import { eq } from 'drizzle-orm';
4
+ import { and, eq } from 'drizzle-orm';
5
+ import { upsertModuleConfig } from '../services/module-config';
5
6
  import { type DbClient, createDbClient } from './client';
6
7
  import { capabilities, moduleConfigs, modules, secrets } from './schema';
7
- import type { NewCapability, NewModule, NewModuleConfig, NewSecret } from './schema';
8
+ import type { NewCapability, NewModule, NewSecret } from './schema';
8
9
 
9
10
  describe('Database Schema', () => {
10
11
  let db: DbClient;
@@ -118,16 +119,17 @@ describe('Database Schema', () => {
118
119
  };
119
120
  db.insert(modules).values(newModule).run();
120
121
 
121
- // Insert config
122
- const newConfig: NewModuleConfig = {
123
- moduleId: 'homebridge',
124
- key: 'target_ip',
125
- value: '192.168.0.50',
126
- };
122
+ // Insert config via the shared upsert helper
123
+ upsertModuleConfig(db, 'homebridge', 'target_ip', '192.168.0.50');
127
124
 
128
- const result = db.insert(moduleConfigs).values(newConfig).returning().get();
125
+ const result = db
126
+ .select()
127
+ .from(moduleConfigs)
128
+ .where(and(eq(moduleConfigs.moduleId, 'homebridge'), eq(moduleConfigs.key, 'target_ip')))
129
+ .get();
129
130
 
130
131
  expect(result).toBeDefined();
132
+ if (!result) throw new Error('expected row');
131
133
  expect(result.moduleId).toBe('homebridge');
132
134
  expect(result.key).toBe('target_ip');
133
135
  expect(result.value).toBe('192.168.0.50');
@@ -203,13 +205,8 @@ describe('Database Schema', () => {
203
205
  };
204
206
  db.insert(modules).values(newModule).run();
205
207
 
206
- // Insert config
207
- const newConfig: NewModuleConfig = {
208
- moduleId: 'homebridge',
209
- key: 'target_ip',
210
- value: '192.168.0.50',
211
- };
212
- db.insert(moduleConfigs).values(newConfig).run();
208
+ // Insert config via the shared upsert helper
209
+ upsertModuleConfig(db, 'homebridge', 'target_ip', '192.168.0.50');
213
210
 
214
211
  // Delete module
215
212
  db.delete(modules).where(eq(modules.id, 'homebridge')).run();