@celilo/cli 0.3.17 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
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',
@@ -477,12 +471,6 @@ export const COMMANDS: CommandDef[] = [
477
471
  },
478
472
  ],
479
473
  },
480
- {
481
- name: 'install',
482
- description: 'Download and import a module from the registry',
483
- args: [{ name: 'name', description: 'Module name' }],
484
- flags: [{ name: 'registry', description: 'Registry URL', takesValue: true }],
485
- },
486
474
  {
487
475
  name: 'search',
488
476
  description: 'Search the module registry',
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Tests for the import-routing rule that disambiguates between
3
+ * `celilo module import caddy` → registry
4
+ * `celilo module import ./modules/caddy` → local path
5
+ *
6
+ * Pure function over the argument string; no network, no filesystem.
7
+ */
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { classifyImportArg } from './module-import';
11
+
12
+ describe('classifyImportArg', () => {
13
+ test('routes bare kebab names to the registry', () => {
14
+ expect(classifyImportArg('caddy')).toBe('name');
15
+ expect(classifyImportArg('homebridge')).toBe('name');
16
+ expect(classifyImportArg('dns-external')).toBe('name');
17
+ expect(classifyImportArg('a')).toBe('name');
18
+ });
19
+
20
+ test('routes leading-dot relative paths to the filesystem', () => {
21
+ expect(classifyImportArg('./modules/caddy')).toBe('path');
22
+ expect(classifyImportArg('../caddy')).toBe('path');
23
+ expect(classifyImportArg('./caddy')).toBe('path');
24
+ });
25
+
26
+ test('routes leading-slash absolute paths to the filesystem', () => {
27
+ expect(classifyImportArg('/tmp/caddy')).toBe('path');
28
+ expect(classifyImportArg('/abs/path/to/module')).toBe('path');
29
+ });
30
+
31
+ test('routes tilde-expanded paths to the filesystem', () => {
32
+ expect(classifyImportArg('~')).toBe('path');
33
+ expect(classifyImportArg('~/dev/caddy')).toBe('path');
34
+ });
35
+
36
+ test('routes any path containing / to the filesystem', () => {
37
+ expect(classifyImportArg('modules/caddy')).toBe('path');
38
+ expect(classifyImportArg('a/b/c')).toBe('path');
39
+ });
40
+
41
+ test('routes .netapp filenames to the filesystem (registry never serves them by name)', () => {
42
+ expect(classifyImportArg('caddy.netapp')).toBe('path');
43
+ expect(classifyImportArg('homebridge-1.0.0.netapp')).toBe('path');
44
+ });
45
+
46
+ test('does not confuse versioned names with paths (no slash, no .netapp)', () => {
47
+ // Future: registry may accept `name@version` syntax. Today this routes
48
+ // to "name" — KEBAB_NAME validation in handleModuleImport will reject
49
+ // until the syntax is implemented.
50
+ expect(classifyImportArg('caddy@1.0.0')).toBe('name');
51
+ });
52
+ });
@@ -1,7 +1,23 @@
1
1
  /**
2
2
  * Module import command
3
+ *
4
+ * Single entry point for both registry and local-source imports. Routing
5
+ * is determined by the shape of the argument:
6
+ *
7
+ * celilo module import caddy → registry (kebab name)
8
+ * celilo module import ./modules/caddy → local dir (relative path)
9
+ * celilo module import /tmp/caddy.netapp → local file (absolute path)
10
+ * celilo module import ~/dev/caddy → local dir (tilde-expanded)
11
+ * celilo module import caddy.netapp → local file (.netapp extension)
12
+ *
13
+ * The previous CLI surface had three overlapping commands for this:
14
+ * `module install` (registry-only shorthand), `module import public-registry`
15
+ * (registry, verbose), and `module import file` (local, verbose). All have
16
+ * been collapsed into this one command — the routing rule below is the
17
+ * canonical disambiguator.
3
18
  */
4
19
 
20
+ import { existsSync } from 'node:fs';
5
21
  import { unlink } from 'node:fs/promises';
6
22
  import { tmpdir } from 'node:os';
7
23
  import { join, resolve } from 'node:path';
@@ -14,47 +30,74 @@ import type { CommandResult } from '../types';
14
30
  import { generateTypesForImportedModule } from './module-types';
15
31
 
16
32
  const USAGE = `Usage:
17
- celilo module import 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}` };
69
+ if (classifyImportArg(arg) === 'name') {
70
+ if (!KEBAB_NAME.test(arg)) {
71
+ return {
72
+ success: false,
73
+ error: `Not a valid module name or path: '${arg}'.\n\n${USAGE}\n\nModule names are kebab-case (lowercase letters, digits, hyphens). For local paths, use ./, /, ~/, or include / in the argument.`,
74
+ };
41
75
  }
42
- return handlePublicRegistryImport(moduleName, flags);
76
+ return handlePublicRegistryImport(arg, flags);
43
77
  }
