@celilo/cli 0.4.0-alpha.1 → 0.4.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 (58) hide show
  1. package/drizzle/0008_aspect_consent.sql +1 -0
  2. package/drizzle/meta/_journal.json +7 -0
  3. package/package.json +5 -6
  4. package/src/cli/command-registry.ts +38 -0
  5. package/src/cli/commands/backup-pull.test.ts +48 -0
  6. package/src/cli/commands/backup-pull.ts +116 -0
  7. package/src/cli/commands/events.test.ts +108 -0
  8. package/src/cli/commands/events.ts +243 -0
  9. package/src/cli/commands/module-generate.ts +5 -4
  10. package/src/cli/commands/module-import-aspect.test.ts +116 -0
  11. package/src/cli/commands/module-import.ts +12 -1
  12. package/src/cli/commands/restore.ts +5 -0
  13. package/src/cli/commands/storage-add-s3.ts +91 -46
  14. package/src/cli/completion.ts +2 -1
  15. package/src/cli/index.ts +11 -0
  16. package/src/db/client.ts +4 -0
  17. package/src/db/schema.ts +9 -1
  18. package/src/hooks/capability-loader.test.ts +31 -1
  19. package/src/hooks/capability-loader.ts +65 -16
  20. package/src/manifest/contracts/v1.ts +12 -0
  21. package/src/manifest/schema.ts +13 -1
  22. package/src/manifest/template-validator.ts +1 -0
  23. package/src/module/import.ts +10 -5
  24. package/src/module/packaging/build.test.ts +75 -0
  25. package/src/module/packaging/build.ts +9 -20
  26. package/src/module/packaging/package-rules.ts +44 -0
  27. package/src/secrets/generators.test.ts +14 -1
  28. package/src/secrets/generators.ts +63 -1
  29. package/src/services/aspect-approvals.test.ts +30 -10
  30. package/src/services/aspect-approvals.ts +61 -31
  31. package/src/services/aspect-runner.test.ts +161 -8
  32. package/src/services/aspect-runner.ts +156 -34
  33. package/src/services/backup-create.ts +11 -2
  34. package/src/services/bus-ensure-flow.test.ts +19 -1
  35. package/src/services/bus-interview.ts +56 -0
  36. package/src/services/bus-secret-flow.test.ts +19 -1
  37. package/src/services/celilo-events.test.ts +122 -0
  38. package/src/services/celilo-events.ts +144 -0
  39. package/src/services/celilo-mgmt-hooks.test.ts +30 -3
  40. package/src/services/config-interview.ts +38 -19
  41. package/src/services/deploy-planner.test.ts +66 -0
  42. package/src/services/deploy-planner.ts +16 -2
  43. package/src/services/deploy-preflight.ts +18 -1
  44. package/src/services/deployed-systems.ts +30 -1
  45. package/src/services/dns-provider-backfill.test.ts +150 -0
  46. package/src/services/dns-provider-backfill.ts +72 -2
  47. package/src/services/e2e-guard.test.ts +38 -0
  48. package/src/services/e2e-guard.ts +43 -0
  49. package/src/services/module-deploy.ts +12 -26
  50. package/src/services/responder-probe.test.ts +87 -0
  51. package/src/services/responder-probe.ts +29 -0
  52. package/src/services/restore-from-file.test.ts +46 -0
  53. package/src/services/restore-from-file.ts +106 -9
  54. package/src/services/storage-providers/s3.test.ts +101 -0
  55. package/src/templates/generator.test.ts +77 -0
  56. package/src/templates/generator.ts +69 -2
  57. package/src/variables/context.ts +34 -0
  58. package/src/variables/lxc-nameserver.test.ts +86 -0
