@celilo/cli 0.4.0-alpha.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) 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/storage-add-s3.ts +91 -46
  13. package/src/cli/completion.ts +2 -1
  14. package/src/cli/index.ts +11 -0
  15. package/src/db/client.ts +4 -0
  16. package/src/db/schema.ts +9 -1
  17. package/src/hooks/capability-loader.test.ts +31 -1
  18. package/src/hooks/capability-loader.ts +65 -16
  19. package/src/manifest/contracts/v1.ts +12 -0
  20. package/src/manifest/schema.ts +13 -1
  21. package/src/manifest/template-validator.ts +1 -0
  22. package/src/module/packaging/build.test.ts +75 -0
  23. package/src/module/packaging/build.ts +9 -20
  24. package/src/module/packaging/package-rules.ts +44 -0
  25. package/src/secrets/generators.test.ts +14 -1
  26. package/src/secrets/generators.ts +63 -1
  27. package/src/services/aspect-approvals.test.ts +30 -10
  28. package/src/services/aspect-approvals.ts +61 -31
  29. package/src/services/aspect-runner.test.ts +161 -8
  30. package/src/services/aspect-runner.ts +156 -34
  31. package/src/services/backup-create.ts +11 -2
  32. package/src/services/bus-ensure-flow.test.ts +19 -1
  33. package/src/services/bus-interview.ts +56 -0
  34. package/src/services/bus-secret-flow.test.ts +19 -1
  35. package/src/services/celilo-events.test.ts +122 -0
  36. package/src/services/celilo-events.ts +144 -0
  37. package/src/services/celilo-mgmt-hooks.test.ts +9 -1
  38. package/src/services/config-interview.ts +38 -19
  39. package/src/services/deploy-planner.test.ts +66 -0
  40. package/src/services/deploy-planner.ts +16 -2
  41. package/src/services/deploy-preflight.ts +18 -1
  42. package/src/services/deployed-systems.ts +30 -1
  43. package/src/services/dns-provider-backfill.test.ts +150 -0
  44. package/src/services/dns-provider-backfill.ts +72 -2
  45. package/src/services/e2e-guard.test.ts +38 -0
  46. package/src/services/e2e-guard.ts +43 -0
  47. package/src/services/module-deploy.ts +12 -26
  48. package/src/services/responder-probe.test.ts +87 -0
  49. package/src/services/responder-probe.ts +29 -0
  50. package/src/services/restore-from-file.ts +16 -6
  51. package/src/services/storage-providers/s3.test.ts +101 -0
  52. package/src/templates/generator.test.ts +77 -0
  53. package/src/templates/generator.ts +69 -2
  54. package/src/variables/context.ts +34 -0
  55. package/src/variables/lxc-nameserver.test.ts +86 -0
@@ -0,0 +1 @@
1
+ ALTER TABLE `aspect_approvals` ADD `consented` integer DEFAULT true NOT NULL;
@@ -57,6 +57,13 @@
57
57
  "when": 1780459200000,
58
58
  "tag": "0007_module_systems",
59
59
  "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "6",
64
+ "when": 1781064000000,
65
+ "tag": "0008_aspect_consent",
66
+ "breakpoints": true
60
67
  }
61
68
  ]
62
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.4.0-alpha.1",
3
+ "version": "0.4.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,9 +52,9 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@aws-sdk/client-s3": "^3.1024.0",
55
- "@celilo/capabilities": "0.2.0-alpha.0",
56
- "@celilo/cli-display": "0.1.9-alpha.0",
57
- "@celilo/event-bus": "0.1.5-alpha.0",
55
+ "@celilo/capabilities": "^0.3.0",
56
+ "@celilo/cli-display": "^0.1.9",
57
+ "@celilo/event-bus": "^0.1.5",
58
58
  "@clack/prompts": "^1.1.0",
59
59
  "ajv": "^8.18.0",
60
60
  "drizzle-orm": "^0.36.4",
@@ -75,6 +75,5 @@
75
75
  "ink-testing-library": "^4.0.0",
76
76
  "typescript": "^5.9.3",
77
77
  "zod-to-json-schema": "^3.25.2"
