@celilo/cli 0.3.16 → 0.3.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api-clients/proxmox.ts +77 -45
- package/src/cli/command-registry.ts +23 -35
- package/src/cli/commands/completion.ts +12 -11
- package/src/cli/commands/module-check.ts +158 -0
- package/src/cli/commands/module-import-routing.test.ts +52 -0
- package/src/cli/commands/module-import.ts +70 -27
- package/src/cli/commands/module-publish.test.ts +3 -90
- package/src/cli/commands/module-publish.ts +14 -118
- package/src/cli/commands/proxmox-template-selection.test.ts +150 -0
- package/src/cli/commands/proxmox-template-selection.ts +258 -0
- package/src/cli/commands/service-add-proxmox.ts +49 -127
- package/src/cli/commands/service-reconfigure.ts +36 -79
- package/src/cli/commands/service-verify.ts +20 -79
- package/src/cli/commands/{doctor.test.ts → system-doctor.test.ts} +1 -1
- package/src/cli/commands/{doctor.ts → system-doctor.ts} +93 -18
- package/src/cli/commands/system-update.ts +1 -1
- package/src/cli/completion.ts +29 -8
- package/src/cli/index.ts +25 -30
- package/src/manifest/schema.ts +9 -1
- package/src/module/import.ts +4 -2
- package/src/registry/client.ts +14 -1
- package/src/services/bus-interview.ts +13 -1
- package/src/services/bus-secret-flow.test.ts +94 -0
- package/src/services/config-interview.ts +66 -6
- package/src/services/module-deploy.ts +19 -1
- package/src/services/module-validator/capability-versions.test.ts +90 -0
- package/src/services/module-validator/capability-versions.ts +115 -0
- package/src/services/module-validator/contract-version.test.ts +24 -0
- package/src/services/module-validator/contract-version.ts +69 -0
- package/src/services/module-validator/git-hygiene.test.ts +141 -0
- package/src/services/module-validator/git-hygiene.ts +144 -0
- package/src/services/module-validator/index.test.ts +67 -0
- package/src/services/module-validator/index.ts +74 -0
- package/src/services/module-validator/manifest-schema.ts +42 -0
- package/src/services/module-validator/types.ts +43 -0
- package/src/services/module-validator/typescript-build.test.ts +58 -0
- package/src/services/module-validator/typescript-build.ts +115 -0
- package/src/services/module-validator/workspace-deps.test.ts +137 -0
- package/src/services/module-validator/workspace-deps.ts +187 -0
- package/src/services/terminal-responder.ts +75 -0
- package/src/system/prereqs.test.ts +374 -0
- package/src/system/prereqs.ts +377 -0
|
@@ -324,4 +324,98 @@ describe('bus-mediated interviewForMissingSecrets', () => {
|
|
|
324
324
|
|
|
325
325
|
handle.close();
|
|
326
326
|
});
|
|
327
|
+
|
|
328
|
+
// string-map secrets: the responder collects key/value pairs via an
|
|
329
|
+
// add-loop and JSON-stringifies the result before writing. The bus
|
|
330
|
+
// payload signals the type so the responder picks the right UX.
|
|
331
|
+
test('string-map: payload carries type=string-map + labels; stored value is JSON-stringified record', async () => {
|
|
332
|
+
const { seen, close } = startTestResponder(
|
|
333
|
+
bus,
|
|
334
|
+
'secret.required.testmod.ddns_passwords',
|
|
335
|
+
{ value: JSON.stringify({ 'lunacycle.net': 'pw1', 'celilo.computer': 'pw2' }) },
|
|
336
|
+
testDb,
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const result = await interviewForMissingSecrets(
|
|
340
|
+
'testmod',
|
|
341
|
+
[
|
|
342
|
+
{
|
|
343
|
+
name: 'ddns_passwords',
|
|
344
|
+
source: 'secret',
|
|
345
|
+
type: 'string-map',
|
|
346
|
+
description: 'Namecheap Dynamic DNS password per managed domain',
|
|
347
|
+
key_label: 'Domain',
|
|
348
|
+
value_label: 'Password',
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
testDb,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
expect(result.success).toBe(true);
|
|
355
|
+
expect(seen.length).toBe(1);
|
|
356
|
+
expect(seen[0].payload.type).toBe('string-map');
|
|
357
|
+
expect(seen[0].payload.key_label).toBe('Domain');
|
|
358
|
+
expect(seen[0].payload.value_label).toBe('Password');
|
|
359
|
+
|
|
360
|
+
// Bus must NOT carry the secret value, even for string-map.
|
|
361
|
+
expect(seen[0].payload).not.toHaveProperty('value');
|
|
362
|
+
|
|
363
|
+
const masterKey = await getOrCreateMasterKey();
|
|
364
|
+
const stored = await readModuleSecretKey('testmod', 'ddns_passwords', testDb, masterKey);
|
|
365
|
+
expect(stored).toBe(JSON.stringify({ 'lunacycle.net': 'pw1', 'celilo.computer': 'pw2' }));
|
|
366
|
+
|
|
367
|
+
close();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('string-map: enrichment from manifest.yml makes its way to the bus payload', async () => {
|
|
371
|
+
// Drop a manifest.yml in tempDir (the module's sourcePath) declaring
|
|
372
|
+
// the secret as string-map with custom labels. validateModuleSecrets
|
|
373
|
+
// reads the manifest and threads the metadata through to the bus.
|
|
374
|
+
writeSecretsSchema(tempDir, {
|
|
375
|
+
ddns_passwords: { source: 'user_provided' },
|
|
376
|
+
});
|
|
377
|
+
writeFileSync(
|
|
378
|
+
join(tempDir, 'manifest.yml'),
|
|
379
|
+
`
|
|
380
|
+
celilo_contract: "1.0"
|
|
381
|
+
id: testmod
|
|
382
|
+
name: Test Module
|
|
383
|
+
version: 1.0.0
|
|
384
|
+
secrets:
|
|
385
|
+
declares:
|
|
386
|
+
- name: ddns_passwords
|
|
387
|
+
type: string-map
|
|
388
|
+
required: true
|
|
389
|
+
description: Per-domain DDNS password
|
|
390
|
+
sensitive: true
|
|
391
|
+
key_label: Domain
|
|
392
|
+
value_label: Password
|
|
393
|
+
`,
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const { seen, close } = startTestResponder(
|
|
397
|
+
bus,
|
|
398
|
+
'secret.required.testmod.ddns_passwords',
|
|
399
|
+
{ value: JSON.stringify({ 'a.test': 'pw' }) },
|
|
400
|
+
testDb,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const { interviewForMissingSecrets, validateModuleSecrets } = await import(
|
|
404
|
+
'./config-interview'
|
|
405
|
+
);
|
|
406
|
+
const missing = await validateModuleSecrets('testmod', testDb);
|
|
407
|
+
expect(missing).toHaveLength(1);
|
|
408
|
+
expect(missing[0].name).toBe('ddns_passwords');
|
|
409
|
+
expect(missing[0].type).toBe('string-map');
|
|
410
|
+
expect(missing[0].key_label).toBe('Domain');
|
|
411
|
+
expect(missing[0].value_label).toBe('Password');
|
|
412
|
+
|
|
413
|
+
const result = await interviewForMissingSecrets('testmod', missing, testDb);
|
|
414
|
+
expect(result.success).toBe(true);
|
|
415
|
+
expect(seen[0].payload.type).toBe('string-map');
|
|
416
|
+
expect(seen[0].payload.key_label).toBe('Domain');
|
|
417
|
+
expect(seen[0].payload.value_label).toBe('Password');
|
|
418
|
+
|
|
419
|
+
close();
|
|
420
|
+
});
|
|
327
421
|
});
|
|
@@ -19,11 +19,54 @@ import {
|
|
|
19
19
|
} from './bus-interview';
|
|
20
20
|
import { getSecretMetadata, loadSecretsSchema } from './secret-schema-loader';
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Pull the manifest's `secrets.declares[]` entries keyed by name. Best-
|
|
24
|
+
* effort — returns an empty Map on any read/parse failure so the caller
|
|
25
|
+
* can fall back to JSON-schema-only interview behavior.
|
|
26
|
+
*/
|
|
27
|
+
async function loadManifestSecretDeclares(
|
|
28
|
+
moduleId: string,
|
|
29
|
+
db: DbClient,
|
|
30
|
+
): Promise<
|
|
31
|
+
Map<string, { type?: string; description?: string; key_label?: string; value_label?: string }>
|
|
32
|
+
> {
|
|
33
|
+
const out = new Map<
|
|
34
|
+
string,
|
|
35
|
+
{ type?: string; description?: string; key_label?: string; value_label?: string }
|
|
36
|
+
>();
|
|
37
|
+
const moduleRow = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
38
|
+
if (!moduleRow?.sourcePath) return out;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const { readFile } = await import('node:fs/promises');
|
|
42
|
+
const { join } = await import('node:path');
|
|
43
|
+
const { parse: parseYaml } = await import('yaml');
|
|
44
|
+
const yamlContent = await readFile(join(moduleRow.sourcePath, 'manifest.yml'), 'utf-8');
|
|
45
|
+
const parsed = parseYaml(yamlContent) as { secrets?: { declares?: unknown[] } } | undefined;
|
|
46
|
+
const declares = parsed?.secrets?.declares;
|
|
47
|
+
if (!Array.isArray(declares)) return out;
|
|
48
|
+
for (const d of declares) {
|
|
49
|
+
if (typeof d !== 'object' || d === null) continue;
|
|
50
|
+
const decl = d as Record<string, unknown>;
|
|
51
|
+
if (typeof decl.name !== 'string') continue;
|
|
52
|
+
out.set(decl.name, {
|
|
53
|
+
type: typeof decl.type === 'string' ? decl.type : undefined,
|
|
54
|
+
description: typeof decl.description === 'string' ? decl.description : undefined,
|
|
55
|
+
key_label: typeof decl.key_label === 'string' ? decl.key_label : undefined,
|
|
56
|
+
value_label: typeof decl.value_label === 'string' ? decl.value_label : undefined,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Manifest unreadable / malformed — fall back to schema-only.
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
22
65
|
export interface MissingVariable {
|
|
23
66
|
name: string;
|
|
24
67
|
source: 'user' | 'secret' | 'capability' | 'system';
|
|
25
68
|
description?: string;
|
|
26
|
-
/** Variable type from manifest (string, array, etc.) */
|
|
69
|
+
/** Variable type from manifest (string, array, string-map, etc.) */
|
|
27
70
|
type?: string;
|
|
28
71
|
/** Derivation source (e.g., "$machine:ipAddress") */
|
|
29
72
|
derive_from?: string;
|
|
@@ -42,6 +85,9 @@ export interface MissingVariable {
|
|
|
42
85
|
length: number;
|
|
43
86
|
encoding: string;
|
|
44
87
|
};
|
|
88
|
+
/** For `type: string-map` only — labels shown in the add-loop prompt. */
|
|
89
|
+
key_label?: string;
|
|
90
|
+
value_label?: string;
|
|
45
91
|
}
|
|
46
92
|
|
|
47
93
|
export interface InterviewResult {
|
|
@@ -445,6 +491,12 @@ export async function validateModuleSecrets(
|
|
|
445
491
|
return [];
|
|
446
492
|
}
|
|
447
493
|
|
|
494
|
+
// Best-effort: load manifest declarations so we can enrich each missing
|
|
495
|
+
// secret with `type` (e.g. 'string-map'), `key_label`, `value_label`.
|
|
496
|
+
// The JSON schema knows shape; the manifest knows interview UX. If the
|
|
497
|
+
// manifest can't be read, we degrade to plain prompts.
|
|
498
|
+
const manifestDeclares = await loadManifestSecretDeclares(moduleId, db);
|
|
499
|
+
|
|
448
500
|
// Check each declared secret
|
|
449
501
|
for (const [secretName, propertySchema] of Object.entries(schema.properties)) {
|
|
450
502
|
// Check if secret exists in database
|
|
@@ -455,10 +507,14 @@ export async function validateModuleSecrets(
|
|
|
455
507
|
.get();
|
|
456
508
|
|
|
457
509
|
if (!existing) {
|
|
510
|
+
const declare = manifestDeclares.get(secretName);
|
|
458
511
|
missingSecrets.push({
|
|
459
512
|
name: secretName,
|
|
460
513
|
source: 'secret',
|
|
461
|
-
description: propertySchema.description || propertySchema.title,
|
|
514
|
+
description: declare?.description || propertySchema.description || propertySchema.title,
|
|
515
|
+
type: declare?.type,
|
|
516
|
+
key_label: declare?.key_label,
|
|
517
|
+
value_label: declare?.value_label,
|
|
462
518
|
});
|
|
463
519
|
}
|
|
464
520
|
}
|
|
@@ -614,13 +670,15 @@ export async function interviewForMissingSecrets(
|
|
|
614
670
|
// value into the encrypted store out-of-band, then replies
|
|
615
671
|
// with `{ acknowledged: true }`. The value never crosses the
|
|
616
672
|
// bus. See INTERACTIVE_DEPLOYS_VIA_BUS.md.
|
|
673
|
+
// The bus payload accepts scalar types and `string-map`
|
|
674
|
+
// (Record<string, string>, gathered via add-loop, stored as JSON).
|
|
675
|
+
// Cross-module ensure flows write composite shapes via a separate
|
|
676
|
+
// path, but the interview-driven `string-map` lands here.
|
|
677
|
+
const declaredType = (variable.type as SecretRequiredPayload['type']) ?? 'string';
|
|
617
678
|
const payload: SecretRequiredPayload = {
|
|
618
679
|
module: moduleId,
|
|
619
680
|
key: variable.name,
|
|
620
|
-
|
|
621
|
-
// wider system can be objects, but those are written via
|
|
622
|
-
// the cross-module ensure flow, not this interview.
|
|
623
|
-
type: 'string',
|
|
681
|
+
type: declaredType === 'string-map' ? 'string-map' : 'string',
|
|
624
682
|
required: true,
|
|
625
683
|
description: variable.description,
|
|
626
684
|
style: source,
|
|
@@ -631,6 +689,8 @@ export async function interviewForMissingSecrets(
|
|
|
631
689
|
length: metadata?.length || 32,
|
|
632
690
|
}
|
|
633
691
|
: undefined,
|
|
692
|
+
key_label: variable.key_label,
|
|
693
|
+
value_label: variable.value_label,
|
|
634
694
|
};
|
|
635
695
|
await busInterview<SecretAck>(EVENT_TYPES.secretRequired(moduleId, variable.name), payload);
|
|
636
696
|
log.success(`Saved ${variable.name}`);
|
|
@@ -1137,13 +1137,31 @@ async function deployModuleImpl(
|
|
|
1137
1137
|
}
|
|
1138
1138
|
}
|
|
1139
1139
|
|
|
1140
|
-
//
|
|
1140
|
+
// Persist target_ip to moduleConfigs (not just inject into the
|
|
1141
|
+
// in-memory hook context). Later hooks like on_backup build
|
|
1142
|
+
// their context from the DB only, so without this they'd see
|
|
1143
|
+
// no target_ip and fail with "No container IP configured".
|
|
1144
|
+
// Mirrors proxmox-state-recovery.ts for the machine-deployed
|
|
1145
|
+
// case.
|
|
1141
1146
|
if (machineId) {
|
|
1142
1147
|
const { getMachine } = await import('./machine-pool');
|
|
1143
1148
|
const deployMachine = await getMachine(machineId);
|
|
1144
1149
|
if (deployMachine) {
|
|
1145
1150
|
installConfigMap['ip.primary'] = deployMachine.ipAddress;
|
|
1146
1151
|
installConfigMap.target_ip = deployMachine.ipAddress;
|
|
1152
|
+
|
|
1153
|
+
await db
|
|
1154
|
+
.insert(pcTable)
|
|
1155
|
+
.values({
|
|
1156
|
+
moduleId,
|
|
1157
|
+
key: 'target_ip',
|
|
1158
|
+
value: deployMachine.ipAddress,
|
|
1159
|
+
})
|
|
1160
|
+
.onConflictDoUpdate({
|
|
1161
|
+
target: [pcTable.moduleId, pcTable.key],
|
|
1162
|
+
set: { value: deployMachine.ipAddress },
|
|
1163
|
+
})
|
|
1164
|
+
.run();
|
|
1147
1165
|
}
|
|
1148
1166
|
}
|
|
1149
1167
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict-publish: manifest-claimed versions for known capabilities must
|
|
3
|
+
* match the framework's runtime registry. Mismatches refuse the publish
|
|
4
|
+
* and surface as failed checks in `module check`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { validateCapabilityVersions } from './capability-versions';
|
|
9
|
+
|
|
10
|
+
describe('validateCapabilityVersions', () => {
|
|
11
|
+
test('no errors when no capabilities are declared', () => {
|
|
12
|
+
expect(validateCapabilityVersions({ id: 'x', version: '1.0.0' })).toEqual([]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('no errors when provides matches the runtime registry exactly', () => {
|
|
16
|
+
expect(
|
|
17
|
+
validateCapabilityVersions({
|
|
18
|
+
id: 'caddy',
|
|
19
|
+
version: '3.0.0',
|
|
20
|
+
provides: { capabilities: [{ name: 'public_web', version: '3.0.0' }] },
|
|
21
|
+
}),
|
|
22
|
+
).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('error when provides[X].version differs from runtime', () => {
|
|
26
|
+
const errors = validateCapabilityVersions({
|
|
27
|
+
id: 'caddy',
|
|
28
|
+
version: '1.0.0',
|
|
29
|
+
provides: { capabilities: [{ name: 'public_web', version: '1.0.0' }] },
|
|
30
|
+
});
|
|
31
|
+
expect(errors).toHaveLength(1);
|
|
32
|
+
expect(errors[0]).toContain('provides[public_web]');
|
|
33
|
+
expect(errors[0]).toContain('1.0.0');
|
|
34
|
+
expect(errors[0]).toContain('3.0.0');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('error when provides[X].version is newer than runtime', () => {
|
|
38
|
+
const errors = validateCapabilityVersions({
|
|
39
|
+
id: 'caddy',
|
|
40
|
+
version: '4.0.0',
|
|
41
|
+
provides: { capabilities: [{ name: 'public_web', version: '4.0.0' }] },
|
|
42
|
+
});
|
|
43
|
+
expect(errors).toHaveLength(1);
|
|
44
|
+
expect(errors[0]).toContain('provides[public_web]');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('skips capabilities not in the framework registry (third-party)', () => {
|
|
48
|
+
expect(
|
|
49
|
+
validateCapabilityVersions({
|
|
50
|
+
id: 'odd',
|
|
51
|
+
version: '1.0.0',
|
|
52
|
+
provides: { capabilities: [{ name: 'custom_metric', version: '99.0.0' }] },
|
|
53
|
+
}),
|
|
54
|
+
).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('error when requires[X].version is a higher major than runtime', () => {
|
|
58
|
+
const errors = validateCapabilityVersions({
|
|
59
|
+
id: 'lunacycle',
|
|
60
|
+
version: '1.0.0',
|
|
61
|
+
requires: { capabilities: [{ name: 'idp', version: '2.0.0' }] },
|
|
62
|
+
});
|
|
63
|
+
expect(errors).toHaveLength(1);
|
|
64
|
+
expect(errors[0]).toContain('requires[idp]');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('no error when requires[X].version is at most the runtime version', () => {
|
|
68
|
+
expect(
|
|
69
|
+
validateCapabilityVersions({
|
|
70
|
+
id: 'caddy',
|
|
71
|
+
version: '1.0.0',
|
|
72
|
+
requires: { capabilities: [{ name: 'dns_registrar', version: '4.0.0' }] },
|
|
73
|
+
}),
|
|
74
|
+
).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('reports multiple errors at once', () => {
|
|
78
|
+
const errors = validateCapabilityVersions({
|
|
79
|
+
id: 'broken',
|
|
80
|
+
version: '1.0.0',
|
|
81
|
+
provides: {
|
|
82
|
+
capabilities: [
|
|
83
|
+
{ name: 'public_web', version: '99.0.0' },
|
|
84
|
+
{ name: 'idp', version: '99.0.0' },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
expect(errors).toHaveLength(2);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CAPABILITY_CONTRACT_VERSIONS,
|
|
3
|
+
type KnownCapabilityName,
|
|
4
|
+
compareProviderToRuntime,
|
|
5
|
+
} from '@celilo/capabilities';
|
|
6
|
+
import type { Check } from './types';
|
|
7
|
+
|
|
8
|
+
export interface ManifestCapabilityClaim {
|
|
9
|
+
name: string;
|
|
10
|
+
version: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ManifestForCapabilityCheck {
|
|
14
|
+
id?: string;
|
|
15
|
+
version?: string;
|
|
16
|
+
provides?: { capabilities?: ManifestCapabilityClaim[] };
|
|
17
|
+
requires?: { capabilities?: ManifestCapabilityClaim[] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isKnownCapability(name: string): name is KnownCapabilityName {
|
|
21
|
+
return name in CAPABILITY_CONTRACT_VERSIONS;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Strict-publish: every `provides[X].version` must match the framework's
|
|
26
|
+
* runtime registry; every `requires[X].version` must be at most the
|
|
27
|
+
* framework's runtime version (consumers can require older minors of
|
|
28
|
+
* still-supported majors).
|
|
29
|
+
*
|
|
30
|
+
* Returns a list of human-readable error messages — empty list means OK.
|
|
31
|
+
* Used both by `module publish` (which fails on any error) and
|
|
32
|
+
* `module check` (which surfaces them as failed checks).
|
|
33
|
+
*/
|
|
34
|
+
export function validateCapabilityVersions(manifest: ManifestForCapabilityCheck): string[] {
|
|
35
|
+
const errors: string[] = [];
|
|
36
|
+
|
|
37
|
+
for (const p of manifest.provides?.capabilities ?? []) {
|
|
38
|
+
if (!isKnownCapability(p.name)) continue;
|
|
39
|
+
const runtime = CAPABILITY_CONTRACT_VERSIONS[p.name];
|
|
40
|
+
const r = compareProviderToRuntime(p.version, runtime);
|
|
41
|
+
if (!r.compatible) {
|
|
42
|
+
errors.push(
|
|
43
|
+
`provides[${p.name}].version is ${p.version} but framework registry is ${runtime} ` +
|
|
44
|
+
`(${r.reason}). Update the manifest, then retry.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const need of manifest.requires?.capabilities ?? []) {
|
|
50
|
+
if (!isKnownCapability(need.name)) continue;
|
|
51
|
+
const runtime = CAPABILITY_CONTRACT_VERSIONS[need.name];
|
|
52
|
+
const r = compareProviderToRuntime(need.version, runtime);
|
|
53
|
+
if (!r.compatible && r.reason === 'major_mismatch_higher') {
|
|
54
|
+
errors.push(
|
|
55
|
+
`requires[${need.name}].version is ${need.version} but framework registry is ${runtime}. Bump the framework before publishing, or lower the manifest version.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return errors;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Per-capability `Check[]` form for `module check`. Emits one OK row for
|
|
65
|
+
* every known capability the manifest references and one FAIL row for
|
|
66
|
+
* every mismatch. A manifest with no known-capability claims yields a
|
|
67
|
+
* single synthetic OK so the report is never empty.
|
|
68
|
+
*/
|
|
69
|
+
export function checkCapabilityVersions(manifest: ManifestForCapabilityCheck): Check[] {
|
|
70
|
+
const checks: Check[] = [];
|
|
71
|
+
|
|
72
|
+
for (const p of manifest.provides?.capabilities ?? []) {
|
|
73
|
+
if (!isKnownCapability(p.name)) continue;
|
|
74
|
+
const runtime = CAPABILITY_CONTRACT_VERSIONS[p.name];
|
|
75
|
+
const r = compareProviderToRuntime(p.version, runtime);
|
|
76
|
+
checks.push({
|
|
77
|
+
category: 'capability',
|
|
78
|
+
name: `provides[${p.name}]`,
|
|
79
|
+
status: r.compatible ? 'ok' : 'fail',
|
|
80
|
+
message: r.compatible
|
|
81
|
+
? `provides ${p.name}@${p.version} matches framework registry ${runtime}`
|
|
82
|
+
: `provides ${p.name}@${p.version} but framework registry is ${runtime} (${r.reason}). Update the manifest.`,
|
|
83
|
+
currentValue: p.version,
|
|
84
|
+
suggestedValue: r.compatible ? undefined : runtime,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const need of manifest.requires?.capabilities ?? []) {
|
|
89
|
+
if (!isKnownCapability(need.name)) continue;
|
|
90
|
+
const runtime = CAPABILITY_CONTRACT_VERSIONS[need.name];
|
|
91
|
+
const r = compareProviderToRuntime(need.version, runtime);
|
|
92
|
+
const tooNew = !r.compatible && r.reason === 'major_mismatch_higher';
|
|
93
|
+
checks.push({
|
|
94
|
+
category: 'capability',
|
|
95
|
+
name: `requires[${need.name}]`,
|
|
96
|
+
status: tooNew ? 'fail' : 'ok',
|
|
97
|
+
message: tooNew
|
|
98
|
+
? `requires ${need.name}@${need.version} but framework registry is ${runtime}. Bump the framework, or lower the manifest version.`
|
|
99
|
+
: `requires ${need.name}@${need.version} can be satisfied by framework registry ${runtime}`,
|
|
100
|
+
currentValue: need.version,
|
|
101
|
+
suggestedValue: tooNew ? runtime : undefined,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (checks.length === 0) {
|
|
106
|
+
checks.push({
|
|
107
|
+
category: 'capability',
|
|
108
|
+
name: 'no known-capability claims',
|
|
109
|
+
status: 'ok',
|
|
110
|
+
message: 'manifest declares no provides/requires for known framework capabilities',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return checks;
|
|
115
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { checkContractVersion } from './contract-version';
|
|
3
|
+
|
|
4
|
+
describe('checkContractVersion', () => {
|
|
5
|
+
test('ok when manifest declares the latest supported contract', () => {
|
|
6
|
+
const r = checkContractVersion({ celilo_contract: '1.0' });
|
|
7
|
+
expect(r.status).toBe('ok');
|
|
8
|
+
expect(r.message).toContain('latest');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('fail when celilo_contract field is missing entirely', () => {
|
|
12
|
+
const r = checkContractVersion({});
|
|
13
|
+
expect(r.status).toBe('fail');
|
|
14
|
+
expect(r.message).toContain('missing');
|
|
15
|
+
expect(r.suggestedValue).toBe('1.0');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('fail when celilo_contract claims an unknown version', () => {
|
|
19
|
+
const r = checkContractVersion({ celilo_contract: '99.0' });
|
|
20
|
+
expect(r.status).toBe('fail');
|
|
21
|
+
expect(r.message).toContain('unknown');
|
|
22
|
+
expect(r.suggestedValue).toBe('1.0');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { SUPPORTED_CONTRACT_VERSIONS } from '../../manifest/contracts';
|
|
2
|
+
import type { Check } from './types';
|
|
3
|
+
|
|
4
|
+
interface ManifestWithContract {
|
|
5
|
+
celilo_contract?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compares the manifest's declared `celilo_contract` against the framework's
|
|
10
|
+
* supported set:
|
|
11
|
+
*
|
|
12
|
+
* - `ok` — claim equals the latest supported contract.
|
|
13
|
+
* - `warn` — claim is older but still supported. The manifest works, but
|
|
14
|
+
* new contract features won't be available.
|
|
15
|
+
* - `fail` — claim is unknown to the framework (typo, future version
|
|
16
|
+
* from a newer celilo, etc.). Manifest schema validation
|
|
17
|
+
* already catches this case more loudly; we still report it
|
|
18
|
+
* so a `module check` against a manifest with a bad contract
|
|
19
|
+
* highlights it as a contract-level problem.
|
|
20
|
+
*
|
|
21
|
+
* Today the framework only ships contract "1.0", so the warn branch is
|
|
22
|
+
* forward-looking; once "1.1" lands, modules still on "1.0" will see a
|
|
23
|
+
* minor-bump suggestion.
|
|
24
|
+
*/
|
|
25
|
+
export function checkContractVersion(manifest: ManifestWithContract): Check {
|
|
26
|
+
const claimed = manifest.celilo_contract;
|
|
27
|
+
const supported = SUPPORTED_CONTRACT_VERSIONS;
|
|
28
|
+
const latest = supported[supported.length - 1];
|
|
29
|
+
|
|
30
|
+
if (!claimed) {
|
|
31
|
+
return {
|
|
32
|
+
category: 'contract_version',
|
|
33
|
+
name: 'celilo_contract',
|
|
34
|
+
status: 'fail',
|
|
35
|
+
message: 'manifest is missing the celilo_contract field',
|
|
36
|
+
suggestedValue: latest,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (claimed === latest) {
|
|
41
|
+
return {
|
|
42
|
+
category: 'contract_version',
|
|
43
|
+
name: 'celilo_contract',
|
|
44
|
+
status: 'ok',
|
|
45
|
+
message: `manifest declares celilo_contract: ${claimed} (latest)`,
|
|
46
|
+
currentValue: claimed,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if ((supported as readonly string[]).includes(claimed)) {
|
|
51
|
+
return {
|
|
52
|
+
category: 'contract_version',
|
|
53
|
+
name: 'celilo_contract',
|
|
54
|
+
status: 'warn',
|
|
55
|
+
message: `manifest declares celilo_contract: ${claimed} (still supported, but ${latest} is available)`,
|
|
56
|
+
currentValue: claimed,
|
|
57
|
+
suggestedValue: latest,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
category: 'contract_version',
|
|
63
|
+
name: 'celilo_contract',
|
|
64
|
+
status: 'fail',
|
|
65
|
+
message: `manifest declares celilo_contract: ${claimed} (unknown to this framework — supported: ${supported.join(', ')})`,
|
|
66
|
+
currentValue: claimed,
|
|
67
|
+
suggestedValue: latest,
|
|
68
|
+
};
|
|
69
|
+
}
|