@celilo/cli 0.3.19 → 0.3.21

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.19",
3
+ "version": "0.3.21",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * Unit tests for the version-change classifier used by `module update`'s
3
- * registry-sweep mode.
3
+ * registry-sweep mode, plus integration tests for `upgradeOne`'s
4
+ * `displayVersion` / `quiet` opts that drive the cleaner sweep output.
4
5
  */
5
6
 
6
- import { describe, expect, test } from 'bun:test';
7
- import { classifyVersionChange } from './module-upgrade';
7
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { type DbClient, getDb } from '../../db/client';
12
+ import { modules } from '../../db/schema';
13
+ import { classifyVersionChange, upgradeOne } from './module-upgrade';
8
14
 
9
15
  describe('classifyVersionChange', () => {
10
16
  test('identical versions are up-to-date', () => {
@@ -56,3 +62,95 @@ describe('classifyVersionChange', () => {
56
62
  expect(classifyVersionChange('1.0.0', '1.0.0+1')).toBe('patch');
57
63
  });
58
64
  });
65
+
66
+ describe('upgradeOne — displayVersion and quiet', () => {
67
+ let tempDir: string;
68
+ let srcDir: string;
69
+ let installedDir: string;
70
+ let db: DbClient;
71
+
72
+ beforeEach(() => {
73
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-'));
74
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
75
+ process.env.CELILO_ORIGINAL_CWD = tempDir;
76
+
77
+ // Pre-installed module landing zone (where files get copied to).
78
+ installedDir = join(tempDir, 'installed', 'testmod');
79
+ mkdirSync(installedDir, { recursive: true });
80
+
81
+ // "New" source dir we're upgrading from (mimics a registry extract).
82
+ srcDir = join(tempDir, 'src');
83
+ mkdirSync(srcDir, { recursive: true });
84
+ writeFileSync(
85
+ join(srcDir, 'manifest.yml'),
86
+ `celilo_contract: "1.0"
87
+ id: testmod
88
+ name: Test Module
89
+ version: 1.0.0
90
+ description: fixture
91
+ `,
92
+ );
93
+
94
+ db = getDb();
95
+ // Seed the modules table with the "currently installed" record.
96
+ // We deliberately put a registry-style version string in here so
97
+ // displayVersion-less upgrades preserve the previousVersion field.
98
+ db.insert(modules)
99
+ .values({
100
+ id: 'testmod',
101
+ name: 'Test Module',
102
+ sourcePath: installedDir,
103
+ version: '1.0.0+5',
104
+ manifestData: {
105
+ celilo_contract: '1.0',
106
+ id: 'testmod',
107
+ name: 'Test Module',
108
+ version: '1.0.0',
109
+ },
110
+ })
111
+ .run();
112
+ });
113
+
114
+ afterEach(() => {
115
+ rmSync(tempDir, { recursive: true, force: true });
116
+ process.env.CELILO_DB_PATH = undefined;
117
+ process.env.CELILO_ORIGINAL_CWD = undefined;
118
+ });
119
+
120
+ test('returns previousVersion + newVersion in the success outcome', async () => {
121
+ const result = await upgradeOne(srcDir, db, {}, { quiet: true });
122
+ expect(result.status).toBe('success');
123
+ if (result.status !== 'success') return; // narrow for ts
124
+ expect(result.moduleId).toBe('testmod');
125
+ expect(result.previousVersion).toBe('1.0.0+5');
126
+ // No displayVersion supplied → falls back to the new manifest's
127
+ // semver core (no +N visible to the operator from a path upgrade).
128
+ expect(result.newVersion).toBe('1.0.0');
129
+ });
130
+
131
+ test('displayVersion is persisted to modules.version (so +N survives)', async () => {
132
+ // The bug case: registry says 1.0.0+6, manifest says 1.0.0. Without
133
+ // displayVersion, the +6 was dropped on the floor and `module list`
134
+ // rolled back to "1.0.0", masking the actual installed revision.
135
+ const result = await upgradeOne(srcDir, db, {}, { quiet: true, displayVersion: '1.0.0+6' });
136
+ expect(result.status).toBe('success');
137
+ if (result.status !== 'success') return;
138
+ expect(result.newVersion).toBe('1.0.0+6');
139
+
140
+ const row = db.select().from(modules).all()[0];
141
+ expect(row.version).toBe('1.0.0+6');
142
+ });
143
+
144
+ test('quiet=true suppresses the per-call log lines (caller renders its own)', async () => {
145
+ // We can't easily intercept @clack's log output without adding test
146
+ // hooks, so instead we exercise that the call simply succeeds and
147
+ // returns the structured outcome — the sweep relies on this to
148
+ // render its own output without duplicates. A non-quiet call
149
+ // exercises the same code path with the log lines enabled; both
150
+ // return the same shape.
151
+ const quietResult = await upgradeOne(srcDir, db, {}, { quiet: true });
152
+ expect(quietResult.status).toBe('success');
153
+ if (quietResult.status !== 'success') return;
154
+ expect(quietResult.newVersion).toBe('1.0.0');
155
+ });
156
+ });
@@ -28,7 +28,16 @@ import { log } from '../prompts';
28
28
  import type { CommandResult } from '../types';
29
29
 
30
30
  type UpgradeOutcome =
31
- | { status: 'success'; moduleId: string }
31
+ | {
32
+ status: 'success';
33
+ moduleId: string;
34
+ /** Version that was on disk before the upgrade (manifest.yml semver). */
35
+ previousVersion: string;
36
+ /** Version that's now installed. Includes +N revision when known
37
+ * (registry-driven upgrades pass the canonical "1.0.0+5" form;
38
+ * path-driven upgrades fall back to the manifest semver). */
39
+ newVersion: string;
40
+ }
32
41
  | { status: 'failed'; moduleId: string; error: string }
33
42
  // `skipped` means the path expanded from a glob but isn't an
34
43
  // upgradable target — either no manifest at all (probably a non-
@@ -37,6 +46,18 @@ type UpgradeOutcome =
37
46
  // `celilo module update modules/*` does what users expect.
38
47
  | { status: 'skipped'; moduleId: string; reason: string };
39
48
 
49
+ /**
50
+ * Tunables for `upgradeOne`. Quiet mode silences the per-call log
51
+ * lines so callers driving a batch (the registry sweep) can render
52
+ * their own structured output without duplicates. `displayVersion`
53
+ * lets the registry caller carry the canonical `+N` revision through
54
+ * to both the DB column and the success log.
55
+ */
56
+ interface UpgradeOpts {
57
+ quiet?: boolean;
58
+ displayVersion?: string;
59
+ }
60
+
40
61
  /**
41
62
  * Parse a celilo version string into [major, minor, patch, revision].
42
63
  * Celilo's published versions look like `1.0.0+3` — semver core plus a
@@ -108,7 +129,18 @@ async function fetchAndUpgrade(
108
129
  try {
109
130
  // Registry packages are pre-verified at publish time; skip the
110
131
  // signature check here to match `module import`'s registry path.
111
- return await upgradeOne(tmpPath, db, { ...flags, 'skip-verify': true });
132
+ // `quiet: true` suppresses upgradeOne's per-call log lines so the
133
+ // sweep can render its own structured per-module output without
134
+ // duplicates. `displayVersion: version` carries the registry's
135
+ // canonical "X.Y.Z+N" through to both the DB column and the success
136
+ // log line — without it, output would say "v1.0.0 → v1.0.0" because
137
+ // the manifest semver doesn't include the +N revision.
138
+ return await upgradeOne(
139
+ tmpPath,
140
+ db,
141
+ { ...flags, 'skip-verify': true },
142
+ { quiet: true, displayVersion: version },
143
+ );
112
144
  } finally {
113
145
  try {
114
146
  await unlink(tmpPath);
@@ -119,10 +151,11 @@ async function fetchAndUpgrade(
119
151
  /**
120
152
  * Upgrade a single module from a source path
121
153
  */
122
- async function upgradeOne(
154
+ export async function upgradeOne(
123
155
  sourcePath: string,
124
156
  db: ReturnType<typeof getDb>,
125
157
  flags: Record<string, string | boolean> = {},
158
+ opts: UpgradeOpts = {},
126
159
  ): Promise<UpgradeOutcome> {
127
160
  const originalCwd = process.env.CELILO_ORIGINAL_CWD || process.cwd();
128
161
  const importPath = resolve(originalCwd, sourcePath);
@@ -162,7 +195,7 @@ async function upgradeOne(
162
195
  error: verifyResult.error || 'Package verification failed',
163
196
  };
164
197
  }
165
- } else {
198
+ } else if (!opts.quiet) {
166
199
  log.warn('Skipping package signature verification (--skip-verify)');
167
200
  }
168
201
  }
@@ -207,8 +240,17 @@ async function upgradeOne(
207
240
  };
208
241
  }
