@celilo/cli 0.3.15 → 0.3.17

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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/api-clients/proxmox.ts +77 -45
  3. package/src/cli/command-registry.ts +25 -14
  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.ts +5 -5
  7. package/src/cli/commands/module-publish.test.ts +3 -90
  8. package/src/cli/commands/module-publish.ts +14 -118
  9. package/src/cli/commands/proxmox-template-selection.test.ts +150 -0
  10. package/src/cli/commands/proxmox-template-selection.ts +258 -0
  11. package/src/cli/commands/service-add-proxmox.ts +49 -127
  12. package/src/cli/commands/service-reconfigure.ts +36 -79
  13. package/src/cli/commands/service-verify.ts +20 -79
  14. package/src/cli/commands/{doctor.test.ts → system-doctor.test.ts} +1 -1
  15. package/src/cli/commands/{doctor.ts → system-doctor.ts} +93 -18
  16. package/src/cli/completion.ts +29 -2
  17. package/src/cli/index.ts +16 -7
  18. package/src/module/import.ts +4 -2
  19. package/src/registry/client.ts +14 -1
  20. package/src/services/module-deploy.ts +19 -1
  21. package/src/services/module-validator/capability-versions.test.ts +90 -0
  22. package/src/services/module-validator/capability-versions.ts +115 -0
  23. package/src/services/module-validator/contract-version.test.ts +24 -0
  24. package/src/services/module-validator/contract-version.ts +69 -0
  25. package/src/services/module-validator/git-hygiene.test.ts +141 -0
  26. package/src/services/module-validator/git-hygiene.ts +144 -0
  27. package/src/services/module-validator/index.test.ts +67 -0
  28. package/src/services/module-validator/index.ts +74 -0
  29. package/src/services/module-validator/manifest-schema.ts +42 -0
  30. package/src/services/module-validator/types.ts +43 -0
  31. package/src/services/module-validator/typescript-build.test.ts +58 -0
  32. package/src/services/module-validator/typescript-build.ts +115 -0
  33. package/src/services/module-validator/workspace-deps.test.ts +137 -0
  34. package/src/services/module-validator/workspace-deps.ts +187 -0
  35. package/src/system/prereqs.test.ts +374 -0
  36. package/src/system/prereqs.ts +377 -0
@@ -1,19 +1,32 @@
1
1
  /**
2
- * `celilo doctor` — diagnose @celilo/* version drift between the running CLI
3
- * and the surrounding workspace (if any).
2
+ * `celilo system doctor` — diagnose system-level health for celilo:
3
+ * 1. System prerequisites (ansible, terraform, ssh, etc.) present
4
+ * and at supported versions.
5
+ * 2. @celilo/* version drift between the running CLI and the
6
+ * surrounding workspace (if any).
4
7
  *
5
- * Catches the canonical "I edited the workspace but my global celilo is
6
- * still running an older published version" failure mode.
8
+ * The canonical failures this catches:
9
+ * - "I just installed celilo on a fresh box but module install is
10
+ * erroring with a child-process exit 127." → prereq section.
11
+ * - "I edited the workspace but my global celilo is still running
12
+ * an older published version." → drift section.
7
13
  *
8
- * Resolution strategy:
9
- * - The running CLI's package.json comes from a relative import — that
10
- * anchors us to whatever copy of `@celilo/cli` is actually executing
11
- * (workspace TS source or globally-installed node_modules tree).
14
+ * Renamed from top-level `celilo doctor` (Phase 0; no alias kept) to
15
+ * fit alongside `system init`, `system audit`, `system config`. See
16
+ * apps/celilo/designs/PREREQ_DETECTION.md.
17
+ *
18
+ * Drift resolution strategy:
19
+ * - The running CLI's package.json comes from a relative import —
20
+ * that anchors us to whatever copy of `@celilo/cli` is actually
21
+ * executing (workspace TS source or globally-installed
22
+ * node_modules tree).
12
23
  * - For each `@celilo/*` dependency, we ask the runtime where it
13
- * resolves the package's `package.json` and read the version there.
14
- * - If we can find a workspace root by walking up from `process.cwd()`,
15
- * we read each `packages/*\/package.json` and flag anything where the
16
- * loaded version is older than the workspace.
24
+ * resolves the package's `package.json` and read the version
25
+ * there.
26
+ * - If we can find a workspace root by walking up from
27
+ * `process.cwd()`, we read each `packages/*\/package.json` and
28
+ * flag anything where the loaded version is older than the
29
+ * workspace.
17
30
  */
