@celilo/cli 0.3.20 → 0.3.22

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.20",
3
+ "version": "0.3.22",
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
+ });
@@ -108,7 +108,7 @@ export function classifyVersionChange(installed: string, latest: string): Versio
108
108
  * the standard upgradeOne path against it. Cleans the temp file in a
109
109
  * finally block so a mid-flight failure doesn't leak a tar.zst on disk.
110
110
  */
111
- async function fetchAndUpgrade(
111
+ export async function fetchAndUpgrade(
112
112
  client: RegistryClient,
113
113
  moduleId: string,
114
114
  version: string,
@@ -151,7 +151,7 @@ async function fetchAndUpgrade(
151
151
  /**
152
152
  * Upgrade a single module from a source path
153
153
  */
154
- async function upgradeOne(
154
+ export async function upgradeOne(
155
155
  sourcePath: string,
156
156
  db: ReturnType<typeof getDb>,
157
157
  flags: Record<string, string | boolean> = {},
@@ -15,6 +15,7 @@
15
15
  * lives in `services/update/orchestrator.ts`.
16
16
  */
17
17
 
18
+ import { spawnSync } from 'node:child_process';
18
19
  import { existsSync, readFileSync } from 'node:fs';
19
20
  import { dirname, join } from 'node:path';
20
21
  import { fileURLToPath } from 'node:url';
@@ -38,6 +39,16 @@ import {
38
39
  import type { SystemUpdateResult } from '../../services/update/types';
39
40
  import { getFlag, hasFlag } from '../parser';
40
41
  import type { CommandResult } from '../types';
42
+ import { fetchAndUpgrade } from './module-upgrade';
43
+
44
+ /**
45
+ * Packages we manage via the global bun install. The self-update step
46
+ * runs `bun update -g` against all of these in one shot, so operators
47
+ * never have to remember the three-package incantation.
48
+ *
49
+ * Ordering matters cosmetically only — bun resolves them all together.
50
+ */
51
+ const MANAGED_PACKAGES = ['@celilo/cli', '@celilo/event-bus', '@celilo/e2e'] as const;
41
52
 
42
53
  function readInstalledCliVersion(): string {
43
54
  const here = dirname(fileURLToPath(import.meta.url));
@@ -141,16 +152,14 @@ async function buildSnapshots(
141
152
  *
142
153
  * - `backup` calls `createModuleBackup`. Modules without an
143
154
  * `on_backup` hook return ok (nothing to back up; not an error).
144
- * - `upgrade` is a no-op for now the in-place upgrade path lives
145
- * in `module import <name>` and needs a refactor before the
146
- * orchestrator can drive it cleanly. Deploy will re-converge
147
- * against whatever's installed, which is the right behavior for
148
- * "redeploy what's there."
155
+ * - `upgrade` fetches the latest version from the registry and runs
156
+ * the in-place upgrade (`fetchAndUpgrade` from module-upgrade.ts).
157
+ * Same code path that `module update` uses for its sweep mode.
149
158
  * - `deploy` calls `deployModule`.
150
159
  * - `health` calls `runModuleHealthCheck`.
151
160
  * - `snapshotCeliloDb` calls `createSystemStateBackup`.
152
161
  */
153
- function buildOps(): OrchestratorOps {
162
+ function buildOps(registry: RegistryClient): OrchestratorOps {
154
163
  return {
155
164
  backup: async (moduleId, _updateId) => {
156
165
  const db = getDb();
@@ -163,7 +172,35 @@ function buildOps(): OrchestratorOps {
163
172
  const result = await createModuleBackup(moduleId);
164
173
  return result.success ? { ok: true } : { ok: false, error: result.error };
165
174
  },
166
- upgrade: async (_id) => ({ ok: true }),
175
+ // Fetch the latest version from the registry and run the in-place
176
+ // upgrade (file-copy + DB update + capability re-register, preserving
177
+ // configs/secrets/infra). Reuses the same path `module update` runs
178
+ // for its sweep mode, so behavior is consistent regardless of which
179
+ // entry point the operator uses.
180
+ upgrade: async (moduleId) => {
181
+ const db = getDb();
182
+ try {
183
+ const entries = await registry.getIndex(moduleId);
184
+ if (entries.length === 0) {
185
+ return { ok: false, error: `${moduleId}: not in registry` };
186
+ }
187
+ const latest = registry.latestVersion(entries);
188
+ if (!latest) {
189
+ return { ok: false, error: `${moduleId}: no non-yanked version` };
190
+ }
191
+ const result = await fetchAndUpgrade(registry, moduleId, latest.vers, db, {});
192
+ if (result.status === 'success') return { ok: true };
193
+ if (result.status === 'failed') return { ok: false, error: result.error };
194
+ // 'skipped' isn't expected here (the module IS installed), but
195
+ // surface it as a non-fatal so the orchestrator can decide.
196
+ return { ok: false, error: `unexpected skip: ${result.reason}` };
197
+ } catch (err) {
198
+ return {
199
+ ok: false,
200
+ error: err instanceof Error ? err.message : String(err),
201
+ };
202
+ }
203
+ },
167
204
  deploy: async (id) => {
168
205
  const db = getDb();
169
206
  // The deploy interview runs through the bus; if config is
@@ -357,7 +394,7 @@ export async function handleSystemUpdate(
357
394
  };
358
395
  }
359
396
 
360
- const ops = buildOps();
397
+ const ops = buildOps(registry);
361
398
 
362
399
  const result = await runSystemUpdate({
363
400
  audit: auditDeps,
@@ -367,10 +404,23 @@ export async function handleSystemUpdate(
367
404
  selfUpdate: {
368
405
  installedVersion: readInstalledCliVersion(),
369
406
  fetcher: fetchLatestCliVersion,
370
- updater: async () => ({
371
- ok: false,
372
- stderr: 'self-update wiring lands in Phase 5',
373
- }),
407
+ // Refresh ALL managed @celilo/* packages in one bun-update call.
408
+ // The orchestrator only tracks @celilo/cli's from/to versions,
409
+ // but we sweep event-bus and e2e along with it so operators
410
+ // don't have to type the three-package incantation themselves.
411
+ // Failures from `bun update` propagate as stderr; the orchestrator
412
+ // surfaces them in the run summary.
413
+ updater: async () => {
414
+ const r = spawnSync('bun', ['update', '-g', ...MANAGED_PACKAGES], {
415
+ stdio: ['ignore', 'pipe', 'pipe'],
416
+ encoding: 'utf-8',
417
+ });
418
+ if (r.status === 0) return { ok: true, stderr: '' };
419
+ return {
420
+ ok: false,
421
+ stderr: (r.stderr ?? '').trim() || `bun update -g exited ${r.status}`,
422
+ };
423
+ },
374
424
  },
375
425
  progress: { emit() {} },
376
426
  noBackup,
@@ -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 {
@@ -373,7 +374,7 @@ function getSuggestedDeploymentOrder(
373
374
  * @param db - Database connection
374
375
  * @returns Array of missing required variables
375
376
  */
376
- async function findMissingRequiredVariables(
377
+ export async function findMissingRequiredVariables(
377
378
  moduleId: string,
378
379
  manifest: ModuleManifest,
379
380
  db: DbClient,
@@ -450,33 +451,22 @@ async function findMissingRequiredVariables(
450
451
  }
451
452
  }
452
453
 
453
- // Check declared secrets
454
- if (manifest.secrets?.declares) {
455
- for (const secret of manifest.secrets.declares) {
456
- if (!secret.required) continue; // Skip optional secrets
457
-
458
- // Check if secret is configured
459
- const secretRecord = await db
460
- .select()
461
- .from(secrets)
462
- .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, secret.name)))
463
- .get();
464
-
465
- if (!secretRecord) {
466
- missing.push({
467
- name: secret.name,
468
- source: 'secret',
469
- description: secret.description,
470
- generate: secret.generate,
471
- // Pass through manifest-declared type + add-loop labels so the
472
- // bus payload can drive the right responder UX (e.g. string-map
473
- // gets the per-key add-loop instead of the JSON-blob prompt).
474
- type: secret.type,
475
- key_label: secret.key_label,
476
- value_label: secret.value_label,
477
- });
478
- }
479
- }
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
+ });
480
470
  }
481
471
 
482
472
  return missing;