209
242
 
210
- const oldManifest = module.manifestData as ModuleManifest;
211
- log.info(`Upgrading ${moduleId}: v${oldManifest.version} v${newManifest.version}`);
243
+ // Old version comes from the DB so we capture whatever was last
244
+ // recorded (which IS the registry-versioned form, e.g. "1.0.0+5",
245
+ // for registry-driven installs/upgrades).
246
+ const previousVersion = module.version;
247
+ // New version: prefer the caller-supplied display version (registry's
248
+ // canonical "X.Y.Z+N"), fall back to the manifest semver core when
249
+ // upgrading from a local path.
250
+ const newVersion = opts.displayVersion ?? newManifest.version;
251
+ if (!opts.quiet) {
252
+ log.info(`Upgrading ${moduleId}: ${previousVersion} → ${newVersion}`);
253
+ }
212
254
 
213
255
  // Copy new module files, preserving generated output and state
214
256
  const installedPath = module.sourcePath;
@@ -226,11 +268,13 @@ async function upgradeOne(
226
268
  // Clean up temp dir if we extracted a .netapp
227
269
  if (tempDir) await cleanupTempDir(tempDir);
228
270
 
229
- // Update manifest in database
271
+ // Update manifest in database. We persist the display version (with
272
+ // +N when known) so subsequent `module list` / `module update` calls
273
+ // see the same version string the registry reported.
230
274
  db.update(modules)
231
275
  .set({
232
276
  manifestData: newManifest as unknown as Record<string, unknown>,
233
- version: newManifest.version,
277
+ version: newVersion,
234
278
  name: newManifest.name,
235
279
  })
236
280
  .where(eq(modules.id, moduleId))
@@ -241,13 +285,18 @@ async function upgradeOne(
241
285
 
242
286
  if (newManifest.provides?.capabilities && newManifest.provides.capabilities.length > 0) {
243
287
  const regResult = await registerModuleCapabilities(moduleId, newManifest, db.$client);
244
- if (!regResult.success) {
288
+ if (!regResult.success && !opts.quiet) {
289
+ // Capability re-registration warnings are useful when upgrading
290
+ // from a path (operator iterating on dev module); for the
291
+ // registry sweep, the caller will surface them itself if needed.
245
292
  log.warn(` ${moduleId}: capability re-registration warning: ${regResult.error}`);
246
293
  }
247
294
  }
248
295
 
249
- log.success(`Upgraded ${moduleId} (v${oldManifest.version} → v${newManifest.version})`);
250
- return { status: 'success', moduleId };
296
+ if (!opts.quiet) {
297
+ log.success(`Upgraded ${moduleId} (${previousVersion} ${newVersion})`);
298
+ }
299
+ return { status: 'success', moduleId, previousVersion, newVersion };
251
300
  }
252
301
 
253
302
  /**
@@ -411,17 +460,18 @@ async function runRegistrySweep(
411
460
 
412
461
  if (nonBreaking.length > 0) {
413
462
  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
463
  for (const plan of nonBreaking) {
420
464
  const result = await fetchAndUpgrade(client, plan.moduleId, plan.targetVersion, db, flags);
421
465
  if (result.status === 'failed') {
422
466
  failed.push({ moduleId: plan.moduleId, error: result.error });
467
+ console.log(
468
+ ` ✗ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (${plan.classification}, FAILED)`,
469
+ );
423
470
  } else if (result.status === 'success') {
424
471
  appliedNonBreaking++;
472
+ console.log(
473
+ ` ✓ ${plan.moduleId.padEnd(30)} ${result.previousVersion} → ${result.newVersion} (${plan.classification})`,
474
+ );
425
475
  }
426
476
  // status === 'skipped' shouldn't happen for registry-fetched packages
427
477
  // (we know the module is installed; the package definitely has a
@@ -453,8 +503,14 @@ async function runRegistrySweep(
453
503
  const result = await fetchAndUpgrade(client, plan.moduleId, plan.targetVersion, db, flags);
454
504
  if (result.status === 'failed') {
455
505
  failed.push({ moduleId: plan.moduleId, error: result.error });
506
+ console.log(
507
+ ` ✗ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (major, FAILED)`,
508
+ );
456
509
  } else if (result.status === 'success') {
457
510
  appliedBreaking++;
511
+ console.log(
512
+ ` ✓ ${plan.moduleId.padEnd(30)} ${result.previousVersion} → ${result.newVersion} (major)`,
513
+ );
458
514
  }
459
515
  }
460
516
  }
@@ -17,51 +17,124 @@ import {
17
17
  type SecretRequiredPayload,
18
18
  busInterview,
19
19
  } from './bus-interview';
20
- import { getSecretMetadata, loadSecretsSchema } from './secret-schema-loader';
20
+ import { getSecretMetadata } from './secret-schema-loader';
21
21
 
22
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.
23
+ * Read a module's manifest from disk. Returns null on any read/parse
24
+ * failure (so callers can degrade gracefully instead of crashing the
25
+ * deploy/generate flow on a malformed install).
26
+ *
27
+ * Used by `validateModuleSecrets` to discover the canonical secret
28
+ * declarations. Deploy-side callers already have the manifest loaded
29
+ * and call `findMissingSecrets` directly without going through here.
26
30
  */
27
- async function loadManifestSecretDeclares(
31
+ async function loadInstalledManifest(
28
32
  moduleId: string,
29
33
  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
- >();
34
+ ): Promise<{ secrets?: { declares?: unknown[] } } | null> {
37
35
  const moduleRow = db.select().from(modules).where(eq(modules.id, moduleId)).get();
38
- if (!moduleRow?.sourcePath) return out;
36
+ if (!moduleRow?.sourcePath) return null;
39
37
 
40
38
  try {
41
39
  const { readFile } = await import('node:fs/promises');
42
40
  const { join } = await import('node:path');
43
41
  const { parse: parseYaml } = await import('yaml');
44
42
  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
- }
43
+ return parseYaml(yamlContent) as { secrets?: { declares?: unknown[] } };
59
44
  } catch {
60
- // Manifest unreadable / malformed — fall back to schema-only.
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Canonical "find missing secrets" entry point. Walks
51
+ * `manifest.secrets.declares[]`, filters to required + uninstalled,
52
+ * and projects each one into a MissingVariable with all the fields
53
+ * downstream consumers (config-interview, terminal-responder) need:
54
+ * type (incl. 'string-map'), key_label/value_label (for the add-loop
55
+ * prompt), and the auto-generation block.
56
+ *
57
+ * Single source of truth for both the deploy path
58
+ * (`findMissingRequiredVariables` in deploy-validation.ts) and the
59
+ * generate path (`validateModuleSecrets` below). Previously these
60
+ * had two separate implementations that diverged on what fields they
61
+ * carried — a regression slipped through where deploy was dropping
62
+ * type/key_label/value_label, leaving namecheap on the old JSON-blob
63
+ * prompt UX even after the manifest declared `string-map`. One impl,
64
+ * one set of tests.
65
+ */
66
+ interface SecretDeclareLike {
67
+ name: string;
68
+ type?: string;
69
+ required?: boolean;
70
+ description?: string;
71
+ key_label?: string;
72
+ value_label?: string;
73
+ generate?: { method: string; length: number; encoding: string };
74
+ }
75
+
76
+ /**
77
+ * Coerce one entry from `manifest.secrets.declares[]` into the strict
78
+ * shape `findMissingSecrets` consumes. Returns null on any item that's
79
+ * unrecognizable (not an object, missing name, etc.) — best-effort
80
+ * parsing matches the deploy/generate paths' historical leniency.
81
+ */
82
+ function coerceSecretDeclare(entry: unknown): SecretDeclareLike | null {
83
+ if (typeof entry !== 'object' || entry === null) return null;
84
+ const e = entry as Record<string, unknown>;
85
+ if (typeof e.name !== 'string') return null;
86
+ const out: SecretDeclareLike = { name: e.name };
87
+ if (typeof e.type === 'string') out.type = e.type;
88
+ if (typeof e.required === 'boolean') out.required = e.required;
89
+ if (typeof e.description === 'string') out.description = e.description;
90
+ if (typeof e.key_label === 'string') out.key_label = e.key_label;
91
+ if (typeof e.value_label === 'string') out.value_label = e.value_label;
92
+ if (typeof e.generate === 'object' && e.generate !== null) {
93
+ const g = e.generate as Record<string, unknown>;
94
+ if (
95
+ typeof g.method === 'string' &&
96
+ typeof g.length === 'number' &&
97
+ typeof g.encoding === 'string'
98
+ ) {
99
+ out.generate = { method: g.method, length: g.length, encoding: g.encoding };
100
+ }
61
101
  }
62
102
  return out;
63
103
  }
64
104
 
105
+ export async function findMissingSecrets(
106
+ moduleId: string,
107
+ manifest: { secrets?: { declares?: unknown[] } },
108
+ db: DbClient,
109
+ ): Promise<MissingVariable[]> {
110
+ const missing: MissingVariable[] = [];
111
+ if (!manifest.secrets?.declares) return missing;
112
+
113
+ for (const raw of manifest.secrets.declares) {
114
+ const secret = coerceSecretDeclare(raw);
115
+ if (!secret) continue;
116
+ if (!secret.required) continue;
117
+
118
+ const existing = db
119
+ .select()
120
+ .from(secrets)
121
+ .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, secret.name)))
122
+ .get();
123
+ if (existing) continue;
124
+
125
+ missing.push({
126
+ name: secret.name,
127
+ source: 'secret',
128
+ description: secret.description,
129
+ type: secret.type,
130
+ key_label: secret.key_label,
131
+ value_label: secret.value_label,
132
+ generate: secret.generate,
133
+ });
134
+ }
135
+ return missing;
136
+ }
137
+
65
138
  export interface MissingVariable {
66
139
  name: string;
67
140
  source: 'user' | 'secret' | 'capability' | 'system';
@@ -482,42 +555,13 @@ export async function validateModuleSecrets(
482
555
  moduleId: string,
483
556
  db: DbClient,
484
557
  ): Promise<MissingVariable[]> {
485
- const missingSecrets: MissingVariable[] = [];
486
-
487
- // Load secrets schema
488
- const schema = await loadSecretsSchema(moduleId, db);
489
- if (!schema) {
490
- // No schema file = no secrets declared
491
- return [];
492
- }
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
-
500
- // Check each declared secret
501
- for (const [secretName, propertySchema] of Object.entries(schema.properties)) {
502
- // Check if secret exists in database
503
- const existing = db
504
- .select()
505
- .from(secrets)
506
- .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, secretName)))
507
- .get();
508
-
509
- if (!existing) {
510
- const declare = manifestDeclares.get(secretName);
511
- missingSecrets.push({
512
- name: secretName,
513
- source: 'secret',
514
- description: declare?.description || propertySchema.description || propertySchema.title,
515
- type: declare?.type,
516
- key_label: declare?.key_label,
517
- value_label: declare?.value_label,
518
- });
519
- }
520
- }
558
+ // The manifest is the canonical declaration of which secrets a
559
+ // module needs (every module in the codebase has one; the
560
+ // schema/secrets.json file is supplementary, used for getSecretMetadata
561
+ // lookups elsewhere). Read it directly and delegate to findMissingSecrets.
562
+ const manifest = await loadInstalledManifest(moduleId, db);
563
+ if (!manifest) return [];
564
+ const missingSecrets = await findMissingSecrets(moduleId, manifest, db);
521
565
 
