@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,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 {
|
|
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
|
-
| {
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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:
|
|
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
|
-
|
|
250
|
-
|
|
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
|
|
20
|
+
import { getSecretMetadata } from './secret-schema-loader';
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
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
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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;
|