@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
package/src/db/schema.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import { sql } from 'drizzle-orm';
2
- import { integer, sqliteTable, text, unique, uniqueIndex } from 'drizzle-orm/sqlite-core';
2
+ import {
3
+ integer,
4
+ primaryKey,
5
+ sqliteTable,
6
+ text,
7
+ unique,
8
+ uniqueIndex,
9
+ } from 'drizzle-orm/sqlite-core';
3
10
 
4
11
  /**
5
12
  * Module lifecycle states
@@ -33,16 +40,25 @@ export const modules = sqliteTable('modules', {
33
40
  });
34
41
 
35
42
  /**
36
- * Module configuration - user-provided key-value pairs
37
- * Stores configuration like container_ip, zone, etc.
43
+ * Module configuration - user-provided key-value pairs.
38
44
  *
39
- * For primitive types (string, number, boolean):
40
- * - value: contains the value (as string)
41
- * - valueJson: NULL
45
+ * Two columns, with strictly different roles:
46
+ * - `valueJson`: JSON-encoded canonical value, ALWAYS populated by
47
+ * the write path. This is the source of truth — readers MUST
48
+ * consume `valueJson` and `JSON.parse` it. Type fidelity is
49
+ * preserved here: `number` round-trips as `number`, `boolean` as
50
+ * `boolean`, arrays/objects as their JSON shape.
51
+ * - `value`: human-readable string form, used only by the CLI for
52
+ * display (`module config get`). NEVER used as a source of typed
53
+ * data — that path leaked stringly-typed numbers through to
54
+ * capability calls (see commit history for the forgejo SSH:2222
55
+ * case, where ssh_external_port was read back as "2222" the
56
+ * string and silently broke firewall.exposeService).
42
57
  *
43
- * For complex types (arrays, objects):
44
- * - value: empty string
45
- * - valueJson: JSON-stringified complex value
58
+ * Historically `value` was the canonical storage for primitives and
59
+ * `valueJson` only for arrays/objects. That was Defect 1 — TS types
60
+ * generated from the manifest claimed `number` while the runtime
61
+ * value was string. Now closed.
46
62
  */
