@celilo/cli 0.3.30 → 0.4.0-alpha.1

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 +6 -5
  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
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Verifies the CLI rename from `celilo backup create <module>` to
3
+ * `celilo module backup <module>`:
4
+ * - The new shape routes to the same handler.
5
+ * - The legacy shape still works but prints a deprecation banner.
6
+ * - The banner is silenced by CELILO_SUPPRESS_DEPRECATION=1.
7
+ *
8
+ * Doesn't invoke a real backup — both routes resolve to handleBackupCreate
9
+ * which fails when there's no module record / no storage. That failure path
10
+ * proves routing landed at the right handler.
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
14
+ import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { closeDb } from '../db/client';
18
+ import { runMigrations } from '../db/migrate';
19
+ import { runCli } from './index';
20
+
21
+ describe('celilo module backup (renamed surface)', () => {
22
+ let dir: string;
23
+ let warnSpy: ((...args: unknown[]) => void) | null = null;
24
+ let warnings: string[] = [];
25
+
26
+ beforeEach(async () => {
27
+ dir = mkdtempSync(join(tmpdir(), 'celilo-rename-test-'));
28
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
29
+ process.env.CELILO_MASTER_KEY_PATH = join(dir, 'master.key');
30
+ process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
31
+ await runMigrations(process.env.CELILO_DB_PATH);
32
+
33
+ warnings = [];
34
+ warnSpy = (...args: unknown[]) => {
35
+ warnings.push(args.map(String).join(' '));
36
+ };
37
+ const realWarn = console.warn;
38
+ console.warn = warnSpy as typeof console.warn;
39
+ // Restore via afterEach by holding the original in a closure.
40
+ (warnSpy as unknown as { __restore: () => void }).__restore = () => {
41
+ console.warn = realWarn;
42
+ };
43
+ });
44
+
45
+ afterEach(() => {
46
+ if (warnSpy) {
47
+ (warnSpy as unknown as { __restore: () => void }).__restore();
48
+ warnSpy = null;
49
+ }
50
+ closeDb();
51
+ process.env.CELILO_DB_PATH = undefined;
52
+ process.env.CELILO_MASTER_KEY_PATH = undefined;
53
+ process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
54
+ try {
55
+ rmSync(dir, { recursive: true, force: true });
56
+ } catch {
57
+ /* ignore */
58
+ }
59
+ });
60
+
61
+ test('celilo module backup <id> routes to the backup-create handler', async () => {
62
+ const result = await runCli(['node', 'celilo', 'module', 'backup', 'no-such-module']);
63
+ // Module doesn't exist → handler returns an error. Routing reached the
64
+ // handler if we get THAT error (rather than "Unknown module subcommand").
65
+ expect(result.success).toBe(false);
66
+ const err = result.success ? '' : (result.error ?? '');
67
+ expect(err).not.toContain('Unknown module subcommand');
68
+ // No deprecation banner on the canonical surface.
69
+ expect(warnings.join('\n')).not.toContain('deprecated');
70
+ });
71
+
72
+ test('celilo backup create <id> still works AND prints a deprecation banner', async () => {
73
+ const result = await runCli(['node', 'celilo', 'backup', 'create', 'no-such-module']);
74
+ // Reaches the same handler (same failure mode as the canonical route).
75
+ expect(result.success).toBe(false);
76
+ const err = result.success ? '' : (result.error ?? '');
77
+ expect(err).not.toContain('Unknown');
78
+ // Banner present, points at the canonical command.
79
+ const banner = warnings.find((w) => w.includes('deprecated'));
80
+ expect(banner).toBeDefined();
81
+ expect(banner ?? '').toContain('celilo module backup no-such-module');
82
+ });
83
+
84
+ test('CELILO_SUPPRESS_DEPRECATION=1 silences the deprecation banner', async () => {
85
+ process.env.CELILO_SUPPRESS_DEPRECATION = '1';
86
+ await runCli(['node', 'celilo', 'backup', 'create', 'no-such-module']);
87
+ expect(warnings.join('\n')).not.toContain('deprecated');
88
+ });
89
+
90
+ test('celilo backup list still routes (only create was renamed)', async () => {
91
+ const result = await runCli(['node', 'celilo', 'backup', 'list']);
92
+ // Empty DB → empty list, success.
93
+ expect(result.success).toBe(true);
94
+ });
95
+ });
@@ -7,6 +7,7 @@ import { closeDb, getDb } from '../db/client';
7
7
  import { moduleConfigs, modules, secrets } from '../db/schema';
8
8
  import { encryptSecret } from '../secrets/encryption';
