@celilo/cli 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +3 -2
  5. package/src/ansible/inventory.ts +5 -1
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +34 -1
  8. package/src/cli/cli.test.ts +2 -2
  9. package/src/cli/command-registry.ts +146 -3
  10. package/src/cli/command-tree-parser.test.ts +1 -1
  11. package/src/cli/command-tree-parser.ts +9 -8
  12. package/src/cli/commands/hook-run.ts +15 -66
  13. package/src/cli/commands/module-audit.ts +14 -44
  14. package/src/cli/commands/module-deploy.ts +4 -1
  15. package/src/cli/commands/module-import-registry.test.ts +115 -0
  16. package/src/cli/commands/module-import.ts +106 -22
  17. package/src/cli/commands/module-publish.test.ts +235 -0
  18. package/src/cli/commands/module-publish.ts +234 -0
  19. package/src/cli/commands/module-remove.ts +82 -2
  20. package/src/cli/commands/module-search.ts +57 -0
  21. package/src/cli/commands/module-secret-get.ts +59 -0
  22. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  23. package/src/cli/commands/module-verify.test.ts +59 -0
  24. package/src/cli/commands/module-verify.ts +53 -0
  25. package/src/cli/commands/status.ts +30 -20
  26. package/src/cli/commands/system-audit.test.ts +138 -0
  27. package/src/cli/commands/system-audit.ts +571 -0
  28. package/src/cli/commands/system-update.ts +391 -0
  29. package/src/cli/completion.ts +15 -1
  30. package/src/cli/fuel-gauge.ts +68 -3
  31. package/src/cli/generate-zsh-completion.ts +13 -3
  32. package/src/cli/index.ts +112 -5
  33. package/src/cli/parser.ts +11 -0
  34. package/src/cli/prompts.ts +36 -5
  35. package/src/cli/tui/audit-state.test.ts +246 -0
  36. package/src/cli/tui/audit-state.ts +525 -0
  37. package/src/cli/tui/audit-tui.test.tsx +135 -0
  38. package/src/cli/tui/audit-tui.tsx +624 -0
  39. package/src/cli/tui/celebration.tsx +29 -0
  40. package/src/cli/tui/clipboard.test.ts +94 -0
  41. package/src/cli/tui/clipboard.ts +101 -0
  42. package/src/cli/tui/icons.ts +22 -0
  43. package/src/cli/tui/keybar.tsx +65 -0
  44. package/src/cli/tui/keymap.test.ts +105 -0
  45. package/src/cli/tui/keymap.ts +70 -0
  46. package/src/cli/tui/modals/analyzing.tsx +75 -0
  47. package/src/cli/tui/modals/celebration.tsx +44 -0
  48. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  49. package/src/cli/tui/modals/remediate.tsx +44 -0
  50. package/src/cli/tui/modals.test.ts +137 -0
  51. package/src/cli/tui/mouse.test.ts +78 -0
  52. package/src/cli/tui/mouse.ts +114 -0
  53. package/src/cli/tui/panes/categories.tsx +62 -0
  54. package/src/cli/tui/panes/command-log.tsx +87 -0
  55. package/src/cli/tui/panes/detail.tsx +175 -0
  56. package/src/cli/tui/panes/findings.tsx +97 -0
  57. package/src/cli/tui/panes/summary.tsx +64 -0
  58. package/src/cli/tui/spawn.ts +130 -0
  59. package/src/cli/tui/theme.ts +42 -0
  60. package/src/cli/tui/wrap.test.ts +43 -0
  61. package/src/cli/tui/wrap.ts +45 -0
  62. package/src/cli/types.ts +5 -0
  63. package/src/db/client.ts +55 -2
  64. package/src/db/schema.ts +26 -17
  65. package/src/hooks/capability-loader.ts +133 -73
  66. package/src/hooks/define-hook.test.ts +9 -1
  67. package/src/hooks/executor.ts +22 -1
  68. package/src/hooks/load-hook-config.test.ts +165 -0
  69. package/src/hooks/load-hook-config.ts +60 -0
  70. package/src/hooks/logger.ts +42 -12
  71. package/src/hooks/run-named-hook.ts +128 -0
  72. package/src/hooks/types.ts +19 -0
  73. package/src/manifest/ensure-schema.test.ts +115 -0
  74. package/src/manifest/schema.ts +76 -0
  75. package/src/module/import.ts +20 -12
  76. package/src/module/packaging/build.ts +85 -16
  77. package/src/module/packaging/release-metadata.test.ts +103 -0
  78. package/src/module/packaging/release-metadata.ts +145 -0
  79. package/src/registry/client.test.ts +228 -0
  80. package/src/registry/client.ts +157 -0
  81. package/src/services/audit/backups.test.ts +233 -0
  82. package/src/services/audit/backups.ts +128 -0
  83. package/src/services/audit/capability-abi.test.ts +153 -0
  84. package/src/services/audit/capability-abi.ts +204 -0
  85. package/src/services/audit/cli-version.test.ts +60 -0
  86. package/src/services/audit/cli-version.ts +87 -0
  87. package/src/services/audit/health.test.ts +84 -0
  88. package/src/services/audit/health.ts +43 -0
  89. package/src/services/audit/index.test.ts +99 -0
  90. package/src/services/audit/index.ts +118 -0
  91. package/src/services/audit/machines-reachable.test.ts +87 -0
  92. package/src/services/audit/machines-reachable.ts +87 -0
  93. package/src/services/audit/module-configs.test.ts +131 -0
  94. package/src/services/audit/module-configs.ts +80 -0
  95. package/src/services/audit/module-versions.test.ts +99 -0
  96. package/src/services/audit/module-versions.ts +154 -0
  97. package/src/services/audit/schema.test.ts +68 -0
  98. package/src/services/audit/schema.ts +115 -0
  99. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  100. package/src/services/audit/secrets-decryptable.ts +97 -0
  101. package/src/services/audit/services-credentials.test.ts +54 -0
  102. package/src/services/audit/services-credentials.ts +64 -0
  103. package/src/services/audit/services-reachable.test.ts +60 -0
  104. package/src/services/audit/services-reachable.ts +64 -0
  105. package/src/services/audit/terraform-plan.test.ts +127 -0
  106. package/src/services/audit/terraform-plan.ts +153 -0
  107. package/src/services/audit/types.test.ts +36 -0
  108. package/src/services/audit/types.ts +90 -0
  109. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  110. package/src/services/audit/unconfigured-modules.ts +71 -0
  111. package/src/services/audit/undeployed-modules.test.ts +66 -0
  112. package/src/services/audit/undeployed-modules.ts +72 -0
  113. package/src/services/build-stream.ts +122 -122
  114. package/src/services/config-interview.ts +407 -2
  115. package/src/services/deploy-ansible.ts +73 -7
  116. package/src/services/deploy-preflight.ts +45 -4
  117. package/src/services/deploy-terraform.ts +31 -24
  118. package/src/services/deploy-validation.ts +167 -23
  119. package/src/services/ensure-interview.test.ts +245 -0
  120. package/src/services/health-runner.ts +110 -38
  121. package/src/services/module-build.ts +11 -13
  122. package/src/services/module-deploy.ts +370 -59
  123. package/src/services/ssh-key-manager.test.ts +1 -1
  124. package/src/services/ssh-key-manager.ts +3 -2
  125. package/src/services/terraform-env.ts +62 -0
  126. package/src/services/update/dep-graph.test.ts +214 -0
  127. package/src/services/update/dep-graph.ts +215 -0
  128. package/src/services/update/orchestrator.test.ts +463 -0
  129. package/src/services/update/orchestrator.ts +359 -0
  130. package/src/services/update/progress.ts +49 -0
  131. package/src/services/update/self-update.test.ts +68 -0
  132. package/src/services/update/self-update.ts +57 -0
  133. package/src/services/update/types.ts +94 -0
  134. package/src/templates/generator.test.ts +1 -1
  135. package/src/templates/generator.ts +42 -1
  136. package/src/test-utils/completion-harness.test.ts +1 -1
  137. package/src/test-utils/completion-harness.ts +4 -4
  138. package/src/variables/capability-self-ref.test.ts +203 -0
  139. package/src/variables/context.ts +49 -1
  140. package/src/variables/declarative-derivation.test.ts +306 -0
  141. package/src/variables/declarative-derivation.ts +4 -2
  142. package/src/variables/parser.test.ts +56 -1
  143. package/src/variables/parser.ts +47 -6
  144. package/src/variables/resolver.ts +27 -9
  145. package/tsconfig.json +1 -0
