@celilo/cli 0.3.17 → 0.3.18
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 +1 -1
- package/src/cli/command-registry.ts +11 -23
- package/src/cli/commands/module-import-routing.test.ts +52 -0
- package/src/cli/commands/module-import.ts +65 -22
- package/src/cli/commands/system-doctor.ts +1 -1
- package/src/cli/commands/system-update.ts +1 -1
- package/src/cli/completion.ts +0 -6
- package/src/cli/index.ts +9 -23
- package/src/manifest/schema.ts +9 -1
- package/src/services/bus-interview.ts +13 -1
- package/src/services/bus-secret-flow.test.ts +94 -0
- package/src/services/config-interview.ts +66 -6
- package/src/services/terminal-responder.ts +75 -0
package/package.json
CHANGED
|
@@ -255,27 +255,21 @@ export const COMMANDS: CommandDef[] = [
|
|
|
255
255
|
subcommands: [
|
|
256
256
|
{
|
|
257
257
|
name: 'import',
|
|
258
|
-
description:
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
name: 'file',
|
|
262
|
-
description: 'Import from local filesystem',
|
|
263
|
-
args: [{ name: 'path', description: 'Module path', completion: 'directories' }],
|
|
264
|
-
},
|
|
258
|
+
description:
|
|
259
|
+
'Import a module from the registry (bare name) or from a local path (./, /, ~, or *.netapp)',
|
|
260
|
+
args: [
|
|
265
261
|
{
|
|
266
|
-
name: '
|
|
267
|
-
description: '
|
|
268
|
-
|
|
269
|
-
flags: [
|
|
270
|
-
{
|
|
271
|
-
name: 'registry',
|
|
272
|
-
description: 'Registry URL (overrides default celilo.computer)',
|
|
273
|
-
takesValue: true,
|
|
274
|
-
},
|
|
275
|
-
],
|
|
262
|
+
name: 'name-or-path',
|
|
263
|
+
description: 'Module name (registry) or filesystem path',
|
|
264
|
+
completion: 'directories',
|
|
276
265
|
},
|
|
277
266
|
],
|
|
278
267
|
flags: [
|
|
268
|
+
{
|
|
269
|
+
name: 'registry',
|
|
270
|
+
description: 'Registry URL (overrides default celilo.computer)',
|
|
271
|
+
takesValue: true,
|
|
272
|
+
},
|
|
279
273
|
{
|
|
280
274
|
name: 'target',
|
|
281
275
|
description: 'Target directory',
|
|
@@ -477,12 +471,6 @@ export const COMMANDS: CommandDef[] = [
|
|
|
477
471
|
},
|
|
478
472
|
],
|
|
479
473
|
},
|
|
480
|
-
{
|
|
481
|
-
name: 'install',
|
|
482
|
-
description: 'Download and import a module from the registry',
|
|
483
|
-
args: [{ name: 'name', description: 'Module name' }],
|
|
484
|
-
flags: [{ name: 'registry', description: 'Registry URL', takesValue: true }],
|
|
485
|
-
},
|
|
486
474
|
{
|
|
487
475
|
name: 'search',
|
|
488
476
|
description: 'Search the module registry',
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the import-routing rule that disambiguates between
|
|
3
|
+
* `celilo module import caddy` → registry
|
|
4
|
+
* `celilo module import ./modules/caddy` → local path
|
|
5
|
+
*
|
|
6
|
+
* Pure function over the argument string; no network, no filesystem.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { classifyImportArg } from './module-import';
|
|
11
|
+
|
|
12
|
+
describe('classifyImportArg', () => {
|
|
13
|
+
test('routes bare kebab names to the registry', () => {
|
|
14
|
+
expect(classifyImportArg('caddy')).toBe('name');
|
|
15
|
+
expect(classifyImportArg('homebridge')).toBe('name');
|
|
16
|
+
expect(classifyImportArg('dns-external')).toBe('name');
|
|
17
|
+
expect(classifyImportArg('a')).toBe('name');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('routes leading-dot relative paths to the filesystem', () => {
|
|
21
|
+
expect(classifyImportArg('./modules/caddy')).toBe('path');
|
|
22
|
+
expect(classifyImportArg('../caddy')).toBe('path');
|
|
23
|
+
expect(classifyImportArg('./caddy')).toBe('path');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('routes leading-slash absolute paths to the filesystem', () => {
|
|
27
|
+
expect(classifyImportArg('/tmp/caddy')).toBe('path');
|
|
28
|
+
expect(classifyImportArg('/abs/path/to/module')).toBe('path');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('routes tilde-expanded paths to the filesystem', () => {
|
|
32
|
+
expect(classifyImportArg('~')).toBe('path');
|
|
33
|
+
expect(classifyImportArg('~/dev/caddy')).toBe('path');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('routes any path containing / to the filesystem', () => {
|
|
37
|
+
expect(classifyImportArg('modules/caddy')).toBe('path');
|
|
38
|
+
expect(classifyImportArg('a/b/c')).toBe('path');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('routes .netapp filenames to the filesystem (registry never serves them by name)', () => {
|
|
42
|
+
expect(classifyImportArg('caddy.netapp')).toBe('path');
|
|
43
|
+
expect(classifyImportArg('homebridge-1.0.0.netapp')).toBe('path');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('does not confuse versioned names with paths (no slash, no .netapp)', () => {
|
|
47
|
+
// Future: registry may accept `name@version` syntax. Today this routes
|
|
48
|
+
// to "name" — KEBAB_NAME validation in handleModuleImport will reject
|
|
49
|
+
// until the syntax is implemented.
|
|
50
|
+
expect(classifyImportArg('caddy@1.0.0')).toBe('name');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module import command
|
|
3
|
+
*
|
|
4
|
+
* Single entry point for both registry and local-source imports. Routing
|
|
5
|
+
* is determined by the shape of the argument:
|
|
6
|
+
*
|
|
7
|
+
* celilo module import caddy → registry (kebab name)
|
|
8
|
+
* celilo module import ./modules/caddy → local dir (relative path)
|
|
9
|
+
* celilo module import /tmp/caddy.netapp → local file (absolute path)
|
|
10
|
+
* celilo module import ~/dev/caddy → local dir (tilde-expanded)
|
|
11
|
+
* celilo module import caddy.netapp → local file (.netapp extension)
|
|
12
|
+
*
|
|
13
|
+
* The previous CLI surface had three overlapping commands for this:
|
|
14
|
+
* `module install` (registry-only shorthand), `module import public-registry`
|
|
15
|
+
* (registry, verbose), and `module import file` (local, verbose). All have
|
|
16
|
+
* been collapsed into this one command — the routing rule below is the
|
|
17
|
+
* canonical disambiguator.
|
|
3
18
|
*/
|
|
4
19
|
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
5
21
|
import { unlink } from 'node:fs/promises';
|
|
6
22
|
import { tmpdir } from 'node:os';
|
|
7
23
|
import { join, resolve } from 'node:path';
|
|
@@ -14,47 +30,74 @@ import type { CommandResult } from '../types';
|
|
|
14
30
|
import { generateTypesForImportedModule } from './module-types';
|
|
15
31
|
|
|
16
32
|
const USAGE = `Usage:
|
|
17
|
-
celilo module import
|
|
18
|
-
celilo module import
|
|
33
|
+
celilo module import <name> Import from celilo.computer registry
|
|
34
|
+
celilo module import <path> Import from a local directory or .netapp file
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
celilo module import caddy
|
|
38
|
+
celilo module import ./modules/caddy
|
|
39
|
+
celilo module import /tmp/caddy.netapp`;
|
|
19
40
|
|
|
20
41
|
/**
|
|
21
|
-
*
|
|
42
|
+
* Decide whether the user's argument should route to local-filesystem
|
|
43
|
+
* import or registry import. Path-like arguments win — see header comment
|
|
44
|
+
* for the routing rules. Anything else is treated as a registry name
|
|
45
|
+
* (validated against the kebab-case rule before the network call).
|
|
22
46
|
*
|
|
23
|
-
*
|
|
24
|
-
* celilo module import public-registry <name>
|
|
25
|
-
* celilo module import <path> (alias for "file", kept for compatibility)
|
|
47
|
+
* Exposed for tests; not exported from the package.
|
|
26
48
|
*/
|
|
49
|
+
export function classifyImportArg(arg: string): 'path' | 'name' {
|
|
50
|
+
if (arg.startsWith('/') || arg.startsWith('./') || arg.startsWith('../')) return 'path';
|
|
51
|
+
if (arg.startsWith('~/') || arg === '~') return 'path';
|
|
52
|
+
if (arg.includes('/')) return 'path';
|
|
53
|
+
if (arg.endsWith('.netapp')) return 'path';
|
|
54
|
+
return 'name';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const KEBAB_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
58
|
+
|
|
27
59
|
export async function handleModuleImport(
|
|
28
60
|
args: string[],
|
|
29
61
|
flags: Record<string, string | boolean>,
|
|
30
62
|
): Promise<CommandResult> {
|
|
31
|
-
const
|
|
63
|
+
const arg = getArg(args, 0);
|
|
32
64
|
|
|
33
|
-
if (!
|
|
34
|
-
return { success: false, error: `
|
|
65
|
+
if (!arg) {
|
|
66
|
+
return { success: false, error: `Module name or path required.\n\n${USAGE}` };
|
|
35
67
|
}
|
|
36
68
|
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
69
|
+
if (classifyImportArg(arg) === 'name') {
|
|
70
|
+
if (!KEBAB_NAME.test(arg)) {
|
|
71
|
+
return {
|
|
72
|
+
success: false,
|
|
73
|
+
error: `Not a valid module name or path: '${arg}'.\n\n${USAGE}\n\nModule names are kebab-case (lowercase letters, digits, hyphens). For local paths, use ./, /, ~/, or include / in the argument.`,
|
|
74
|
+
};
|
|
41
75
|
}
|
|
42
|
-
return handlePublicRegistryImport(
|
|
76
|
+
return handlePublicRegistryImport(arg, flags);
|
|
43
77
|
}
|
|
44
78
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (!sourcePath) {
|
|
49
|
-
return { success: false, error: `Path required.\n\n${USAGE}` };
|
|
50
|
-
}
|
|
79
|
+
return handleFileImport(arg, flags);
|
|
80
|
+
}
|
|
51
81
|
|
|
52
|
-
|
|
82
|
+
async function handleFileImport(
|
|
83
|
+
sourcePath: string,
|
|
84
|
+
flags: Record<string, string | boolean>,
|
|
85
|
+
): Promise<CommandResult> {
|
|
53
86
|
const resolvedSourcePath = resolve(sourcePath);
|
|
54
87
|
const targetBasePath = getFlag(flags, 'target', getModuleStoragePath());
|
|
55
88
|
const resolvedTargetBasePath = resolve(targetBasePath);
|
|
56
89
|
|
|
57
|
-
//
|
|
90
|
+
// Surface a more useful error than "module directory does not exist"
|
|
91
|
+
// when the user typed a registry name with a typo (e.g. `caddy/` or
|
|
92
|
+
// `./caddy`) but no such directory is present. Helps them realize
|
|
93
|
+
// they probably wanted the registry form.
|
|
94
|
+
if (!existsSync(resolvedSourcePath)) {
|
|
95
|
+
return {
|
|
96
|
+
success: false,
|
|
97
|
+
error: `Path does not exist: ${resolvedSourcePath}\n\nIf you meant the registry, use a bare module name (no leading ./ or /):\n celilo module import <name>`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
58
101
|
const db = getDb();
|
|
59
102
|
const result = await importModule({
|
|
60
103
|
sourcePath: resolvedSourcePath,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* surrounding workspace (if any).
|
|
7
7
|
*
|
|
8
8
|
* The canonical failures this catches:
|
|
9
|
-
* - "I just installed celilo on a fresh box but module
|
|
9
|
+
* - "I just installed celilo on a fresh box but module import is
|
|
10
10
|
* erroring with a child-process exit 127." → prereq section.
|
|
11
11
|
* - "I edited the workspace but my global celilo is still running
|
|
12
12
|
* an older published version." → drift section.
|
|
@@ -142,7 +142,7 @@ async function buildSnapshots(
|
|
|
142
142
|
* - `backup` calls `createModuleBackup`. Modules without an
|
|
143
143
|
* `on_backup` hook return ok (nothing to back up; not an error).
|
|
144
144
|
* - `upgrade` is a no-op for now — the in-place upgrade path lives
|
|
145
|
-
* in `module
|
|
145
|
+
* in `module import <name>` and needs a refactor before the
|
|
146
146
|
* orchestrator can drive it cleanly. Deploy will re-converge
|
|
147
147
|
* against whatever's installed, which is the right behavior for
|
|
148
148
|
* "redeploy what's there."
|
package/src/cli/completion.ts
CHANGED
|
@@ -123,7 +123,6 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
123
123
|
const subcommands = [
|
|
124
124
|
'check',
|
|
125
125
|
'import',
|
|
126
|
-
'install',
|
|
127
126
|
'list',
|
|
128
127
|
'publish',
|
|
129
128
|
'remove',
|
|
@@ -150,11 +149,6 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
150
149
|
return filterSuggestions(subcommands, args[1] || '');
|
|
151
150
|
}
|
|
152
151
|
|
|
153
|
-
// Module import subcommands (celilo module import file|public-registry)
|
|
154
|
-
if (command === 'module' && args[1] === 'import' && currentIndex === 2) {
|
|
155
|
-
return filterSuggestions(['file', 'public-registry'], args[2] || '');
|
|
156
|
-
}
|
|
157
|
-
|
|
158
152
|
// Module config subcommands (celilo module config set/get)
|
|
159
153
|
if (command === 'module' && args[1] === 'config' && currentIndex === 2) {
|
|
160
154
|
const subcommands = ['set', 'get'];
|
package/src/cli/index.ts
CHANGED
|
@@ -49,7 +49,7 @@ import { handleModuleConfigGet, handleModuleConfigSet } from './commands/module-
|
|
|
49
49
|
import { handleModuleDeploy } from './commands/module-deploy';
|
|
50
50
|
import { handleModuleGenerate } from './commands/module-generate';
|
|
51
51
|
import { handleModuleHealth } from './commands/module-health';
|
|
52
|
-
import { handleModuleImport
|
|
52
|
+
import { handleModuleImport } from './commands/module-import';
|
|
53
53
|
import { handleModuleList } from './commands/module-list';
|
|
54
54
|
import { handleModuleLogs } from './commands/module-logs';
|
|
55
55
|
import { handleModulePublish } from './commands/module-publish';
|
|
@@ -218,7 +218,7 @@ Examples:
|
|
|
218
218
|
celilo package ../custom-module
|
|
219
219
|
|
|
220
220
|
Related Commands:
|
|
221
|
-
celilo module import <
|
|
221
|
+
celilo module import <name-or-path> Import a module (registry name or local path)
|
|
222
222
|
celilo module verify <module-id> Verify package integrity
|
|
223
223
|
`;
|
|
224
224
|
|
|
@@ -313,8 +313,9 @@ Usage:
|
|
|
313
313
|
celilo module <subcommand> [args...] [options]
|
|
314
314
|
|
|
315
315
|
Subcommands:
|
|
316
|
-
import <path>
|
|
316
|
+
import <name-or-path> Import a module from the registry (bare name) or a local path
|
|
317
317
|
Options:
|
|
318
|
+
--registry <url> Use a custom registry (default: https://celilo.computer/registry)
|
|
318
319
|
--target <path> Target base directory (default: platform-specific)
|
|
319
320
|
--auto-generate-secrets Auto-generate all secrets without prompting
|
|
320
321
|
|
|
@@ -359,10 +360,6 @@ Subcommands:
|
|
|
359
360
|
update <path> [path...] Update module code while preserving state (configs, secrets, infra)
|
|
360
361
|
|
|
361
362
|
Registry:
|
|
362
|
-
install <name> Download and import a module from the registry
|
|
363
|
-
Options:
|
|
364
|
-
--registry <url> Use a custom registry (default: https://celilo.computer/registry)
|
|
365
|
-
|
|
366
363
|
search [query] Search the registry for modules
|
|
367
364
|
Options:
|
|
368
365
|
--registry <url> Use a custom registry
|
|
@@ -384,14 +381,14 @@ Registry:
|
|
|
384
381
|
--strict Treat warnings as failures
|
|
385
382
|
|
|
386
383
|
Examples:
|
|
387
|
-
celilo module
|
|
388
|
-
celilo module
|
|
384
|
+
celilo module import caddy # registry (bare name)
|
|
385
|
+
celilo module import namecheap --registry https://my-registry.example.com/registry
|
|
386
|
+
celilo module import ./modules/homebridge # local directory
|
|
387
|
+
celilo module import homebridge.netapp # local .netapp file
|
|
388
|
+
celilo module import /abs/path/to/module --target /custom/location
|
|
389
389
|
celilo module search dns
|
|
390
390
|
celilo module publish ./modules/caddy --token mytoken
|
|
391
391
|
celilo module publish ./modules/* # publish every module in a dir
|
|
392
|
-
celilo module import ./modules/homebridge
|
|
393
|
-
celilo module import homebridge.netapp
|
|
394
|
-
celilo module import /abs/path/to/module --target /custom/location
|
|
395
392
|
celilo module list
|
|
396
393
|
celilo module remove homebridge
|
|
397
394
|
celilo module verify homebridge
|
|
@@ -1165,17 +1162,6 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1165
1162
|
return handleModuleShowZone(parsed.args);
|
|
1166
1163
|
case 'search':
|
|
1167
1164
|
return handleModuleSearch(parsed.args, parsed.flags);
|
|
1168
|
-
case 'install': {
|
|
1169
|
-
// `celilo module install <name>` — shorthand for `module import public-registry <name>`
|
|
1170
|
-
const name = parsed.args[0] ?? parsed.subcommand;
|
|
1171
|
-
if (!name) {
|
|
1172
|
-
return {
|
|
1173
|
-
success: false,
|
|
1174
|
-
error: 'Module name required\n\nUsage: celilo module install <name> [--registry <url>]',
|
|
1175
|
-
};
|
|
1176
|
-
}
|
|
1177
|
-
return handlePublicRegistryImport(name, parsed.flags);
|
|
1178
|
-
}
|
|
1179
1165
|
case 'publish':
|
|
1180
1166
|
return handleModulePublish(parsed.args, parsed.flags);
|
|
1181
1167
|
case 'check':
|
package/src/manifest/schema.ts
CHANGED
|
@@ -95,7 +95,10 @@ export const SecretGenerateSchema = z.object({
|
|
|
95
95
|
|
|
96
96
|
export const SecretDeclareSchema = z.object({
|
|
97
97
|
name: z.string().min(1),
|
|
98
|
-
|
|
98
|
+
// `string-map` is `Record<string, string>` (e.g. domain → password). It's
|
|
99
|
+
// stored as JSON.stringify'd text on disk, but the interview uses an
|
|
100
|
+
// add-loop UX so the operator never has to type JSON braces.
|
|
101
|
+
type: z.enum(['string', 'integer', 'number', 'string-map']).default('string'),
|
|
99
102
|
required: z.boolean().default(false),
|
|
100
103
|
description: z.string().optional(),
|
|
101
104
|
sensitive: z.boolean().default(true), // Don't log in CLI output
|
|
@@ -104,6 +107,11 @@ export const SecretDeclareSchema = z.object({
|
|
|
104
107
|
minimum: z.number().optional(),
|
|
105
108
|
maximum: z.number().optional(),
|
|
106
109
|
pattern: z.string().optional(),
|
|
110
|
+
// For `type: string-map` only: human-readable labels shown in the
|
|
111
|
+
// add-loop prompt. Defaults are 'key' / 'value' which are usually too
|
|
112
|
+
// generic; namecheap wants 'Domain' / 'Password'.
|
|
113
|
+
key_label: z.string().optional(),
|
|
114
|
+
value_label: z.string().optional(),
|
|
107
115
|
});
|
|
108
116
|
|
|
109
117
|
/**
|
|
@@ -70,11 +70,23 @@ export interface ConfigReply {
|
|
|
70
70
|
export interface SecretRequiredPayload {
|
|
71
71
|
module: string;
|
|
72
72
|
key: string;
|
|
73
|
-
|
|
73
|
+
/**
|
|
74
|
+
* `string-map` = Record<string, string>; the responder runs an
|
|
75
|
+
* add-loop and serializes to JSON before storing, so the operator
|
|
76
|
+
* never types braces or commas. See terminal-responder.ts for the UX.
|
|
77
|
+
*/
|
|
78
|
+
type: 'string' | 'integer' | 'number' | 'string-map';
|
|
74
79
|
required: boolean;
|
|
75
80
|
description?: string;
|
|
76
81
|
style?: 'user_provided' | 'user_password' | 'generated_optional';
|
|
77
82
|
generate?: { format: string; length: number };
|
|
83
|
+
/**
|
|
84
|
+
* For `type: string-map` only — labels shown in the add-loop prompt.
|
|
85
|
+
* Defaults to 'key' / 'value' when absent. namecheap uses
|
|
86
|
+
* 'Domain' / 'Password'.
|
|
87
|
+
*/
|
|
88
|
+
key_label?: string;
|
|
89
|
+
value_label?: string;
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
export interface SecretAck {
|
|
@@ -324,4 +324,98 @@ describe('bus-mediated interviewForMissingSecrets', () => {
|
|
|
324
324
|
|
|
325
325
|
handle.close();
|
|
326
326
|
});
|
|
327
|
+
|
|
328
|
+
// string-map secrets: the responder collects key/value pairs via an
|
|
329
|
+
// add-loop and JSON-stringifies the result before writing. The bus
|
|
330
|
+
// payload signals the type so the responder picks the right UX.
|
|
331
|
+
test('string-map: payload carries type=string-map + labels; stored value is JSON-stringified record', async () => {
|
|
332
|
+
const { seen, close } = startTestResponder(
|
|
333
|
+
bus,
|
|
334
|
+
'secret.required.testmod.ddns_passwords',
|
|
335
|
+
{ value: JSON.stringify({ 'lunacycle.net': 'pw1', 'celilo.computer': 'pw2' }) },
|
|
336
|
+
testDb,
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const result = await interviewForMissingSecrets(
|
|
340
|
+
'testmod',
|
|
341
|
+
[
|
|
342
|
+
{
|
|
343
|
+
name: 'ddns_passwords',
|
|
344
|
+
source: 'secret',
|
|
345
|
+
type: 'string-map',
|
|
346
|
+
description: 'Namecheap Dynamic DNS password per managed domain',
|
|
347
|
+
key_label: 'Domain',
|
|
348
|
+
value_label: 'Password',
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
testDb,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
expect(result.success).toBe(true);
|
|
355
|
+
expect(seen.length).toBe(1);
|
|
356
|
+
expect(seen[0].payload.type).toBe('string-map');
|
|
357
|
+
expect(seen[0].payload.key_label).toBe('Domain');
|
|
358
|
+
expect(seen[0].payload.value_label).toBe('Password');
|
|
359
|
+
|
|
360
|
+
// Bus must NOT carry the secret value, even for string-map.
|
|
361
|
+
expect(seen[0].payload).not.toHaveProperty('value');
|
|
362
|
+
|
|
363
|
+
const masterKey = await getOrCreateMasterKey();
|
|
364
|
+
const stored = await readModuleSecretKey('testmod', 'ddns_passwords', testDb, masterKey);
|
|
365
|
+
expect(stored).toBe(JSON.stringify({ 'lunacycle.net': 'pw1', 'celilo.computer': 'pw2' }));
|
|
366
|
+
|
|
367
|
+
close();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('string-map: enrichment from manifest.yml makes its way to the bus payload', async () => {
|
|
371
|
+
// Drop a manifest.yml in tempDir (the module's sourcePath) declaring
|
|
372
|
+
// the secret as string-map with custom labels. validateModuleSecrets
|
|
373
|
+
// reads the manifest and threads the metadata through to the bus.
|
|
374
|
+
writeSecretsSchema(tempDir, {
|
|
375
|
+
ddns_passwords: { source: 'user_provided' },
|
|
376
|
+
});
|
|
377
|
+
writeFileSync(
|
|
378
|
+
join(tempDir, 'manifest.yml'),
|
|
379
|
+
`
|
|
380
|
+
celilo_contract: "1.0"
|
|
381
|
+
id: testmod
|
|
382
|
+
name: Test Module
|
|
383
|
+
version: 1.0.0
|
|
384
|
+
secrets:
|
|
385
|
+
declares:
|
|
386
|
+
- name: ddns_passwords
|
|
387
|
+
type: string-map
|
|
388
|
+
required: true
|
|
389
|
+
description: Per-domain DDNS password
|
|
390
|
+
sensitive: true
|
|
391
|
+
key_label: Domain
|
|
392
|
+
value_label: Password
|
|
393
|
+
`,
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const { seen, close } = startTestResponder(
|
|
397
|
+
bus,
|
|
398
|
+
'secret.required.testmod.ddns_passwords',
|
|
399
|
+
{ value: JSON.stringify({ 'a.test': 'pw' }) },
|
|
400
|
+
testDb,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const { interviewForMissingSecrets, validateModuleSecrets } = await import(
|
|
404
|
+
'./config-interview'
|
|
405
|
+
);
|
|
406
|
+
const missing = await validateModuleSecrets('testmod', testDb);
|
|
407
|
+
expect(missing).toHaveLength(1);
|
|
408
|
+
expect(missing[0].name).toBe('ddns_passwords');
|
|
409
|
+
expect(missing[0].type).toBe('string-map');
|
|
410
|
+
expect(missing[0].key_label).toBe('Domain');
|
|
411
|
+
expect(missing[0].value_label).toBe('Password');
|
|
412
|
+
|
|
413
|
+
const result = await interviewForMissingSecrets('testmod', missing, testDb);
|
|
414
|
+
expect(result.success).toBe(true);
|
|
415
|
+
expect(seen[0].payload.type).toBe('string-map');
|
|
416
|
+
expect(seen[0].payload.key_label).toBe('Domain');
|
|
417
|
+
expect(seen[0].payload.value_label).toBe('Password');
|
|
418
|
+
|
|
419
|
+
close();
|
|
420
|
+
});
|
|
327
421
|
});
|
|
@@ -19,11 +19,54 @@ import {
|
|
|
19
19
|
} from './bus-interview';
|
|
20
20
|
import { getSecretMetadata, loadSecretsSchema } from './secret-schema-loader';
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Pull the manifest's `secrets.declares[]` entries keyed by name. Best-
|
|
24
|
+
* effort — returns an empty Map on any read/parse failure so the caller
|
|
25
|
+
* can fall back to JSON-schema-only interview behavior.
|
|
26
|
+
*/
|
|
27
|
+
async function loadManifestSecretDeclares(
|
|
28
|
+
moduleId: string,
|
|
29
|
+
db: DbClient,
|
|
30
|
+
): Promise<
|
|
31
|
+
Map<string, { type?: string; description?: string; key_label?: string; value_label?: string }>
|
|
32
|
+
> {
|
|
33
|
+
const out = new Map<
|
|
34
|
+
string,
|
|
35
|
+
{ type?: string; description?: string; key_label?: string; value_label?: string }
|
|
36
|
+
>();
|
|
37
|
+
const moduleRow = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
38
|
+
if (!moduleRow?.sourcePath) return out;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const { readFile } = await import('node:fs/promises');
|
|
42
|
+
const { join } = await import('node:path');
|
|
43
|
+
const { parse: parseYaml } = await import('yaml');
|
|
44
|
+
const yamlContent = await readFile(join(moduleRow.sourcePath, 'manifest.yml'), 'utf-8');
|
|
45
|
+
const parsed = parseYaml(yamlContent) as { secrets?: { declares?: unknown[] } } | undefined;
|
|
46
|
+
const declares = parsed?.secrets?.declares;
|
|
47
|
+
if (!Array.isArray(declares)) return out;
|
|
48
|
+
for (const d of declares) {
|
|
49
|
+
if (typeof d !== 'object' || d === null) continue;
|
|
50
|
+
const decl = d as Record<string, unknown>;
|
|
51
|
+
if (typeof decl.name !== 'string') continue;
|
|
52
|
+
out.set(decl.name, {
|
|
53
|
+
type: typeof decl.type === 'string' ? decl.type : undefined,
|
|
54
|
+
description: typeof decl.description === 'string' ? decl.description : undefined,
|
|
55
|
+
key_label: typeof decl.key_label === 'string' ? decl.key_label : undefined,
|
|
56
|
+
value_label: typeof decl.value_label === 'string' ? decl.value_label : undefined,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Manifest unreadable / malformed — fall back to schema-only.
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
22
65
|
export interface MissingVariable {
|
|
23
66
|
name: string;
|
|
24
67
|
source: 'user' | 'secret' | 'capability' | 'system';
|
|
25
68
|
description?: string;
|
|
26
|
-
/** Variable type from manifest (string, array, etc.) */
|
|
69
|
+
/** Variable type from manifest (string, array, string-map, etc.) */
|
|
27
70
|
type?: string;
|
|
28
71
|
/** Derivation source (e.g., "$machine:ipAddress") */
|
|
29
72
|
derive_from?: string;
|
|
@@ -42,6 +85,9 @@ export interface MissingVariable {
|
|
|
42
85
|
length: number;
|
|
43
86
|
encoding: string;
|
|
44
87
|
};
|
|
88
|
+
/** For `type: string-map` only — labels shown in the add-loop prompt. */
|
|
89
|
+
key_label?: string;
|
|
90
|
+
value_label?: string;
|
|
45
91
|
}
|
|
46
92
|
|
|
47
93
|
export interface InterviewResult {
|
|
@@ -445,6 +491,12 @@ export async function validateModuleSecrets(
|
|
|
445
491
|
return [];
|
|
446
492
|
}
|
|
447
493
|
|
|
494
|
+
// Best-effort: load manifest declarations so we can enrich each missing
|
|
495
|
+
// secret with `type` (e.g. 'string-map'), `key_label`, `value_label`.
|
|
496
|
+
// The JSON schema knows shape; the manifest knows interview UX. If the
|
|
497
|
+
// manifest can't be read, we degrade to plain prompts.
|
|
498
|
+
const manifestDeclares = await loadManifestSecretDeclares(moduleId, db);
|
|
499
|
+
|
|
448
500
|
// Check each declared secret
|
|
449
501
|
for (const [secretName, propertySchema] of Object.entries(schema.properties)) {
|
|
450
502
|
// Check if secret exists in database
|
|
@@ -455,10 +507,14 @@ export async function validateModuleSecrets(
|
|
|
455
507
|
.get();
|
|
456
508
|
|
|
457
509
|
if (!existing) {
|
|
510
|
+
const declare = manifestDeclares.get(secretName);
|
|
458
511
|
missingSecrets.push({
|
|
459
512
|
name: secretName,
|
|
460
513
|
source: 'secret',
|
|
461
|
-
description: propertySchema.description || propertySchema.title,
|
|
514
|
+
description: declare?.description || propertySchema.description || propertySchema.title,
|
|
515
|
+
type: declare?.type,
|
|
516
|
+
key_label: declare?.key_label,
|
|
517
|
+
value_label: declare?.value_label,
|
|
462
518
|
});
|
|
463
519
|
}
|
|
464
520
|
}
|
|
@@ -614,13 +670,15 @@ export async function interviewForMissingSecrets(
|
|
|
614
670
|
// value into the encrypted store out-of-band, then replies
|
|
615
671
|
// with `{ acknowledged: true }`. The value never crosses the
|
|
616
672
|
// bus. See INTERACTIVE_DEPLOYS_VIA_BUS.md.
|
|
673
|
+
// The bus payload accepts scalar types and `string-map`
|
|
674
|
+
// (Record<string, string>, gathered via add-loop, stored as JSON).
|
|
675
|
+
// Cross-module ensure flows write composite shapes via a separate
|
|
676
|
+
// path, but the interview-driven `string-map` lands here.
|
|
677
|
+
const declaredType = (variable.type as SecretRequiredPayload['type']) ?? 'string';
|
|
617
678
|
const payload: SecretRequiredPayload = {
|
|
618
679
|
module: moduleId,
|
|
619
680
|
key: variable.name,
|
|
620
|
-
|
|
621
|
-
// wider system can be objects, but those are written via
|
|
622
|
-
// the cross-module ensure flow, not this interview.
|
|
623
|
-
type: 'string',
|
|
681
|
+
type: declaredType === 'string-map' ? 'string-map' : 'string',
|
|
624
682
|
required: true,
|
|
625
683
|
description: variable.description,
|
|
626
684
|
style: source,
|
|
@@ -631,6 +689,8 @@ export async function interviewForMissingSecrets(
|
|
|
631
689
|
length: metadata?.length || 32,
|
|
632
690
|
}
|
|
633
691
|
: undefined,
|
|
692
|
+
key_label: variable.key_label,
|
|
693
|
+
value_label: variable.value_label,
|
|
634
694
|
};
|
|
635
695
|
await busInterview<SecretAck>(EVENT_TYPES.secretRequired(moduleId, variable.name), payload);
|
|
636
696
|
log.success(`Saved ${variable.name}`);
|
|
@@ -325,6 +325,13 @@ async function promptForSecret(payload: SecretRequiredPayload): Promise<string |
|
|
|
325
325
|
: `${payload.module}.${payload.key}:`;
|
|
326
326
|
const style = payload.style ?? 'user_provided';
|
|
327
327
|
|
|
328
|
+
// string-map: Record<string, string> gathered via add-loop, stored as
|
|
329
|
+
// JSON. Bypasses the style switch — string-map secrets are always
|
|
330
|
+
// collected key-by-key, never as a single masked input.
|
|
331
|
+
if (payload.type === 'string-map') {
|
|
332
|
+
return promptForStringMap(payload);
|
|
333
|
+
}
|
|
334
|
+
|
|
328
335
|
if (style === 'user_password') {
|
|
329
336
|
while (true) {
|
|
330
337
|
const value = await promptPassword({
|
|
@@ -368,6 +375,74 @@ async function promptForSecret(payload: SecretRequiredPayload): Promise<string |
|
|
|
368
375
|
return value;
|
|
369
376
|
}
|
|
370
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Add-loop UX for `type: string-map` secrets. Collects key/value pairs
|
|
380
|
+
* one at a time (key via `promptText`, value via `promptPassword` since
|
|
381
|
+
* we're inside the secret responder), then JSON-stringifies the result
|
|
382
|
+
* for storage. The operator never has to type braces, quotes, or commas.
|
|
383
|
+
*
|
|
384
|
+
* Empty key terminates the loop. If `payload.required` and zero entries
|
|
385
|
+
* have been collected, we re-ask rather than ack-ing with an empty map —
|
|
386
|
+
* the alternative would be a successful "ack" that fails downstream
|
|
387
|
+
* validation, which is worse UX.
|
|
388
|
+
*/
|
|
389
|
+
async function promptForStringMap(payload: SecretRequiredPayload): Promise<string | undefined> {
|
|
390
|
+
const keyLabel = payload.key_label ?? 'key';
|
|
391
|
+
const valueLabel = payload.value_label ?? 'value';
|
|
392
|
+
const header = payload.description
|
|
393
|
+
? `${payload.module}.${payload.key} — ${payload.description}`
|
|
394
|
+
: `${payload.module}.${payload.key}`;
|
|
395
|
+
log.message(header);
|
|
396
|
+
log.message(
|
|
397
|
+
`Add ${keyLabel.toLowerCase()} → ${valueLabel.toLowerCase()} entries one at a time. Press Enter on an empty ${keyLabel.toLowerCase()} when done.`,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const collected: Record<string, string> = {};
|
|
401
|
+
|
|
402
|
+
while (true) {
|
|
403
|
+
const count = Object.keys(collected).length;
|
|
404
|
+
const keyMessage = count === 0 ? `${keyLabel}:` : `${keyLabel} (or Enter to finish):`;
|
|
405
|
+
|
|
406
|
+
const key = await promptText({
|
|
407
|
+
message: keyMessage,
|
|
408
|
+
validate: () => undefined, // empty = finish
|
|
409
|
+
});
|
|
410
|
+
if (key === undefined) {
|
|
411
|
+
// User cancelled (Ctrl-C). Return undefined so the responder
|
|
412
|
+
// doesn't ack — the deploy stays paused.
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
const trimmedKey = key.trim();
|
|
416
|
+
if (trimmedKey === '') {
|
|
417
|
+
if (count === 0 && payload.required) {
|
|
418
|
+
log.error(
|
|
419
|
+
`At least one ${keyLabel.toLowerCase()} is required. Add an entry or Ctrl-C to cancel.`,
|
|
420
|
+
);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (collected[trimmedKey] !== undefined) {
|
|
427
|
+
log.warn(`${keyLabel} '${trimmedKey}' was already entered — overwriting previous value.`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const value = await promptPassword({
|
|
431
|
+
message: `${valueLabel} for '${trimmedKey}':`,
|
|
432
|
+
validate: (val) => (!val || val.trim() === '' ? 'Required' : undefined),
|
|
433
|
+
});
|
|
434
|
+
if (value === undefined) {
|
|
435
|
+
// User cancelled mid-entry; abort the whole interview.
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
collected[trimmedKey] = value;
|
|
440
|
+
log.success(`Added ${trimmedKey}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return JSON.stringify(collected);
|
|
444
|
+
}
|
|
445
|
+
|
|
371
446
|
/**
|
|
372
447
|
* Short hint shown in the prompt for non-string types so the operator
|
|
373
448
|
* isn't guessing what shape we want. Returns null for plain strings —
|