522
566
  return missingSecrets;
523
567
  }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Regression tests for the deploy-side missing-secret discovery path
3
+ * AND the shared `findMissingSecrets` it now delegates to.
4
+ *
5
+ * Background: there used to be two implementations of "find missing
6
+ * secrets" — `validateModuleSecrets` (used by `module generate`) and
7
+ * `findMissingRequiredVariables` (used by `module deploy`). They
8
+ * diverged on what fields they carried; deploy was silently dropping
9
+ * `type` / `key_label` / `value_label`, which routed string-map
10
+ * secrets through the wrong responder UX.
11
+ *
12
+ * Both call sites now delegate to `findMissingSecrets` in
13
+ * config-interview.ts. These tests cover both layers: the shared
14
+ * function directly, and the public deploy entry point that wraps it.
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
18
+ import { mkdtempSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { type DbClient, getDb } from '../db/client';
22
+ import { modules, secrets } from '../db/schema';
23
+ import type { ModuleManifest } from '../manifest/schema';
24
+ import { findMissingSecrets } from './config-interview';
25
+ import { findMissingRequiredVariables } from './deploy-validation';
26
+
27
+ function manifestWithStringMapSecret(): ModuleManifest {
28
+ return {
29
+ celilo_contract: '1.0',
30
+ id: 'testmod',
31
+ name: 'Test Module',
32
+ version: '1.0.0',
33
+ description: 'fixture',
34
+ secrets: {
35
+ declares: [
36
+ {
37
+ name: 'ddns_passwords',
38
+ type: 'string-map',
39
+ required: true,
40
+ description: 'DDNS password per managed domain',
41
+ sensitive: true,
42
+ key_label: 'Domain',
43
+ value_label: 'Password',
44
+ },
45
+ ],
46
+ },
47
+ } as unknown as ModuleManifest;
48
+ }
49
+
50
+ function manifestWithPlainStringSecret(): ModuleManifest {
51
+ return {
52
+ celilo_contract: '1.0',
53
+ id: 'testmod',
54
+ name: 'Test Module',
55
+ version: '1.0.0',
56
+ description: 'fixture',
57
+ secrets: {
58
+ declares: [
59
+ {
60
+ name: 'api_key',
61
+ type: 'string',
62
+ required: true,
63
+ description: 'Upstream API key',
64
+ sensitive: true,
65
+ },
66
+ ],
67
+ },
68
+ } as unknown as ModuleManifest;
69
+ }
70
+
71
+ describe('findMissingRequiredVariables (deploy path)', () => {
72
+ let tempDir: string;
73
+ let db: DbClient;
74
+
75
+ beforeEach(() => {
76
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-deploy-validation-'));
77
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
78
+ db = getDb();
79
+ db.insert(modules)
80
+ .values({
81
+ id: 'testmod',
82
+ name: 'Test Module',
83
+ sourcePath: tempDir,
84
+ version: '1.0.0',
85
+ manifestData: {},
86
+ })
87
+ .run();
88
+ });
89
+
90
+ afterEach(() => {
91
+ rmSync(tempDir, { recursive: true, force: true });
92
+ process.env.CELILO_DB_PATH = undefined;
93
+ });
94
+
95
+ // The bug we're guarding against: the deploy used to drop type +
96
+ // labels on the floor here, so the bus payload arrived with type:
97
+ // 'string' and the responder defaulted to single-line input.
98
+ test('propagates type / key_label / value_label for string-map secrets', async () => {
99
+ const missing = await findMissingRequiredVariables(
100
+ 'testmod',
101
+ manifestWithStringMapSecret(),
102
+ db,
103
+ );
104
+ expect(missing).toHaveLength(1);
105
+ expect(missing[0]).toMatchObject({
106
+ name: 'ddns_passwords',
107
+ source: 'secret',
108
+ type: 'string-map',
109
+ key_label: 'Domain',
110
+ value_label: 'Password',
111
+ description: 'DDNS password per managed domain',
112
+ });
113
+ });
114
+
115
+ test('plain string secrets still arrive with type=string and no labels', async () => {
116
+ const missing = await findMissingRequiredVariables(
117
+ 'testmod',
118
+ manifestWithPlainStringSecret(),
119
+ db,
120
+ );
121
+ expect(missing).toHaveLength(1);
122
+ expect(missing[0]).toMatchObject({
123
+ name: 'api_key',
124
+ source: 'secret',
125
+ type: 'string',
126
+ description: 'Upstream API key',
127
+ });
128
+ expect(missing[0].key_label).toBeUndefined();
129
+ expect(missing[0].value_label).toBeUndefined();
130
+ });
131
+
132
+ test('configured secrets are not reported as missing', async () => {
133
+ db.insert(secrets)
134
+ .values({
135
+ moduleId: 'testmod',
136
+ name: 'ddns_passwords',
137
+ encryptedValue: 'dummy',
138
+ iv: 'dummy',
139
+ authTag: 'dummy',
140
+ })
141
+ .run();
142
+ const missing = await findMissingRequiredVariables(
143
+ 'testmod',
144
+ manifestWithStringMapSecret(),
145
+ db,
146
+ );
147
+ expect(missing).toHaveLength(0);
148
+ });
149
+
150
+ test('optional secrets are skipped (only required show up)', async () => {
151
+ const manifest = {
152
+ ...manifestWithStringMapSecret(),
153
+ secrets: {
154
+ declares: [
155
+ {
156
+ name: 'ddns_passwords',
157
+ type: 'string-map',
158
+ required: false,
159
+ description: 'optional',
160
+ sensitive: true,
161
+ },
162
+ ],
163
+ },
164
+ } as unknown as ModuleManifest;
165
+ const missing = await findMissingRequiredVariables('testmod', manifest, db);
166
+ expect(missing).toHaveLength(0);
167
+ });
168
+ });
169
+
170
+ describe('findMissingSecrets (shared)', () => {
171
+ let tempDir: string;
172
+ let db: DbClient;
173
+
174
+ beforeEach(() => {
175
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-find-missing-secrets-'));
176
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
177
+ db = getDb();
178
+ db.insert(modules)
179
+ .values({
180
+ id: 'testmod',
181
+ name: 'Test Module',
182
+ sourcePath: tempDir,
183
+ version: '1.0.0',
184
+ manifestData: {},
185
+ })
186
+ .run();
187
+ });
188
+
189
+ afterEach(() => {
190
+ rmSync(tempDir, { recursive: true, force: true });
191
+ process.env.CELILO_DB_PATH = undefined;
192
+ });
193
+
194
+ test('accepts loose unknown[] declares (manifest read off disk)', async () => {
195
+ // The validateModuleSecrets path reads manifest.yml off disk and
196
+ // hands us a loose-typed shape. findMissingSecrets coerces each
197
+ // entry per-field; malformed entries are skipped, well-formed ones
198
+ // pass through with their fields preserved.
199
+ const looseManifest = {
200
+ secrets: {
201
+ declares: [
202
+ {
203
+ name: 'ok_secret',
204
+ type: 'string-map',
205
+ required: true,
206
+ key_label: 'K',
207
+ value_label: 'V',
208
+ },
209
+ { name: 'no_required_field', type: 'string' }, // required missing → skipped
210
+ { name: 'optional', type: 'string', required: false }, // required:false → skipped
211
+ 'this is not an object', // skipped
212
+ { /* no name */ required: true }, // skipped
213
+ {
214
+ name: 'with_generate',
215
+ required: true,
216
+ generate: { method: 'random', length: 32, encoding: 'base64' },
217
+ },
218
+ ],
219
+ },
220
+ };
221
+ const missing = await findMissingSecrets('testmod', looseManifest, db);
222
+ expect(missing.map((m) => m.name)).toEqual(['ok_secret', 'with_generate']);
223
+
224
+ const okEntry = missing.find((m) => m.name === 'ok_secret');
225
+ expect(okEntry?.type).toBe('string-map');
226
+ expect(okEntry?.key_label).toBe('K');
227
+ expect(okEntry?.value_label).toBe('V');
228
+
229
+ const genEntry = missing.find((m) => m.name === 'with_generate');
230
+ expect(genEntry?.generate).toEqual({ method: 'random', length: 32, encoding: 'base64' });
231
+ });
232
+
233
+ test('returns empty array when manifest has no secrets section', async () => {
234
+ expect(await findMissingSecrets('testmod', {}, db)).toEqual([]);
235
+ expect(await findMissingSecrets('testmod', { secrets: {} }, db)).toEqual([]);
236
+ expect(await findMissingSecrets('testmod', { secrets: { declares: [] } }, db)).toEqual([]);
237
+ });
238
+
239
+ test('drops entries whose secrets are already in the DB', async () => {
240
+ db.insert(secrets)
241
+ .values({
242
+ moduleId: 'testmod',
243
+ name: 'already_set',
244
+ encryptedValue: 'x',
245
+ iv: 'x',
246
+ authTag: 'x',
247
+ })
248
+ .run();
249
+ const missing = await findMissingSecrets(
250
+ 'testmod',
251
+ {
252
+ secrets: {
253
+ declares: [
254
+ { name: 'already_set', required: true },
255
+ { name: 'still_missing', required: true },
256
+ ],
257
+ },
258
+ },
259
+ db,
260
+ );
261
+ expect(missing.map((m) => m.name)).toEqual(['still_missing']);
262
+ });
263
+ });
@@ -9,9 +9,10 @@ import { join } from 'node:path';
9
9
  import { compareConsumerToProvider } from '@celilo/capabilities';
10
10
  import { and, eq } from 'drizzle-orm';
11
11
  import type { DbClient } from '../db/client';
12
- import { capabilities, moduleConfigs, modules, secrets } from '../db/schema';
12
+ import { capabilities, moduleConfigs, modules } from '../db/schema';
13
13
  import type { ModuleManifest } from '../manifest/schema';
14
14
  import { generateTemplates } from '../templates/generator';
15
+ import { findMissingSecrets } from './config-interview';
15
16
  import { buildModuleFromSource, getModuleBuildStatus, verifyArtifactsExist } from './module-build';
16
17
 
17
18
  export interface ValidationResult {
@@ -29,6 +30,9 @@ export interface ValidationResult {
29
30
  options?: Array<{ value: string; label: string; hint?: string }>;
30
31
  per_selection?: { key_pattern: string; prompt: string; type?: string; derive_from?: string };
31
32
  generate?: { method: string; length: number; encoding: string };
33
+ /** For `type: string-map` only — labels shown in the add-loop prompt. */
34
+ key_label?: string;
35
+ value_label?: string;
32
36
  }>;
33
37
  }
34
38
 
@@ -370,7 +374,7 @@ function getSuggestedDeploymentOrder(
370
374
  * @param db - Database connection
371
375
  * @returns Array of missing required variables
372
376
  */
373
- async function findMissingRequiredVariables(
377
+ export async function findMissingRequiredVariables(
374
378
  moduleId: string,
375
379
  manifest: ModuleManifest,
376
380
  db: DbClient,
@@ -383,6 +387,9 @@ async function findMissingRequiredVariables(
383
387
  type?: string;
384
388
  options?: Array<{ value: string; label: string; hint?: string }>;
385
389
  per_selection?: { key_pattern: string; prompt: string; type?: string; derive_from?: string };
390
+ generate?: { method: string; length: number; encoding: string };
391
+ key_label?: string;
392
+ value_label?: string;
386
393
  }>
387
394
  > {
388
395
  const missing: Array<{
@@ -394,6 +401,8 @@ async function findMissingRequiredVariables(
394
401
  options?: Array<{ value: string; label: string; hint?: string }>;
395
402
  per_selection?: { key_pattern: string; prompt: string; type?: string; derive_from?: string };
396
403
  generate?: { method: string; length: number; encoding: string };
404
+ key_label?: string;
405
+ value_label?: string;
397
406
  }> = [];
398
407
 
399
408
  // Check declared variables (user config, capability, system, infrastructure)
@@ -442,27 +451,22 @@ async function findMissingRequiredVariables(
442
451
  }
443
452
  }
444
453
 
445
- // Check declared secrets
446
- if (manifest.secrets?.declares) {
447
- for (const secret of manifest.secrets.declares) {
448
- if (!secret.required) continue; // Skip optional secrets
449
-
450
- // Check if secret is configured
451
- const secretRecord = await db
452
- .select()
453
- .from(secrets)
454
- .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, secret.name)))
455
- .get();
456
-
457
- if (!secretRecord) {
458
- missing.push({
459
- name: secret.name,
460
- source: 'secret',
461
- description: secret.description,
462
- generate: secret.generate,
463
- });
464
- }
465
- }
454
+ // Delegate the secret-discovery half to the canonical implementation
455
+ // in config-interview.ts. Previously the secrets section was inlined
456
+ // here and diverged from validateModuleSecrets — most recently
457
+ // dropping `type` / `key_label` / `value_label` on the floor, which
458
+ // routed string-map secrets through the wrong responder UX.
459
+ const missingSecrets = await findMissingSecrets(moduleId, manifest, db);
460
+ for (const s of missingSecrets) {
461
+ missing.push({
462
+ name: s.name,
463
+ source: s.source,
464
+ description: s.description,
465
+ type: s.type,
466
+ generate: s.generate,
467
+ key_label: s.key_label,
468
+ value_label: s.value_label,
469
+ });
466
470
  }
467
471
 
468
472
  return missing;