@@ -17,20 +17,13 @@ import {
17
17
  isCompiledCapabilityFactory,
18
18
  wrapWithLogging,
19
19
  } from '@celilo/capabilities';
20
- import type { HookLogger, RouteOps } from '@celilo/capabilities';
20
+ import type { DnsInternalCapability, HookLogger, RouteOps } from '@celilo/capabilities';
21
21
  import { and, eq } from 'drizzle-orm';
22
22
  import type { DbClient } from '../db/client';
23
- import {
24
- capabilities,
25
- machines,
26
- moduleConfigs,
27
- moduleInfrastructure,
28
- modules,
29
- secrets,
30
- webRoutes,
31
- } from '../db/schema';
23
+ import { capabilities, modules, secrets, webRoutes } from '../db/schema';
32
24
  import { decryptSecret } from '../secrets/encryption';
33
25
  import { getOrCreateMasterKey } from '../secrets/master-key';
26
+ import { loadHookConfigMap } from './load-hook-config';
34
27
 
35
28
  /**
36
29
  * Mapping of capability names to their function module entry points.
@@ -110,41 +103,9 @@ export async function loadCapabilityFunctions(
110
103
  `Loading capabilities. Registry has: public_web (framework), ${Object.keys(CAPABILITY_MODULE_MAP).join(', ')}`,
111
104
  );
112
105
 
113
- // Handle public_web via framework implementation (no dynamic import needed)
114
- const publicWebProviders = db
115
- .select()
116
- .from(capabilities)
117
- .where(eq(capabilities.capabilityName, 'public_web'))
118
- .all();
119
-
120
- if (publicWebProviders.length > 0) {
121
- const provider = publicWebProviders[0];
122
- debugLog(`public_web: found provider module ${provider.moduleId}`);
123
- const providerConfig = await loadModuleConfig(provider.moduleId, db);
124
- const providerSecrets = await loadModuleSecrets(provider.moduleId, masterKey, db);
125
- const routeOps = buildRouteOps(db);
126
-
127
- try {
128
- // createPublicWeb internally applies wrapWithLogging using the
129
- // logger we pass in here, so the consumer doesn't need to wrap
130
- // anything at this layer.
131
- result.public_web = createPublicWeb({
132
- moduleId: consumingModuleId,
133
- logger,
134
- config: providerConfig,
135
- secrets: providerSecrets,
136
- routeOps,
137
- });
138
- debugLog(`public_web: loaded via framework implementation for ${consumingModuleId}`);
139
- } catch (error) {
140
- debugLog(
141
- `public_web: FAILED to load: ${error instanceof Error ? error.message : String(error)}`,
142
- );
143
- }
144
- } else {
145
- debugLog('public_web: not registered in DB, skipping');
146
- }
147
-
106
+ // Load all non-public_web capabilities first so dns_internal (and others)
107
+ // are available when we construct the framework-owned public_web capability.
108
+ // public_web optionally threads dns_internal through for split-horizon DNS.
148
109
  for (const [capName, moduleInfo] of Object.entries(CAPABILITY_MODULE_MAP)) {
149
110
  // Check if this capability is registered (a provider module is deployed)
150
111
  // For zone-scoped capabilities (like firewall), there may be multiple providers
@@ -251,6 +212,115 @@ export async function loadCapabilityFunctions(
251
212
  }
252
213
  }
253
214
 
215
+ // Handle public_web via framework implementation (no dynamic import needed).
216
+ // Loaded after the capability loop so dns_internal is already available to
217
+ // thread through for split-horizon DNS record registration.
218
+ const publicWebProviders = db
219
+ .select()
220
+ .from(capabilities)
221
+ .where(eq(capabilities.capabilityName, 'public_web'))
222
+ .all();
223
+
224
+ if (publicWebProviders.length > 0) {
225
+ const provider = publicWebProviders[0];
226
+ debugLog(`public_web: found provider module ${provider.moduleId}`);
227
+ const providerConfig = await loadModuleConfig(provider.moduleId, db);
228
+ const providerSecrets = await loadModuleSecrets(provider.moduleId, masterKey, db);
229
+ const routeOps = buildRouteOps(db);
230
+
231
+ // Find the firewall NAT IP so split-horizon records point to the iptables
232
+ // internal interface rather than Caddy's unreachable DMZ container IP.
233
+ const firewallProviders = db
234
+ .select()
235
+ .from(capabilities)
236
+ .where(eq(capabilities.capabilityName, 'firewall'))
237
+ .all();
238
+ let firewallNatIp: string | undefined;
239
+ for (const fp of firewallProviders) {
240
+ const fpConfig = await loadModuleConfig(fp.moduleId, db);
241
+ if (typeof fpConfig.nat_ip === 'string' && fpConfig.nat_ip) {
242
+ firewallNatIp = fpConfig.nat_ip;
243
+ debugLog(
244
+ `public_web: using firewall natIp ${firewallNatIp} from ${fp.moduleId} for internal DNS`,
245
+ );
246
+ break;
247
+ }
248
+ }
249
+
250
+ // Caddy's configured hostnames — public_web rejects routes for any
251
+ // hostname not in this list, throwing a structured error that runs
252
+ // caddy's `managed_hostname` ensure interview. Empty list means
253
+ // caddy isn't configured yet — also a structured error path
254
+ // (caller will see it on the first register_route).
255
+ const caddyHostnames: string[] = [];
256
+ if (Array.isArray(providerConfig.hostnames)) {
257
+ for (const h of providerConfig.hostnames) {
258
+ if (typeof h === 'string' && h) caddyHostnames.push(h);
259
+ }
260
+ }
261
+
262
+ // Domains managed by some dns_registrar (primary + additional).
263
+ // Each entry in caddy's hostnames must also be in this list — else
264
+ // DNS won't resolve and TLS will eventually break. The registrar's
265
+ // module id drives the second ensure interview when a hostname
266
+ // isn't covered.
267
+ const dnsRegistrarProviders = db
268
+ .select()
269
+ .from(capabilities)
270
+ .where(eq(capabilities.capabilityName, 'dns_registrar'))
271
+ .all();
272
+ const dnsManagedDomains: string[] = [];
273
+ let dnsRegistrarModuleId: string | undefined;
274
+ for (const drp of dnsRegistrarProviders) {
275
+ // First registrar wins — multi-registrar setups can't be auto-extended
276
+ // since we'd have to pick which one to add the new domain to.
277
+ if (!dnsRegistrarModuleId) dnsRegistrarModuleId = drp.moduleId;
278
+ const drConfig = await loadModuleConfig(drp.moduleId, db);
279
+ // dns_registrar 4.0.0+ exposes a single `domains` array (the
280
+ // primary_domain/additional_domains split was collapsed — see
281
+ // apps/celilo/designs/DNS_REGISTRAR_MULTI_DOMAIN.md). Older
282
+ // registrars that haven't been migrated still use the split shape;
283
+ // we keep that fallback so a stale config can still be read.
284
+ if (Array.isArray(drConfig.domains)) {
285
+ for (const d of drConfig.domains) {
286
+ if (typeof d === 'string' && d) dnsManagedDomains.push(d);
287
+ }
288
+ } else {
289
+ if (typeof drConfig.primary_domain === 'string' && drConfig.primary_domain) {
290
+ dnsManagedDomains.push(drConfig.primary_domain);
291
+ }
292
+ if (Array.isArray(drConfig.additional_domains)) {
293
+ for (const d of drConfig.additional_domains) {
294
+ if (typeof d === 'string' && d) dnsManagedDomains.push(d);
295
+ }
296
+ }
297
+ }
298
+ }
299
+
300
+ try {
301
+ result.public_web = createPublicWeb({
302
+ moduleId: consumingModuleId,
303
+ logger,
304
+ config: providerConfig,
305
+ secrets: providerSecrets,
306
+ routeOps,
307
+ dnsInternal: result.dns_internal as DnsInternalCapability | undefined,
308
+ firewallNatIp,
309
+ hostnames: caddyHostnames,
310
+ caddyModuleId: provider.moduleId,
311
+ dnsManagedDomains,
312
+ dnsRegistrarModuleId,
313
+ });
314
+ debugLog(`public_web: loaded via framework implementation for ${consumingModuleId}`);
315
+ } catch (error) {
316
+ debugLog(
317
+ `public_web: FAILED to load: ${error instanceof Error ? error.message : String(error)}`,
318
+ );
319
+ }
320
+ } else {
321
+ debugLog('public_web: not registered in DB, skipping');
322
+ }
323
+
254
324
  return result;
255
325
  }
256
326
 
@@ -294,33 +364,15 @@ function buildCapabilityInterface(
294
364
  /**
295
365
  * Load all config values for a module.
296
366
  *
297
- * Injects `target_ip` from the deployment machine when the module was deployed
298
- * to an existing machine (IPAM writes `target_ip` to moduleConfigs for
299
- * container deploys, but machine deploys skip that path).
367
+ * Thin wrapper around `loadHookConfigMap` kept private here so
368
+ * existing callers in this file don't need an import-path churn. The
369
+ * shared helper handles the `target_ip` / `ip.primary` machine-IP
370
+ * fallback for machine deploys (IPAM writes those keys to
371
+ * `module_configs` for container deploys, but machine deploys skip
372
+ * that write).
300
373
  */