44
78
 
45
- // Resolve the source path: "file <path>" or bare "<path>" alias
46
- const sourcePath = subcommand === 'file' ? getArg(args, 1) : subcommand;
47
-
48
- if (!sourcePath) {
49
- return { success: false, error: `Path required.\n\n${USAGE}` };
50
- }
79
+ return handleFileImport(arg, flags);
80
+ }
51
81
 
52
- // Resolve paths
82
+ async function handleFileImport(
83
+ sourcePath: string,
84
+ flags: Record<string, string | boolean>,
85
+ ): Promise<CommandResult> {
53
86
  const resolvedSourcePath = resolve(sourcePath);
54
87
  const targetBasePath = getFlag(flags, 'target', getModuleStoragePath());
55
88
  const resolvedTargetBasePath = resolve(targetBasePath);
56
89
 
57
- // Import module
90
+ // Surface a more useful error than "module directory does not exist"
91
+ // when the user typed a registry name with a typo (e.g. `caddy/` or
92
+ // `./caddy`) but no such directory is present. Helps them realize
93
+ // they probably wanted the registry form.
94
+ if (!existsSync(resolvedSourcePath)) {
95
+ return {
96
+ success: false,
97
+ error: `Path does not exist: ${resolvedSourcePath}\n\nIf you meant the registry, use a bare module name (no leading ./ or /):\n celilo module import <name>`,
98
+ };
99
+ }
100
+
58
101
  const db = getDb();
59
102
  const result = await importModule({
60
103
  sourcePath: resolvedSourcePath,
@@ -6,7 +6,7 @@
6
6
  * surrounding workspace (if any).
7
7
  *
8
8
  * The canonical failures this catches:
9
- * - "I just installed celilo on a fresh box but module 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
 
@@ -359,10 +360,6 @@ Subcommands:
359
360
  update <path> [path...] Update module code while preserving state (configs, secrets, infra)
360
361
 
361
362
  Registry:
362
- install <name> Download and import a module from the registry
363
- Options:
364
- --registry <url> Use a custom registry (default: https://celilo.computer/registry)
365
-
366
363
  search [query] Search the registry for modules
367
364
  Options:
368
365
  --registry <url> Use a custom registry
@@ -384,14 +381,14 @@ Registry:
384
381
  --strict Treat warnings as failures
385
382
 
386
383
  Examples:
387
- celilo module install caddy
388
- celilo module install namecheap --registry https://my-registry.example.com/registry
384
+ celilo module import caddy # registry (bare name)
385
+ celilo module import namecheap --registry https://my-registry.example.com/registry
386
+ celilo module import ./modules/homebridge # local directory
387
+ celilo module import homebridge.netapp # local .netapp file
388
+ celilo module import /abs/path/to/module --target /custom/location
389
389
  celilo module search dns
390
390
  celilo module publish ./modules/caddy --token mytoken
391
391
  celilo module publish ./modules/* # publish every module in a dir
392
- celilo module import ./modules/homebridge
393
- celilo module import homebridge.netapp
394
- celilo module import /abs/path/to/module --target /custom/location
395
392
  celilo module list
396
393
  celilo module remove homebridge
397
394
  celilo module verify homebridge
@@ -1165,17 +1162,6 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1165
1162
  return handleModuleShowZone(parsed.args);
1166
1163
  case 'search':
1167
1164
  return handleModuleSearch(parsed.args, parsed.flags);
1168
- case 'install': {
1169
- // `celilo module install <name>` — shorthand for `module import public-registry <name>`
1170
- const name = parsed.args[0] ?? parsed.subcommand;
1171
- if (!name) {
1172
- return {
1173
- success: false,
1174
- error: 'Module name required\n\nUsage: celilo module install <name> [--registry <url>]',
1175
- };
1176
- }
1177
- return handlePublicRegistryImport(name, parsed.flags);
1178
- }
1179
1165
  case 'publish':
1180
1166
  return handleModulePublish(parsed.args, parsed.flags);
1181
1167
  case 'check':
@@ -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 —