@celilo/cli 0.3.16 → 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 +12 -12
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -501,40 +501,29 @@ export async function listAvailableTemplates(
501
501
  }
502
502
 
503
503
  /**
504
- * Download an LXC template from Proxmox repository
505
- * Returns task ID (UPID) for tracking download progress
504
+ * Make an authenticated POST request to the Proxmox API.
505
+ * Shares connection/auth handling with makeProxmoxRequest; the only differences
506
+ * are the verb and the form-encoded body.
506
507
  */
507
- export async function downloadTemplate(
508
+ async function makeProxmoxPost<T>(
508
509
  credentials: ProxmoxCredentials,
509
- nodeName: string,
510
- storageName: string,
511
- templateUrl: string,
512
- ): Promise<ProxmoxResult<string>> {
510
+ path: string,
511
+ params: Record<string, string>,
512
+ ): Promise<ProxmoxResult<T>> {
513
513
  return new Promise((resolve) => {
514
514
  try {
515
515
  const { api_url, api_token_id, api_token_secret } = credentials;
516
516
  const authHeader = `PVEAPIToken=${api_token_id}=${api_token_secret}`;
517
- const fullUrl = `${api_url}/nodes/${nodeName}/storage/${storageName}/download-url`;
517
+ const fullUrl = `${api_url}${path}`;
518
518
  const url = new URL(fullUrl);
519
+ const postData = new URLSearchParams(params).toString();
519
520
 
520
521
  if (process.env.DEBUG) {
521
- console.log(`[Proxmox] Download template: ${fullUrl}`);
522
- console.log(`[Proxmox] Template URL: ${templateUrl}`);
523
- console.log(`[Proxmox] Storage: ${storageName}`);
522
+ console.log(`[Proxmox] POST: ${fullUrl}`);
523
+ console.log(`[Proxmox] Body: ${postData}`);
524
524
  }
525
525
 
526
- const agent = new https.Agent({
527
- rejectUnauthorized: false,
528
- });
529
-
530
- // POST request with form data
531
- // Note: Proxmox requires 'filename' parameter for download-url endpoint
532
- const filename = templateUrl.split('/').pop() || 'template.tar.zst';
533
- const postData = new URLSearchParams({
534
- url: templateUrl,
535
- content: 'vztmpl',
536
- filename: filename,
537
- }).toString();
526
+ const agent = new https.Agent({ rejectUnauthorized: false });
538
527
 
539
528
  const req = https.request(
540
529
  {
@@ -551,33 +540,31 @@ export async function downloadTemplate(
551
540
  },
552
541
  (res) => {
553
542
  let body = '';
554
-
555
543
  res.on('data', (chunk) => {
556
544
  body += chunk;
557
545
  });
558
-
559
546
  res.on('end', () => {
560
547
  const statusCode = res.statusCode || 0;
561
548
 
562
549
  if (statusCode < 200 || statusCode >= 300) {
563
- if (process.env.DEBUG || statusCode === 400) {
564
- console.error(`[Proxmox] Download failed: ${body}`);
550
+ if (process.env.DEBUG || statusCode >= 400) {
551
+ console.error(`[Proxmox] POST ${path} failed (${statusCode}): ${body}`);
565
552
  }
566
553
  resolve({
567
554
  success: false,
568
- message: `Download request failed with status ${statusCode}: ${body}`,
555
+ message: `Request failed with status ${statusCode}: ${body}`,
569
556
  details: { status: statusCode, response: body },
570
557
  });
571
558
  return;
572
559
  }
573
560
 
574
561
  try {
575
- const data = JSON.parse(body) as ProxmoxApiResponse<string>;
562
+ const data = JSON.parse(body) as ProxmoxApiResponse<T>;
576
563
  resolve({ success: true, data: data.data });
577
564
  } catch (error) {
578
565
  resolve({
579
566
  success: false,
580
- message: 'Failed to parse download response',
567
+ message: 'Failed to parse response',
581
568
  details: { error: String(error), body },
582
569
  });
583
570
  }
@@ -588,7 +575,7 @@ export async function downloadTemplate(
588
575
  req.on('error', (error) => {
589
576
  resolve({
590
577
  success: false,
591
- message: `Download request failed: ${error.message}`,
578
+ message: `Request failed: ${error.message}`,
592
579
  details: { error: String(error) },
593
580
  });
594
581
  });
@@ -598,13 +585,67 @@ export async function downloadTemplate(
598
585
  } catch (error) {
599
586
  resolve({
600
587
  success: false,
601
- message: `Download failed: ${error instanceof Error ? error.message : String(error)}`,
588
+ message: `Request failed: ${error instanceof Error ? error.message : String(error)}`,
602
589
  details: { error: String(error) },
603
590
  });
604
591
  }
605
592
  });
606
593
  }
607
594
 
595
+ /**
596
+ * Entry from Proxmox's appliance catalog (`pveam available`). The `template`
597
+ * field is the canonical filename (revision included) that should be passed to
598
+ * downloadAppliance; constructing it ourselves is a known foot-gun because
599
+ * Proxmox refreshes revisions over time.
600
+ */
601
+ export interface ProxmoxAppliance {
602
+ /** Full canonical filename, e.g. "ubuntu-24.04-standard_24.04-2_amd64.tar.zst" */
603
+ template: string;
604
+ /** Package family, e.g. "ubuntu-24.04-standard" */
605
+ package: string;
606
+ /** Version including revision, e.g. "24.04-2" */
607
+ version: string;
608
+ /** Template type, typically "lxc" */
609
+ type?: string;
610
+ /** Operating system, e.g. "ubuntu" */
611
+ os?: string;
612
+ /** Section, e.g. "system" — what `pveam available --section system` filters on */
613
+ section?: string;
614
+ /** Display headline */
615
+ headline?: string;
616
+ }
617
+
618
+ /**
619
+ * List the LXC templates Proxmox knows are downloadable from its mirror.
620
+ * Wraps `GET /nodes/{node}/aplinfo` — the same data source `pveam available`
621
+ * uses. Use this rather than building URLs against download.proxmox.com so
622
+ * that revision bumps (e.g. ubuntu-24.04 -1 → -2) are picked up automatically.
623
+ */
624
+ export async function listAvailableAppliances(
625
+ credentials: ProxmoxCredentials,
626
+ nodeName: string,
627
+ ): Promise<ProxmoxResult<ProxmoxAppliance[]>> {
628
+ return makeProxmoxRequest(credentials, `/nodes/${nodeName}/aplinfo`);
629
+ }
630
+
631
+ /**
632
+ * Start a download of an appliance template from Proxmox's mirror.
633
+ * `templateName` must be the exact `template` field from listAvailableAppliances
634
+ * (the same string `pveam download <storage> <template>` accepts).
635
+ * Returns a UPID for status polling via checkTaskStatus.
636
+ */
637
+ export async function downloadAppliance(
638
+ credentials: ProxmoxCredentials,
639
+ nodeName: string,
640
+ storageName: string,
641
+ templateName: string,
642
+ ): Promise<ProxmoxResult<string>> {
643
+ return makeProxmoxPost<string>(credentials, `/nodes/${nodeName}/aplinfo`, {
644
+ storage: storageName,
645
+ template: templateName,
646
+ });
647
+ }
648
+
608
649
  /**
609
650
  * Check status of a running task (UPID)
610
651
  * Returns task status and completion percentage
@@ -619,15 +660,6 @@ export async function checkTaskStatus(
619
660
  return makeProxmoxRequest(credentials, `/nodes/${nodeName}/tasks/${encodedUpid}/status`);
620
661
  }
621
662
 
622
- /**
623
- * Build template URL for downloading from Proxmox repository
624
- */
625
- export function buildTemplateUrl(ubuntuVersion: string): string {
626
- // Proxmox mirrors Ubuntu cloud images
627
- // Format: http://download.proxmox.com/images/system/ubuntu-{version}-standard_{version}-1_amd64.tar.zst
628
- return `http://download.proxmox.com/images/system/ubuntu-${ubuntuVersion}-standard_${ubuntuVersion}-1_amd64.tar.zst`;
629
- }
630
-
631
663
  /**
632
664
  * Extract template filename from full template path
633
665
  * Format: "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst" -> "ubuntu-22.04-standard_22.04-1_amd64.tar.zst"
@@ -639,11 +671,11 @@ export function extractTemplateFilename(templatePath: string): string {
639
671
  }
640
672
 
641
673
  /**
642
- * Build full template path from components
674
+ * Build full template path (volid) from a storage name and the canonical
675
+ * template filename returned by listAvailableAppliances.
643
676
  */
644
- export function buildTemplatePath(storageName: string, ubuntuVersion: string): string {
645
- const filename = `ubuntu-${ubuntuVersion}-standard_${ubuntuVersion}-1_amd64.tar.zst`;
646
- return `${storageName}:vztmpl/${filename}`;
677
+ export function buildTemplatePath(storageName: string, templateFilename: string): string {
678
+ return `${storageName}:vztmpl/${templateFilename}`;
647
679
  }
648
680
 
649
681
  /**
@@ -66,18 +66,6 @@ export const COMMANDS: CommandDef[] = [
66
66
  name: 'status',
67
67
  description: 'Show system and module status',
68
68
  },
69
- {
70
- name: 'doctor',
71
- description: 'Diagnose @celilo/* version drift between the running CLI and the workspace',
72
- flags: [
73
- {
74
- name: 'fix',
75
- description:
76
- 'Repair drift by `bun link`-ing each drifted @celilo/* package from the workspace',
77
- takesValue: false,
78
- },
79
- ],
80
- },
81
69
  {
82
70
  name: 'audit',
83
71
  description: 'Top-level alias for `system audit`',
@@ -815,6 +803,18 @@ export const COMMANDS: CommandDef[] = [
815
803
  },
816
804
  ],
817
805
  },
806
+ {
807
+ name: 'doctor',
808
+ description: 'Diagnose system prerequisites and @celilo/* version drift',
809
+ flags: [
810
+ {
811
+ name: 'fix',
812
+ description:
813
+ 'Repair drift by `bun link`-ing each drifted @celilo/* package from the workspace',
814
+ takesValue: false,
815
+ },
816
+ ],
817
+ },
818
818
  ],
819
819
  },
820
820
  {
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { COMMANDS } from '../command-registry';
7
- import { generateBashCompletion } from '../completion';
7
+ import { generateBashCompletion, generateFishCompletion } from '../completion';
8
8
  import { generateRichZshCompletion } from '../generate-zsh-completion';
9
9
  import { celiloIntro } from '../prompts';
10
10
  import type { CommandResult } from '../types';
@@ -32,6 +32,7 @@ export async function handleCompletion(
32
32
  Usage:
33
33
  celilo completion bash Generate bash completion script
34
34
  celilo completion zsh Generate zsh completion script
35
+ celilo completion fish Generate fish completion script
35
36
 
36
37
  Install zsh completions:
37
38
  celilo completion zsh > ~/.zsh/completions/_celilo
@@ -41,19 +42,15 @@ Install zsh completions:
41
42
  }
42
43
 
43
44
  if (shell === 'bash') {
44
- const script = generateBashCompletion();
45
- return {
46
- success: true,
47
- message: script,
48
- };
45
+ return { success: true, message: generateBashCompletion() };
49
46
  }
50
47
 
51
48
  if (shell === 'zsh') {
52
- const script = generateRichZshCompletion(COMMANDS);
53
- return {
54
- success: true,
55
- message: script,
56
- };
49
+ return { success: true, message: generateRichZshCompletion(COMMANDS) };
50
+ }
51
+
52
+ if (shell === 'fish') {
53
+ return { success: true, message: generateFishCompletion() };
57
54
  }
58
55
 
59
56
  celiloIntro('Shell Completion');
@@ -65,6 +62,7 @@ Install zsh completions:
65
62
  Supported shells:
66
63
  bash Generate bash completion script
67
64
  zsh Generate zsh completion script
65
+ fish Generate fish completion script
68
66
 
69
67
  Usage:
70
68
  # Bash
@@ -76,6 +74,9 @@ Usage:
76
74
  celilo completion zsh > ~/.zsh/completions/_celilo
77
75
  # OR for user install:
78
76
  celilo completion zsh > /usr/local/share/zsh/site-functions/_celilo
77
+
78
+ # Fish
79
+ celilo completion fish > ~/.config/fish/completions/celilo.fish
79
80
  `,
80
81
  };
81
82
  } catch (error) {
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `celilo module check <path>` — drift detection for third-party modules.
3
+ *
4
+ * Runs every checker in services/module-validator against the module
5
+ * at <path> (defaults to ".") and reports a human-friendly summary or
6
+ * a structured JSON payload.
7
+ *
8
+ * Flags:
9
+ * --no-build Skip `bunx tsc --noEmit`. Useful for fast iteration
10
+ * or when the module has no TypeScript surface.
11
+ * --json Emit the structured Check[] payload instead of the
12
+ * formatted text. Good for CI.
13
+ * --strict Treat warnings as failures (any non-OK is non-zero).
14
+ *
15
+ * Exit codes:
16
+ * - all checks pass (or only warns) → success
17
+ * - one or more fails → CommandError (CLI exits 1)
18
+ * - --strict turns warns into fails too
19
+ *
20
+ * Pure filesystem + manifest + npm. No DB writes.
21
+ */
22
+
23
+ import { resolve } from 'node:path';
24
+ import { type Check, runChecks } from '../../services/module-validator';
25
+ import { hasFlag } from '../parser';
26
+ import type { CommandResult } from '../types';
27
+
28
+ interface CheckOptions {
29
+ noBuild: boolean;
30
+ json: boolean;
31
+ strict: boolean;
32
+ }
33
+
34
+ function parseOptions(flags: Record<string, string | boolean>): CheckOptions {
35
+ return {
36
+ noBuild: hasFlag(flags, 'no-build'),
37
+ json: hasFlag(flags, 'json'),
38
+ strict: hasFlag(flags, 'strict'),
39
+ };
40
+ }
41
+
42
+ function summarize(checks: Check[]): { ok: number; warn: number; fail: number } {
43
+ const summary = { ok: 0, warn: 0, fail: 0 };
44
+ for (const c of checks) summary[c.status]++;
45
+ return summary;
46
+ }
47
+
48
+ function statusIcon(status: Check['status']): string {
49
+ switch (status) {
50
+ case 'ok':
51
+ return '✓';
52
+ case 'warn':
53
+ return '!';
54
+ case 'fail':
55
+ return '✗';
56
+ }
57
+ }
58
+
59
+ function formatTextReport(modulePath: string, checks: Check[]): string {
60
+ const lines: string[] = [`Module check: ${modulePath}`, ''];
61
+ const byCategory = new Map<string, Check[]>();
62
+ for (const check of checks) {
63
+ const list = byCategory.get(check.category) ?? [];
64
+ list.push(check);
65
+ byCategory.set(check.category, list);
66
+ }
67
+
68
+ const order: Check['category'][] = [
69
+ 'manifest_schema',
70
+ 'contract_version',
71
+ 'capability',
72
+ 'workspace_dep',
73
+ 'git_hygiene',
74
+ 'typescript_build',
75
+ ];
76
+ for (const category of order) {
77
+ const items = byCategory.get(category);
78
+ if (!items || items.length === 0) continue;
79
+ lines.push(formatCategoryHeader(category));
80
+ for (const c of items) {
81
+ lines.push(` ${statusIcon(c.status)} ${c.name}`);
82
+ lines.push(` ${c.message}`);
83
+ if (c.suggestedValue && c.status !== 'ok') {
84
+ lines.push(` suggest: ${c.suggestedValue}`);
85
+ }
86
+ if (c.migrationUrl) {
87
+ lines.push(` migration: ${c.migrationUrl}`);
88
+ }
89
+ }
90
+ lines.push('');
91
+ }
92
+
93
+ const summary = summarize(checks);
94
+ lines.push(`Summary: ${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`);
95
+ return lines.join('\n');
96
+ }
97
+
98
+ function formatCategoryHeader(category: Check['category']): string {
99
+ switch (category) {
100
+ case 'manifest_schema':
101
+ return 'Manifest schema:';
102
+ case 'contract_version':
103
+ return 'Contract version:';
104
+ case 'capability':
105
+ return 'Capability versions:';
106
+ case 'workspace_dep':
107
+ return 'Workspace deps (@celilo/*):';
108
+ case 'git_hygiene':
109
+ return 'Publish readiness (git):';
110
+ case 'typescript_build':
111
+ return 'TypeScript build:';
112
+ }
113
+ }
114
+
115
+ function formatJsonReport(modulePath: string, checks: Check[]): string {
116
+ return JSON.stringify(
117
+ {
118
+ module: { path: modulePath },
119
+ checks,
120
+ summary: summarize(checks),
121
+ },
122
+ null,
123
+ 2,
124
+ );
125
+ }
126
+
127
+ export async function handleModuleCheck(
128
+ args: string[],
129
+ flags: Record<string, string | boolean>,
130
+ ): Promise<CommandResult> {
131
+ const options = parseOptions(flags);
132
+ const modulePath = resolve(args[0] ?? '.');
133
+
134
+ const checks = await runChecks(modulePath, { noBuild: options.noBuild });
135
+ const summary = summarize(checks);
136
+
137
+ const message = options.json
138
+ ? formatJsonReport(modulePath, checks)
139
+ : formatTextReport(modulePath, checks);
140
+
141
+ const hasFails = summary.fail > 0;
142
+ const hasWarns = summary.warn > 0;
143
+ const failed = hasFails || (options.strict && hasWarns);
144
+
145
+ if (failed) {
146
+ return {
147
+ success: false,
148
+ error: message,
149
+ };
150
+ }
151
+
152
+ return {
153
+ success: true,
154
+ message,
155
+ rawOutput: options.json,
156
+ data: { checks, summary },
157
+ };
158
+ }
@@ -141,17 +141,17 @@ async function handlePublicRegistryImport(
141
141
  return { success: false, error: result.error, details: result.details };
142
142
  }
143
143
 
144
- const typesPath = await generateTypesForImportedModule(result.targetPath);
145
- const typesMessage = typesPath ? `\nGenerated types: ${shortenPath(typesPath)}` : '';
146
-
144
+ // Type-generation is a developer-mode aid for module AUTHORS editing
145
+ // hook scripts in the source tree. Registry installs are black-box
146
+ // dependencies — the operator isn't editing them — so skipping the
147
+ // type-gen pass saves the work and keeps the install output clean.
147
148
  return {
148
149
  success: true,
149
- message: `Imported ${result.moduleId}@${latest.vers}\nFiles: ${shortenPath(result.targetPath)}${typesMessage}`,
150
+ message: `Imported ${result.moduleId}@${latest.vers}\nFiles: ${shortenPath(result.targetPath)}`,
150
151
  data: {
151
152
  moduleId: result.moduleId,
152
153
  version: latest.vers,
153
154
  targetPath: result.targetPath,
154
- typesPath: typesPath ?? undefined,
155
155
  },
156
156
  };
157
157
  } finally {
@@ -15,7 +15,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
15
15
  import { mkdir, rm, writeFile } from 'node:fs/promises';
16
16
  import { join } from 'node:path';
17
17
  import type { IndexEntry } from '../../registry/client';
18
- import { handleModulePublish, resolveToken, validateCapabilityVersions } from './module-publish';
18
+ import { handleModulePublish, resolveToken } from './module-publish';
19
19
 
20
20
  const TEST_DIR = `/tmp/test-module-publish-${Date.now()}`;
21
21
 
@@ -155,95 +155,8 @@ describe('revision auto-detection algorithm', () => {
155
155
  });
156
156
  });
157
157
 
158
- // ── Strict-publish: capability version validation (CELILO_UPDATE D4) ──────────
159
- //
160
- // Manifest-claimed versions for known capabilities must match the framework's
161
- // runtime registry; mismatches refuse the publish.
162
-
163
- describe('validateCapabilityVersions', () => {
164
- test('no errors when no capabilities are declared', () => {
165
- expect(validateCapabilityVersions({ id: 'x', version: '1.0.0' })).toEqual([]);
166
- });
167
-
168
- test('no errors when provides matches the runtime registry exactly', () => {
169
- expect(
170
- validateCapabilityVersions({
171
- id: 'caddy',
172
- version: '3.0.0',
173
- provides: { capabilities: [{ name: 'public_web', version: '3.0.0' }] },
174
- }),
175
- ).toEqual([]);
176
- });
177
-
178
- test('error when provides[X].version differs from runtime', () => {
179
- // The runtime is set to 3.0.0 for public_web; claiming 1.0.0 is a bug.
180
- const errors = validateCapabilityVersions({
181
- id: 'caddy',
182
- version: '1.0.0',
183
- provides: { capabilities: [{ name: 'public_web', version: '1.0.0' }] },
184
- });
185
- expect(errors).toHaveLength(1);
186
- expect(errors[0]).toContain('provides[public_web]');
187
- expect(errors[0]).toContain('1.0.0');
188
- expect(errors[0]).toContain('3.0.0');
189
- });
190
-
191
- test('error when provides[X].version is newer than runtime', () => {
192
- const errors = validateCapabilityVersions({
193
- id: 'caddy',
194
- version: '4.0.0',
195
- provides: { capabilities: [{ name: 'public_web', version: '4.0.0' }] },
196
- });
197
- expect(errors).toHaveLength(1);
198
- expect(errors[0]).toContain('provides[public_web]');
199
- });
200
-
201
- test('skips capabilities not in the framework registry (third-party)', () => {
202
- expect(
203
- validateCapabilityVersions({
204
- id: 'odd',
205
- version: '1.0.0',
206
- provides: { capabilities: [{ name: 'custom_metric', version: '99.0.0' }] },
207
- }),
208
- ).toEqual([]);
209
- });
210
-
211
- test('error when requires[X].version is a higher major than runtime', () => {
212
- // Framework can't satisfy a requirement it doesn't know about.
213
- const errors = validateCapabilityVersions({
214
- id: 'lunacycle',
215
- version: '1.0.0',
216
- requires: { capabilities: [{ name: 'idp', version: '2.0.0' }] },
217
- });
218
- expect(errors).toHaveLength(1);
219
- expect(errors[0]).toContain('requires[idp]');
220
- });
221
-
222
- test('no error when requires[X].version is at most the runtime version', () => {
223
- // dns_registrar runtime is 4.0.0; consumer requiring 4.0.0 is fine.
224
- expect(
225
- validateCapabilityVersions({
226
- id: 'caddy',
227
- version: '1.0.0',
228
- requires: { capabilities: [{ name: 'dns_registrar', version: '4.0.0' }] },
229
- }),
230
- ).toEqual([]);
231
- });
232
-
233
- test('reports multiple errors at once', () => {
234
- const errors = validateCapabilityVersions({
235
- id: 'broken',
236
- version: '1.0.0',
237
- provides: {
238
- capabilities: [
239
- { name: 'public_web', version: '99.0.0' },
240
- { name: 'idp', version: '99.0.0' },
241
- ],
242
- },
243
- });
244
- expect(errors).toHaveLength(2);
245
- });
246
- });
158
+ // validateCapabilityVersions tests live with the function in
159
+ // services/module-validator/capability-versions.test.ts.
247
160
 
248
161
  // ── Token resolution: --token → env → secret store ────────────────────────────
249
162