@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.
- package/drizzle/0008_aspect_consent.sql +1 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -6
- package/src/cli/command-registry.ts +38 -0
- package/src/cli/commands/backup-pull.test.ts +48 -0
- package/src/cli/commands/backup-pull.ts +116 -0
- package/src/cli/commands/events.test.ts +108 -0
- package/src/cli/commands/events.ts +243 -0
- package/src/cli/commands/module-generate.ts +5 -4
- package/src/cli/commands/module-import-aspect.test.ts +116 -0
- package/src/cli/commands/module-import.ts +12 -1
- package/src/cli/commands/restore.ts +5 -0
- package/src/cli/commands/storage-add-s3.ts +91 -46
- package/src/cli/completion.ts +2 -1
- package/src/cli/index.ts +11 -0
- package/src/db/client.ts +4 -0
- package/src/db/schema.ts +9 -1
- package/src/hooks/capability-loader.test.ts +31 -1
- package/src/hooks/capability-loader.ts +65 -16
- package/src/manifest/contracts/v1.ts +12 -0
- package/src/manifest/schema.ts +13 -1
- package/src/manifest/template-validator.ts +1 -0
- package/src/module/import.ts +10 -5
- package/src/module/packaging/build.test.ts +75 -0
- package/src/module/packaging/build.ts +9 -20
- package/src/module/packaging/package-rules.ts +44 -0
- package/src/secrets/generators.test.ts +14 -1
- package/src/secrets/generators.ts +63 -1
- package/src/services/aspect-approvals.test.ts +30 -10
- package/src/services/aspect-approvals.ts +61 -31
- package/src/services/aspect-runner.test.ts +161 -8
- package/src/services/aspect-runner.ts +156 -34
- package/src/services/backup-create.ts +11 -2
- package/src/services/bus-ensure-flow.test.ts +19 -1
- package/src/services/bus-interview.ts +56 -0
- package/src/services/bus-secret-flow.test.ts +19 -1
- package/src/services/celilo-events.test.ts +122 -0
- package/src/services/celilo-events.ts +144 -0
- package/src/services/celilo-mgmt-hooks.test.ts +30 -3
- package/src/services/config-interview.ts +38 -19
- package/src/services/deploy-planner.test.ts +66 -0
- package/src/services/deploy-planner.ts +16 -2
- package/src/services/deploy-preflight.ts +18 -1
- package/src/services/deployed-systems.ts +30 -1
- package/src/services/dns-provider-backfill.test.ts +150 -0
- package/src/services/dns-provider-backfill.ts +72 -2
- package/src/services/e2e-guard.test.ts +38 -0
- package/src/services/e2e-guard.ts +43 -0
- package/src/services/module-deploy.ts +12 -26
- package/src/services/responder-probe.test.ts +87 -0
- package/src/services/responder-probe.ts +29 -0
- package/src/services/restore-from-file.test.ts +46 -0
- package/src/services/restore-from-file.ts +106 -9
- package/src/services/storage-providers/s3.test.ts +101 -0
- package/src/templates/generator.test.ts +77 -0
- package/src/templates/generator.ts +69 -2
- package/src/variables/context.ts +34 -0
- package/src/variables/lxc-nameserver.test.ts +86 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `aspect_approvals` ADD `consented` integer DEFAULT true NOT NULL;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
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.
|
|
56
|
-
"@celilo/cli-display": "0.1.9
|
|
57
|
-
"@celilo/event-bus": "0.1.5
|
|
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
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
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-
|