18
31
 
19
32
  import { spawnSync } from 'node:child_process';
@@ -21,6 +34,7 @@ import { existsSync, readFileSync } from 'node:fs';
21
34
  import { createRequire } from 'node:module';
22
35
  import { dirname, join, resolve } from 'node:path';
23
36
  import cliPkg from '../../../package.json' with { type: 'json' };
37
+ import { checkAllPrerequisites, failingPrerequisites } from '../../system/prereqs';
24
38
  import type { CommandResult } from '../types';
25
39
 
26
40
  interface CeliloPkgInfo {
@@ -247,7 +261,42 @@ function applyFix(drifted: DriftedDep[], cliRoot: string): string[] {
247
261
  return lines;
248
262
  }
249
263
 
250
- export async function handleDoctor(
264
+ /**
265
+ * Render the system-prerequisites block: every tool from the
266
+ * PREREQUISITES table with its detection result, formatted into the
267
+ * doctor's output column-aligned style.
268
+ */
269
+ function renderPrereqSection(): { lines: string[]; failingCount: number } {
270
+ const checks = checkAllPrerequisites();
271
+ const lines: string[] = [];
272
+
273
+ lines.push('System prerequisites');
274
+ // Right-pad column for alignment. Take the longest tool name +1
275
+ // for spacing.
276
+ const nameCol = Math.max(...checks.map((c) => c.name.length), 12);
277
+
278
+ for (const c of checks) {
279
+ if (c.present && c.meetsMinimum) {
280
+ const version = c.version ? c.version : `${ANSI.dim}(version unknown)${ANSI.reset}`;
281
+ lines.push(` ${ANSI.green}✔${ANSI.reset} ${c.name.padEnd(nameCol)} ${version}`);
282
+ } else if (c.present && !c.meetsMinimum) {
283
+ // Present but below minimum (or version-parse failed with a minimum).
284
+ const detail = c.version ? `${c.version} — below minimum required` : 'version unreadable';
285
+ lines.push(` ${ANSI.yellow}⚠${ANSI.reset} ${c.name.padEnd(nameCol)} ${detail}`);
286
+ lines.push(` ${ANSI.dim}install: ${c.installHint}${ANSI.reset}`);
287
+ } else {
288
+ // Missing entirely.
289
+ lines.push(
290
+ ` ${ANSI.red}✗${ANSI.reset} ${c.name.padEnd(nameCol)} ${ANSI.dim}not installed${ANSI.reset}`,
291
+ );
292
+ lines.push(` ${ANSI.dim}install: ${c.installHint}${ANSI.reset}`);
293
+ }
294
+ }
295
+
296
+ return { lines, failingCount: failingPrerequisites(checks).length };
297
+ }
298
+
299
+ export async function handleSystemDoctor(
251
300
  _args: string[],
252
301
  flags: Record<string, string | boolean>,
253
302
  ): Promise<CommandResult> {
@@ -261,6 +310,12 @@ export async function handleDoctor(
261
310
  lines.push(`${ANSI.dim}running from ${cliRoot}${ANSI.reset}`);
262
311
  lines.push('');
263
312
 
313
+ // System prerequisites first — they're the most common reason a
314
+ // fresh management box can't run modules.
315
+ const prereqResult = renderPrereqSection();
316
+ lines.push(...prereqResult.lines);
317
+ lines.push('');
318
+
264
319
  const workspaceRoot = findWorkspaceRoot(process.cwd());
265
320
  const workspaceVersions = workspaceRoot ? collectWorkspaceVersions(workspaceRoot) : [];
266
321
  const workspaceMap = new Map(workspaceVersions.map((w) => [w.name, w]));
@@ -347,8 +402,17 @@ export async function handleDoctor(
347
402
  lines.push(...applyFix(drifted, cliRoot));
348
403
  lines.push('');
349
404
  lines.push(
350
- `${ANSI.dim}Re-run \`celilo doctor\` to verify; \`bun unlink\` from each workspace dir reverses.${ANSI.reset}`,
405
+ `${ANSI.dim}Re-run \`celilo system doctor\` to verify; \`bun unlink\` from each workspace dir reverses.${ANSI.reset}`,
351
406
  );
407
+ // Note: --fix only addresses drift, not missing prereqs. If prereqs
408
+ // failed, surface that even on a successful --fix run.
409
+ if (prereqResult.failingCount > 0) {
410
+ return {
411
+ success: false,
412
+ error: `${prereqResult.failingCount} system prerequisite(s) missing or below minimum — install before running celilo`,
413
+ details: lines.join('\n'),
414
+ };
415
+ }
352
416
  return {
353
417
  success: true,
354
418
  message: lines.join('\n'),
@@ -360,23 +424,34 @@ export async function handleDoctor(
360
424
  lines.push(`${ANSI.dim}--fix: nothing to repair.${ANSI.reset}`);
361
425
  }
362
426
 
363
- if (driftCount > 0 || unresolvedCount > 0) {
427
+ if (driftCount > 0 || unresolvedCount > 0 || prereqResult.failingCount > 0) {
364
428
  const summary: string[] = [];
429
+ if (prereqResult.failingCount > 0) {
430
+ summary.push(`${prereqResult.failingCount} prerequisite(s) missing/below-minimum`);
431
+ }
365
432
  if (driftCount > 0) summary.push(`${driftCount} package(s) behind workspace`);
366
433
  if (unresolvedCount > 0) summary.push(`${unresolvedCount} unresolved`);
367
434
  if (drifted.length > 0) {
368
435
  lines.push(
369
- `${ANSI.dim}Run \`celilo doctor --fix\` to bun-link drifted packages from the workspace.${ANSI.reset}`,
436
+ `${ANSI.dim}Run \`celilo system doctor --fix\` to bun-link drifted packages from the workspace.${ANSI.reset}`,
370
437
  );
371
438
  }
439
+ // Distinguish prereq-only from drift-only from both — the
440
+ // operator's next move is different.
441
+ const errorPrefix =
442
+ driftCount === 0 && unresolvedCount === 0
443
+ ? 'System prerequisites missing'
444
+ : prereqResult.failingCount === 0
445
+ ? 'Drift detected'
446
+ : 'Issues detected';
372
447
  return {
373
448
  success: false,
374
- error: `Drift detected: ${summary.join(', ')}`,
449
+ error: `${errorPrefix}: ${summary.join(', ')}`,
375
450
  details: lines.join('\n'),
376
451
  };
377
452
  }
378
453
 
379
- lines.push(`${ANSI.green}OK${ANSI.reset} — no drift detected`);
454
+ lines.push(`${ANSI.green}OK${ANSI.reset} — no issues detected`);
380
455
  return {
381
456
  success: true,
382
457
  message: lines.join('\n'),
@@ -32,7 +32,6 @@ export async function getCompletions(words: string[], current: number): Promise<
32
32
  'backup',
33
33
  'capability',
34
34
  'completion',
35
- 'doctor',
36
35
  'events',
37
36
  'help',
38
37
  'hook',
@@ -122,6 +121,7 @@ export async function getCompletions(words: string[], current: number): Promise<
122
121
  // Module subcommands
123
122
  if (command === 'module' && currentIndex === 1) {
124
123
  const subcommands = [
124
+ 'check',
125
125
  'import',
126
126
  'install',
127
127
  'list',
@@ -437,7 +437,7 @@ export async function getCompletions(words: string[], current: number): Promise<
437
437
 
438
438
  // System subcommands
439
439
  if (command === 'system' && currentIndex === 1) {
440
- const subcommands = ['init', 'config', 'secret', 'vault-password', 'audit', 'update'];
440
+ const subcommands = ['init', 'config', 'secret', 'vault-password', 'audit', 'update', 'doctor'];
441
441
  return filterSuggestions(subcommands, args[1] || '');
442
442
  }
443
443
 
@@ -518,3 +518,30 @@ _celilo() {
518
518
  compdef _celilo celilo
519
519
  `;
520
520
  }
521
+
522
+ /**
523
+ * Generate fish completion script.
524
+ *
525
+ * Fish doesn't have an equivalent of bash's `compgen -W` or zsh's `compadd`,
526
+ * so we register a single dynamic completer that calls the CLI's
527
+ * --get-completions hook on each TAB. The CLI emits one completion per line;
528
+ * fish picks them up as candidates.
529
+ *
530
+ * The shell context fish exposes is `commandline -opc` (tokens before the
531
+ * in-progress word) plus `commandline -ct` (the in-progress word). We
532
+ * recombine them into the same words + cword shape the bash/zsh wrappers
533
+ * use, so the same TypeScript completion logic serves all three shells.
534
+ */
535
+ export function generateFishCompletion(): string {
536
+ return `# Celilo fish completion
537
+ function __celilo_complete
538
+ set -l tokens (commandline -opc)
539
+ set -l current (commandline -ct)
540
+ set -l words celilo $tokens[2..-1] $current
541
+ set -l cword (math (count $words) - 1)
542
+ celilo --get-completions $words $cword 2>/dev/null
543
+ end
544
+
545
+ complete -c celilo -f -a '(__celilo_complete)'
546
+ `;
547
+ }
package/src/cli/index.ts CHANGED
@@ -10,7 +10,6 @@ import { COMMANDS, type CommandDef } from './command-registry';
10
10
  import { handleCapabilityInfo } from './commands/capability-info';
11
11
  import { handleCapabilityList } from './commands/capability-list';
12
12
  import { handleCompletion } from './commands/completion';
13
- import { handleDoctor } from './commands/doctor';
14
13
  import {
15
14
  handleEventsAck,
16
15
  handleEventsDrain,
@@ -45,6 +44,7 @@ import { handleMachineRemove } from './commands/machine-remove';
45
44
  import { handleMachineStatus } from './commands/machine-status';
46
45
  import { moduleAudit } from './commands/module-audit';
47
46
  import { handleModuleBuild } from './commands/module-build';
47
+ import { handleModuleCheck } from './commands/module-check';
48
48
  import { handleModuleConfigGet, handleModuleConfigSet } from './commands/module-config';
49
49
  import { handleModuleDeploy } from './commands/module-deploy';
50
50
  import { handleModuleGenerate } from './commands/module-generate';
@@ -75,6 +75,7 @@ import { handleServiceVerify } from './commands/service-verify';
75
75
  import { handleStatus } from './commands/status';
76
76
  import { handleSystemAudit } from './commands/system-audit';
77
77
  import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
78
+ import { handleSystemDoctor } from './commands/system-doctor';
78
79
  import { handleSystemInit } from './commands/system-init';
79
80
  import { handleSystemSecretGet } from './commands/system-secret-get';
80
81
  import { handleSystemSecretSet } from './commands/system-secret-set';
@@ -148,7 +149,6 @@ Usage:
148
149
 
149
150
  Commands:
150
151
  status Show system and module status
151
- doctor Diagnose @celilo/* version drift between the running CLI and the workspace
152
152
  audit Top-level alias for 'system audit'
153
153
  events SQLite event-bus operations (status, tail, run dispatcher, etc.)
154
154
  capability View registered module capabilities
@@ -377,6 +377,12 @@ Registry:
377
377
  --allow-dirty Permit publishing from a dirty git tree
378
378
  --allow-stale Skip the manifest-vs-src stale-check. Use sparingly.
379
379
 
380
+ check [path] Check a module for drift against the framework (default: .)
381
+ Options:
382
+ --no-build Skip the TypeScript build check
383
+ --json Emit a structured Check[] payload (CI-friendly)
384
+ --strict Treat warnings as failures
385
+
380
386
  Examples:
381
387
  celilo module install caddy
382
388
  celilo module install namecheap --registry https://my-registry.example.com/registry
@@ -787,6 +793,8 @@ Subcommands:
787
793
  update [--module <id>] [--no-backup] [--allow-destructive] [--dry-run] [--json]
788
794
  Bring the system to the audit-determined READY state
789
795
 
796
+ doctor [--fix] Diagnose system prerequisites and @celilo/* version drift
797
+
790
798
  Runbook:
791
799
  https://celilo.computer/docs/system-update
792
800
  (offline summary in apps/celilo/docs/INDEX.md)
@@ -907,11 +915,6 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
907
915
  return handleStatus();
908
916
  }
909
917
 
910
- // Handle doctor command
911
- if (parsed.command === 'doctor') {
912
- return handleDoctor(parsed.args, parsed.flags);
913
- }
914
-
915
918
  // Top-level alias: `celilo audit` → `celilo system audit`
916
919
  if (parsed.command === 'audit') {
917
920
  return handleSystemAudit(parsed.args, parsed.flags);
@@ -1175,6 +1178,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1175
1178
  }
1176
1179
  case 'publish':
1177
1180
  return handleModulePublish(parsed.args, parsed.flags);
1181
+ case 'check':
1182
+ return handleModuleCheck(parsed.args, parsed.flags);
1178
1183
  default:
1179
1184
  return {
1180
1185
  success: false,
@@ -1512,6 +1517,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1512
1517
  return handleSystemUpdate(parsed.args, parsed.flags);
1513
1518
  }
1514
1519
 
1520
+ if (parsed.subcommand === 'doctor') {
1521
+ return handleSystemDoctor(parsed.args, parsed.flags);
1522
+ }
1523
+
1515
1524
  return {
1516
1525
  success: false,
1517
1526
  error: `Unknown system subcommand: ${parsed.subcommand}\n\nRun "celilo system --help" for usage`,
@@ -417,8 +417,10 @@ async function installScriptDependencies(
417
417
  return;
418
418
  }
419
419
 
420
- // Run bun install in the scripts directory
421
- log.info('Installing script dependencies...');
420
+ // Run bun install in the scripts directory. The bun-install can take a
421
+ // few seconds on a cold cache, so we surface a status line — silent
422
+ // pauses look like hangs.
423
+ log.info('Installing dependencies for module hook scripts...');
422
424
  execSync('bun install', {
423
425
  cwd: scriptsDir,
424
426
  timeout: 120_000,
@@ -115,11 +115,24 @@ export class RegistryClient {
115
115
  version: string;
116
116
  netappPath: string;
117
117
  token: string;
118
+ /**
119
+ * One-line description from the module's manifest.yml. Optional
120
+ * (server tolerates absence) but recommended — it's what shows
121
+ * up in the registry's browse UI per
122
+ * apps/celilo/designs/REGISTRY_BROWSE_UI.md (Phase 2 step 0).
123
+ */
124
+ description?: string;
118
125
  }): Promise<{ ok: boolean; name: string; vers: string }> {
119
126
  const fileData = await readFile(opts.netappPath);
120
127
  const cksum = `sha256:${createHash('sha256').update(fileData).digest('hex')}`;
121
128
 
122
- const meta = JSON.stringify({ name: opts.name, vers: opts.version, deps: [], cksum });
129
+ const meta = JSON.stringify({
130
+ name: opts.name,
131
+ vers: opts.version,
132
+ deps: [],
133
+ cksum,
134
+ ...(opts.description ? { description: opts.description } : {}),
135
+ });
123
136
  const metaBuf = Buffer.from(meta, 'utf-8');
124
137
 
125
138
  const body = Buffer.allocUnsafe(4 + metaBuf.length + 4 + fileData.length);
@@ -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
+ });