47
63
  export const moduleConfigs = sqliteTable(
48
64
  'module_configs',
@@ -298,6 +314,13 @@ export const machines = sqliteTable('machines', {
298
314
  .default(sql`'[]'`),
299
315
  /** Module ID this machine is earmarked for. If set, only this module can use this machine. */
300
316
  earmarkedModule: text('earmarked_module'),
317
+ /**
318
+ * Appliance machines (e.g., greenwave = ISP modem) where celilo
319
+ * has no shell-level access — only API calls. Base-module aspects
320
+ * cannot Ansible to these systems, so the aspect runner skips
321
+ * them. See v2/CELILO_BASE.md D8.
322
+ */
323
+ apiOnly: integer('api_only', { mode: 'boolean' }).notNull().default(false),
301
324
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
302
325
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
303
326
  });
@@ -317,9 +340,66 @@ export const moduleInfrastructure = sqliteTable('module_infrastructure', {
317
340
  machineId: text('machine_id').references(() => machines.id),
318
341
  serviceId: text('service_id').references(() => containerServices.id),
319
342
  containerMetadata: text('container_metadata', { mode: 'json' }).$type<Record<string, unknown>>(), // VMID, droplet ID, etc.
343
+ /**
344
+ * Mirrors machines.api_only — set when the container_service
345
+ * driver registers an entry that has no SSH surface (rare today,
346
+ * but exists for future API-only providers). Aspect runner reads
347
+ * this to decide whether the system is reachable via Ansible.
348
+ * See v2/CELILO_BASE.md D8.
349
+ */
350
+ apiOnly: integer('api_only', { mode: 'boolean' }).notNull().default(false),
320
351
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
321
352
  });
322
353
 
354
+ /**
355
+ * Module systems table
356
+ *
357
+ * A module deploys onto 0..N *systems*, each an addressed host. This is the
358
+ * deployment-STATE counterpart to module_configs (declared inputs): it records,
359
+ * per deployed system, the hostname + IPv4 + zone + where it lives. Replaces the
360
+ * old scalar `target_ip`/`vmid` rows in module_configs, which baked in the
361
+ * one-module-one-host assumption. See v2/MODULE_SYSTEMS_ADDRESSING.md.
362
+ *
363
+ * - 0 rows: API-only modules (e.g. namecheap — no host).
364
+ * - 1 row: the common case (technitium, homebridge, …).
365
+ * - N rows: multi-system modules (e.g. app + db).
366
+ *
367
+ * Keyed by (module_id, name): `name` is the stable, authoring-time handle from
368
+ * the manifest's `requires.systems[].name` — what templates reference via
369
+ * `$infra:<name>.…`. `hostname` is the runtime DNS hostname (often == name, but
370
+ * user/well-known-assignable), used by DNS and events. See
371
+ * v2/MODULE_SYSTEMS_ADDRESSING.md.
372
+ */
373
+ export const moduleSystems = sqliteTable(
374
+ 'module_systems',
375
+ {
376
+ moduleId: text('module_id')
377
+ .notNull()
378
+ .references(() => modules.id, { onDelete: 'cascade' }),
379
+ /** Stable authoring-time handle (requires.systems[].name) — the per-system key. */
380
+ name: text('name').notNull(),
381
+ /** Runtime DNS hostname (often == name). */
382
+ hostname: text('hostname').notNull(),
383
+ /** CIDR-stripped IPv4 — the canonical address. */
384
+ ipv4Address: text('ipv4_address').notNull(),
385
+ /** Network zone the host sits in; CIDR is derived from this + ipv4Address. */
386
+ zone: text('zone').$type<NetworkZone>().notNull(),
387
+ /** Where the system lives: a pool machine or a celilo-provisioned container. */
388
+ infraType: text('infra_type').$type<'machine' | 'container_service'>().notNull(),
389
+ /** FK machines.id — set for machine-pool deploys (null otherwise). */
390
+ machineId: text('machine_id').references(() => machines.id),
391
+ /** FK container_services.id — set for container_service deploys (null otherwise). */
392
+ serviceId: text('service_id').references(() => containerServices.id),
393
+ /** Proxmox VMID — set only for proxmox containers. */
394
+ vmid: integer('vmid'),
395
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
396
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
397
+ },
398
+ (table) => ({
399
+ pk: primaryKey({ columns: [table.moduleId, table.name] }),
400
+ }),
401
+ );
402
+
323
403
  /**
324
404
  * Web routes table
325
405
  * Tracks routes registered by modules via public_web capability functions.
@@ -404,6 +484,74 @@ export const backups = sqliteTable('backups', {
404
484
  completedAt: integer('completed_at', { mode: 'timestamp' }),
405
485
  });
406
486
 
487
+ /**
488
+ * Module operations - in-flight tracking for deploy/uninstall/backup/restore.
489
+ *
490
+ * Used by `checkInFlight()` to refuse a backup or restore while another
491
+ * operation is active on any module. A row is created with status='in_progress'
492
+ * when an operation starts and updated to 'completed' or 'failed' when it ends.
493
+ *
494
+ * The `pid` column carries the process that started the operation; a row whose
495
+ * pid is no longer alive is treated as abandoned (the process crashed before
496
+ * the completion update landed) and ignored by in-flight checks.
497
+ */
498
+ export type ModuleOperationKind = 'deploy' | 'uninstall' | 'backup' | 'restore';
499
+ export type ModuleOperationStatus = 'in_progress' | 'completed' | 'failed';
500
+
501
+ export const moduleOperations = sqliteTable('module_operations', {
502
+ id: text('id').primaryKey(), // UUID
503
+ moduleId: text('module_id').notNull(), // not a FK — survives module deletion (uninstall flow)
504
+ operation: text('operation').$type<ModuleOperationKind>().notNull(),
505
+ status: text('status').$type<ModuleOperationStatus>().notNull().default('in_progress'),
506
+ pid: integer('pid').notNull(),
507
+ startedAt: integer('started_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
508
+ completedAt: integer('completed_at', { mode: 'timestamp' }),
509
+ errorMessage: text('error_message'),
510
+ });
511
+
512
+ /**
513
+ * Aspect approvals table
514
+ *
515
+ * Records operator consent for a module's base-module aspect. The
516
+ * operator approves the aspect's scope (applicable_zones +
517
+ * triggers) at `celilo module import` time; that consent is
518
+ * captured here and consulted before any aspect fan-out.
519
+ *
520
+ * `scopeHash` is a stable digest of `applicable_zones` + `triggers`
521
+ * — it lets the framework detect scope-changing upgrades (D7).
522
+ * If a new module version has the same hash, the prior approval
523
+ * still covers it; if the hash differs, the upgrade is blocked
524
+ * until the operator re-approves.
525
+ *
526
+ * Unique on (moduleId, version) — there's at most one approval
527
+ * per module version. Cascade delete on module removal so stale
528
+ * approvals don't linger.
529
+ *
530
+ * See v2/CELILO_BASE.md D2 + D7.
531
+ */
532
+ export const aspectApprovals = sqliteTable(
533
+ 'aspect_approvals',
534
+ {
535
+ id: text('id').primaryKey(), // UUID
536
+ moduleId: text('module_id')
537
+ .notNull()
538
+ .references(() => modules.id, { onDelete: 'cascade' }),
539
+ /** Module version this approval covers (matches modules.version). */
540
+ version: text('version').notNull(),
541
+ /** Hash of `applicable_zones` + `triggers` — see table comment. */
542
+ scopeHash: text('scope_hash').notNull(),
543
+ approvedAt: integer('approved_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
544
+ /** Operator identifier (e.g., $USER at approval time). Null when --accept-aspects was used in a context with no USER. */
545
+ approver: text('approver'),
546
+ },
547
+ (table) => ({
548
+ uniqueModuleVersion: unique('aspect_approvals_module_version').on(
549
+ table.moduleId,
550
+ table.version,
551
+ ),
552
+ }),
553
+ );
554
+
407
555
  /**
408
556
  * Type exports for use in application code
409
557
  */
@@ -441,3 +589,7 @@ export type BackupStorage = typeof backupStorages.$inferSelect;
441
589
  export type NewBackupStorage = typeof backupStorages.$inferInsert;
442
590
  export type Backup = typeof backups.$inferSelect;
443
591
  export type NewBackup = typeof backups.$inferInsert;
592
+ export type ModuleOperation = typeof moduleOperations.$inferSelect;
593
+ export type NewModuleOperation = typeof moduleOperations.$inferInsert;
594
+ export type AspectApproval = typeof aspectApprovals.$inferSelect;
595
+ export type NewAspectApproval = typeof aspectApprovals.$inferInsert;
@@ -10,6 +10,7 @@ import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import type { HookLogger } from '@celilo/capabilities';
12
12
  import type { DbClient } from '../db/client';
13
+ import { upsertModuleConfig } from '../services/module-config';
13
14
  import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
14
15
  import { loadCapabilityFunctions } from './capability-loader';
15
16
 
@@ -102,9 +103,7 @@ describe('Firewall Chain Building', () => {
102
103
  db.$client.run(
103
104
  `INSERT INTO capabilities (module_id, capability_name, version, data, zones) VALUES ('greenwave', 'firewall', '1.0.0', '{"has_external":true}', '["internal"]')`,
104
105
  );
105
- db.$client.run(
106
- `INSERT INTO module_configs (module_id, key, value) VALUES ('greenwave', 'router_ip', '192.168.0.1')`,
107
- );
106
+ upsertModuleConfig(db, 'greenwave', 'router_ip', '192.168.0.1');
108
107
 
109
108
  // Set up encrypted secrets
110
109
  const { encryptSecret } = await import('../secrets/encryption');
@@ -152,9 +151,7 @@ describe('Firewall Chain Building', () => {
152
151
  db.$client.run(
153
152
  `INSERT INTO capabilities (module_id, capability_name, version, data, zones) VALUES ('greenwave', 'firewall', '1.0.0', '{"has_external":true}', '["internal"]')`,
154
153
  );
155
- db.$client.run(
156
- `INSERT INTO module_configs (module_id, key, value) VALUES ('greenwave', 'router_ip', '192.168.0.1')`,
157
- );
154
+ upsertModuleConfig(db, 'greenwave', 'router_ip', '192.168.0.1');
158
155
 
159
156
  const { encryptSecret } = await import('../secrets/encryption');
160
157
  const { getOrCreateMasterKey } = await import('../secrets/master-key');
@@ -181,15 +178,9 @@ describe('Firewall Chain Building', () => {
181
178
  db.$client.run(
182
179
  `INSERT INTO capabilities (module_id, capability_name, version, data, zones) VALUES ('iptables', 'firewall', '1.0.0', '{}', '["dmz","app","secure"]')`,
183
180
  );
184
- db.$client.run(
185
- `INSERT INTO module_configs (module_id, key, value) VALUES ('iptables', 'firewall_ip', '192.168.0.254')`,
186
- );
187
- db.$client.run(
188
- `INSERT INTO module_configs (module_id, key, value) VALUES ('iptables', 'nat_ip', '192.168.0.253')`,
189
- );
190
- db.$client.run(
191
- `INSERT INTO module_configs (module_id, key, value) VALUES ('iptables', 'upstream_zone', 'internal')`,
192
- );
181
+ upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254');
182
+ upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
183
+ upsertModuleConfig(db, 'iptables', 'upstream_zone', 'internal');
193
184
 
194
185
  const result = await loadCapabilityFunctions('test-consumer', db, noopLogger);
195
186
  expect(result.firewall).toBeTruthy();
@@ -8,6 +8,7 @@ import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import type { HookLogger } from '@celilo/capabilities';
10
10
  import type { DbClient } from '../db/client';
11
+ import { upsertModuleConfig } from '../services/module-config';
11
12
  import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
12
13
  import { loadCapabilityFunctions } from './capability-loader';
13
14
 
@@ -78,9 +79,7 @@ describe('Capability Loader', () => {
78
79
  db.$client.run(
79
80
  `INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('namecheap', 'dns_registrar', '2.0.0', '{}', unixepoch())`,
80
81
  );
81
- db.$client.run(
82
- `INSERT INTO module_configs (module_id, key, value) VALUES ('namecheap', 'primary_domain', 'example.com')`,
83
- );
82
+ upsertModuleConfig(db, 'namecheap', 'primary_domain', 'example.com');
84
83
 
85
84
  // Set encrypted secrets
86
85
  const { encryptSecret } = await import('../secrets/encryption');
@@ -23,6 +23,7 @@ import type { DbClient } from '../db/client';
23
23
  import { capabilities, modules, secrets, webRoutes } from '../db/schema';
24
24
  import { decryptSecret } from '../secrets/encryption';
25
25
  import { getOrCreateMasterKey } from '../secrets/master-key';
26
+ import { getModuleSystems } from '../services/deployed-systems';
26
27
  import { loadHookConfigMap } from './load-hook-config';
27
28
 
28
29
  /**
@@ -184,6 +185,7 @@ export async function loadCapabilityFunctions(
184
185
  const capabilityInterface = exported({
185
186
  config: providerConfig,
186
187
  secrets: providerSecrets,
188
+ systems: getModuleSystems(capability.moduleId, db),
187
189
  logger,
188
190
  });
189
191
  result[capName] = capabilityInterface;
@@ -281,17 +283,48 @@ export async function loadCapabilityFunctions(
281
283
  // apps/celilo/designs/DNS_REGISTRAR_MULTI_DOMAIN.md). Older
282
284
  // registrars that haven't been migrated still use the split shape;
283
285
  // we keep that fallback so a stale config can still be read.
286
+ let foundAny = false;
284
287
  if (Array.isArray(drConfig.domains)) {
285
288
  for (const d of drConfig.domains) {
286
- if (typeof d === 'string' && d) dnsManagedDomains.push(d);
289
+ if (typeof d === 'string' && d) {
290
+ dnsManagedDomains.push(d);
291
+ foundAny = true;
292
+ }
287
293
  }
288
294
  } else {
289
295
  if (typeof drConfig.primary_domain === 'string' && drConfig.primary_domain) {
290
296
  dnsManagedDomains.push(drConfig.primary_domain);
297
+ foundAny = true;
291
298
  }
292
299
  if (Array.isArray(drConfig.additional_domains)) {
293
300
  for (const d of drConfig.additional_domains) {
294
- if (typeof d === 'string' && d) dnsManagedDomains.push(d);
301
+ if (typeof d === 'string' && d) {
302
+ dnsManagedDomains.push(d);
303
+ foundAny = true;
304
+ }
305
+ }
306
+ }
307
+ }
308
+ // namecheap 3.1.1+ derives its managed-domain list from
309
+ // `Object.keys(secret.ddns_passwords)` (a JSON-encoded
310
+ // domain→password map) and no longer exposes a `domains` config.
311
+ // Mirror that derivation here so the cross-module flow knows
312
+ // which zones the registrar covers. See
313
+ // apps/celilo/designs/STRING_LIST_AND_DERIVED_KEYS.md.
314
+ if (!foundAny) {
315
+ const drSecrets = await loadModuleSecrets(drp.moduleId, masterKey, db);
316
+ const raw = drSecrets.ddns_passwords;
317
+ if (typeof raw === 'string' && raw) {
318
+ try {
319
+ const parsed = JSON.parse(raw);
320
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
321
+ for (const k of Object.keys(parsed)) {
322
+ if (k) dnsManagedDomains.push(k);
323
+ }
324
+ }
325
+ } catch {
326
+ // Bad JSON → empty list; consumer will throw
327
+ // MissingProviderInputError naming this registrar.
295
328
  }
296
329
  }
297
330
  }
@@ -462,6 +495,7 @@ async function buildFirewallChain(
462
495
  leafFirewall = leafExported({
463
496
  config: leafConfig,
464
497
  secrets: leafSecrets,
498
+ systems: getModuleSystems(hasExternal.moduleId, db),
465
499
  logger,
466
500
  });
467
501
  debugLog(
@@ -44,6 +44,7 @@ function makeContext(overrides: Partial<HookContext> = {}): HookContext {
44
44
  return {
45
45
  config: {},
46
46
  secrets: {},
47
+ systems: [],
47
48
  logger: makeLogger(),
48
49
  debug: false,
49
50
  screenshotDir: '',
@@ -329,6 +330,7 @@ describe('defineCapabilityFunction', () => {
329
330
  const methods = factory({
330
331
  config: { target_ip: '10.0.0.5' },
331
332
  secrets: { token: 'abc' },
333
+ systems: [],
332
334
  logger: makeLogger(),
333
335
  });
334
336
 
@@ -354,6 +356,7 @@ describe('defineCapabilityFunction', () => {
354
356
  const methods = factory({
355
357
  config: {},
356
358
  secrets: {},
359
+ systems: [],
357
360
  logger: makeLogger(),
358
361
  });
359
362
 
@@ -381,6 +384,7 @@ describe('defineCapabilityFunction', () => {
381
384
  const methods = factory({
382
385
  config: {},
383
386
  secrets: {},
387
+ systems: [],
384
388
  logger: makeLogger(),
385
389
  });
386
390
 
@@ -112,6 +112,7 @@ describe('Hook Executor', () => {
112
112
  const result = await executeHookScript(scriptPath, {
113
113
  config: { username: 'testuser' },
114
114
  secrets: { password: 'secret' },
115
+ systems: [],
115
116
  logger,
116
117
  debug: false,
117
118
  screenshotDir: '/tmp',
@@ -131,6 +132,7 @@ describe('Hook Executor', () => {
131
132
  executeHookScript('/nonexistent/hook.ts', {
132
133
  config: {},
133
134
  secrets: {},
135
+ systems: [],
134
136
  logger,
135
137
  debug: false,
136
138
  screenshotDir: '/tmp',
@@ -147,6 +149,7 @@ describe('Hook Executor', () => {
147
149
  executeHookScript(scriptPath, {
148
150
  config: {},
149
151
  secrets: {},
152
+ systems: [],
150
153
  logger,
151
154
  debug: false,
152
155
  screenshotDir: '/tmp',
@@ -167,6 +170,7 @@ describe('Hook Executor', () => {
167
170
  executeHookScript(scriptPath, {
168
171
  config: {},
169
172
  secrets: {},
173
+ systems: [],
170
174
  logger,
171
175
  debug: false,
172
176
  screenshotDir: '/tmp',
@@ -182,6 +186,7 @@ describe('Hook Executor', () => {
182
186
  const result = await executeHookScript(scriptPath, {
183
187
  config: {},
184
188
  secrets: {},
189
+ systems: [],
185
190
  logger,
186
191
  debug: false,
187
192
  screenshotDir: '/tmp',
@@ -199,6 +204,7 @@ describe('Hook Executor', () => {
199
204
  executeHookScript(scriptPath, {
200
205
  config: {},
201
206
  secrets: {},
207
+ systems: [],
202
208
  logger,
203
209
  debug: false,
204
210
  screenshotDir: '/tmp',
@@ -458,5 +464,17 @@ describe('Hook Executor', () => {
458
464
  expect(result).toContain("'idp'");
459
465
  expect(result).toContain('each missing capability');
460
466
  });
467
+
468
+ test('skips framework-granted privileges (not provider-loaded)', () => {
469
+ // cross_module_read is a privilege granted by the framework to the
470
+ // hooks that use it (backup/restore), not a provider-loaded
471
+ // capability. It must not block other hooks like on_install
472
+ // (regression: celilo-mgmt deploy, v2/NETWORK_CONFIG_TO_FIREWALL.md).
473
+ expect(checkRequiredCapabilities('on_install', ['cross_module_read'], {})).toBeNull();
474
+ // Real missing providers still flagged alongside a privilege.
475
+ const mixed = checkRequiredCapabilities('on_install', ['cross_module_read', 'idp'], {});
476
+ expect(mixed).toContain("'idp'");
477
+ expect(mixed).not.toContain('cross_module_read');
478
+ });
461
479
  });
462
480
  });
@@ -21,12 +21,17 @@
21
21
 
22
22
  import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
23
23
  import { join, resolve } from 'node:path';
24
- import { isCompiledHook, isMissingProviderInputError } from '@celilo/capabilities';
24
+ import {
25
+ type DeployedSystem,
26
+ isCompiledHook,
27
+ isMissingProviderInputError,
28
+ } from '@celilo/capabilities';
25
29
  import {
26
30
  type ContractHookSignature,
27
31
  resolveContract,
28
32
  supportedContractVersions,
29
33
  } from '../manifest/contracts';
34
+ import { isPrivilegedCapability } from '../manifest/validate';
30
35
  import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
31
36
 
32
37
  /** Default total timeout: 60 seconds */
@@ -256,6 +261,13 @@ export interface InvokeHookOptions {
256
261
  debug?: boolean;
257
262
  /** Pre-loaded capability function interfaces to inject into context */
258
263
  capabilities?: Record<string, unknown>;
264
+ /**
265
+ * The module's 0..N deployed systems, injected as `ctx.systems`
266
+ * (v2/MODULE_SYSTEMS_ADDRESSING.md). Callers load these from the DB via
267
+ * `getModuleSystems` — the executor stays decoupled from the database, the
268
+ * same way `capabilities` is loaded by the caller. Defaults to `[]`.
269
+ */
270
+ systems?: DeployedSystem[];
259
271
  /**
260
272
  * Names of capabilities the hook's module declares in
261
273
  * `requires.capabilities`. The executor checks each one is present in
@@ -280,7 +292,13 @@ export function checkRequiredCapabilities(
280
292
  requiredCapabilities: string[],
281
293
  loadedCapabilities: Record<string, unknown>,
282
294
  ): string | null {
283
- const missing = requiredCapabilities.filter((name) => !(name in loadedCapabilities));
295
+ // Framework-granted privileges (e.g. cross_module_read) aren't loaded as
296
+ // provider capability-functions — they're granted by the framework to the
297
+ // specific hooks that use them (backup/restore), via a separate mechanism.
298
+ // Don't treat them as missing providers for every hook (e.g. on_install).
299
+ const missing = requiredCapabilities.filter(
300
+ (name) => !isPrivilegedCapability(name) && !(name in loadedCapabilities),
301
+ );
284
302
  if (missing.length === 0) return null;
285
303
 
286
304
  const lines = [
@@ -404,6 +422,7 @@ export async function invokeHook(
404
422
  ...inputs,
405
423
  config,
406
424
  secrets,
425
+ systems: options.systems ?? [],
407
426
  logger,
408
427
  debug,
409
428
  screenshotDir,
@@ -18,7 +18,8 @@
18
18
 
19
19
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
20
20
  import type { DbClient } from '../db/client';
21
- import { machines, moduleConfigs, moduleInfrastructure, modules } from '../db/schema';
21
+ import { machines, moduleInfrastructure, modules } from '../db/schema';
22
+ import { upsertModuleConfig } from '../services/module-config';
22
23
  import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
23
24
  import { loadHookConfigMap } from './load-hook-config';
24
25
 
@@ -48,39 +49,42 @@ describe('loadHookConfigMap', () => {
48
49
  });
49
50
 
50
51
  test('returns scalar string config values from module_configs', async () => {
51
- db.insert(moduleConfigs)
52
- .values([
53
- { moduleId: 'mod', key: 'hostname', value: 'caddy', valueJson: null },
54
- { moduleId: 'mod', key: 'acme_email', value: 'admin@example.com', valueJson: null },
55
- ])
56
- .run();
52
+ upsertModuleConfig(db, 'mod', 'hostname', 'caddy');
53
+ upsertModuleConfig(db, 'mod', 'acme_email', 'admin@example.com');
57
54
 
58
55
  const result = await loadHookConfigMap('mod', db);
59
56
  expect(result).toEqual({ hostname: 'caddy', acme_email: 'admin@example.com' });
60
57
  });
61
58
 
62
59
  test('parses valueJson for complex types (arrays, objects)', async () => {
63
- db.insert(moduleConfigs)
64
- .values([
65
- {
66
- moduleId: 'mod',
67
- key: 'hostnames',
68
- value: '',
69
- valueJson: '["www.example.com","example.com"]',
70
- },
71
- { moduleId: 'mod', key: 'plain', value: 'string-val', valueJson: null },
72
- ])
73
- .run();
60
+ upsertModuleConfig(db, 'mod', 'hostnames', ['www.example.com', 'example.com']);
61
+ upsertModuleConfig(db, 'mod', 'plain', 'string-val');
74
62
 
75
63
  const result = await loadHookConfigMap('mod', db);
76
64
  expect(result.hostnames).toEqual(['www.example.com', 'example.com']);
77
65
  expect(result.plain).toBe('string-val');
78
66
  });
79
67
 
68
+ test('preserves primitive types (numbers, booleans) — Defect 1 round-trip', async () => {
69
+ // The headline defect that motivated Commit C: primitives used to
70
+ // stringify on write and come back as strings (e.g. ssh_external_port:
71
+ // 2222 → stored "2222" → read "2222" → silently broke
72
+ // firewall.exposeService that expected a number). With upsertModuleConfig
73
+ // routing through JSON, types round-trip cleanly.
74
+ upsertModuleConfig(db, 'mod', 'port', 2222);
75
+ upsertModuleConfig(db, 'mod', 'enabled', true);
76
+ upsertModuleConfig(db, 'mod', 'disabled', false);
77
+
78
+ const result = await loadHookConfigMap('mod', db);
79
+ expect(result.port).toBe(2222);
80
+ expect(typeof result.port).toBe('number');
81
+ expect(result.enabled).toBe(true);
82
+ expect(typeof result.enabled).toBe('boolean');
83
+ expect(result.disabled).toBe(false);
84
+ });
85
+
80
86
  test('preserves an explicit target_ip in module_configs (skips machine fallback)', async () => {
81
- db.insert(moduleConfigs)
82
- .values({ moduleId: 'mod', key: 'target_ip', value: '10.0.10.10', valueJson: null })
83
- .run();
87
+ upsertModuleConfig(db, 'mod', 'target_ip', '10.0.10.10');
84
88
  // A machine record exists but should NOT override the explicit value.
85
89
  db.insert(machines)
86
90
  .values({
@@ -137,9 +141,7 @@ describe('loadHookConfigMap', () => {
137
141
  });
138
142
 
139
143
  test('leaves target_ip unset when there is no infrastructure record', async () => {
140
- db.insert(moduleConfigs)
141
- .values({ moduleId: 'mod', key: 'hostname', value: 'caddy', valueJson: null })
142
- .run();
144
+ upsertModuleConfig(db, 'mod', 'hostname', 'caddy');
143
145
  // no moduleInfrastructure row inserted
144
146
 
145
147
  const result = await loadHookConfigMap('mod', db);
@@ -12,7 +12,15 @@
12
12
  * code, easy to miss.
13
13
  *
14
14
  * The shape:
15
- * - Every row from `module_configs`, parsed from `valueJson` when set.
15
+ * - Every row from `module_configs`, parsed from `valueJson` via
16
+ * the shared `parseStoredConfigValue` helper. This preserves the
17
+ * types declared in each module's manifest: `number` reads as
18
+ * `number`, `boolean` as `boolean`, complex types as their
19
+ * parsed JSON shape. Pre-Defect-1, this path returned raw strings
20
+ * for primitives (because `valueJson` was NULL for them); that
21
+ * broke capability calls like `firewall.exposeService({ports:[...]})`
22
+ * that did a `typeof === 'number'` check downstream. Fixed in
23
+ * v2 by always populating valueJson on write.
16
24
  * - If `target_ip` isn't in the row set, look up the deployment
17
25
  * machine and inject both `target_ip` AND `ip.primary` from
18
26
  * `machines.ipAddress`. Two keys because consumers historically used
@@ -26,6 +34,7 @@
26
34
  import { eq } from 'drizzle-orm';
27
35
  import type { DbClient } from '../db/client';
28
36
  import { machines, moduleConfigs, moduleInfrastructure } from '../db/schema';
37
+ import { parseStoredConfigValue } from '../services/module-config';
29
38
 
30
39
  export async function loadHookConfigMap(
31
40
  moduleId: string,
@@ -39,7 +48,7 @@ export async function loadHookConfigMap(
39
48
 
40
49
  const configMap: Record<string, unknown> = {};
41
50
  for (const c of configRecords) {
42
- configMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
51
+ configMap[c.key] = parseStoredConfigValue(c);
43
52
  }
44
53
 
45
54
  if (configMap.target_ip) return configMap;
@@ -18,6 +18,7 @@ import { modules, secrets } from '../db/schema';
18
18
  import type { ModuleManifest } from '../manifest/schema';
19
19
  import { decryptSecret } from '../secrets/encryption';
20
20
  import { getOrCreateMasterKey } from '../secrets/master-key';
21
+ import { getModuleSystems } from '../services/deployed-systems';
21
22
  import { loadCapabilityFunctions } from './capability-loader';
22
23
  import { invokeHook } from './executor';
23
24
  import { loadHookConfigMap } from './load-hook-config';
@@ -81,6 +82,20 @@ export async function runNamedHook(
81
82
  notDefined: true,
82
83
  };
83
84
  }
85
+ // Defensive narrow: `on_upstream_publish` is also under
86
+ // `manifest.hooks` (build-bus, Phase 4) but is an ARRAY of
87
+ // match-rule hooks dispatched by the receiver daemon — NOT
88
+ // runnable via `celilo module run-hook`. The HookName type
89
+ // excludes it, but the indexed-access return type can't prove
90
+ // that, so narrow at the value level.
91
+ if (Array.isArray(hookDef)) {
92
+ return {
93
+ success: true,
94
+ outputs: {},
95
+ duration: Date.now() - startedAt,
96
+ notDefined: true,
97
+ };
98
+ }
84
99
 
85
100
  const configMap = await loadHookConfigMap(moduleId, db);
86
101
 
@@ -123,6 +138,7 @@ export async function runNamedHook(
123
138
  debug: options.debug ?? false,
124
139
  capabilities: capabilityFunctions,
125
140
  requiredCapabilities,
141
+ systems: getModuleSystems(moduleId, db),
126
142
  },
127
143
  );
128
144
  }