@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -255,27 +255,21 @@ export const COMMANDS: CommandDef[] = [
255
255
  subcommands: [
256
256
  {
257
257
  name: 'import',
258
- description: 'Import a module (file <path> | public-registry <name>)',
259
- subcommands: [
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: 'public-registry',
267
- description: 'Import from celilo.computer registry',
268
- args: [{ name: 'name', description: 'Module name' }],
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: 'Update module code while preserving state',
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 file <path> Import from local filesystem
18
- celilo module import public-registry <name> Import from celilo.computer registry`;
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
- * Handle module import command
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
- * Usage: celilo module import file <path> [--target <path>]
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 subcommand = getArg(args, 0);
63
+ const arg = getArg(args, 0);
32
64
 
33
- if (!subcommand) {
34
- return { success: false, error: `Subcommand required.\n\n${USAGE}` };
65
+ if (!arg) {
66
+ return { success: false, error: `Module name or path required.\n\n${USAGE}` };
35
67
  }
36
68
 
37
- if (subcommand === 'public-registry') {
38
- const moduleName = getArg(args, 1);
39
- if (!moduleName) {
40
- return { success: false, error: `Module name required.\n\n${USAGE}` };
41
- }
42
- return handlePublicRegistryImport(moduleName, flags);
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
- // Resolve the source path: "file <path>" or bare "<path>" alias
46
- const sourcePath = subcommand === 'file' ? getArg(args, 1) : subcommand;
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 (!sourcePath) {
49
- return { success: false, error: `Path required.\n\n${USAGE}` };
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
- // Resolve paths
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
- // Import module
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 install is
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 install <name>` and needs a refactor before the
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."
@@ -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, handlePublicRegistryImport } from './commands/module-import';
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 <package.netapp> Import a packaged module
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> Import a module from directory or .netapp file
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 <path> [path...] Update module code while preserving state (configs, secrets, infra)
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> Use a custom registry (default: https://celilo.computer/registry)
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 install caddy
388
- celilo module install namecheap --registry https://my-registry.example.com/registry
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':
@@ -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
- type: z.enum(['string', 'integer', 'number']).default('string'),
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
- type: 'string' | 'integer' | 'number';
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
- // The bus payload narrows to scalar types. Secrets in the
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 —