@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
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`',
@@ -267,27 +255,21 @@ export const COMMANDS: CommandDef[] = [
267
255
  subcommands: [
268
256
  {
269
257
  name: 'import',
270
- description: 'Import a module (file <path> | public-registry <name>)',
271
- subcommands: [
272
- {
273
- name: 'file',
274
- description: 'Import from local filesystem',
275
- args: [{ name: 'path', description: 'Module path', completion: 'directories' }],
276
- },
258
+ description:
259
+ 'Import a module from the registry (bare name) or from a local path (./, /, ~, or *.netapp)',
260
+ args: [
277
261
  {
278
- name: 'public-registry',
279
- description: 'Import from celilo.computer registry',
280
- args: [{ name: 'name', description: 'Module name' }],
281
- flags: [
282
- {
283
- name: 'registry',
284
- description: 'Registry URL (overrides default celilo.computer)',
285
- takesValue: true,
286
- },
287
- ],
262
+ name: 'name-or-path',
263
+ description: 'Module name (registry) or filesystem path',
264
+ completion: 'directories',
288
265
  },
289
266
  ],
290
267
  flags: [
268
+ {
269
+ name: 'registry',
270
+ description: 'Registry URL (overrides default celilo.computer)',
271
+ takesValue: true,
272
+ },
291
273
  {
292
274
  name: 'target',
293
275
  description: 'Target directory',
@@ -489,12 +471,6 @@ export const COMMANDS: CommandDef[] = [
489
471
  },
490
472
  ],
491
473
  },
492
- {
493
- name: 'install',
494
- description: 'Download and import a module from the registry',
495
- args: [{ name: 'name', description: 'Module name' }],
496
- flags: [{ name: 'registry', description: 'Registry URL', takesValue: true }],
497
- },
498
474
  {
499
475
  name: 'search',
500
476
  description: 'Search the module registry',
@@ -815,6 +791,18 @@ export const COMMANDS: CommandDef[] = [
815
791
  },
816
792
  ],
817
793
  },
794
+ {
795
+ name: 'doctor',
796
+ description: 'Diagnose system prerequisites and @celilo/* version drift',
797
+ flags: [
798
+ {
799
+ name: 'fix',
800
+ description:
801
+ 'Repair drift by `bun link`-ing each drifted @celilo/* package from the workspace',
802
+ takesValue: false,
803
+ },
804
+ ],
805
+ },
818
806
  ],
819
807
  },
820
808
  {
@@ -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
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Tests for the import-routing rule that disambiguates between
3
+ * `celilo module import caddy` → registry
4
+ * `celilo module import ./modules/caddy` → local path
5
+ *
6
+ * Pure function over the argument string; no network, no filesystem.
7
+ */
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { classifyImportArg } from './module-import';
11
+
12
+ describe('classifyImportArg', () => {
13
+ test('routes bare kebab names to the registry', () => {
14
+ expect(classifyImportArg('caddy')).toBe('name');
15
+ expect(classifyImportArg('homebridge')).toBe('name');
16
+ expect(classifyImportArg('dns-external')).toBe('name');
17
+ expect(classifyImportArg('a')).toBe('name');
18
+ });
19
+
20
+ test('routes leading-dot relative paths to the filesystem', () => {
21
+ expect(classifyImportArg('./modules/caddy')).toBe('path');
22
+ expect(classifyImportArg('../caddy')).toBe('path');
23
+ expect(classifyImportArg('./caddy')).toBe('path');
24
+ });
25
+
26
+ test('routes leading-slash absolute paths to the filesystem', () => {
27
+ expect(classifyImportArg('/tmp/caddy')).toBe('path');
28
+ expect(classifyImportArg('/abs/path/to/module')).toBe('path');
29
+ });
30
+
31
+ test('routes tilde-expanded paths to the filesystem', () => {
32
+ expect(classifyImportArg('~')).toBe('path');
33
+ expect(classifyImportArg('~/dev/caddy')).toBe('path');
34
+ });
35
+
36
+ test('routes any path containing / to the filesystem', () => {
37
+ expect(classifyImportArg('modules/caddy')).toBe('path');
38
+ expect(classifyImportArg('a/b/c')).toBe('path');
39
+ });
40
+
41
+ test('routes .netapp filenames to the filesystem (registry never serves them by name)', () => {
42
+ expect(classifyImportArg('caddy.netapp')).toBe('path');
43
+ expect(classifyImportArg('homebridge-1.0.0.netapp')).toBe('path');
44
+ });
45
+
46
+ test('does not confuse versioned names with paths (no slash, no .netapp)', () => {
47
+ // Future: registry may accept `name@version` syntax. Today this routes
48
+ // to "name" — KEBAB_NAME validation in handleModuleImport will reject
49
+ // until the syntax is implemented.
50
+ expect(classifyImportArg('caddy@1.0.0')).toBe('name');
51
+ });
52
+ });