@celilo/cli 0.3.4 → 0.3.9
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/package.json +2 -2
- package/src/cli/command-registry.ts +34 -5
- package/src/cli/commands/events.ts +181 -0
- package/src/cli/commands/module-deploy.ts +10 -4
- package/src/cli/commands/module-remove.ts +2 -2
- package/src/cli/commands/system-update.ts +5 -1
- package/src/cli/index.ts +7 -1
- package/src/services/bus-ensure-flow.test.ts +380 -0
- package/src/services/bus-interview.test.ts +73 -5
- package/src/services/bus-interview.ts +24 -4
- package/src/services/bus-secret-flow.test.ts +327 -0
- package/src/services/config-interview.ts +278 -255
- package/src/services/ensure-interview.test.ts +4 -6
- package/src/services/module-deploy.ts +57 -45
- package/src/services/programmatic-responder.ts +294 -0
- package/src/services/terminal-responder.ts +266 -39
- package/src/test-utils/bus-responder.ts +126 -0
- package/src/test-utils/index.ts +7 -0
- package/src/test-utils/integration.ts +12 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1 — bus-mediated secret-interview flow.
|
|
3
|
+
*
|
|
4
|
+
* Drives `interviewForMissingSecrets` against a real sqlite bus + real
|
|
5
|
+
* encrypted store + a programmatic test responder. No fixture modules,
|
|
6
|
+
* no machines, no clack — the responder is just a `bus.watch` that
|
|
7
|
+
* mimics what `terminal-responder.ts` does.
|
|
8
|
+
*
|
|
9
|
+
* Covers what stage 3 introduced:
|
|
10
|
+
* - `user_provided` / `user_password` / `generated_optional` go through
|
|
11
|
+
* the bus; the responder writes the secret out-of-band; the deploy
|
|
12
|
+
* reads from the store after ack.
|
|
13
|
+
* - `generated` and derived secrets bypass the bus entirely.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
17
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
|
|
21
|
+
import { type DbClient, getDb } from '../db/client';
|
|
22
|
+
import { modules } from '../db/schema';
|
|
23
|
+
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
24
|
+
import type { SecretRequiredPayload } from './bus-interview';
|
|
25
|
+
import {
|
|
26
|
+
interviewForMissingSecrets,
|
|
27
|
+
readModuleSecretKey,
|
|
28
|
+
writeModuleSecretKey,
|
|
29
|
+
} from './config-interview';
|
|
30
|
+
|
|
31
|
+
const NO_SCHEMAS = defineEvents({});
|
|
32
|
+
|
|
33
|
+
interface ResponderReply {
|
|
34
|
+
/** Value the responder "types" — written out-of-band before ack. */
|
|
35
|
+
value: string;
|
|
36
|
+
/**
|
|
37
|
+
* If true, fall back to auto-generate using the payload's `generate`
|
|
38
|
+
* hint (mimics what terminal-responder does on empty input for
|
|
39
|
+
* `generated_optional`). Overrides `value` when set.
|
|
40
|
+
*/
|
|
41
|
+
autoGenerate?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* In-process test responder. Watches a single secret.required event
|
|
46
|
+
* type and replies with a fixed value (or auto-generates) — mirrors
|
|
47
|
+
* the writeModuleSecretKey + emit-ack flow that terminal-responder
|
|
48
|
+
* does on a TTY.
|
|
49
|
+
*
|
|
50
|
+
* Returns a `seen` ref that captures the payloads the responder saw,
|
|
51
|
+
* so tests can assert on what the deploy emitted.
|
|
52
|
+
*/
|
|
53
|
+
function startTestResponder(
|
|
54
|
+
bus: Bus,
|
|
55
|
+
pattern: string,
|
|
56
|
+
reply: ResponderReply,
|
|
57
|
+
db: DbClient,
|
|
58
|
+
): { seen: Array<{ payload: SecretRequiredPayload; type: string }>; close: () => void } {
|
|
59
|
+
const seen: Array<{ payload: SecretRequiredPayload; type: string }> = [];
|
|
60
|
+
|
|
61
|
+
const handle = bus.watch(pattern, async (event) => {
|
|
62
|
+
if (event.replyFor !== null) return;
|
|
63
|
+
const payload = event.payload as SecretRequiredPayload;
|
|
64
|
+
seen.push({ payload, type: event.type });
|
|
65
|
+
|
|
66
|
+
let value = reply.value;
|
|
67
|
+
if (reply.autoGenerate && payload.generate) {
|
|
68
|
+
const { generateSecret } = await import('../secrets/generators');
|
|
69
|
+
value = generateSecret(payload.generate);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const masterKey = await getOrCreateMasterKey();
|
|
73
|
+
await writeModuleSecretKey(payload.module, payload.key, value, db, masterKey);
|
|
74
|
+
|
|
75
|
+
bus.emitRaw(
|
|
76
|
+
`${event.type}.reply`,
|
|
77
|
+
{ acknowledged: true },
|
|
78
|
+
{ replyFor: event.id, emittedBy: 'test-responder' },
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return { seen, close: () => handle.close() };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeSecretsSchema(
|
|
86
|
+
dir: string,
|
|
87
|
+
properties: Record<
|
|
88
|
+
string,
|
|
89
|
+
{
|
|
90
|
+
source: 'generated' | 'user_provided' | 'user_password' | 'generated_optional';
|
|
91
|
+
format?: string;
|
|
92
|
+
length?: number;
|
|
93
|
+
derive_from?: string;
|
|
94
|
+
derive_method?: string;
|
|
95
|
+
description?: string;
|
|
96
|
+
}
|
|
97
|
+
>,
|
|
98
|
+
): void {
|
|
99
|
+
mkdirSync(join(dir, 'schema'), { recursive: true });
|
|
100
|
+
const schema = {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: Object.fromEntries(
|
|
103
|
+
Object.entries(properties).map(([name, props]) => [name, { type: 'string', ...props }]),
|
|
104
|
+
),
|
|
105
|
+
};
|
|
106
|
+
writeFileSync(join(dir, 'schema', 'secrets.json'), JSON.stringify(schema));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe('bus-mediated interviewForMissingSecrets', () => {
|
|
110
|
+
let tempDir: string;
|
|
111
|
+
let testDb: DbClient;
|
|
112
|
+
let bus: Bus;
|
|
113
|
+
let busDbPath: string;
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-bus-secret-'));
|
|
117
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
118
|
+
busDbPath = join(tempDir, 'events.db');
|
|
119
|
+
process.env.EVENT_BUS_DB = busDbPath;
|
|
120
|
+
|
|
121
|
+
testDb = getDb();
|
|
122
|
+
testDb
|
|
123
|
+
.insert(modules)
|
|
124
|
+
.values({
|
|
125
|
+
id: 'testmod',
|
|
126
|
+
name: 'Test Module',
|
|
127
|
+
sourcePath: tempDir,
|
|
128
|
+
version: '1.0.0',
|
|
129
|
+
manifestData: {},
|
|
130
|
+
})
|
|
131
|
+
.run();
|
|
132
|
+
|
|
133
|
+
bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
bus.close();
|
|
138
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
139
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
140
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('user_provided: emits secret.required, responder writes, value lands in store', async () => {
|
|
144
|
+
// No schema file → defaults to user_provided per
|
|
145
|
+
// interviewForMissingSecrets's `metadata?.source || 'user_provided'`.
|
|
146
|
+
// Use a snake_case secret name to also exercise the pattern
|
|
147
|
+
// compiler's LIKE escape — pre-0.1.4 this would have thrown.
|
|
148
|
+
const { seen, close } = startTestResponder(
|
|
149
|
+
bus,
|
|
150
|
+
'secret.required.testmod.api_key',
|
|
151
|
+
{ value: 'sk-12345' },
|
|
152
|
+
testDb,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const result = await interviewForMissingSecrets(
|
|
156
|
+
'testmod',
|
|
157
|
+
[{ name: 'api_key', source: 'secret', description: 'API key for upstream' }],
|
|
158
|
+
testDb,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(result.success).toBe(true);
|
|
162
|
+
expect(result.configured).toContain('api_key (secret)');
|
|
163
|
+
|
|
164
|
+
expect(seen.length).toBe(1);
|
|
165
|
+
expect(seen[0].type).toBe('secret.required.testmod.api_key');
|
|
166
|
+
expect(seen[0].payload.module).toBe('testmod');
|
|
167
|
+
expect(seen[0].payload.key).toBe('api_key');
|
|
168
|
+
expect(seen[0].payload.style).toBe('user_provided');
|
|
169
|
+
expect(seen[0].payload.description).toBe('API key for upstream');
|
|
170
|
+
|
|
171
|
+
// The bus payload must NEVER carry the secret value.
|
|
172
|
+
expect(seen[0].payload).not.toHaveProperty('value');
|
|
173
|
+
|
|
174
|
+
const masterKey = await getOrCreateMasterKey();
|
|
175
|
+
const stored = await readModuleSecretKey('testmod', 'api_key', testDb, masterKey);
|
|
176
|
+
expect(stored).toBe('sk-12345');
|
|
177
|
+
|
|
178
|
+
close();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('user_password: payload carries style:user_password', async () => {
|
|
182
|
+
writeSecretsSchema(tempDir, {
|
|
183
|
+
admin_password: { source: 'user_password', description: 'Admin login' },
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const { seen, close } = startTestResponder(
|
|
187
|
+
bus,
|
|
188
|
+
'secret.required.testmod.admin_password',
|
|
189
|
+
{ value: 'hunter2' },
|
|
190
|
+
testDb,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const result = await interviewForMissingSecrets(
|
|
194
|
+
'testmod',
|
|
195
|
+
[{ name: 'admin_password', source: 'secret' }],
|
|
196
|
+
testDb,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(result.success).toBe(true);
|
|
200
|
+
expect(seen.length).toBe(1);
|
|
201
|
+
expect(seen[0].payload.style).toBe('user_password');
|
|
202
|
+
|
|
203
|
+
const masterKey = await getOrCreateMasterKey();
|
|
204
|
+
const stored = await readModuleSecretKey('testmod', 'admin_password', testDb, masterKey);
|
|
205
|
+
expect(stored).toBe('hunter2');
|
|
206
|
+
|
|
207
|
+
close();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('generated_optional: payload carries generate hints + style', async () => {
|
|
211
|
+
writeSecretsSchema(tempDir, {
|
|
212
|
+
session_key: { source: 'generated_optional', format: 'hex', length: 16 },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const { seen, close } = startTestResponder(
|
|
216
|
+
bus,
|
|
217
|
+
'secret.required.testmod.session_key',
|
|
218
|
+
{ value: 'user-typed-it' },
|
|
219
|
+
testDb,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const result = await interviewForMissingSecrets(
|
|
223
|
+
'testmod',
|
|
224
|
+
[{ name: 'session_key', source: 'secret' }],
|
|
225
|
+
testDb,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
expect(result.success).toBe(true);
|
|
229
|
+
expect(seen[0].payload.style).toBe('generated_optional');
|
|
230
|
+
expect(seen[0].payload.generate).toEqual({ format: 'hex', length: 16 });
|
|
231
|
+
|
|
232
|
+
const masterKey = await getOrCreateMasterKey();
|
|
233
|
+
const stored = await readModuleSecretKey('testmod', 'session_key', testDb, masterKey);
|
|
234
|
+
expect(stored).toBe('user-typed-it');
|
|
235
|
+
|
|
236
|
+
close();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('generated_optional + responder auto-generates: secret in store has the generated shape', async () => {
|
|
240
|
+
writeSecretsSchema(tempDir, {
|
|
241
|
+
session_key: { source: 'generated_optional', format: 'hex', length: 16 },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const { close } = startTestResponder(
|
|
245
|
+
bus,
|
|
246
|
+
'secret.required.testmod.session_key',
|
|
247
|
+
{ value: '', autoGenerate: true },
|
|
248
|
+
testDb,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const result = await interviewForMissingSecrets(
|
|
252
|
+
'testmod',
|
|
253
|
+
[{ name: 'session_key', source: 'secret' }],
|
|
254
|
+
testDb,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(result.success).toBe(true);
|
|
258
|
+
|
|
259
|
+
const masterKey = await getOrCreateMasterKey();
|
|
260
|
+
const stored = await readModuleSecretKey('testmod', 'session_key', testDb, masterKey);
|
|
261
|
+
// hex format with 16 bytes → 32 hex chars.
|
|
262
|
+
expect(stored).toMatch(/^[0-9a-f]{32}$/);
|
|
263
|
+
|
|
264
|
+
close();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('source=generated bypasses the bus (no event fired, no responder needed)', async () => {
|
|
268
|
+
writeSecretsSchema(tempDir, {
|
|
269
|
+
auto_token: { source: 'generated', format: 'base64', length: 32 },
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Wildcard catches any spurious user-input bus event.
|
|
273
|
+
const fired: string[] = [];
|
|
274
|
+
const handle = bus.watch('secret.required.testmod.*', (event) => {
|
|
275
|
+
if (event.replyFor !== null) return;
|
|
276
|
+
fired.push(event.type);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const result = await interviewForMissingSecrets(
|
|
280
|
+
'testmod',
|
|
281
|
+
[{ name: 'auto_token', source: 'secret' }],
|
|
282
|
+
testDb,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
expect(result.success).toBe(true);
|
|
286
|
+
expect(fired).toEqual([]);
|
|
287
|
+
|
|
288
|
+
const masterKey = await getOrCreateMasterKey();
|
|
289
|
+
const stored = await readModuleSecretKey('testmod', 'auto_token', testDb, masterKey);
|
|
290
|
+
expect(stored).toBeTruthy();
|
|
291
|
+
expect(stored?.length).toBeGreaterThan(0);
|
|
292
|
+
|
|
293
|
+
handle.close();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('manifest generate field overrides schema source — also bypasses bus', async () => {
|
|
297
|
+
writeSecretsSchema(tempDir, {
|
|
298
|
+
// Schema says user_provided, but the missing-variable carries an
|
|
299
|
+
// explicit `generate` block. The latter wins per the
|
|
300
|
+
// `hasManifestGenerate` priority in interviewForMissingSecrets.
|
|
301
|
+
override_secret: { source: 'user_provided' },
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const fired: string[] = [];
|
|
305
|
+
const handle = bus.watch('secret.required.testmod.*', (event) => {
|
|
306
|
+
if (event.replyFor !== null) return;
|
|
307
|
+
fired.push(event.type);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const result = await interviewForMissingSecrets(
|
|
311
|
+
'testmod',
|
|
312
|
+
[
|
|
313
|
+
{
|
|
314
|
+
name: 'override_secret',
|
|
315
|
+
source: 'secret',
|
|
316
|
+
generate: { method: 'random', length: 16, encoding: 'hex' },
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
testDb,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(result.success).toBe(true);
|
|
323
|
+
expect(fired).toEqual([]);
|
|
324
|
+
|
|
325
|
+
handle.close();
|
|
326
|
+
});
|
|
327
|
+
});
|