@@ -0,0 +1,150 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { DnsRecordRequest, HookLogger } from '@celilo/capabilities';
6
+ import { closeDb, getDb } from '../db/client';
7
+ import { runMigrations } from '../db/migrate';
8
+ import { capabilities, moduleConfigs, modules, webRoutes } from '../db/schema';
9
+ import { backfillWebRouteDns } from './dns-provider-backfill';
10
+
11
+ const silentLogger: HookLogger = {
12
+ info() {},
13
+ warn() {},
14
+ error() {},
15
+ debug() {},
16
+ } as unknown as HookLogger;
17
+
18
+ describe('backfillWebRouteDns (ISS-0029)', () => {
19
+ let dir: string;
20
+
21
+ beforeEach(async () => {
22
+ dir = mkdtempSync(join(tmpdir(), 'celilo-webroute-backfill-'));
23
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
24
+ await runMigrations(process.env.CELILO_DB_PATH);
25
+ });
26
+
27
+ afterEach(() => {
28
+ closeDb();
29
+ process.env.CELILO_DB_PATH = undefined;
30
+ try {
31
+ rmSync(dir, { recursive: true, force: true });
32
+ } catch {
33
+ /* ignore */
34
+ }
35
+ });
36
+
37
+ function seedModule(id: string) {
38
+ getDb()
39
+ .insert(modules)
40
+ .values({
41
+ id,
42
+ name: id,
43
+ version: '1.0.0',
44
+ manifestData: { id, name: id, version: '1.0.0', celilo_contract: '1.0' },
45
+ sourcePath: `/tmp/${id}`,
46
+ })
47
+ .run();
48
+ }
49
+
50
+ function seedFirewall(natIp: string) {
51
+ seedModule('iptables');
52
+ getDb()
53
+ .insert(capabilities)
54
+ .values({ moduleId: 'iptables', capabilityName: 'firewall', version: '1.0.0', data: {} })
55
+ .run();
56
+ getDb()
57
+ .insert(moduleConfigs)
58
+ .values({
59
+ moduleId: 'iptables',
60
+ key: 'nat_ip',
61
+ value: natIp,
62
+ valueJson: JSON.stringify(natIp),
63
+ })
64
+ .run();
65
+ }
66
+
67
+ function seedRoute(hostname: string, path = '/') {
68
+ // web_routes.module_id is a FK → modules; ensure the owner exists.
69
+ getDb()
70
+ .insert(modules)
71
+ .values({
72
+ id: 'caddy',
73
+ name: 'caddy',
74
+ version: '1.0.0',
75
+ manifestData: { id: 'caddy', name: 'caddy', version: '1.0.0', celilo_contract: '1.0' },
76
+ sourcePath: '/tmp/caddy',
77
+ })
78
+ .onConflictDoNothing()
79
+ .run();
80
+ getDb()
81
+ .insert(webRoutes)
82
+ .values({
83
+ slug: `${hostname}${path}`,
84
+ moduleId: 'caddy',
85
+ type: 'reverse_proxy',
86
+ path,
87
+ hostname,
88
+ })
89
+ .run();
90
+ }
91
+
92
+ /** Stub capability loader returning a dns_internal that records its calls. */
93
+ function stubLoader(calls: DnsRecordRequest[]) {
94
+ return async () => ({
95
+ dns_internal: {
96
+ async registerRecord(req: DnsRecordRequest) {
97
+ calls.push(req);
98
+ },
99
+ async deleteRecord() {},
100
+ },
101
+ });
102
+ }
103
+
104
+ it('registers each DISTINCT web-route hostname at the firewall nat_ip', async () => {
105
+ seedFirewall('100.64.0.1');
106
+ seedRoute('apt.celilo.computer', '/');
107
+ seedRoute('apt.celilo.computer', '/-/publish'); // same host, different path → deduped
108
+ seedRoute('registry.lunacycle.net', '/');
109
+
110
+ const calls: DnsRecordRequest[] = [];
111
+ await backfillWebRouteDns('technitium', getDb(), silentLogger, stubLoader(calls));
112
+
113
+ expect(calls.map((c) => c.host).sort()).toEqual([
114
+ 'apt.celilo.computer',
115
+ 'registry.lunacycle.net',
116
+ ]);
117
+ expect(calls.every((c) => c.value === '100.64.0.1' && c.type === 'A')).toBe(true);
118
+ });
119
+
120
+ it('skips without throwing when no firewall nat_ip is available', async () => {
121
+ seedRoute('apt.celilo.computer');
122
+ const calls: DnsRecordRequest[] = [];
123
+ await backfillWebRouteDns('technitium', getDb(), silentLogger, stubLoader(calls));
124
+ expect(calls).toHaveLength(0);
125
+ });
126
+
127
+ it('is a no-op when there are no web routes', async () => {
128
+ seedFirewall('100.64.0.1');
129
+ const calls: DnsRecordRequest[] = [];
130
+ await backfillWebRouteDns('technitium', getDb(), silentLogger, stubLoader(calls));
131
+ expect(calls).toHaveLength(0);
132
+ });
133
+
134
+ it('aggregates per-host failures into one error', async () => {
135
+ seedFirewall('100.64.0.1');
136
+ seedRoute('a.celilo.computer');
137
+ seedRoute('b.celilo.computer');
138
+ const failing = async () => ({
139
+ dns_internal: {
140
+ async registerRecord(req: DnsRecordRequest) {
141
+ if (req.host === 'a.celilo.computer') throw new Error('boom');
142
+ },
143
+ async deleteRecord() {},
144
+ },
145
+ });
146
+ await expect(backfillWebRouteDns('technitium', getDb(), silentLogger, failing)).rejects.toThrow(
147
+ /failed for 1 host/,
148
+ );
149
+ });
150
+ });
@@ -14,10 +14,11 @@
14
14
  * D5 division of labour: celilo owns host inventory, the module owns DNS.