301
374
  async function loadModuleConfig(moduleId: string, db: DbClient): Promise<Record<string, unknown>> {
302
- const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all();
303
-
304
- const result: Record<string, unknown> = {};
305
- for (const c of configs) {
306
- result[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
307
- }
308
-
309
- if (!result.target_ip) {
310
- const infra = db
311
- .select()
312
- .from(moduleInfrastructure)
313
- .where(eq(moduleInfrastructure.moduleId, moduleId))
314
- .get();
315
- if (infra?.machineId) {
316
- const machine = db.select().from(machines).where(eq(machines.id, infra.machineId)).get();
317
- if (machine) {
318
- result.target_ip = machine.ipAddress;
319
- }
320
- }
321
- }
322
-
323
- return result;
375
+ return loadHookConfigMap(moduleId, db);
324
376
  }
325
377
 
326
378
  /**
@@ -490,9 +542,17 @@ function buildRouteOps(db: DbClient): RouteOps {
490
542
  return db.select().from(webRoutes).all();
491
543
  },
492
544
  upsertRoute(route) {
493
- // Delete existing route for this path + module, then insert
545
+ // Path uniqueness is scoped to (hostname, path). Same module +
546
+ // same (hostname, path) is treated as an upsert, so delete first
547
+ // to keep the operation atomic in the eyes of the unique index.
494
548
  db.delete(webRoutes)
495
- .where(and(eq(webRoutes.path, route.path), eq(webRoutes.moduleId, route.moduleId)))
549
+ .where(
550
+ and(
551
+ eq(webRoutes.hostname, route.hostname),
552
+ eq(webRoutes.path, route.path),
553
+ eq(webRoutes.moduleId, route.moduleId),
554
+ ),
555
+ )
496
556
  .run();
497
557
  db.insert(webRoutes)
498
558
  .values({
@@ -500,9 +560,9 @@ function buildRouteOps(db: DbClient): RouteOps {
500
560
  moduleId: route.moduleId,
501
561
  type: route.type,
502
562
  path: route.path,
563
+ hostname: route.hostname,
503
564
  targetHost: route.targetHost ?? null,
504
565
  targetPort: route.targetPort ?? null,
505
- subdomain: route.subdomain ?? null,
506
566
  websocket: route.websocket ?? false,
507
567
  contentHash: route.contentHash ?? null,
508
568
  })
@@ -55,6 +55,7 @@ function makeContext(overrides: Partial<HookContext> = {}): HookContext {
55
55
  // Minimal fakes for the capability interfaces — used in both consumer and
56
56
  // provider tests below.
57
57
  const fakePublicWeb: PublicWebCapability = {
58
+ defaultHostname: 'www.example.com',
58
59
  async publishStaticSite() {
59
60
  return { success: true, path: '/test', filesUploaded: 0, contentHash: 'fake' };
60
61
  },
@@ -70,6 +71,9 @@ const fakePublicWeb: PublicWebCapability = {
70
71
  async unregister_routes() {
71
72
  return undefined;
72
73
  },
74
+ async getServerIp() {
75
+ return '10.0.10.10';
76
+ },
73
77
  };
74
78
 
75
79
  const fakeIdp: IdpCapability = {
@@ -261,6 +265,7 @@ describe('defineCapabilityFunction', () => {
261
265
  const factory = defineCapabilityFunction({
262
266
  capability: 'public_web',
263
267
  handler: () => ({
268
+ defaultHostname: 'www.example.com',
264
269
  async publishStaticSite() {
265
270
  return { success: true, path: '/x', filesUploaded: 0, contentHash: 'x' };
266
271
  },
@@ -276,6 +281,9 @@ describe('defineCapabilityFunction', () => {
276
281
  async unregister_routes() {
277
282
  return undefined;
278
283
  },
284
+ async getServerIp() {
285
+ return '10.0.10.10';
286
+ },
279
287
  }),
280
288
  });
281
289
 
@@ -340,7 +348,7 @@ describe('defineCapabilityFunction', () => {
340
348
  logger: makeLogger(),
341
349
  });
342
350
 
343
- const result = await methods.registerHost({ host: 'www', ip: '1.2.3.4' });
351
+ const result = await methods.registerHost({ fqdn: 'www.example.com', ip: '1.2.3.4' });
344
352
  expect(result.success).toBe(true);
345
353
  expect(result.outputs.registered_ip).toBe('1.2.3.4');
346
354
  });
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
14
14
  import { join, resolve } from 'node:path';
15
- import { isCompiledHook } from '@celilo/capabilities';
15
+ import { isCompiledHook, isMissingProviderInputError } from '@celilo/capabilities';
16
16
  import {
17
17
  type ContractHookSignature,
18
18
  resolveContract,
@@ -458,6 +458,27 @@ export async function invokeHook(
458
458
  logger.info(`Screenshot saved: ${screenshotPath}`);
459
459
  }
460
460
 
461
+ // Recognise the cross-module structured error so the orchestrator can
462
+ // run the ensure interview and retry the hook. The check goes through
463
+ // a duck-typed guard because @celilo/capabilities can be loaded from
464
+ // either the workspace package or a module's bundled copy, and
465
+ // `instanceof` fails across those identity boundaries.
466
+ if (isMissingProviderInputError(error)) {
467
+ return {
468
+ success: false,
469
+ outputs: {},
470
+ error: message,
471
+ screenshotPath,
472
+ duration: Date.now() - startTime,
473
+ missingProviderInput: {
474
+ providerModuleId: error.providerModuleId,
475
+ ensureId: error.ensureId,
476
+ value: error.value,
477
+ humanContext: error.humanContext,
478
+ },
479
+ };
480
+ }
481
+
461
482
  return {
462
483
  success: false,
463
484
  outputs: {},
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Unit tests for `loadHookConfigMap`.
3
+ *
4
+ * The helper centralises the "build the config map a hook script will
5
+ * see" logic across `capability-loader`, `run-named-hook`, and
6
+ * `health-runner`. The behaviour under test:
7
+ *
8
+ * 1. `module_configs` rows are loaded as-is, parsed via `valueJson`
9
+ * when present.
10
+ * 2. If `target_ip` is already in the rows, the machine fallback is
11
+ * skipped entirely (deploy already wrote it).
12
+ * 3. If `target_ip` isn't set AND the module has a `module_infrastructure`
13
+ * row pointing at a machine, fill BOTH `target_ip` and `ip.primary`
14
+ * from `machines.ipAddress`.
15
+ * 4. If neither is true (no infra row, or infra has no machineId,
16
+ * or the machine record is gone), neither key gets filled.
17
+ */
18
+
19
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
20
+ import type { DbClient } from '../db/client';
21
+ import { machines, moduleConfigs, moduleInfrastructure, modules } from '../db/schema';
22
+ import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
23
+ import { loadHookConfigMap } from './load-hook-config';
24
+
25
+ describe('loadHookConfigMap', () => {
26
+ let db: DbClient;
27
+
28
+ beforeEach(async () => {
29
+ db = await setupTestDatabase();
30
+ db.insert(modules)
31
+ .values({
32
+ id: 'mod',
33
+ name: 'test',
34
+ version: '1.0.0',
35
+ sourcePath: '/tmp/x',
36
+ manifestData: {},
37
+ })
38
+ .run();
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await cleanupTestDatabase(db);
43
+ });
44
+
45
+ test('returns empty config map when no rows + no infrastructure', async () => {
46
+ const result = await loadHookConfigMap('mod', db);
47
+ expect(result).toEqual({});
48
+ });
49
+
50
+ 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();
57
+
58
+ const result = await loadHookConfigMap('mod', db);
59
+ expect(result).toEqual({ hostname: 'caddy', acme_email: 'admin@example.com' });
60
+ });
61
+
62
+ 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();
74
+
75
+ const result = await loadHookConfigMap('mod', db);
76
+ expect(result.hostnames).toEqual(['www.example.com', 'example.com']);
77
+ expect(result.plain).toBe('string-val');
78
+ });
79
+
80
+ 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();
84
+ // A machine record exists but should NOT override the explicit value.
85
+ db.insert(machines)
86
+ .values({
87
+ id: 'machine-1',
88
+ hostname: 'm1',
89
+ zone: 'dmz',
90
+ ipAddress: '192.168.0.99',
91
+ sshUser: 'root',
92
+ sshKeyEncrypted: 'x',
93
+ hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 10 },
94
+ })
95
+ .run();
96
+ db.insert(moduleInfrastructure)
97
+ .values({
98
+ id: `infra-${Math.random().toString(36).slice(2)}`,
99
+ moduleId: 'mod',
100
+ infrastructureType: 'machine',
101
+ machineId: 'machine-1',
102
+ })
103
+ .run();
104
+
105
+ const result = await loadHookConfigMap('mod', db);
106
+ expect(result.target_ip).toBe('10.0.10.10');
107
+ // ip.primary is only filled by the fallback path; explicit
108
+ // target_ip means the helper short-circuits and never looks at the
109
+ // machine.
110
+ expect(result['ip.primary']).toBeUndefined();
111
+ });
112
+
113
+ test('fills BOTH target_ip and ip.primary from machine.ipAddress when target_ip is unset', async () => {
114
+ db.insert(machines)
115
+ .values({
116
+ id: 'machine-1',
117
+ hostname: 'caddy-host',
118
+ zone: 'dmz',
119
+ ipAddress: '10.0.10.10',
120
+ sshUser: 'root',
121
+ sshKeyEncrypted: 'x',
122
+ hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 10 },
123
+ })
124
+ .run();
125
+ db.insert(moduleInfrastructure)
126
+ .values({
127
+ id: `infra-${Math.random().toString(36).slice(2)}`,
128
+ moduleId: 'mod',
129
+ infrastructureType: 'machine',
130
+ machineId: 'machine-1',
131
+ })
132
+ .run();
133
+
134
+ const result = await loadHookConfigMap('mod', db);
135
+ expect(result.target_ip).toBe('10.0.10.10');
136
+ expect(result['ip.primary']).toBe('10.0.10.10');
137
+ });
138
+
139
+ 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();
143
+ // no moduleInfrastructure row inserted
144
+
145
+ const result = await loadHookConfigMap('mod', db);
146
+ expect(result.hostname).toBe('caddy');
147
+ expect(result.target_ip).toBeUndefined();
148
+ expect(result['ip.primary']).toBeUndefined();
149
+ });
150
+
151
+ test('leaves target_ip unset when infrastructure row has no machineId', async () => {
152
+ db.insert(moduleInfrastructure)
153
+ .values({
154
+ id: `infra-${Math.random().toString(36).slice(2)}`,
155
+ moduleId: 'mod',
156
+ infrastructureType: 'container_service',
157
+ machineId: null,
158
+ })
159
+ .run();
160
+
161
+ const result = await loadHookConfigMap('mod', db);
162
+ expect(result.target_ip).toBeUndefined();
163
+ expect(result['ip.primary']).toBeUndefined();
164
+ });
165
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Build the canonical config map a hook script sees.
3
+ *
4
+ * Every code path that runs a hook (deploy / health_check / on_uninstall /
5
+ * `module run-hook`) needs to assemble the same config shape; without a
6
+ * single source of truth the implementations drift and hooks see
7
+ * subtly-different state depending on which command invoked them. The
8
+ * concrete bug that motivated this helper: caddy's `on_uninstall` hook
9
+ * skipped its DNAT cleanup because `runNamedHook` (a recently-extracted
10
+ * helper) didn't apply the `target_ip` fallback that
11
+ * `capability-loader.ts:loadModuleConfig` had — same shape, different
12
+ * code, easy to miss.
13
+ *
14
+ * The shape:
15
+ * - Every row from `module_configs`, parsed from `valueJson` when set.
16
+ * - If `target_ip` isn't in the row set, look up the deployment
17
+ * machine and inject both `target_ip` AND `ip.primary` from
18
+ * `machines.ipAddress`. Two keys because consumers historically used
19
+ * either name (e.g. caddy's `setup-network.ts` checks
20
+ * `target_ip || ip.primary`); fixing the drift means filling both.
21
+ *
22
+ * Container deploys write `target_ip` into `module_configs` explicitly
23
+ * during generate/deploy, so the fallback only fires for machine
24
+ * deploys (existing iron, no terraform — what every e2e test uses).
25
+ */
26
+ import { eq } from 'drizzle-orm';
27
+ import type { DbClient } from '../db/client';
28
+ import { machines, moduleConfigs, moduleInfrastructure } from '../db/schema';
29
+
30
+ export async function loadHookConfigMap(
31
+ moduleId: string,
32
+ db: DbClient,
33
+ ): Promise<Record<string, unknown>> {
34
+ const configRecords = db
35
+ .select()
36
+ .from(moduleConfigs)
37
+ .where(eq(moduleConfigs.moduleId, moduleId))
38
+ .all();
39
+
40
+ const configMap: Record<string, unknown> = {};
41
+ for (const c of configRecords) {
42
+ configMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
43
+ }
44
+
45
+ if (configMap.target_ip) return configMap;
46
+
47
+ const infra = db
48
+ .select()
49
+ .from(moduleInfrastructure)
50
+ .where(eq(moduleInfrastructure.moduleId, moduleId))
51
+ .get();
52
+ if (!infra?.machineId) return configMap;
53
+
54
+ const machine = db.select().from(machines).where(eq(machines.id, infra.machineId)).get();
55
+ if (!machine) return configMap;
56
+
57
+ configMap.target_ip = machine.ipAddress;
58
+ configMap['ip.primary'] = machine.ipAddress;
59
+ return configMap;
60
+ }
@@ -6,8 +6,15 @@
6
6
  */