78
- },
79
- "gitHead": "26189a1eaaa2a7da94a9098a16305d8a9a95ac50"
78
+ }
80
79
  }
@@ -148,6 +148,21 @@ export const COMMANDS: CommandDef[] = [
148
148
  { name: 'emitted-by', description: 'Audit tag', takesValue: true },
149
149
  ],
150
150
  },
151
+ {
152
+ name: 'reply',
153
+ description: 'Answer one pending interview query by event id (config/secret/ensure)',
154
+ args: [
155
+ { name: 'event_id', description: 'Query event id from `tail`' },
156
+ { name: 'value', description: "Answer as JSON (e.g. '\"foo\"', '8080', '[\"a\"]')" },
157
+ ],
158
+ flags: [
159
+ {
160
+ name: 'emitted-by',
161
+ description: 'Responder identity for the reply audit trail',
162
+ takesValue: true,
163
+ },
164
+ ],
165
+ },
151
166
  {
152
167
  name: 'ack',
153
168
  description: 'Mark a running delivery succeeded',
@@ -1091,6 +1106,16 @@ export const COMMANDS: CommandDef[] = [
1091
1106
  takesValue: true,
1092
1107
  },
1093
1108
  { name: 'name', description: 'Storage name', takesValue: true },
1109
+ {
1110
+ name: 'access-key-id',
1111
+ description: 'S3 access key ID (non-interactive)',
1112
+ takesValue: true,
1113
+ },
1114
+ {
1115
+ name: 'secret-access-key',
1116
+ description: 'S3 secret access key (non-interactive)',
1117
+ takesValue: true,
1118
+ },
1094
1119
  ],
1095
1120
  },
1096
1121
  ],
@@ -1210,6 +1235,19 @@ export const COMMANDS: CommandDef[] = [
1210
1235
  { name: 'yes', description: 'Skip confirmation', takesValue: false },
1211
1236
  ],
1212
1237
  },
1238
+ {
1239
+ name: 'pull',
1240
+ description: "Download another box's backup artifact from a storage destination",
1241
+ flags: [
1242
+ { name: 'storage', description: 'Storage destination name', takesValue: true },
1243
+ {
1244
+ name: 'module',
1245
+ description: 'Module ID whose backup to pull (default: system state)',
1246
+ takesValue: true,
1247
+ },
1248
+ { name: 'output', description: 'Local path to write the artifact', takesValue: true },
1249
+ ],
1250
+ },
1213
1251
  ],
1214
1252
  },