15
15
  */
16
16
 
17
- import type { HookLogger } from '@celilo/capabilities';
17
+ import type { DnsInternalCapability, HookLogger } from '@celilo/capabilities';
18
18
  import { and, eq } from 'drizzle-orm';
19
19
  import type { DbClient } from '../db/client';
20
- import { capabilities as capabilitiesTable, modules } from '../db/schema';
20
+ import { capabilities as capabilitiesTable, modules, webRoutes } from '../db/schema';
21
+ import { loadCapabilityFunctions, resolveFirewallNatIp } from '../hooks/capability-loader';
21
22
  import { runNamedHook } from '../hooks/run-named-hook';
22
23
  import type { HookName } from '../hooks/types';
23
24
  import { getModuleSystems } from './deployed-systems';
@@ -73,3 +74,72 @@ export async function backfillProviderDns(
73
74
  throw new Error(`DNS backfill failed for ${failures.length} host(s): ${failures.join('; ')}`);
74
75
  }
75
76
  }
77
+
78
+ /**
79
+ * Backfill split-horizon records for every PUBLISHED web-route hostname
80
+ * (ISS-0029). The per-system backfill above covers each deployed host by its
81
+ * bare hostname; this covers the FQDNs modules publish via public_web (e.g.
82
+ * `apt.celilo.computer`), which a late-deploying provider would otherwise never
83
+ * learn. Each hostname is registered at the firewall NAT IP — the same value
84
+ * public_web uses for the live registration — so backfill and live agree.
85
+ *
86
+ * Unlike the per-system path, this does NOT go through `on_system_event` (which
87
+ * concatenates `<hostname>.<zone>` and would corrupt an already-FQDN host).
88
+ * It calls the provider's `registerRecord` directly with the FQDN; the
89
+ * provider creates the split-horizon zone on demand (Phase 1). Attempts every
90
+ * hostname, then throws an aggregate error if any failed.
91
+ */
92
+ export async function backfillWebRouteDns(
93
+ moduleId: string,
94
+ db: DbClient,
95
+ logger: HookLogger,
96
+ // Injectable so unit tests don't dynamically import a real provider module.
97
+ loadCaps: typeof loadCapabilityFunctions = loadCapabilityFunctions,
98
+ ): Promise<void> {
99
+ const hostnames = [
100
+ ...new Set(
101
+ db
102
+ .select({ hostname: webRoutes.hostname })
103
+ .from(webRoutes)
104
+ .all()
105
+ .map((r) => r.hostname),
106
+ ),
107
+ ];
108
+ if (hostnames.length === 0) return;
109
+
110
+ const natIp = await resolveFirewallNatIp(db);
111
+ if (!natIp) {
112
+ // No firewall NAT IP to point at — the live public_web path falls back to
113
+ // Caddy's IP per route; we don't replicate that here. Records land when the
114
+ // route is (re)published. Surface it rather than silently doing nothing.
115
+ logger.warn(
116
+ `web-route DNS backfill for '${moduleId}' skipped: no firewall nat_ip available (${hostnames.length} hostname(s) deferred to live registration)`,
117
+ );
118
+ return;
119
+ }
120
+
121
+ const caps = await loadCaps(moduleId, db, logger);
122
+ const dnsInternal = caps.dns_internal as DnsInternalCapability | undefined;
123
+ if (!dnsInternal) {
124
+ logger.warn(`web-route DNS backfill: dns_internal capability not loadable for '${moduleId}'`);
125
+ return;
126
+ }
127
+
128
+ logger.info(
129
+ `Backfilling ${hostnames.length} web-route hostname(s) into '${moduleId}' at ${natIp}`,
130
+ );
131
+ const failures: string[] = [];
132
+ for (const host of hostnames) {
133
+ try {
134
+ await dnsInternal.registerRecord({ host, type: 'A', value: natIp });
135
+ } catch (err) {
136
+ failures.push(`${host}: ${err instanceof Error ? err.message : String(err)}`);
137
+ }
138
+ }
139
+
140
+ if (failures.length > 0) {
141
+ throw new Error(
142
+ `web-route DNS backfill failed for ${failures.length} host(s): ${failures.join('; ')}`,
143
+ );
144
+ }
145
+ }
@@ -0,0 +1,38 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { E2E_CONFLICT_FIX, E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
5
+
6
+ describe('e2e-guard', () => {
7
+ let prev: string | undefined;
8
+ beforeEach(() => {
9
+ prev = process.env.CELILO_DB_PATH;
10
+ });
11
+ afterEach(() => {
12
+ process.env.CELILO_DB_PATH = prev;
13
+ });
14
+
15
+ it('skips the docker check when running against a temp test DB', () => {
16
+ // Integration tests point CELILO_DB_PATH at os.tmpdir() and must not trip
17
+ // over a developer's unrelated e2e stack.
18
+ process.env.CELILO_DB_PATH = join(tmpdir(), 'celilo-test.db');
19
+ expect(runningE2eContainers()).toEqual([]);
20
+ });
21
+
22
+ it('returns an array and never throws when docker is checked', () => {
23
+ // A non-temp path defeats the short-circuit, exercising the real docker
24
+ // path. Result depends on the host's docker state; the contract is "an
25
+ // array of celilo-e2e-* names, never an exception".
26
+ process.env.CELILO_DB_PATH = '/opt/celilo/celilo.db';
27
+ const result = runningE2eContainers();
28
+ expect(Array.isArray(result)).toBe(true);
29
+ for (const name of result) {
30
+ expect(name.startsWith('celilo-e2e-')).toBe(true);
31
+ }
32
+ });
33
+
34
+ it('exposes a deploy message that includes the remediation hint', () => {
35
+ expect(E2E_CONFLICT_MESSAGE).toContain(E2E_CONFLICT_FIX);
36
+ expect(E2E_CONFLICT_MESSAGE).toContain('mutually exclusive');
37
+ });
38
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Live/e2e mutual-exclusion guard.
3
+ *
4
+ * A live deploy and the e2e simulator can't run at the same time: the e2e
5
+ * stack binds the same docker networks and hostnames a live deploy touches,
6
+ * so a deploy fired while an e2e network is up (e.g. a leftover `--keep` run)
7
+ * would collide. Both the deploy itself and the fast pre-flight check use this
8
+ * helper to refuse before touching any infrastructure.
9
+ */
10
+
11
+ import { execSync } from 'node:child_process';
12
+ import { tmpdir } from 'node:os';
13
+
14
+ /** One-line remediation, shared by the deploy error and the preflight error. */
15
+ export const E2E_CONFLICT_FIX = 'Stop e2e tests first: cele2e down';
16
+
17
+ /** Flat error string the deploy returns when an e2e stack is up. */
18
+ export const E2E_CONFLICT_MESSAGE = `Cannot deploy: e2e test containers are running.
19
+ Live and e2e environments are mutually exclusive.
20
+ ${E2E_CONFLICT_FIX}`;
21
+
22
+ /**
23
+ * Names of running `celilo-e2e-*` containers (empty = clear to deploy).
24
+ *
25
+ * Skipped (returns `[]`) when CELILO_DB_PATH points at a temp dir, since
26
+ * integration tests run against throwaway DBs and never touch docker — they
27
+ * must not trip over a developer's unrelated e2e stack. A docker failure
28
+ * (not installed / not running) is treated as "clear": there's nothing to
29
+ * conflict with.
30
+ */
31
+ export function runningE2eContainers(): string[] {
32
+ if (process.env.CELILO_DB_PATH?.startsWith(tmpdir())) return [];
33
+ try {
34
+ const running = execSync('docker ps --format "{{.Names}}" 2>/dev/null', {
35
+ encoding: 'utf-8',
36
+ timeout: 5000,
37
+ });
38
+ return running.split('\n').filter((name) => name.startsWith('celilo-e2e-'));
39
+ } catch {
40
+ // docker not installed / not running — nothing to conflict with.
41
+ return [];
42
+ }
43
+ }
@@ -35,6 +35,7 @@ import { waitForSSH } from './deploy-ssh';
35
35
  import { executeTerraform, parseTerraformOutputs } from './deploy-terraform';
36
36
  import { validateAndPrepareDeployment } from './deploy-validation';
37
37
  import { getModuleSystems } from './deployed-systems';
38
+ import { E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
38
39
  import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
39
40
  import { findMachineForModule } from './machine-pool';
40
41
  import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
@@ -341,31 +342,12 @@ async function deployModuleImpl(
341
342
  : null;
342
343
 
343
344
  try {
344
- // Check for e2e test containers live and e2e environments are mutually exclusive.
345
- // Skip when using a test database (integration tests use os.tmpdir() paths).
346
- const { tmpdir } = await import('node:os');
347
- const usingTestDb = process.env.CELILO_DB_PATH?.startsWith(tmpdir());
348
- if (!usingTestDb) {
349
- try {
350
- const { execSync } = await import('node:child_process');
351
- const running = execSync('docker ps --format "{{.Names}}" 2>/dev/null', {
352
- encoding: 'utf-8',
353
- timeout: 5000,
354
- });
355
- const e2eContainers = running.split('\n').filter((n) => n.startsWith('celilo-e2e-'));
356
- if (e2eContainers.length > 0) {
357
- return {
358
- success: false,
359
- error:
360
- 'Cannot deploy: e2e test containers are running.\n' +
361
- 'Live and e2e environments are mutually exclusive.\n' +
362
- 'Stop e2e tests first: cele2e down',
363
- phases,
364
- };
365
- }
366
- } catch {
367
- // docker ps failed — Docker may not be installed or running, that's fine
368
- }
345
+ // Live and e2e environments are mutually exclusive refuse if an e2e
346
+ // stack is up (shared docker networks/hostnames). See e2e-guard.ts.
347
+ // Pre-flight checks the same condition; this is the defense-in-depth
348
+ // guard for callers that skip pre-flight.
349
+ if (runningE2eContainers().length > 0) {
350
+ return { success: false, error: E2E_CONFLICT_MESSAGE, phases };
369
351
  }
370
352
 
371
353
  const validation = await validateAndPrepareDeployment(moduleId, db);
@@ -1292,7 +1274,7 @@ async function deployModuleImpl(
1292
1274
  // provider (deliveries bind at emit time). Non-providers skip this
1293
1275
  // entirely; their registration rides the system.created event below.
1294
1276
  // v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md.
1295
- const { isDnsInternalProvider, backfillProviderDns } = await import(
1277
+ const { isDnsInternalProvider, backfillProviderDns, backfillWebRouteDns } = await import(
1296
1278
  './dns-provider-backfill'
1297
1279
  );
1298
1280
  if (isDnsInternalProvider(moduleId, db)) {
@@ -1304,7 +1286,11 @@ async function deployModuleImpl(
1304
1286
  dnsGauge.start();
1305
1287
  try {
1306
1288
  const dnsLogger = createGaugeLogger(dnsGauge, moduleId, 'dns_backfill');
1289
+ // Per-system records (bare hostnames) ...
1307
1290
  await backfillProviderDns(moduleId, db, dnsLogger);
1291
+ // ... and published web-route FQDNs (apt.celilo.computer), which the
1292
+ // per-system path doesn't cover (ISS-0029).
1293
+ await backfillWebRouteDns(moduleId, db, dnsLogger);
1308
1294
  dnsGauge.stop(true);
1309
1295
  } catch (error) {
1310
1296
  dnsGauge.stop(false);
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Recurrence gate for ISS-0025: a headless deploy interview must fail fast
3
+ * instead of hanging forever when no responder is listening. The deploy path
4
+ * routes every interview query through `busInterviewGuarded`, which calls
5
+ * `ensureResponderForInterview` — so this guard is the choke point to protect.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { defineEvents, openBus } from '@celilo/event-bus';
13
+ import { ensureResponderForInterview } from './responder-probe';
14
+
15
+ const NO_SCHEMAS = defineEvents({});
16
+
17
+ function setTTY(value: boolean | undefined): void {
18
+ Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true });
19
+ }
20
+
21
+ describe('ensureResponderForInterview (ISS-0025)', () => {
22
+ let dir: string;
23
+ let dbPath: string;
24
+ let origEnv: string | undefined;
25
+ let origTTY: boolean | undefined;
26
+
27
+ beforeEach(() => {
28
+ dir = mkdtempSync(join(tmpdir(), 'responder-guard-'));
29
+ dbPath = join(dir, 'events.db');
30
+ origEnv = process.env.EVENT_BUS_DB;
31
+ process.env.EVENT_BUS_DB = dbPath;
32
+ origTTY = process.stdin.isTTY;
33
+ });
34
+ afterEach(() => {
35
+ if (origEnv === undefined) process.env.EVENT_BUS_DB = undefined;
36
+ else process.env.EVENT_BUS_DB = origEnv;
37
+ setTTY(origTTY);
38
+ try {
39
+ rmSync(dir, { recursive: true, force: true });
40
+ } catch {
41
+ /* ignore */
42
+ }
43
+ });
44
+
45
+ it('throws an actionable error when non-TTY and no responder is listening', async () => {
46
+ setTTY(false);
47
+ // No responder on the bus → the probe times out → fail fast, not a hang.
48
+ await expect(ensureResponderForInterview('config.required.foo.bar')).rejects.toThrow(
49
+ /No responder is listening/,
50
+ );
51
+ });
52
+
53
+ it('names the blocked prompt and the remediations in the error', async () => {
54
+ setTTY(false);
55
+ let message = '';
56
+ try {
57
+ await ensureResponderForInterview('secret.required.caddy.api_token');
58
+ } catch (err) {
59
+ message = err instanceof Error ? err.message : String(err);
60
+ }
61
+ expect(message).toContain('secret.required.caddy.api_token');
62
+ expect(message).toContain('celilo events respond');
63
+ });
64
+
65
+ it('resolves when a responder answers the probe', async () => {
66
+ setTTY(false);
67
+ const responder = openBus({ dbPath, events: NO_SCHEMAS });
68
+ responder.watch('responder.probe', (event) => {
69
+ responder.emitRaw(
70
+ `${event.type}.reply`,
71
+ { kind: 'programmatic', emittedBy: 'test' },
72
+ { replyFor: event.id, emittedBy: 'test' },
73
+ );
74
+ });
75
+
76
+ await expect(ensureResponderForInterview('config.required.foo.bar')).resolves.toBeUndefined();
77
+
78
+ responder.close();
79
+ });
80
+
81
+ it('skips the probe entirely on a TTY (the terminal-responder will answer)', async () => {
82
+ setTTY(true);
83
+ // No responder running, but a TTY short-circuits before probing — the
84
+ // built-in terminal-responder is the responder.
85
+ await expect(ensureResponderForInterview('config.required.foo.bar')).resolves.toBeUndefined();
86
+ });
87
+ });
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { defineEvents, openBus } from '@celilo/event-bus';
16
+ import { getEventBusPath } from '../config/paths';
16
17
 
17
18
  const NO_SCHEMAS = defineEvents({});
18
19
 
@@ -43,3 +44,31 @@ export async function probeForResponder(busDbPath: string, timeoutMs = 1500): Pr
43
44
  bus.close();
44
45
  }
45
46
  }
47
+
48
+ /**
49
+ * Fail fast instead of hanging forever on a deploy interview (ISS-0025).
50
+ *
51
+ * `busInterview` waits indefinitely (`timeoutMs: 0`) for a responder's reply.
52
+ * On a TTY the deploy registers a terminal-responder, so a prompt will be
53
+ * answered — we skip the probe. Headless (non-TTY) with no responder listening,
54
+ * the prompt would hang forever; we probe once and throw an actionable error so
55
+ * a `module generate`-style fail-fast applies to deploys too. Call this
56
+ * immediately before emitting an interview query (see `busInterviewGuarded`).
57
+ *
58
+ * @param queryType the interview event type about to be emitted (e.g.
59
+ * `config.required.<m>.<k>`), echoed in the error so the operator sees which
60
+ * prompt is blocked.
61
+ */
62
+ export async function ensureResponderForInterview(queryType: string): Promise<void> {
63
+ if (process.stdin.isTTY) return;
64
+ const available = await probeForResponder(getEventBusPath());
65
+ if (available) return;
66
+ throw new Error(
67
+ `No responder is listening and stdin isn't a TTY, so this interview prompt can't be answered (${queryType}).
68
+
69
+ Either:
70
+ 1. Run it in a terminal — the built-in prompt will ask, or
71
+ 2. Start a responder in another shell: celilo events respond
72
+ (or pre-stage answers: celilo events respond --values <file>)`,
73
+ );
74
+ }
@@ -8,6 +8,7 @@
8
8
  * via this service) is an e2e concern, not exercised at the unit level.
9
9
  */
10
10
 
11
+ import { Database } from 'bun:sqlite';
11
12
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
12
13
  import {
13
14
  existsSync,
@@ -38,11 +39,14 @@ describe('applyStagedSystemFiles', () => {
38
39
  keyPath = join(dir, 'master.key');
39
40
  process.env.CELILO_DB_PATH = livePath;
40
41
  process.env.CELILO_MASTER_KEY_PATH = keyPath;
42
+ process.env.CELILO_DATA_DIR = dir; // getModuleStoragePath() = dir/modules
41
43
  });
42
44
 
43
45
  afterEach(() => {
46
+ closeDb();
44
47
  process.env.CELILO_DB_PATH = undefined;
45
48
  process.env.CELILO_MASTER_KEY_PATH = undefined;
49
+ process.env.CELILO_DATA_DIR = undefined;
46
50
  try {
47
51
  rmSync(dir, { recursive: true, force: true });
48
52
  } catch {
@@ -124,6 +128,48 @@ describe('applyStagedSystemFiles', () => {
124
128
  expect(result.dbApplied).toBe(false);
125
129
  expect(result.keyApplied).toBe(false);
126
130
  });
131
+
132
+ it('lays down staged module source dirs at the modules storage path', () => {
133
+ const stagedSrc = join(stagingDir, 'module_src');
134
+ mkdirSync(join(stagedSrc, 'caddy', 'scripts'), { recursive: true });
135
+ writeFileSync(join(stagedSrc, 'caddy', 'manifest.yml'), 'id: caddy');
136
+ writeFileSync(join(stagedSrc, 'caddy', 'scripts', 'hook.ts'), '// hook');
137
+
138
+ const result = applyStagedSystemFiles(stagingDir);
139
+ expect(result.moduleSourcesApplied).toBe(1);
140
+ // getModuleStoragePath() = <CELILO_DATA_DIR>/modules = dir/modules.
141
+ const laid = join(dir, 'modules', 'caddy');
142
+ expect(readFileSync(join(laid, 'manifest.yml'), 'utf-8')).toBe('id: caddy');
143
+ expect(readFileSync(join(laid, 'scripts', 'hook.ts'), 'utf-8')).toBe('// hook');
144
+ });
145
+
146
+ it("rewrites every module's source_path to this box on the staged DB before swap", () => {
147
+ // A real staged SQLite DB whose module points at a FOREIGN (macOS) path —
148
+ // exactly the macOS→Linux cross-host case ISS-0051 broke on. Build a minimal
149
+ // self-contained DB (no runMigrations, which would hold a connection and
150
+ // lock the journal-mode switch the rewrite needs).
151
+ const stagedDbPath = join(stagingDir, 'celilo.db');
152
+ const seed = new Database(stagedDbPath);
153
+ seed.run(
154
+ 'CREATE TABLE modules (id TEXT PRIMARY KEY, name TEXT, version TEXT, manifest_data TEXT, source_path TEXT)',
155
+ );
156
+ seed.run(
157
+ "INSERT INTO modules (id, name, version, manifest_data, source_path) VALUES ('caddy', 'caddy', '2.0.0', '{}', '/Users/someone/Library/Application Support/celilo/modules/caddy')",
158
+ );
159
+ seed.close();
160
+
161
+ const result = applyStagedSystemFiles(stagingDir);
162
+ expect(result.dbApplied).toBe(true);
163
+
164
+ // The swapped-in live DB must now point at THIS box's modules dir, not the
165
+ // source box's dead macOS path.
166
+ const live = new Database(livePath);
167
+ const row = live.query("SELECT source_path AS sp FROM modules WHERE id = 'caddy'").get() as {
168
+ sp: string;
169
+ };
170
+ live.close();
171
+ expect(row.sp).toBe(join(dir, 'modules', 'caddy'));
172
+ });
127
173
  });
128
174
 
129
175
  describe('restoreFromArtifactFile error paths', () => {