7
7
 
8
8
  import type { FuelGauge } from '../cli/fuel-gauge';
9
+ import { getActiveDisplay } from '../cli/prompts';
9
10
  import type { HookLogger } from './types';
10
11
 
12
+ const DIM = '\x1b[2m';
13
+ const RESET = '\x1b[0m';
14
+ const GREEN = '\x1b[32m';
15
+ const YELLOW = '\x1b[33m';
16
+ const RED = '\x1b[31m';
17
+
11
18
  /**
12
19
  * Create a hook logger that outputs to a FuelGauge progress indicator
13
20
  *
@@ -22,20 +29,43 @@ export function createGaugeLogger(
22
29
  hookName: string,
23
30
  ): HookLogger {
24
31
  const prefix = `[${moduleId}:${hookName}]`;
32
+ const nonInteractive = !process.stdout.isTTY;
33
+
34
+ // When a ProgressDisplay is active, the running step header
35
+ // (e.g. "… celilo-website: on_install") already names the hook,
36
+ // so the per-line "[moduleId:hookName]" prefix becomes noise.
37
+ // Dim the message and skip the stdout double-write — display
38
+ // owns the output channel and forwards via gauge.addOutput →
39
+ // display.subEvent. When no display is active, fall back to
40
+ // the prefixed gauge output.
41
+ function emit(level: 'info' | 'warn' | 'error' | 'success', message: string) {
42
+ if (getActiveDisplay()) {
43
+ const icon =
44
+ level === 'warn'
45
+ ? `${YELLOW}⚠${RESET} `
46
+ : level === 'error'
47
+ ? `${RED}✗${RESET} `
48
+ : level === 'success'
49
+ ? `${GREEN}✓${RESET} `
50
+ : '';
51
+ gauge.addOutput(`${icon}${DIM}${message}${RESET}`);
52
+ return;
53
+ }
54
+
55
+ const icon =
56
+ level === 'warn' ? '⚠ ' : level === 'error' ? '✗ ' : level === 'success' ? '✓ ' : '';
57
+ const line = `${prefix} ${icon}${message}`;
58
+ gauge.addOutput(line);
59
+ if (nonInteractive) {
60
+ process.stdout.write(`${line}\n`);
61
+ }
62
+ }
25
63
 
26
64
  return {
27
- info(message: string) {
28
- gauge.addOutput(`${prefix} ${message}`);
29
- },
30
- warn(message: string) {
31
- gauge.addOutput(`${prefix} ⚠ ${message}`);
32
- },
33
- error(message: string) {
34
- gauge.addOutput(`${prefix} ✗ ${message}`);
35
- },
36
- success(message: string) {
37
- gauge.addOutput(`${prefix} ✓ ${message}`);
38
- },
65
+ info: (message: string) => emit('info', message),
66
+ warn: (message: string) => emit('warn', message),
67
+ error: (message: string) => emit('error', message),
68
+ success: (message: string) => emit('success', message),
39
69
  };
40
70
  }
41
71