9
9
  import { generateMasterKey, writeMasterKey } from '../secrets/master-key';
10
+ import { upsertModuleConfig } from '../services/module-config';
10
11
  import { runCli } from './index';
11
12
 
12
13
  const TEST_DB_PATH = `./test-cli-${Date.now()}-${Math.random()}.db`;
@@ -103,15 +104,16 @@ id: test-module
103
104
  name: Test Module
104
105
  version: 1.0.0
105
106
  description: Test module for CLI
106
-
107
- secrets:
108
- declares:
109
- - name: api_key
110
- type: string
111
- required: true
112
- description: Test API key
113
107
  `,
114
108
  );
109
+ // NOTE: the manifest file deliberately does NOT declare any required
110
+ // secrets. The secret-set tests below construct their own manifest_data
111
+ // JSON in their inner beforeEach (read by handleSecretSet from
112
+ // module.manifestData), so they don't depend on this file. The
113
+ // module-generate test, however, reads the FILE manifest via
114
+ // loadInstalledManifest in services/config-interview.ts — if any
115
+ // required secret is declared here, generate fails on the
116
+ // non-interactive-and-no-responder probe.
115
117
 
116
118
  // Create master key for secret tests
117
119
  const masterKey = generateMasterKey();
@@ -298,9 +300,7 @@ secrets:
298
300
  test('should update existing config value', async () => {
299
301
  const db = getDb();
300
302
  // Set initial value
301
- db.insert(moduleConfigs)
302
- .values({ moduleId: 'test-module', key: 'hostname', value: 'oldhost' })
303
- .run();
303
+ upsertModuleConfig(db, 'test-module', 'hostname', 'oldhost');
304
304
 
305
305
  const result = await runCli([
306
306
  'node',
@@ -363,9 +363,7 @@ secrets:
363
363
 
364
364
  test('should get specific config value', async () => {
365
365
  const db = getDb();
366
- db.insert(moduleConfigs)
367
- .values({ moduleId: 'test-module', key: 'hostname', value: 'myhost' })
368
- .run();
366
+ upsertModuleConfig(db, 'test-module', 'hostname', 'myhost');
369
367
 
370
368
  const result = await runCli([
371
369
  'node',
@@ -384,12 +382,8 @@ secrets:
384
382
 
385
383
  test('should get all config values', async () => {
386
384
  const db = getDb();
387
- db.insert(moduleConfigs)
388
- .values([
389
- { moduleId: 'test-module', key: 'hostname', value: 'myhost' },
390
- { moduleId: 'test-module', key: 'ip', value: '192.168.0.50' },
391
- ])
392
- .run();
385
+ upsertModuleConfig(db, 'test-module', 'hostname', 'myhost');
386
+ upsertModuleConfig(db, 'test-module', 'ip', '192.168.0.50');
393
387
 
394
388
  const result = await runCli(['node', 'celilo', 'module', 'config', 'get', 'test-module']);
395
389
 
@@ -579,7 +573,9 @@ secrets:
579
573
 
580
574
  expect(result.success).toBe(false);
581
575
  if (result.success) return;
582
- expect(result.error).toContain('Subcommand required');
576
+ // `module import` now takes a positional (name or path), so the error
577
+ // names what's missing rather than "subcommand required".
578
+ expect(result.error).toContain('Module name or path required');
583
579
  });
584
580
 
585
581
  test('should error on non-existent path', async () => {
@@ -614,9 +610,7 @@ secrets:
614
610
  );
615
611
 
616
612
  // Add config
617
- db.insert(moduleConfigs)
618
- .values({ moduleId: 'test-module', key: 'hostname', value: 'testhost' })
619
- .run();
613
+ upsertModuleConfig(db, 'test-module', 'hostname', 'testhost');
620
614
 
621
615
  // Create template directory
622
616
  await mkdir(join(TEST_MODULE_DIR, 'terraform'), { recursive: true });
@@ -249,6 +249,62 @@ export const COMMANDS: CommandDef[] = [
249
249
  },
250
250
  ],
251
251
  },
252
+ {
253
+ name: 'publish',
254
+ description:
255
+ 'Publish workspace packages to npm, bump consumer pins, refresh global install, and ship modules to celilo.computer',
256
+ flags: [
257
+ {
258
+ name: 'dry-run',
259
+ description: 'Preflight report only — no publishing, no file changes',
260
+ takesValue: false,
261
+ },
262
+ {
263
+ name: 'release-touch',
264
+ description: 'Auto-touch drifted module manifests with a fresh release marker, then exit',
265
+ takesValue: false,
266
+ },
267
+ {
268
+ name: 'allow-stale',
269
+ description: 'Bypass workspace and module stale-version safeguards',
270
+ takesValue: false,
271
+ },
272
+ {
273
+ name: 'yes',
274
+ description: 'Auto-confirm every prompt (also -y). Does NOT bypass safety gates.',
275
+ takesValue: false,
276
+ },
277
+ {
278
+ name: 'alpha',
279
+ description: 'Publish X.Y.Z-alpha.N to npm @alpha dist-tag (consumer-first iteration mode)',
280
+ takesValue: false,
281
+ },
282
+ {
283
+ name: 'track-alpha',
284
+ description:
285
+ 'Force-pin just-published alphas into the bun global install (requires --alpha)',
286
+ takesValue: false,
287
+ },
288
+ {
289
+ name: 'alpha-modules',
290
+ description:
291
+ 'Also publish module +N revisions in alpha mode (requires --alpha; default skips Phase 4)',
292
+ takesValue: false,
293
+ },
294
+ {
295
+ name: 'promote',
296
+ description:
297
+ 'Graduate a named alpha to its base X.Y.Z (e.g. --promote @celilo/e2e@0.7.14-alpha.3)',
298
+ takesValue: true,
299
+ },
300
+ {
301
+ name: 'skip-changesets',
302
+ description:
303
+ 'Skip the pending-changesets prompt; publish current package.json versions as-is',
304
+ takesValue: false,
305
+ },
306
+ ],
307
+ },
252
308
  {
253
309
  name: 'module',
254
310
  description: 'Manage modules (import, generate, configure)',
@@ -286,6 +342,12 @@ export const COMMANDS: CommandDef[] = [
286
342
  description: 'Skip package signature verification',
287
343
  takesValue: false,
288
344
  },
345
+ {
346
+ name: 'accept-aspects',
347
+ description:
348
+ 'Auto-approve any base-module aspect declared by the imported module (non-interactive contexts; CI; e2e)',
349
+ takesValue: false,
350
+ },
289
351
  ],
290
352
  },
291
353
  { name: 'list', description: 'List all installed modules' },
@@ -512,6 +574,31 @@ export const COMMANDS: CommandDef[] = [
512
574
  },
513
575
  ],
514
576
  },
577
+ {
578
+ name: 'backup',
579
+ description: "Create a backup of a module's data (invokes its on_backup hook)",
580
+ args: [
581
+ {
582
+ name: 'module-id',
583
+ description: 'Module ID (optional, backs up all if omitted)',
584
+ completion: 'module_ids',
585
+ },
586
+ ],
587
+ flags: [
588
+ { name: 'force', description: 'Ignore schedule, back up now', takesValue: false },
589
+ {
590
+ name: 'now',
591
+ description: 'Ignore schedule, back up now (alias for --force)',
592
+ takesValue: false,
593
+ },
594
+ { name: 'storage', description: 'Storage destination ID', takesValue: true },
595
+ {
596
+ name: 'no-interactive',
597
+ description: 'Non-interactive mode (for cron)',
598
+ takesValue: false,
599
+ },
600
+ ],
601
+ },
515
602
  ],
516
603
  },
517
604
  {
@@ -637,6 +724,12 @@ export const COMMANDS: CommandDef[] = [
637
724
  description: 'Earmark machine for a specific module',
638
725
  takesValue: true,
639
726
  },
727
+ {
728
+ name: 'local',
729
+ description:
730
+ 'This box itself — deploy over Ansible local connection (no SSH/key). Implied by ip 127.0.0.1.',
731
+ takesValue: false,
732
+ },
640
733
  ],
641
734
  },
642
735
  {
@@ -696,6 +789,25 @@ export const COMMANDS: CommandDef[] = [
696
789
  },
697
790
  ],
698
791
  },
792
+ {
793
+ name: 'apply-config',
794
+ description:
795
+ 'Headless write of <key=value> overrides to systemConfig (for hooks / automation; no prompts, no guidance)',
796
+ args: [
797
+ {
798
+ name: 'overrides',
799
+ description: 'Config overrides as <key=value> positionals',
800
+ variadic: true,
801
+ },
802
+ ],
803
+ flags: [
804
+ {
805
+ name: 'from-stdin',
806
+ description: 'Read a JSON map of overrides from stdin instead of positional args',
807
+ takesValue: false,
808
+ },
809
+ ],
810
+ },
699
811
  {
700
812
  name: 'config',
701
813
  description: 'Get or set system configuration',
@@ -882,6 +994,75 @@ export const COMMANDS: CommandDef[] = [
882
994
  },
883
995
  ],
884
996
  },
997
+ {
998
+ name: 'subscribers',
999
+ description: 'Manage build-bus subscribers (cross-machine publish-event delivery)',
1000
+ subcommands: [
1001
+ {
1002
+ name: 'list',
1003
+ description: 'Show registered build-bus subscribers',
1004
+ },
1005
+ {
1006
+ name: 'add',
1007
+ description: 'Add a build-bus subscriber',
1008
+ args: [{ name: 'url', description: 'Subscriber webhook URL (https://...)' }],
1009
+ flags: [
1010
+ { name: 'secret', description: 'Per-subscriber HMAC secret', takesValue: true },
1011
+ { name: 'name', description: 'Human-readable label', takesValue: true },
1012
+ {
1013
+ name: 'registry',
1014
+ description:
1015
+ 'Filter: deliver only events from this registry (e.g. npm, celilo-registry)',
1016
+ takesValue: true,
1017
+ },
1018
+ {
1019
+ name: 'tag',
1020
+ description: 'Filter: deliver only events with this dist-tag (e.g. latest, alpha)',
1021
+ takesValue: true,
1022
+ },
1023
+ {
1024
+ name: 'package-pattern',
1025
+ description:
1026
+ 'Filter: deliver only events whose package name matches this glob (e.g. @celilo/*)',
1027
+ takesValue: true,
1028
+ },
1029
+ ],
1030
+ },
1031
+ {
1032
+ name: 'remove',
1033
+ description: 'Remove a subscriber by URL',
1034
+ args: [{ name: 'url', description: 'Subscriber webhook URL to remove' }],
1035
+ },
1036
+ {
1037
+ name: 'test',
1038
+ description: 'Fire a synthetic event at a subscriber and report the delivery outcome',
1039
+ args: [{ name: 'url', description: 'Subscriber webhook URL to test' }],
1040
+ },
1041
+ {
1042
+ name: 'serve',
1043
+ description:
1044
+ 'Run the build-bus HTTP receiver daemon (verifies signed webhooks, emits to local bus, dispatches module hooks)',
1045
+ flags: [
1046
+ { name: 'port', description: 'TCP port to listen on (default 8123)', takesValue: true },
1047
+ {
1048
+ name: 'secret',
1049
+ description: 'Shared HMAC secret incoming webhooks must sign with',
1050
+ takesValue: true,
1051
+ },
1052
+ {
1053
+ name: 'no-dispatch',
1054
+ description: 'Skip the in-process hook dispatcher (receiver-only mode)',
1055
+ takesValue: false,
1056
+ },
1057
+ ],
1058
+ },
1059
+ {
1060
+ name: 'status',
1061
+ description:
1062
+ 'Per-subscriber delivery history (success rate, last delivery, recent failures)',
1063
+ },
1064
+ ],
1065
+ },
885
1066
  {
886
1067
  name: 'storage',
887
1068
  description: 'Manage backup storage destinations',
@@ -1031,6 +1212,24 @@ export const COMMANDS: CommandDef[] = [
1031
1212
  },
1032
1213
  ],
1033
1214
  },
1215
+ {
1216
+ name: 'restore',
1217
+ description: 'Restore a celilo-mgmt backup from a local artifact file (fresh-bootstrap path)',
1218
+ args: [
1219
+ {
1220
+ name: 'artifact-path',
1221
+ description: 'Path to the .backup file (also accepted via --from)',
1222
+ },
1223
+ ],
1224
+ flags: [
1225
+ { name: 'from', description: 'Path to the .backup file', takesValue: true },
1226
+ {
1227
+ name: 'force',
1228
+ description: 'Override non-destructive pre-flight (clobbers existing state)',
1229
+ takesValue: false,
1230
+ },
1231
+ ],
1232
+ },
1034
1233
  {
1035
1234
  name: 'completion',
1036
1235
  description: 'Generate shell completion scripts',
@@ -99,7 +99,7 @@ export async function handleBackupList(
99
99
  if (backupList.length === 0) {
100
100
  console.log('No backups found.\n');
101
101
  console.log('Create a backup:');
102
- console.log(' celilo backup create');
102
+ console.log(' celilo module backup <module-id>');
103
103
  return { success: true, message: 'No backups found' };
104
104
  }
105
105
 
@@ -26,7 +26,14 @@ import {
26
26
  recoverFromCrash,
27
27
  runDispatcher,
28
28
  } from '@celilo/event-bus';
29
+ import { eq } from 'drizzle-orm';
29
30
  import { getEventBusPath, shortenPath } from '../../config/paths';
31
+ import { getDb } from '../../db/client';
32
+ import { modules } from '../../db/schema';
33
+ import { createConsoleLogger } from '../../hooks/logger';
34
+ import { runNamedHook } from '../../hooks/run-named-hook';
35
+ import type { HookName } from '../../hooks/types';
36
+ import type { ModuleManifest } from '../../manifest/schema';
30
37
  import { installDaemon, readInstalledUnit, uninstallDaemon } from '../../services/events-daemon';
31
38
  import { getArg, hasFlag } from '../parser';
32
39
  import type { CommandResult } from '../types';
@@ -54,6 +61,95 @@ export async function handleEventsStatus(): Promise<CommandResult> {
54
61
  }
55
62
  }
56
63
 
64
+ /** camelCase → snake_case for every key (payloads are camelCase; hook inputs snake_case). */
65
+ function snakeCaseKeys(obj: Record<string, unknown>): Record<string, unknown> {
66
+ const out: Record<string, unknown> = {};
67
+ for (const [k, v] of Object.entries(obj)) {
68
+ out[k.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`)] = v;
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * `celilo events run-hook <module> <sub-name> [<event_id>]` — the generic
75
+ * runner a `hook:` subscription resolves to (v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md).
76
+ *
77
+ * The dispatcher spawns this as a fault-isolated subprocess. It re-reads the
78
+ * module's manifest to find the named subscription's `hook` + `hook_inputs`,
79
+ * assembles the hook inputs as `{ ...hook_inputs, ...snakeCase(eventPayload) }`,
80
+ * and runs the hook through the normal backend executor (config + decrypted
81
+ * secrets + the module's own capabilities injected). It is dumb pass-through —
82
+ * it knows nothing about DNS or any specific event type.
83
+ */
84
+ export async function handleEventsRunHook(args: string[]): Promise<CommandResult> {
85
+ const moduleId = args[0];
86
+ const subName = args[1];
87
+ // Event id: an explicit 3rd arg wins, else the dispatcher's $EVENT_ID.
88
+ const idStr = args[2] ?? process.env.EVENT_ID;
89
+ if (!moduleId || !subName) {
90
+ return {
91
+ success: false,
92
+ error: 'usage: celilo events run-hook <module> <sub-name> [<event_id>]',
93
+ };
94
+ }
95
+ const eventId = Number(idStr);
96
+ if (!idStr || !Number.isInteger(eventId)) {
97
+ return {
98
+ success: false,
99
+ error: 'events run-hook requires an event id (3rd arg or $EVENT_ID)',
100
+ };
101
+ }
102
+
103
+ const db = getDb();
104
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
105
+ if (!module) return { success: false, error: `Module not found: ${moduleId}` };
106
+
107
+ const manifest = module.manifestData as ModuleManifest;
108
+ const sub = (manifest.subscriptions ?? []).find((s) => s.name === subName);
109
+ if (!sub) {
110
+ return { success: false, error: `Module '${moduleId}' has no subscription named '${subName}'` };
111
+ }
112
+ if (!sub.hook) {
113
+ return {
114
+ success: false,
115
+ error: `Subscription '${subName}' on '${moduleId}' is not a hook subscription`,
116
+ };
117
+ }
118
+
119
+ const bus = openCliBus();
120
+ let payload: Record<string, unknown> = {};
121
+ let eventType: string;
122
+ try {
123
+ const event = bus.getEvent(eventId);
124
+ if (!event) return { success: false, error: `Event ${eventId} not found on the bus` };
125
+ eventType = event.type;
126
+ if (event.payload && typeof event.payload === 'object') {
127
+ payload = event.payload as Record<string, unknown>;
128
+ }
129
+ } finally {
130
+ bus.close();
131
+ }
132
+
133
+ const inputs: Record<string, unknown> = {
134
+ ...(sub.hook_inputs ?? {}),
135
+ ...snakeCaseKeys(payload),
136
+ };
137
+
138
+ const logger = createConsoleLogger(moduleId, sub.hook);
139
+ const result = await runNamedHook(moduleId, sub.hook as HookName, db, logger, { inputs });
140
+
141
+ if (result.notDefined) {
142
+ return { success: false, error: `Module '${moduleId}' declares no '${sub.hook}' hook to run` };
143
+ }
144
+ if (!result.success) {
145
+ return { success: false, error: result.error ?? `hook '${sub.hook}' failed` };
146
+ }
147
+ return {
148
+ success: true,
149
+ message: `Ran ${moduleId}.${sub.hook} for event ${eventId} (${eventType})`,
150
+ };
151
+ }
152
+
57
153
  export async function handleEventsTail(
58
154
  _args: string[],
59
155
  flags: Record<string, string | boolean>,