@celilo/cli 0.3.17 → 0.3.19
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 +14 -25
- package/src/cli/commands/module-import-routing.test.ts +80 -0
- package/src/cli/commands/module-import.ts +93 -22
- package/src/cli/commands/module-upgrade.test.ts +58 -0
- package/src/cli/commands/module-upgrade.ts +257 -5
- 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 +13 -24
- 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',
|
|
@@ -309,11 +303,12 @@ export const COMMANDS: CommandDef[] = [
|
|
|
309
303
|
},
|
|
310
304
|
{
|
|
311
305
|
name: 'update',
|
|
312
|
-
description:
|
|
306
|
+
description:
|
|
307
|
+
'Update modules. No args = sweep registry (auto-apply non-breaking, prompt for breaking). With args = update from local path(s).',
|
|
313
308
|
args: [
|
|
314
309
|
{
|
|
315
310
|
name: 'path',
|
|
316
|
-
description: 'Path to updated module source',
|
|
311
|
+
description: 'Path to updated module source (omit to sweep registry)',
|
|
317
312
|
completion: 'directories',
|
|
318
313
|
variadic: true,
|
|
319
314
|
},
|
|
@@ -477,12 +472,6 @@ export const COMMANDS: CommandDef[] = [
|
|
|
477
472
|
},
|
|
478
473
|
],
|
|
479
474
|
},
|
|
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
475
|
{
|
|
487
476
|
name: 'search',
|
|
488
477
|
description: 'Search the module registry',
|
|
@@ -0,0 +1,80 @@
|
|
|
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, handleModuleImport } 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
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('legacy subcommand migration hints', () => {
|
|
55
|
+
test('catches `module import file <path>` and suggests the new form', async () => {
|
|
56
|
+
const result = await handleModuleImport(['file', 'celilo-registry'], {});
|
|
57
|
+
if (result.success) throw new Error('expected failure');
|
|
58
|
+
expect(result.error).toContain("'file' is no longer a subcommand");
|
|
59
|
+
// 'file' was the LOCAL form; a bare name now goes to the registry,
|
|
60
|
+
// so the hint must lead the operator to a path-shaped form.
|
|
61
|
+
expect(result.error).toContain('./celilo-registry');
|
|
62
|
+
expect(result.error).toContain('/abs/path/to/celilo-registry');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('catches `module import public-registry <name>` and suggests the new form', async () => {
|
|
66
|
+
const result = await handleModuleImport(['public-registry', 'caddy'], {});
|
|
67
|
+
if (result.success) throw new Error('expected failure');
|
|
68
|
+
expect(result.error).toContain("'public-registry' is no longer a subcommand");
|
|
69
|
+
expect(result.error).toContain('celilo module import caddy');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('does not catch a real registry name that happens to share no characters with legacy commands', async () => {
|
|
73
|
+
// Sanity: caddy → registry lookup. We can't fully exercise this without
|
|
74
|
+
// network mocks, but we can confirm it doesn't fall through the legacy
|
|
75
|
+
// branch (the error message would mention "no longer a subcommand").
|
|
76
|
+
const result = await handleModuleImport(['caddy'], { registry: 'http://0.0.0.0:1' });
|
|
77
|
+
if (result.success) throw new Error('expected failure');
|
|
78
|
+
expect(result.error).not.toContain('no longer a subcommand');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -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,102 @@ 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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
69
|
+
// Catch the legacy subcommand-style invocation (`module import file <path>`,
|
|
70
|
+
// `module import public-registry <name>`) before routing. Without this guard
|
|
71
|
+
// 'file' would round-trip to the registry as a name and surface a confusing
|
|
72
|
+
// "module not found" — the user thinks they're using a real subcommand.
|
|
73
|
+
// Both forms have the second arg in the same slot regardless of which the
|
|
74
|
+
// operator used, so the migration hint can offer the exact fix.
|
|
75
|
+
if (arg === 'file' || arg === 'public-registry') {
|
|
76
|
+
const next = getArg(args, 1);
|
|
77
|
+
// 'file' was the local-path form. A bare name now routes to the
|
|
78
|
+
// registry, so we have to suggest a path-shaped form (./, /, etc.)
|
|
79
|
+
// to preserve the original intent.
|
|
80
|
+
// 'public-registry' was the registry form. A bare name does what
|
|
81
|
+
// the operator wanted, so the hint is straightforward.
|
|
82
|
+
const example =
|
|
83
|
+
arg === 'public-registry'
|
|
84
|
+
? ` celilo module import ${next ?? '<name>'} # registry (bare name)`
|
|
85
|
+
: ` celilo module import ./${next ?? '<dir>'} # local directory (leading ./)
|
|
86
|
+
celilo module import /abs/path/to/${next ?? '<dir>'} # absolute path`;
|
|
87
|
+
return {
|
|
88
|
+
success: false,
|
|
89
|
+
error: `'${arg}' is no longer a subcommand of 'module import'. The command auto-routes by argument shape now:
|
|
90
|
+
|
|
91
|
+
${example}
|
|
44
92
|
|
|
45
|
-
|
|
46
|
-
|
|
93
|
+
A bare name (no leading ./, /, ~) is treated as a registry name. See 'celilo module import --help' for the full routing rules.`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
47
96
|
|
|
48
|
-
if (
|
|
49
|
-
|
|
97
|
+
if (classifyImportArg(arg) === 'name') {
|
|
98
|
+
if (!KEBAB_NAME.test(arg)) {
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
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.`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return handlePublicRegistryImport(arg, flags);
|
|
50
105
|
}
|
|
51
106
|
|
|
52
|
-
|
|
107
|
+
return handleFileImport(arg, flags);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function handleFileImport(
|
|
111
|
+
sourcePath: string,
|
|
112
|
+
flags: Record<string, string | boolean>,
|
|
113
|
+
): Promise<CommandResult> {
|
|
53
114
|
const resolvedSourcePath = resolve(sourcePath);
|
|
54
115
|
const targetBasePath = getFlag(flags, 'target', getModuleStoragePath());
|
|
55
116
|
const resolvedTargetBasePath = resolve(targetBasePath);
|
|
56
117
|
|
|
57
|
-
//
|
|
118
|
+
// Surface a more useful error than "module directory does not exist"
|
|
119
|
+
// when the user typed a registry name with a typo (e.g. `caddy/` or
|
|
120
|
+
// `./caddy`) but no such directory is present. Helps them realize
|
|
121
|
+
// they probably wanted the registry form.
|
|
122
|
+
if (!existsSync(resolvedSourcePath)) {
|
|
123
|
+
return {
|
|
124
|
+
success: false,
|
|
125
|
+
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>`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
58
129
|
const db = getDb();
|
|
59
130
|
const result = await importModule({
|
|
60
131
|
sourcePath: resolvedSourcePath,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the version-change classifier used by `module update`'s
|
|
3
|
+
* registry-sweep mode.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { classifyVersionChange } from './module-upgrade';
|
|
8
|
+
|
|
9
|
+
describe('classifyVersionChange', () => {
|
|
10
|
+
test('identical versions are up-to-date', () => {
|
|
11
|
+
expect(classifyVersionChange('1.0.0', '1.0.0')).toBe('up-to-date');
|
|
12
|
+
expect(classifyVersionChange('1.0.0+3', '1.0.0+3')).toBe('up-to-date');
|
|
13
|
+
expect(classifyVersionChange('2.4.7+5', '2.4.7+5')).toBe('up-to-date');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('major bump → breaking', () => {
|
|
17
|
+
expect(classifyVersionChange('1.0.0+3', '2.0.0+1')).toBe('major');
|
|
18
|
+
expect(classifyVersionChange('1.5.9', '2.0.0')).toBe('major');
|
|
19
|
+
// Even a tiny step into the next major counts as breaking; the
|
|
20
|
+
// operator decides whether to take it.
|
|
21
|
+
expect(classifyVersionChange('1.0.0+9', '2.0.0+0')).toBe('major');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('minor bump → non-breaking', () => {
|
|
25
|
+
expect(classifyVersionChange('1.0.0+3', '1.1.0+1')).toBe('minor');
|
|
26
|
+
expect(classifyVersionChange('2.5.0', '2.6.0')).toBe('minor');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('patch bump → non-breaking', () => {
|
|
30
|
+
expect(classifyVersionChange('1.0.0+3', '1.0.1+1')).toBe('patch');
|
|
31
|
+
expect(classifyVersionChange('2.5.7', '2.5.8')).toBe('patch');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('revision-only bump (+N) → patch (non-breaking)', () => {
|
|
35
|
+
// Exact case the user is hitting today: same code, fresh publish.
|
|
36
|
+
expect(classifyVersionChange('1.0.0+3', '1.0.0+4')).toBe('patch');
|
|
37
|
+
expect(classifyVersionChange('namecheap-1.0.0', 'namecheap-1.0.0+5')).not.toBe('major');
|
|
38
|
+
expect(classifyVersionChange('3.1.0+0', '3.1.0+9')).toBe('patch');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('installed ahead of registry → ahead (skip silently)', () => {
|
|
42
|
+
// Operator pushed locally without publishing — registry is stale.
|
|
43
|
+
expect(classifyVersionChange('2.0.0+1', '1.5.0+9')).toBe('ahead');
|
|
44
|
+
expect(classifyVersionChange('1.0.1', '1.0.0')).toBe('ahead');
|
|
45
|
+
expect(classifyVersionChange('1.0.0+5', '1.0.0+3')).toBe('ahead');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('tolerates `v` / `=` prefixes', () => {
|
|
49
|
+
expect(classifyVersionChange('v1.0.0+3', 'v1.0.0+4')).toBe('patch');
|
|
50
|
+
expect(classifyVersionChange('=1.0.0', '=1.1.0')).toBe('minor');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('missing patch / revision segments default to 0', () => {
|
|
54
|
+
expect(classifyVersionChange('1.0', '1.0.1')).toBe('patch');
|
|
55
|
+
expect(classifyVersionChange('1.0', '2.0')).toBe('major');
|
|
56
|
+
expect(classifyVersionChange('1.0.0', '1.0.0+1')).toBe('patch');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { cpSync, existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
13
|
+
import { unlink } from 'node:fs/promises';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
13
15
|
import { join, resolve } from 'node:path';
|
|
16
|
+
import * as p from '@clack/prompts';
|
|
14
17
|
import { eq } from 'drizzle-orm';
|
|
15
18
|
import { parse as parseYaml } from 'yaml';
|
|
16
19
|
import { registerModuleCapabilities } from '../../capabilities/registration';
|
|
@@ -19,6 +22,8 @@ import { capabilities, modules } from '../../db/schema';
|
|
|
19
22
|
import { ModuleManifestSchema } from '../../manifest/schema';
|
|
20
23
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
21
24
|
import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
|
|
25
|
+
import { RegistryClient } from '../../registry/client';
|
|
26
|
+
import { getFlag } from '../parser';
|
|
22
27
|
import { log } from '../prompts';
|
|
23
28
|
import type { CommandResult } from '../types';
|
|
24
29
|
|
|
@@ -32,6 +37,85 @@ type UpgradeOutcome =
|
|
|
32
37
|
// `celilo module update modules/*` does what users expect.
|
|
33
38
|
| { status: 'skipped'; moduleId: string; reason: string };
|
|
34
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Parse a celilo version string into [major, minor, patch, revision].
|
|
42
|
+
* Celilo's published versions look like `1.0.0+3` — semver core plus a
|
|
43
|
+
* publish revision suffix (the +N resets on every semver bump). Missing
|
|
44
|
+
* segments default to 0; non-numeric segments are clamped to 0 so we
|
|
45
|
+
* never throw on weird upstream input.
|
|
46
|
+
*/
|
|
47
|
+
function parseModuleVersion(v: string): [number, number, number, number] {
|
|
48
|
+
const cleaned = v.replace(/^[v=]+/, '');
|
|
49
|
+
const [core, rev] = cleaned.split('+');
|
|
50
|
+
const parts = (core ?? '').split('.');
|
|
51
|
+
const num = (s: string | undefined) => {
|
|
52
|
+
const n = Number(s ?? '0');
|
|
53
|
+
return Number.isNaN(n) ? 0 : n;
|
|
54
|
+
};
|
|
55
|
+
return [num(parts[0]), num(parts[1]), num(parts[2]), num(rev)];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type VersionChangeKind = 'up-to-date' | 'ahead' | 'patch' | 'minor' | 'major';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Classify a registry-side update relative to the installed version.
|
|
62
|
+
* `major` = breaking (semver-major bump). Operator must approve.
|
|
63
|
+
* `minor` = additive feature. Auto-applied.
|
|
64
|
+
* `patch` = bugfix or revision-only (+N) bump. Auto-applied.
|
|
65
|
+
* `up-to-date` = identical version.
|
|
66
|
+
* `ahead` = installed is newer than registry. Skip silently — usually
|
|
67
|
+
* means the operator pushed locally without publishing.
|
|
68
|
+
*
|
|
69
|
+
* Exported for unit tests.
|
|
70
|
+
*/
|
|
71
|
+
export function classifyVersionChange(installed: string, latest: string): VersionChangeKind {
|
|
72
|
+
const [aMaj, aMin, aPat, aRev] = parseModuleVersion(installed);
|
|
73
|
+
const [bMaj, bMin, bPat, bRev] = parseModuleVersion(latest);
|
|
74
|
+
if (bMaj > aMaj) return 'major';
|
|
75
|
+
if (bMaj < aMaj) return 'ahead';
|
|
76
|
+
if (bMin > aMin) return 'minor';
|
|
77
|
+
if (bMin < aMin) return 'ahead';
|
|
78
|
+
if (bPat > aPat) return 'patch';
|
|
79
|
+
if (bPat < aPat) return 'ahead';
|
|
80
|
+
if (bRev > aRev) return 'patch';
|
|
81
|
+
if (bRev < aRev) return 'ahead';
|
|
82
|
+
return 'up-to-date';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Download a module package from the registry into a temp file and run
|
|
87
|
+
* the standard upgradeOne path against it. Cleans the temp file in a
|
|
88
|
+
* finally block so a mid-flight failure doesn't leak a tar.zst on disk.
|
|
89
|
+
*/
|
|
90
|
+
async function fetchAndUpgrade(
|
|
91
|
+
client: RegistryClient,
|
|
92
|
+
moduleId: string,
|
|
93
|
+
version: string,
|
|
94
|
+
db: ReturnType<typeof getDb>,
|
|
95
|
+
flags: Record<string, string | boolean>,
|
|
96
|
+
): Promise<UpgradeOutcome> {
|
|
97
|
+
const tmpPath = join(tmpdir(), `${moduleId}-${version}-${Date.now()}.netapp`);
|
|
98
|
+
try {
|
|
99
|
+
const pkgData = await client.download(moduleId, version);
|
|
100
|
+
await Bun.write(tmpPath, pkgData);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return {
|
|
103
|
+
status: 'failed',
|
|
104
|
+
moduleId,
|
|
105
|
+
error: `Download failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
// Registry packages are pre-verified at publish time; skip the
|
|
110
|
+
// signature check here to match `module import`'s registry path.
|
|
111
|
+
return await upgradeOne(tmpPath, db, { ...flags, 'skip-verify': true });
|
|
112
|
+
} finally {
|
|
113
|
+
try {
|
|
114
|
+
await unlink(tmpPath);
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
35
119
|
/**
|
|
36
120
|
* Upgrade a single module from a source path
|
|
37
121
|
*/
|
|
@@ -176,14 +260,15 @@ export async function handleModuleUpgrade(
|
|
|
176
260
|
args: string[],
|
|
177
261
|
flags: Record<string, string | boolean> = {},
|
|
178
262
|
): Promise<CommandResult> {
|
|
263
|
+
const db = getDb();
|
|
264
|
+
|
|
265
|
+
// Zero args = registry sweep: walk every installed module, pick up
|
|
266
|
+
// any non-breaking update from the registry automatically, and
|
|
267
|
+
// prompt per-module for breaking (semver-major) updates.
|
|
179
268
|
if (args.length === 0) {
|
|
180
|
-
return
|
|
181
|
-
success: false,
|
|
182
|
-
error: 'At least one module path is required\n\nUsage: celilo module update <path> [path...]',
|
|
183
|
-
};
|
|
269
|
+
return runRegistrySweep(db, flags);
|
|
184
270
|
}
|
|
185
271
|
|
|
186
|
-
const db = getDb();
|
|
187
272
|
const results: UpgradeOutcome[] = [];
|
|
188
273
|
|
|
189
274
|
for (const path of args) {
|
|
@@ -241,3 +326,170 @@ export async function handleModuleUpgrade(
|
|
|
241
326
|
}
|
|
242
327
|
return { success: true, message: lines.join('\n\n') };
|
|
243
328
|
}
|
|
329
|
+
|
|
330
|
+
interface UpdatePlan {
|
|
331
|
+
moduleId: string;
|
|
332
|
+
installedVersion: string;
|
|
333
|
+
targetVersion: string;
|
|
334
|
+
classification: Exclude<VersionChangeKind, 'up-to-date' | 'ahead'>;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Walk every installed module, query the registry, and produce a plan.
|
|
339
|
+
* Auto-apply non-breaking updates (patch/minor); prompt per-module for
|
|
340
|
+
* breaking (major) updates. Modules absent from the registry are
|
|
341
|
+
* surfaced as a skip — typically these are local-only modules the
|
|
342
|
+
* operator imported from a path, never published.
|
|
343
|
+
*/
|
|
344
|
+
async function runRegistrySweep(
|
|
345
|
+
db: ReturnType<typeof getDb>,
|
|
346
|
+
flags: Record<string, string | boolean>,
|
|
347
|
+
): Promise<CommandResult> {
|
|
348
|
+
const installed = db.select().from(modules).all();
|
|
349
|
+
if (installed.length === 0) {
|
|
350
|
+
return { success: true, message: 'No modules installed.' };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const registryUrl = getFlag(flags, 'registry', '');
|
|
354
|
+
const client = new RegistryClient(registryUrl || undefined);
|
|
355
|
+
|
|
356
|
+
log.info(`Checking ${installed.length} installed module(s) against the registry…`);
|
|
357
|
+
|
|
358
|
+
const plans: UpdatePlan[] = [];
|
|
359
|
+
const upToDate: string[] = [];
|
|
360
|
+
const notInRegistry: string[] = [];
|
|
361
|
+
const errored: Array<{ moduleId: string; error: string }> = [];
|
|
362
|
+
|
|
363
|
+
for (const mod of installed) {
|
|
364
|
+
try {
|
|
365
|
+
const entries = await client.getIndex(mod.id);
|
|
366
|
+
if (entries.length === 0) {
|
|
367
|
+
notInRegistry.push(mod.id);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const latest = client.latestVersion(entries);
|
|
371
|
+
if (!latest) {
|
|
372
|
+
// All versions yanked.
|
|
373
|
+
notInRegistry.push(mod.id);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const cmp = classifyVersionChange(mod.version, latest.vers);
|
|
377
|
+
if (cmp === 'up-to-date' || cmp === 'ahead') {
|
|
378
|
+
upToDate.push(mod.id);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
plans.push({
|
|
382
|
+
moduleId: mod.id,
|
|
383
|
+
installedVersion: mod.version,
|
|
384
|
+
targetVersion: latest.vers,
|
|
385
|
+
classification: cmp,
|
|
386
|
+
});
|
|
387
|
+
} catch (err) {
|
|
388
|
+
errored.push({
|
|
389
|
+
moduleId: mod.id,
|
|
390
|
+
error: err instanceof Error ? err.message : String(err),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (plans.length === 0) {
|
|
396
|
+
const lines: string[] = ['All installed modules are up to date.'];
|
|
397
|
+
if (notInRegistry.length > 0) {
|
|
398
|
+
lines.push(`Not in registry: ${notInRegistry.join(', ')}`);
|
|
399
|
+
}
|
|
400
|
+
if (errored.length > 0) {
|
|
401
|
+
lines.push(`Registry errors: ${errored.map((e) => `${e.moduleId} (${e.error})`).join('; ')}`);
|
|
402
|
+
}
|
|
403
|
+
return { success: true, message: lines.join('\n') };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const nonBreaking = plans.filter((p) => p.classification !== 'major');
|
|
407
|
+
const breaking = plans.filter((p) => p.classification === 'major');
|
|
408
|
+
|
|
409
|
+
let appliedNonBreaking = 0;
|
|
410
|
+
const failed: Array<{ moduleId: string; error: string }> = [];
|
|
411
|
+
|
|
412
|
+
if (nonBreaking.length > 0) {
|
|
413
|
+
log.info(`Auto-applying ${nonBreaking.length} non-breaking update(s):`);
|
|
414
|
+
for (const plan of nonBreaking) {
|
|
415
|
+
console.log(
|
|
416
|
+
` ↑ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (${plan.classification})`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
for (const plan of nonBreaking) {
|
|
420
|
+
const result = await fetchAndUpgrade(client, plan.moduleId, plan.targetVersion, db, flags);
|
|
421
|
+
if (result.status === 'failed') {
|
|
422
|
+
failed.push({ moduleId: plan.moduleId, error: result.error });
|
|
423
|
+
} else if (result.status === 'success') {
|
|
424
|
+
appliedNonBreaking++;
|
|
425
|
+
}
|
|
426
|
+
// status === 'skipped' shouldn't happen for registry-fetched packages
|
|
427
|
+
// (we know the module is installed; the package definitely has a
|
|
428
|
+
// manifest), but treat it as a no-op if it does.
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let appliedBreaking = 0;
|
|
433
|
+
let skippedBreaking = 0;
|
|
434
|
+
|
|
435
|
+
if (breaking.length > 0) {
|
|
436
|
+
log.info('\nBreaking updates available — review required (semver-major bump):');
|
|
437
|
+
for (const plan of breaking) {
|
|
438
|
+
console.log(
|
|
439
|
+
` ⚠ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion}`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
log.message('Each breaking update will be applied only on explicit confirmation.\n');
|
|
443
|
+
|
|
444
|
+
for (const plan of breaking) {
|
|
445
|
+
const proceed = await p.confirm({
|
|
446
|
+
message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} → ${plan.targetVersion})?`,
|
|
447
|
+
initialValue: false,
|
|
448
|
+
});
|
|
449
|
+
if (p.isCancel(proceed) || !proceed) {
|
|
450
|
+
skippedBreaking++;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const result = await fetchAndUpgrade(client, plan.moduleId, plan.targetVersion, db, flags);
|
|
454
|
+
if (result.status === 'failed') {
|
|
455
|
+
failed.push({ moduleId: plan.moduleId, error: result.error });
|
|
456
|
+
} else if (result.status === 'success') {
|
|
457
|
+
appliedBreaking++;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Summary
|
|
463
|
+
const summary: string[] = [];
|
|
464
|
+
const totalApplied = appliedNonBreaking + appliedBreaking;
|
|
465
|
+
if (totalApplied > 0) {
|
|
466
|
+
const parts = [`${appliedNonBreaking} non-breaking`];
|
|
467
|
+
if (appliedBreaking > 0) parts.push(`${appliedBreaking} breaking`);
|
|
468
|
+
summary.push(`Applied ${totalApplied} update(s) (${parts.join(', ')}).`);
|
|
469
|
+
} else {
|
|
470
|
+
summary.push('No updates applied.');
|
|
471
|
+
}
|
|
472
|
+
if (skippedBreaking > 0) {
|
|
473
|
+
summary.push(`Skipped ${skippedBreaking} breaking update(s) (operator declined).`);
|
|
474
|
+
}
|
|
475
|
+
if (notInRegistry.length > 0) {
|
|
476
|
+
summary.push(`Not in registry (${notInRegistry.length}): ${notInRegistry.join(', ')}`);
|
|
477
|
+
}
|
|
478
|
+
if (errored.length > 0) {
|
|
479
|
+
summary.push(
|
|
480
|
+
`Registry errors (${errored.length}): ${errored.map((e) => `${e.moduleId} — ${e.error}`).join('; ')}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
if (failed.length > 0) {
|
|
484
|
+
return {
|
|
485
|
+
success: false,
|
|
486
|
+
error: [
|
|
487
|
+
...summary,
|
|
488
|
+
'',
|
|
489
|
+
'Failures:',
|
|
490
|
+
...failed.map((f) => ` ${f.moduleId}: ${f.error}`),
|
|
491
|
+
].join('\n'),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
return { success: true, message: summary.join('\n') };
|
|
495
|
+
}
|
|
@@ -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
|
|
|
@@ -356,13 +357,12 @@ Subcommands:
|
|
|
356
357
|
Options:
|
|
357
358
|
--debug Run with visible browser (Playwright hooks)
|
|
358
359
|
|
|
359
|
-
update
|
|
360
|
-
|
|
361
|
-
Registry:
|
|
362
|
-
install <name> Download and import a module from the registry
|
|
360
|
+
update Sweep registry: auto-apply non-breaking updates, prompt for breaking
|
|
361
|
+
update <path> [path...] Update module(s) from local path(s) (preserves config/secrets/infra)
|
|
363
362
|
Options:
|
|
364
|
-
--registry <url>
|
|
363
|
+
--registry <url> Use a custom registry (sweep mode only)
|
|
365
364
|
|
|
365
|
+
Registry:
|
|
366
366
|
search [query] Search the registry for modules
|
|
367
367
|
Options:
|
|
368
368
|
--registry <url> Use a custom registry
|
|
@@ -384,14 +384,14 @@ Registry:
|
|
|
384
384
|
--strict Treat warnings as failures
|
|
385
385
|
|
|
386
386
|
Examples:
|
|
387
|
-
celilo module
|
|
388
|
-
celilo module
|
|
387
|
+
celilo module import caddy # registry (bare name)
|
|
388
|
+
celilo module import namecheap --registry https://my-registry.example.com/registry
|
|
389
|
+
celilo module import ./modules/homebridge # local directory
|
|
390
|
+
celilo module import homebridge.netapp # local .netapp file
|
|
391
|
+
celilo module import /abs/path/to/module --target /custom/location
|
|
389
392
|
celilo module search dns
|
|
390
393
|
celilo module publish ./modules/caddy --token mytoken
|
|
391
394
|
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
395
|
celilo module list
|
|
396
396
|
celilo module remove homebridge
|
|
397
397
|
celilo module verify homebridge
|
|
@@ -1165,17 +1165,6 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1165
1165
|
return handleModuleShowZone(parsed.args);
|
|
1166
1166
|
case 'search':
|
|
1167
1167
|
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
1168
|
case 'publish':
|
|
1180
1169
|
return handleModulePublish(parsed.args, parsed.flags);
|
|
1181
1170
|
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 —
|