@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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/api-clients/proxmox.ts +77 -45
  3. package/src/cli/command-registry.ts +23 -35
  4. package/src/cli/commands/completion.ts +12 -11
  5. package/src/cli/commands/module-check.ts +158 -0
  6. package/src/cli/commands/module-import-routing.test.ts +52 -0
  7. package/src/cli/commands/module-import.ts +70 -27
  8. package/src/cli/commands/module-publish.test.ts +3 -90
  9. package/src/cli/commands/module-publish.ts +14 -118
  10. package/src/cli/commands/proxmox-template-selection.test.ts +150 -0
  11. package/src/cli/commands/proxmox-template-selection.ts +258 -0
  12. package/src/cli/commands/service-add-proxmox.ts +49 -127
  13. package/src/cli/commands/service-reconfigure.ts +36 -79
  14. package/src/cli/commands/service-verify.ts +20 -79
  15. package/src/cli/commands/{doctor.test.ts → system-doctor.test.ts} +1 -1
  16. package/src/cli/commands/{doctor.ts → system-doctor.ts} +93 -18
  17. package/src/cli/commands/system-update.ts +1 -1
  18. package/src/cli/completion.ts +29 -8
  19. package/src/cli/index.ts +25 -30
  20. package/src/manifest/schema.ts +9 -1
  21. package/src/module/import.ts +4 -2
  22. package/src/registry/client.ts +14 -1
  23. package/src/services/bus-interview.ts +13 -1
  24. package/src/services/bus-secret-flow.test.ts +94 -0
  25. package/src/services/config-interview.ts +66 -6
  26. package/src/services/module-deploy.ts +19 -1
  27. package/src/services/module-validator/capability-versions.test.ts +90 -0
  28. package/src/services/module-validator/capability-versions.ts +115 -0
  29. package/src/services/module-validator/contract-version.test.ts +24 -0
  30. package/src/services/module-validator/contract-version.ts +69 -0
  31. package/src/services/module-validator/git-hygiene.test.ts +141 -0
  32. package/src/services/module-validator/git-hygiene.ts +144 -0
  33. package/src/services/module-validator/index.test.ts +67 -0
  34. package/src/services/module-validator/index.ts +74 -0
  35. package/src/services/module-validator/manifest-schema.ts +42 -0
  36. package/src/services/module-validator/types.ts +43 -0
  37. package/src/services/module-validator/typescript-build.test.ts +58 -0
  38. package/src/services/module-validator/typescript-build.ts +115 -0
  39. package/src/services/module-validator/workspace-deps.test.ts +137 -0
  40. package/src/services/module-validator/workspace-deps.ts +187 -0
  41. package/src/services/terminal-responder.ts +75 -0
  42. package/src/system/prereqs.test.ts +374 -0
  43. 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
- // The bus payload narrows to scalar types. Secrets in the
621
- // wider system can be objects, but those are written via
622
- // the cross-module ensure flow, not this interview.
623
- type: 'string',
681
+ type: declaredType === 'string-map' ? 'string-map' : 'string',
624
682
  required: true,
625
683
  description: variable.description,
626
684
  style: source,
@@ -631,6 +689,8 @@ export async function interviewForMissingSecrets(
631
689
  length: metadata?.length || 32,
632
690
  }
633
691
  : undefined,
692
+ key_label: variable.key_label,
693
+ value_label: variable.value_label,
634
694
  };
635
695
  await busInterview<SecretAck>(EVENT_TYPES.secretRequired(moduleId, variable.name), payload);
636
696
  log.success(`Saved ${variable.name}`);
@@ -1137,13 +1137,31 @@ async function deployModuleImpl(
1137
1137
  }
1138
1138
  }
1139
1139
 
1140
- // Inject target IP into hook config works for both machine and container deploys
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
+ }