1215
1253
  {
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { pickNewestArtifact } from './backup-pull';
3
+
4
+ describe('pickNewestArtifact', () => {
5
+ const keys = [
6
+ '2026-06-02/celilo-mgmt-2026-06-02T10-00-00-000Z.backup',
7
+ '2026-06-04/celilo-mgmt-2026-06-04T21-05-21-637Z.backup',
8
+ '2026-06-03/celilo-mgmt-2026-06-03T08-30-00-000Z.backup',
9
+ '2026-06-04/system-2026-06-04T22-00-00-000Z.backup',
10
+ '2026-06-01/celilo-2026-06-01T00-00-00-000Z.backup',
11
+ ];
12
+
13
+ test('picks the chronologically newest module artifact', () => {
14
+ expect(pickNewestArtifact(keys, 'celilo-mgmt')).toBe(
15
+ '2026-06-04/celilo-mgmt-2026-06-04T21-05-21-637Z.backup',
16
+ );
17
+ });
18
+
19
+ test('does not treat a shorter module id as a prefix of a longer one', () => {
20
+ // 'celilo' must NOT match 'celilo-mgmt-...' artifacts — only the literal
21
+ // celilo-<year> artifact.
22
+ expect(pickNewestArtifact(keys, 'celilo')).toBe(
23
+ '2026-06-01/celilo-2026-06-01T00-00-00-000Z.backup',
24
+ );
25
+ });
26
+
27
+ test('matches system-state artifacts via the system prefix', () => {
28
+ expect(pickNewestArtifact(keys, 'system')).toBe(
29
+ '2026-06-04/system-2026-06-04T22-00-00-000Z.backup',
30
+ );
31
+ });
32
+
33
+ test('returns null when nothing matches', () => {
34
+ expect(pickNewestArtifact(keys, 'homebridge')).toBeNull();
35
+ expect(pickNewestArtifact([], 'celilo-mgmt')).toBeNull();
36
+ });
37
+
38
+ test('ignores non-.backup objects and verify probes', () => {
39
+ const noisy = [
40
+ 'celilo-mgmt/.celilo-verify-test',
41
+ '2026-06-04/celilo-mgmt-2026-06-04T21-05-21-637Z.backup',
42
+ '2026-06-04/celilo-mgmt-notes.txt',
43
+ ];
44
+ expect(pickNewestArtifact(noisy, 'celilo-mgmt')).toBe(
45
+ '2026-06-04/celilo-mgmt-2026-06-04T21-05-21-637Z.backup',
46
+ );
47
+ });
48
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Backup Pull Command
3
+ *
4
+ * Downloads a backup artifact that ANOTHER box wrote to a shared storage
5
+ * destination, onto THIS box, as a local `.backup` file ready for
6
+ * `celilo restore --from <file>`.
7
+ *
8
+ * This closes the fresh-box gap in the S3 migration path: `restore --from`
9
+ * needs a local file, `backup restore <id>` needs a local DB row, and
10
+ * `backup import` goes the wrong way. None of them let a freshly-bootstrapped
11
+ * celilo-mgr fetch turnip's backup straight out of the bucket. The storage
12
+ * provider already exposes list()/download(); this is the missing glue.
13
+ *
14
+ * celilo storage add s3 ... # same creds as the source box
15
+ * celilo backup pull --storage <id> --module celilo-mgmt
16
+ * celilo restore --from <printed-path> --force
17
+ *
18
+ * Part of P5 (v2/CELILO_MGMT_MIGRATION.md), apps/celilo/designs/P5_MIGRATION_E2E.md.
19
+ */
20
+
21
+ import { tmpdir } from 'node:os';
22
+ import { basename, join } from 'node:path';
23
+ import {
24
+ createStorageProvider,
25
+ getBackupStorageByStorageId,
26
+ getDefaultBackupStorage,
27
+ } from '../../services/backup-storage';
28
+ import { celiloIntro, celiloOutro } from '../prompts';
29
+ import type { CommandResult } from '../types';
30
+
31
+ /**
32
+ * Pick the newest backup artifact key from a storage listing.
33
+ *
34
+ * Storage layout (backup-create.ts): `<YYYY-MM-DD>/<prefix>-<ISO-ts>.backup`
35
+ * where `<prefix>` is the moduleId for module backups or `system` for
36
+ * system-state backups, and `<ISO-ts>` is an ISO timestamp with `:`/`.`
37
+ * replaced by `-`. ISO timestamps sort lexically as chronologically, so the
38
+ * lexically-greatest matching key is the newest.
39
+ *
40
+ * `keyPrefix` matches the basename's leading `<prefix>-<4-digit-year>` so a
41
+ * module id is never a false-prefix of a longer one (e.g. `celilo` vs
42
+ * `celilo-mgmt`).
43
+ */
44
+ export function pickNewestArtifact(keys: string[], keyPrefix: string): string | null {
45
+ const matcher = new RegExp(`^${keyPrefix}-\\d{4}-.*\\.backup$`);
46
+ const matching = keys.filter((k) => matcher.test(basename(k)));
47
+ if (matching.length === 0) return null;
48
+ // Lexical sort; newest (greatest) last.
49
+ matching.sort();
50
+ return matching[matching.length - 1] ?? null;
51
+ }
52
+
53
+ export async function handleBackupPull(
54
+ _args: string[],
55
+ flags: Record<string, boolean | string> = {},
56
+ ): Promise<CommandResult> {
57
+ try {
58
+ celiloIntro('Pull Backup');
59
+
60
+ const storageName = typeof flags.storage === 'string' ? flags.storage : undefined;
61
+ const moduleId = typeof flags.module === 'string' ? flags.module : undefined;
62
+ const outputFlag = typeof flags.output === 'string' ? flags.output : undefined;
63
+
64
+ const storage = storageName
65
+ ? getBackupStorageByStorageId(storageName)
66
+ : getDefaultBackupStorage();
67
+
68
+ if (!storage) {
69
+ return {
70
+ success: false,
71
+ error: storageName
72
+ ? `Storage not found: ${storageName}\n\nList destinations: celilo storage list`
73
+ : 'No default backup storage configured.\n\nAdd storage first: celilo storage add s3',
74
+ };
75
+ }
76
+ if (!storage.verified) {
77
+ return {
78
+ success: false,
79
+ error: `Storage '${storage.storageId}' is not verified. Run: celilo storage verify ${storage.storageId}`,
80
+ };
81
+ }
82
+
83
+ // `system` is the artifact prefix for system-state backups; otherwise we
84
+ // match the module's artifacts.
85
+ const keyPrefix = moduleId ?? 'system';
86
+
87
+ console.log(`\n▸ Listing backups in '${storage.storageId}'...`);
88
+ const provider = await createStorageProvider(storage.id);
89
+ const keys = await provider.list('');
90
+
91
+ const remotePath = pickNewestArtifact(keys, keyPrefix);
92
+ if (!remotePath) {
93
+ return {
94
+ success: false,
95
+ error: `No '${keyPrefix}' backup artifact found in storage '${storage.storageId}'.\n\nExpected an object like <date>/${keyPrefix}-<timestamp>.backup`,
96
+ };
97
+ }
98
+
99
+ const outputPath = outputFlag ?? join(tmpdir(), basename(remotePath));
100
+
101
+ console.log(`▸ Downloading ${remotePath}...`);
102
+ await provider.download(remotePath, outputPath);
103
+
104
+ console.log(`✓ Pulled ${basename(remotePath)}`);
105
+ console.log(` → ${outputPath}`);
106
+
107
+ celiloOutro(`Restore it with:\n celilo restore --from ${outputPath} --force`);
108
+
109
+ return { success: true, message: `Pulled backup to ${outputPath}` };
110
+ } catch (error) {
111
+ return {
112
+ success: false,
113
+ error: `Pull failed: ${error instanceof Error ? error.message : String(error)}`,
114
+ };
115
+ }
116
+ }
@@ -11,6 +11,7 @@ import {
11
11
  handleEventsListPending,
12
12
  handleEventsListSubscribers,
13
13
  handleEventsRepair,
14
+ handleEventsReply,
14
15
  handleEventsStatus,
15
16
  handleEventsTail,
16
17
  } from './events';
@@ -153,4 +154,111 @@ describe('celilo events command handlers', () => {
153
154
  expect((result.data as { recovered: boolean }).recovered).toBe(true);
154
155
  expect((result.data as { stuckCount: number }).stuckCount).toBe(1);
155
156
  });
157
+
158
+ it('reply answers a config.required query with a correlated reply', async () => {
159
+ const setupBus = openBus({ dbPath, events: defineEvents({}) });
160
+ const query = setupBus.emitRaw('config.required.lunacycle.domain', {
161
+ module: 'lunacycle',
162
+ key: 'domain',
163
+ type: 'string',
164
+ required: true,
165
+ });
166
+ setupBus.close();
167
+
168
+ const res = await handleEventsReply([String(query.id), '"lunacycle.net"'], {});
169
+ expect(res.success).toBe(true);
170
+ if (!res.success) throw new Error('expected success');
171
+ const data = res.data as { status: string; family: string; value: unknown };
172
+ expect(data.status).toBe('replied');
173
+ expect(data.family).toBe('config');
174
+ expect(data.value).toBe('lunacycle.net');
175
+
176
+ const checkBus = openBus({ dbPath, events: defineEvents({}) });
177
+ const replies = checkBus.recentEvents({ type: 'config.required.lunacycle.domain.reply' });
178
+ checkBus.close();
179
+ expect(replies).toHaveLength(1);
180
+ expect(replies[0].replyFor).toBe(query.id);
181
+ expect(replies[0].payload).toEqual({ value: 'lunacycle.net' });
182
+ expect(replies[0].emittedBy).toBe('claude-config-responder');
183
+ });
184
+
185
+ it('reply honors --emitted-by for the audit identity', async () => {
186
+ const setupBus = openBus({ dbPath, events: defineEvents({}) });
187
+ const query = setupBus.emitRaw('config.required.caddy.acme_email', {
188
+ module: 'caddy',
189
+ key: 'acme_email',
190
+ type: 'string',
191
+ required: true,
192
+ });
193
+ setupBus.close();
194
+
195
+ await handleEventsReply([String(query.id), '"a@b.com"'], { 'emitted-by': 'operator-x' });
196
+
197
+ const checkBus = openBus({ dbPath, events: defineEvents({}) });
198
+ const replies = checkBus.recentEvents({ type: 'config.required.caddy.acme_email.reply' });
199
+ checkBus.close();
200
+ expect(replies[0].emittedBy).toBe('operator-x');
201
+ });
202
+
203
+ it('reply on an unknown event id fails clearly', async () => {
204
+ const res = await handleEventsReply(['99999', '"x"'], {});
205
+ expect(res.success).toBe(false);
206
+ if (res.success) throw new Error('expected failure');
207
+ expect(res.error).toContain('No event with id 99999');
208
+ });
209
+
210
+ it('reply on a non-interview event fails clearly', async () => {
211
+ const setupBus = openBus({ dbPath, events: defineEvents({}) });
212
+ const event = setupBus.emitRaw('custom.thing', { x: 1 });
213
+ setupBus.close();
214
+
215
+ const res = await handleEventsReply([String(event.id), '"x"'], {});
216
+ expect(res.success).toBe(false);
217
+ if (res.success) throw new Error('expected failure');
218
+ expect(res.error).toContain('not an interview query');
219
+ });
220
+
221
+ it('reply rejects a non-JSON value', async () => {
222
+ const setupBus = openBus({ dbPath, events: defineEvents({}) });
223
+ const query = setupBus.emitRaw('config.required.lunacycle.domain', {
224
+ module: 'lunacycle',
225
+ key: 'domain',
226
+ type: 'string',
227
+ required: true,
228
+ });
229
+ setupBus.close();
230
+
231
+ // A bare word isn't valid JSON — the operator must quote strings.
232
+ const res = await handleEventsReply([String(query.id), 'lunacycle.net'], {});
233
+ expect(res.success).toBe(false);
234
+ if (res.success) throw new Error('expected failure');
235
+ expect(res.error).toContain('Invalid JSON value');
236
+ });
237
+
238
+ it('reply is idempotent — a second reply reports already-answered and does not re-emit', async () => {
239
+ const setupBus = openBus({ dbPath, events: defineEvents({}) });
240
+ const query = setupBus.emitRaw('config.required.lunacycle.domain', {
241
+ module: 'lunacycle',
242
+ key: 'domain',
243
+ type: 'string',
244
+ required: true,
245
+ });
246
+ setupBus.close();
247
+
248
+ const first = await handleEventsReply([String(query.id), '"a.net"'], {});
249
+ expect(first.success).toBe(true);
250
+ if (!first.success) throw new Error('expected success');
251
+ expect((first.data as { status: string }).status).toBe('replied');
252
+
253
+ const second = await handleEventsReply([String(query.id), '"b.net"'], {});
254
+ expect(second.success).toBe(true);
255
+ if (!second.success) throw new Error('expected success');
256
+ expect((second.data as { status: string }).status).toBe('already-answered');
257
+
258
+ const checkBus = openBus({ dbPath, events: defineEvents({}) });
259
+ const replies = checkBus.recentEvents({ type: 'config.required.lunacycle.domain.reply' });
260
+ checkBus.close();
261
+ expect(replies).toHaveLength(1);
262
+ expect(replies[0].payload).toEqual({ value: 'a.net' });
263
+ });
156
264
  });
@@ -13,6 +13,7 @@
13
13
  * drain process pending deliveries once
14
14
  * run long-running dispatcher (foreground; SIGINT to stop)
15
15
  * emit <type> [json] emit an event (operator/test path; bypasses schema)
16
+ * reply <id> <value> answer one pending interview query by event id
16
17
  * ack <event_id> mark a running delivery succeeded
17
18
  * fail <event_id> mark a running delivery failed
18
19
  * repair crash-recovery sweep without starting the dispatcher
@@ -34,6 +35,7 @@ import { createConsoleLogger } from '../../hooks/logger';
34
35
  import { runNamedHook } from '../../hooks/run-named-hook';
35
36
  import type { HookName } from '../../hooks/types';
36
37
  import type { ModuleManifest } from '../../manifest/schema';
38
+ import type { EnsureRequiredPayload } from '../../services/bus-interview';
37
39
  import { installDaemon, readInstalledUnit, uninstallDaemon } from '../../services/events-daemon';
38
40
  import { getArg, hasFlag } from '../parser';
39
41
  import type { CommandResult } from '../types';
@@ -354,6 +356,247 @@ export async function handleEventsRepair(): Promise<CommandResult> {
354
356
  }
355
357
  }
356
358
 
359
+ const INTERVIEW_FAMILIES = ['config', 'secret', 'ensure', 'aspect'] as const;
360
+ type InterviewFamily = (typeof INTERVIEW_FAMILIES)[number];
361
+
362
+ /** Classify a query event type into its interview family, or null if it isn't one. */
363
+ function interviewFamily(type: string): InterviewFamily | null {
364
+ if (type.startsWith('config.required.')) return 'config';
365
+ if (type.startsWith('secret.required.')) return 'secret';
366
+ if (type.startsWith('ensure.required.')) return 'ensure';
367
+ if (type.startsWith('aspect.required.')) return 'aspect';
368
+ return null;
369
+ }
370
+
371
+ /**
372
+ * `celilo events reply <query-event-id> <value-json>` — answer ONE pending
373
+ * interview query by its event id. The one-shot reply primitive a
374
+ * `claude-config-responder` uses: read the question with
375
+ * `events tail --type 'config.required.*'`, ask the operator, emit the answer
376
+ * here. Unlike `events respond` (which must be subscribed BEFORE the query is
377
+ * emitted — bus watches don't replay history) this looks the query up by id
378
+ * and emits a correlated reply carrying `replyFor`, which plain `events emit`
379
+ * can't set.
380
+ *
381
+ * The query's event type selects how `<value-json>` is interpreted:
382
+ * - config.required.<m>.<k> → value is the answer itself ('"foo"', '8080',
383
+ * '["a","b"]'); replies { value }.
384
+ * - secret.required.<m>.<k> → value is the secret (string, or JSON object for
385
+ * structured secrets). Written out-of-band to the
386
+ * encrypted store; replies { acknowledged: true }
387
+ * so the value never lands on the bus.
388
+ * - ensure.required.<p>.<id> → value is an object keyed by each input `target`
389
+ * (e.g. '{"config.x":"v","secret.y":"v"}'). Config
390
+ * targets go in the reply's `values`; secret targets
391
+ * are written out-of-band (merged into the JSON
392
+ * object keyed by the input's objectKey); replies
393
+ * { values, acknowledged? }.
394
+ * - aspect.required.<m>.<r> → value is a boolean — 'true' approves the
395
+ * base-module aspect, 'false' refuses it (ISS-0027).
396
+ * Replies { consented }; the deploy records the
397
+ * approval/denial.
398
+ */
399
+ export async function handleEventsReply(
400
+ args: string[],
401
+ flags: Record<string, string | boolean>,
402
+ ): Promise<CommandResult> {
403
+ const idArg = getArg(args, 0);
404
+ const valueArg = getArg(args, 1);
405
+ if (!idArg || valueArg === undefined) {
406
+ return {
407
+ success: false,
408
+ error: `Usage: celilo events reply <query-event-id> <value-json>
409
+ e.g. celilo events reply 42 '"lunacycle.net"'`,
410
+ };
411
+ }
412
+ const queryId = Number(idArg);
413
+ if (!Number.isInteger(queryId)) {
414
+ return { success: false, error: 'query-event-id must be an integer (from `events tail`)' };
415
+ }
416
+
417
+ let value: unknown;
418
+ try {
419
+ value = JSON.parse(valueArg);
420
+ } catch (err) {
421
+ return {
422
+ success: false,
423
+ error: `Invalid JSON value: ${err instanceof Error ? err.message : String(err)}
424
+ Encode the answer as JSON, e.g. '"lunacycle.net"', '8080', '["a","b"]'.`,
425
+ };
426
+ }
427
+
428
+ const emittedBy =
429
+ typeof flags['emitted-by'] === 'string' ? flags['emitted-by'] : 'claude-config-responder';
430
+
431
+ const bus = openCliBus();
432
+ try {
433
+ const query = bus.getEvent(queryId);
434
+ if (!query) {
435
+ return {
436
+ success: false,
437
+ error: `No event with id ${queryId} on the bus (check \`celilo events tail\`).`,
438
+ };
439
+ }
440
+
441
+ const family = interviewFamily(query.type);
442
+ if (!family) {
443
+ return {
444
+ success: false,
445
+ error: `Event ${queryId} is type '${query.type}', not an interview query.
446
+ Expected one of: config.required.* / secret.required.* / ensure.required.* / aspect.required.*`,
447
+ };
448
+ }
449
+
450
+ // First-reply-wins: if a reply already landed for this query, don't
451
+ // double-answer (and don't re-write a secret). Report who answered so the
452
+ // caller's loop can move on rather than treat it as an error.
453
+ const existingReply = bus
454
+ .recentEvents({ limit: 500, type: `${query.type}.reply` })
455
+ .find((e) => e.replyFor === queryId);
456
+ if (existingReply) {
457
+ return jsonResult({
458
+ ok: true,
459
+ status: 'already-answered',
460
+ queryId,
461
+ type: query.type,
462
+ repliedBy: existingReply.emittedBy ?? null,
463
+ });
464
+ }
465
+
466
+ if (family === 'config') {
467
+ bus.emitRaw(`${query.type}.reply`, { value }, { replyFor: queryId, emittedBy });
468
+ return jsonResult({ ok: true, status: 'replied', queryId, type: query.type, family, value });
469
+ }
470
+
471
+ if (family === 'secret') {
472
+ const payload = query.payload as { module?: unknown; key?: unknown };
473
+ if (typeof payload?.module !== 'string' || typeof payload?.key !== 'string') {
474
+ return {
475
+ success: false,
476
+ error: `Secret query ${queryId} has a malformed payload (missing module/key).`,
477
+ };
478
+ }
479
+ const { getOrCreateMasterKey } = await import('../../secrets/master-key');
480
+ const { writeModuleSecretKey } = await import('../../services/config-interview');
481
+ const plaintext = typeof value === 'string' ? value : JSON.stringify(value);
482
+ const masterKey = await getOrCreateMasterKey();
483
+ await writeModuleSecretKey(payload.module, payload.key, plaintext, getDb(), masterKey);
484
+ bus.emitRaw(`${query.type}.reply`, { acknowledged: true }, { replyFor: queryId, emittedBy });
485
+ return jsonResult({
486
+ ok: true,
487
+ status: 'replied',
488
+ queryId,
489
+ type: query.type,
490
+ family,
491
+ secretWritten: `${payload.module}.${payload.key}`,
492
+ });
493
+ }
494
+
495
+ if (family === 'aspect') {
496
+ // Aspect-consent (ISS-0027): the reply carries only the decision; the
497
+ // deploy process records the approval/denial (it holds module/version/
498
+ // scope). Config-like — `'true'` approves, `'false'` refuses.
499
+ if (typeof value !== 'boolean') {
500
+ return {
501
+ success: false,
502
+ error: `Aspect-consent reply must be a JSON boolean — 'true' to approve the aspect, 'false' to refuse. Got: ${valueArg}`,
503
+ };
504
+ }
505
+ bus.emitRaw(`${query.type}.reply`, { consented: value }, { replyFor: queryId, emittedBy });
506
+ return jsonResult({
507
+ ok: true,
508
+ status: 'replied',
509
+ queryId,
510
+ type: query.type,
511
+ family,
512
+ consented: value,
513
+ });
514
+ }
515
+
516
+ // family === 'ensure'
517
+ const payload = query.payload as EnsureRequiredPayload;
518
+ if (typeof payload?.provider !== 'string' || !Array.isArray(payload?.inputs)) {
519
+ return {
520
+ success: false,
521
+ error: `Ensure query ${queryId} has a malformed payload (missing provider/inputs).`,
522
+ };
523
+ }
524
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
525
+ const targets = payload.inputs.map((i) => i.target).join(', ');
526
+ return {
527
+ success: false,
528
+ error: `Ensure reply must be a JSON object keyed by input target, e.g.
529
+ '{"config.foo":"x","secret.bar":"y"}'
530
+ Required targets: ${targets}`,
531
+ };
532
+ }
533
+ const provided = value as Record<string, unknown>;
534
+
535
+ const { getOrCreateMasterKey } = await import('../../secrets/master-key');
536
+ const { readModuleSecretKey, writeModuleSecretKey } = await import(
537
+ '../../services/config-interview'
538
+ );
539
+ const db = getDb();
540
+ const replyValues: Record<string, unknown> = {};
541
+ let acknowledged = false;
542
+ let masterKey: Buffer | null = null;
543
+
544
+ for (const input of payload.inputs) {
545
+ const providedValue = provided[input.target];
546
+ if (providedValue === undefined) {
547
+ const targets = payload.inputs.map((i) => i.target).join(', ');
548
+ return {
549
+ success: false,
550
+ error: `Ensure reply is missing target '${input.target}'. Provide all of: ${targets}`,
551
+ };
552
+ }
553
+
554
+ if (input.target.startsWith('config.')) {
555
+ replyValues[input.target] = providedValue;
556
+ continue;
557
+ }
558
+
559
+ // Secret target: read-merge-write the JSON-encoded secret object so we
560
+ // never clobber sibling keys, then ack (value stays off the bus).
561
+ const name = input.target.slice('secret.'.length);
562
+ if (!masterKey) masterKey = await getOrCreateMasterKey();
563
+ let obj: Record<string, unknown> = {};
564
+ const currentRaw = await readModuleSecretKey(payload.provider, name, db, masterKey);
565
+ if (currentRaw) {
566
+ try {
567
+ const parsed = JSON.parse(currentRaw);
568
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
569
+ obj = parsed as Record<string, unknown>;
570
+ }
571
+ } catch {
572
+ /* malformed existing secret — overwrite */
573
+ }
574
+ }
575
+ obj[input.objectKey] =
576
+ typeof providedValue === 'string' ? providedValue : JSON.stringify(providedValue);
577
+ await writeModuleSecretKey(payload.provider, name, JSON.stringify(obj), db, masterKey);
578
+ acknowledged = true;
579
+ }
580
+
581
+ bus.emitRaw(
582
+ `${query.type}.reply`,
583
+ acknowledged ? { values: replyValues, acknowledged: true } : { values: replyValues },
584
+ { replyFor: queryId, emittedBy },
585
+ );
586
+ return jsonResult({
587
+ ok: true,
588
+ status: 'replied',
589
+ queryId,
590
+ type: query.type,
591
+ family,
592
+ values: replyValues,
593
+ acknowledged,
594
+ });
595
+ } finally {
596
+ bus.close();
597
+ }
598
+ }
599
+
357
600
  /**
358
601
  * `celilo events respond` — start a responder against the bus and
359
602
  * block. Two modes:
@@ -134,10 +134,11 @@ export async function handleModuleGenerate(
134
134
 
135
135
  if (moduleSecretsMissing.length > 0) {
136
136
  // interviewForMissingSecrets fires `secret.required.*` bus events for
137
- // user_provided secrets and waits forever for a responder. In a
138
- // non-interactive context (no TTY, no piped responder), that's a
139
- // silent hang. Probe the bus first; if nothing answers, fail fast
140
- // with an actionable error instead of stalling indefinitely.
137
+ // user_provided secrets and waits for a responder. busInterviewGuarded
138
+ // (ISS-0025) is the shared backstop that fails fast when none is listening,
139
+ // but we probe here FIRST so `module generate` can emit a command-tailored
140
+ // error: the full list of missing secrets plus a `module secret set` line
141
+ // for each. The shared guard would only name one prompt at a time.
141
142
  //
142
143
  // Auto-generated secrets (manifest `generate:` field or schema
143
144
  // source: 'generated') don't go through the bus, so missing-but-