@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
@@ -1,7 +1,23 @@
1
1
  /**
2
2
  * Module import command
3
+ *
4
+ * Single entry point for both registry and local-source imports. Routing
5
+ * is determined by the shape of the argument:
6
+ *
7
+ * celilo module import caddy → registry (kebab name)
8
+ * celilo module import ./modules/caddy → local dir (relative path)
9
+ * celilo module import /tmp/caddy.netapp → local file (absolute path)
10
+ * celilo module import ~/dev/caddy → local dir (tilde-expanded)
11
+ * celilo module import caddy.netapp → local file (.netapp extension)
12
+ *
13
+ * The previous CLI surface had three overlapping commands for this:
14
+ * `module install` (registry-only shorthand), `module import public-registry`
15
+ * (registry, verbose), and `module import file` (local, verbose). All have
16
+ * been collapsed into this one command — the routing rule below is the
17
+ * canonical disambiguator.
3
18
  */
4
19
 
20
+ import { existsSync } from 'node:fs';
5
21
  import { unlink } from 'node:fs/promises';
6
22
  import { tmpdir } from 'node:os';
7
23
  import { join, resolve } from 'node:path';
@@ -14,47 +30,74 @@ import type { CommandResult } from '../types';
14
30
  import { generateTypesForImportedModule } from './module-types';
15
31
 
16
32
  const USAGE = `Usage:
17
- celilo module import file <path> Import from local filesystem
18
- celilo module import public-registry <name> Import from celilo.computer registry`;
33
+ celilo module import <name> Import from celilo.computer registry
34
+ celilo module import <path> Import from a local directory or .netapp file
35
+
36
+ Examples:
37
+ celilo module import caddy
38
+ celilo module import ./modules/caddy
39
+ celilo module import /tmp/caddy.netapp`;
19
40
 
20
41
  /**
21
- * Handle module import command
42
+ * Decide whether the user's argument should route to local-filesystem
43
+ * import or registry import. Path-like arguments win — see header comment
44
+ * for the routing rules. Anything else is treated as a registry name
45
+ * (validated against the kebab-case rule before the network call).
22
46
  *
23
- * Usage: celilo module import file <path> [--target <path>]
24
- * celilo module import public-registry <name>
25
- * celilo module import <path> (alias for "file", kept for compatibility)
47
+ * Exposed for tests; not exported from the package.
26
48
  */
49
+ export function classifyImportArg(arg: string): 'path' | 'name' {
50
+ if (arg.startsWith('/') || arg.startsWith('./') || arg.startsWith('../')) return 'path';
51
+ if (arg.startsWith('~/') || arg === '~') return 'path';
52
+ if (arg.includes('/')) return 'path';
53
+ if (arg.endsWith('.netapp')) return 'path';
54
+ return 'name';
55
+ }
56
+
57
+ const KEBAB_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
58
+
27
59
  export async function handleModuleImport(
28
60
  args: string[],
29
61
  flags: Record<string, string | boolean>,
30
62
  ): Promise<CommandResult> {
31
- const subcommand = getArg(args, 0);
63
+ const arg = getArg(args, 0);
32
64
 
33
- if (!subcommand) {
34
- return { success: false, error: `Subcommand required.\n\n${USAGE}` };
65
+ if (!arg) {
66
+ return { success: false, error: `Module name or path required.\n\n${USAGE}` };
35
67
  }
36
68
 
37
- if (subcommand === 'public-registry') {
38
- const moduleName = getArg(args, 1);
39
- if (!moduleName) {
40
- return { success: false, error: `Module name required.\n\n${USAGE}` };
69
+ if (classifyImportArg(arg) === 'name') {
70
+ if (!KEBAB_NAME.test(arg)) {
71
+ return {
72
+ success: false,
73
+ error: `Not a valid module name or path: '${arg}'.\n\n${USAGE}\n\nModule names are kebab-case (lowercase letters, digits, hyphens). For local paths, use ./, /, ~/, or include / in the argument.`,
74
+ };
41
75
  }
42
- return handlePublicRegistryImport(moduleName, flags);
76
+ return handlePublicRegistryImport(arg, flags);
43
77
  }
44
78
 
45
- // Resolve the source path: "file <path>" or bare "<path>" alias
46
- const sourcePath = subcommand === 'file' ? getArg(args, 1) : subcommand;
47
-
48
- if (!sourcePath) {
49
- return { success: false, error: `Path required.\n\n${USAGE}` };
50
- }
79
+ return handleFileImport(arg, flags);
80
+ }
51
81
 
52
- // Resolve paths
82
+ async function handleFileImport(
83
+ sourcePath: string,
84
+ flags: Record<string, string | boolean>,
85
+ ): Promise<CommandResult> {
53
86
  const resolvedSourcePath = resolve(sourcePath);
54
87
  const targetBasePath = getFlag(flags, 'target', getModuleStoragePath());
55
88
  const resolvedTargetBasePath = resolve(targetBasePath);
56
89
 
57
- // Import module
90
+ // Surface a more useful error than "module directory does not exist"
91
+ // when the user typed a registry name with a typo (e.g. `caddy/` or
92
+ // `./caddy`) but no such directory is present. Helps them realize
93
+ // they probably wanted the registry form.
94
+ if (!existsSync(resolvedSourcePath)) {
95
+ return {
96
+ success: false,
97
+ error: `Path does not exist: ${resolvedSourcePath}\n\nIf you meant the registry, use a bare module name (no leading ./ or /):\n celilo module import <name>`,
98
+ };
99
+ }
100
+
58
101
  const db = getDb();
59
102
  const result = await importModule({
60
103
  sourcePath: resolvedSourcePath,
@@ -141,17 +184,17 @@ async function handlePublicRegistryImport(
141
184
  return { success: false, error: result.error, details: result.details };
142
185
  }
143
186
 
144
- const typesPath = await generateTypesForImportedModule(result.targetPath);
145
- const typesMessage = typesPath ? `\nGenerated types: ${shortenPath(typesPath)}` : '';
146
-
187
+ // Type-generation is a developer-mode aid for module AUTHORS editing
188
+ // hook scripts in the source tree. Registry installs are black-box
189
+ // dependencies — the operator isn't editing them — so skipping the
190
+ // type-gen pass saves the work and keeps the install output clean.
147
191
  return {
148
192
  success: true,
149
- message: `Imported ${result.moduleId}@${latest.vers}\nFiles: ${shortenPath(result.targetPath)}${typesMessage}`,
193
+ message: `Imported ${result.moduleId}@${latest.vers}\nFiles: ${shortenPath(result.targetPath)}`,
150
194
  data: {
151
195
  moduleId: result.moduleId,
152
196
  version: latest.vers,
153
197
  targetPath: result.targetPath,
154
- typesPath: typesPath ?? undefined,
155
198
  },
156
199
  };
157
200
  } 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
 
@@ -35,16 +35,10 @@
35
35
  * timestamp / CLI version / optional --message.
36
36
  */
37
37
 
38
- import { spawnSync } from 'node:child_process';
39
38
  import { rm } from 'node:fs/promises';
40
39
  import { readFile } from 'node:fs/promises';
41
40
  import { tmpdir } from 'node:os';
42
41
  import { join, resolve } from 'node:path';
43
- import {
44
- CAPABILITY_CONTRACT_VERSIONS,
45
- type KnownCapabilityName,
46
- compareProviderToRuntime,
47
- } from '@celilo/capabilities';
48
42
  import { parse as parseYaml } from 'yaml';
49
43
  import { buildModule } from '../../module/packaging/build';
50
44
  import {
@@ -55,122 +49,18 @@ import {
55
49
  } from '../../module/packaging/release-metadata';
56
50
  import { RegistryClient } from '../../registry/client';
57
51
  import { CrossModuleDataManager } from '../../services/cross-module-data-manager';
52
+ import {
53
+ type ManifestForCapabilityCheck,
54
+ validateCapabilityVersions,
55
+ } from '../../services/module-validator/capability-versions';
56
+ import { checkModuleStale } from '../../services/module-validator/git-hygiene';
58
57
  import { getFlag, hasFlag } from '../parser';
59
58
  import type { CommandResult } from '../types';
60
59
 
61
- interface ManifestCapabilityClaim {
62
- name: string;
63
- version: string;
64
- }
65
-
66
- interface ManifestForPublish {
60
+ interface ManifestForPublish extends ManifestForCapabilityCheck {
67
61
  id: string;
68
62
  version: string;
69
- provides?: { capabilities?: ManifestCapabilityClaim[] };
70
- requires?: { capabilities?: ManifestCapabilityClaim[] };
71
- }
72
-
73
- function isKnownCapability(name: string): name is KnownCapabilityName {
74
- return name in CAPABILITY_CONTRACT_VERSIONS;
75
- }
76
-
77
- /**
78
- * Strict-publish: every `provides[X].version` must match the framework's
79
- * runtime registry; every `requires[X].version` must be at most the
80
- * framework's runtime version (consumers can require older minors of
81
- * still-supported majors).
82
- *
83
- * Exported for testing.
84
- */
85
- export function validateCapabilityVersions(manifest: ManifestForPublish): string[] {
86
- const errors: string[] = [];
87
-
88
- for (const p of manifest.provides?.capabilities ?? []) {
89
- if (!isKnownCapability(p.name)) continue;
90
- const runtime = CAPABILITY_CONTRACT_VERSIONS[p.name];
91
- const r = compareProviderToRuntime(p.version, runtime);
92
- if (!r.compatible) {
93
- errors.push(
94
- `provides[${p.name}].version is ${p.version} but framework registry is ${runtime} ` +
95
- `(${r.reason}). Update the manifest, then retry.`,
96
- );
97
- }
98
- }
99
-
100
- for (const need of manifest.requires?.capabilities ?? []) {
101
- if (!isKnownCapability(need.name)) continue;
102
- const runtime = CAPABILITY_CONTRACT_VERSIONS[need.name];
103
- // Treat the runtime as the de-facto provider for publish-time validation:
104
- // if a consumer's manifest claims it requires a contract newer than the
105
- // framework even has registered, no in-tree provider can satisfy it.
106
- const r = compareProviderToRuntime(need.version, runtime);
107
- if (!r.compatible && r.reason === 'major_mismatch_higher') {
108
- errors.push(
109
- `requires[${need.name}].version is ${need.version} but framework registry is ${runtime}. Bump the framework before publishing, or lower the manifest version.`,
110
- );
111
- }
112
- }
113
-
114
- return errors;
115
- }
116
-
117
- /**
118
- * Look up the SHA of the last commit touching the given pathspecs. Returns
119
- * null on any git failure (no repo, never-committed file, etc.) — callers
120
- * treat that as "can't determine, skip the check."
121
- */
122
- function lastCommitTouching(pathspec: string[]): string | null {
123
- const r = spawnSync('git', ['log', '-1', '--format=%H', '--', ...pathspec], {
124
- stdio: ['ignore', 'pipe', 'ignore'],
125
- encoding: 'utf-8',
126
- });
127
- if (r.status !== 0) return null;
128
- const sha = r.stdout.trim();
129
- return sha || null;
130
- }
131
-
132
- function isAncestor(maybeAncestor: string, descendant: string): boolean {
133
- const r = spawnSync('git', ['merge-base', '--is-ancestor', maybeAncestor, descendant], {
134
- stdio: 'ignore',
135
- });
136
- return r.status === 0;
137
- }
138
-
139
- export interface StalenessIssue {
140
- moduleDir: string;
141
- lastSrcCommit: string;
142
- lastManifestCommit: string;
143
- }
144
-
145
- /**
146
- * Detect "I edited module src but forgot to bump (or touch) manifest.yml."
147
- *
148
- * Returns null when the manifest is the most recently-touched file in the
149
- * dir (or when neither side has any commit history — e.g. brand-new module
150
- * not yet committed). Returns a StalenessIssue when src has commits AFTER
151
- * the last manifest.yml change — the operator must bump the manifest
152
- * (semver change → reset +N to 1) or just touch it (release-only change →
153
- * auto-bump +N), then re-publish.
154
- *
155
- * Exported for testing.
156
- */
157
- export function checkModuleStale(moduleDir: string): StalenessIssue | null {
158
- const manifestPath = join(moduleDir, 'manifest.yml');
159
- const lastManifest = lastCommitTouching([manifestPath]);
160
- // Excluding manifest.yml from the "src" pathspec is the whole point — we
161
- // want to know if anything ELSE in the dir moved past it. node_modules and
162
- // common build outputs are gitignored already, but be explicit defensively.
163
- const lastSrc = lastCommitTouching([
164
- moduleDir,
165
- `:(exclude)${manifestPath}`,
166
- `:(exclude)${moduleDir}/node_modules`,
167
- `:(exclude)${moduleDir}/dist`,
168
- ]);
169
- if (!lastManifest || !lastSrc) return null;
170
- if (lastSrc === lastManifest) return null; // same commit changed both
171
- if (!isAncestor(lastManifest, lastSrc)) return null; // manifest moved last
172
-
173
- return { moduleDir, lastSrcCommit: lastSrc, lastManifestCommit: lastManifest };
63
+ description?: string;
174
64
  }
175
65
 
176
66
  /**
@@ -378,7 +268,13 @@ export async function publishOneModule(
378
268
  const client = new RegistryClient(opts.registryUrl || undefined);
379
269
  try {
380
270
  console.log(`Publishing ${name}@${version} to ${client.baseUrl}...`);
381
- await client.publish({ name, version, netappPath: buildResult.packagePath, token: opts.token });
271
+ await client.publish({
272
+ name,
273
+ version,
274
+ netappPath: buildResult.packagePath,
275
+ token: opts.token,
276
+ description: manifest.description?.trim() || undefined,
277
+ });
382
278
  } catch (err) {
383
279
  return {
384
280
  moduleDir,
@@ -0,0 +1,150 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ProxmoxAppliance } from '../../api-clients/proxmox';
3
+ import { buildUbuntuOptions, pickInitialTemplate } from './proxmox-template-selection';
4
+
5
+ // Snapshot of `pveam available --section system` filtered to Ubuntu, taken
6
+ // 2026-05-08. The point of these tests is to lock in the catalog-driven
7
+ // behavior that replaced the hardcoded `-1` revision URL.
8
+ const catalog: ProxmoxAppliance[] = [
9
+ {
10
+ template: 'ubuntu-22.04-standard_22.04-1_amd64.tar.zst',
11
+ package: 'ubuntu-22.04-standard',
12
+ version: '22.04-1',
13
+ section: 'system',
14
+ os: 'ubuntu',
15
+ type: 'lxc',
16
+ },
17
+ {
18
+ template: 'ubuntu-24.04-standard_24.04-2_amd64.tar.zst',
19
+ package: 'ubuntu-24.04-standard',
20
+ version: '24.04-2',
21
+ section: 'system',
22
+ os: 'ubuntu',
23
+ type: 'lxc',
24
+ },
25
+ {
26
+ template: 'ubuntu-24.10-standard_24.10-1_amd64.tar.zst',
27
+ package: 'ubuntu-24.10-standard',
28
+ version: '24.10-1',
29
+ section: 'system',
30
+ os: 'ubuntu',
31
+ type: 'lxc',
32
+ },
33
+ {
34
+ template: 'ubuntu-25.04-standard_25.04-1.1_amd64.tar.zst',
35
+ package: 'ubuntu-25.04-standard',
36
+ version: '25.04-1.1',
37
+ section: 'system',
38
+ os: 'ubuntu',
39
+ type: 'lxc',
40
+ },
41
+ {
42
+ template: 'ubuntu-26.04-standard_26.04-1_amd64.tar.zst',
43
+ package: 'ubuntu-26.04-standard',
44
+ version: '26.04-1',
45
+ section: 'system',
46
+ os: 'ubuntu',
47
+ type: 'lxc',
48
+ },
49
+ ];
50
+
51
+ describe('buildUbuntuOptions', () => {
52
+ test('returns Ubuntu options sorted newest-first', () => {
53
+ const options = buildUbuntuOptions(catalog);
54
+ expect(options.map((o) => o.appliance.package)).toEqual([
55
+ 'ubuntu-26.04-standard',
56
+ 'ubuntu-25.04-standard',
57
+ 'ubuntu-24.10-standard',
58
+ 'ubuntu-24.04-standard',
59
+ 'ubuntu-22.04-standard',
60
+ ]);
61
+ });
62
+
63
+ test('flags LTS releases correctly', () => {
64
+ const options = buildUbuntuOptions(catalog);
65
+ const ltsByPackage = Object.fromEntries(options.map((o) => [o.appliance.package, o.isLts]));
66
+ expect(ltsByPackage).toEqual({
67
+ 'ubuntu-22.04-standard': true,
68
+ 'ubuntu-24.04-standard': true,
69
+ 'ubuntu-24.10-standard': false,
70
+ 'ubuntu-25.04-standard': false,
71
+ 'ubuntu-26.04-standard': true,
72
+ });
73
+ });
74
+
75
+ test('drops non-Ubuntu and non-system entries', () => {
76
+ const mixed: ProxmoxAppliance[] = [
77
+ ...catalog,
78
+ {
79
+ template: 'debian-12-standard_12.7-1_amd64.tar.zst',
80
+ package: 'debian-12-standard',
81
+ version: '12.7-1',
82
+ section: 'system',
83
+ os: 'debian',
84
+ },
85
+ {
86
+ template: 'ubuntu-24.04-special_24.04-1_amd64.tar.zst',
87
+ package: 'ubuntu-24.04-standard',
88
+ version: '24.04-1',
89
+ section: 'turnkeylinux',
90
+ os: 'ubuntu',
91
+ },
92
+ ];
93
+ const options = buildUbuntuOptions(mixed);
94
+ expect(options).toHaveLength(catalog.length);
95
+ expect(options.every((o) => (o.appliance.section ?? 'system') === 'system')).toBe(true);
96
+ });
97
+
98
+ test('returns empty array when nothing matches', () => {
99
+ expect(buildUbuntuOptions([])).toEqual([]);
100
+ });
101
+ });
102
+
103
+ describe('pickInitialTemplate', () => {
104
+ const options = buildUbuntuOptions(catalog);
105
+
106
+ test('defaults to newest LTS when no current template given', () => {
107
+ expect(pickInitialTemplate(options)).toBe('ubuntu-26.04-standard_26.04-1_amd64.tar.zst');
108
+ });
109
+
110
+ test('exact match wins when revision still on mirror', () => {
111
+ expect(pickInitialTemplate(options, 'ubuntu-22.04-standard_22.04-1_amd64.tar.zst')).toBe(
112
+ 'ubuntu-22.04-standard_22.04-1_amd64.tar.zst',
113
+ );
114
+ });
115
+
116
+ test('falls back to family match when revision has been refreshed', () => {
117
+ // The case that motivated the refactor: user has stale -1 cached, mirror has -2.
118
+ expect(pickInitialTemplate(options, 'ubuntu-24.04-standard_24.04-1_amd64.tar.zst')).toBe(
119
+ 'ubuntu-24.04-standard_24.04-2_amd64.tar.zst',
120
+ );
121
+ });
122
+
123
+ test('falls through to newest LTS when current does not match any family', () => {
124
+ expect(pickInitialTemplate(options, 'ubuntu-18.04-standard_18.04-1_amd64.tar.zst')).toBe(
125
+ 'ubuntu-26.04-standard_26.04-1_amd64.tar.zst',
126
+ );
127
+ });
128
+
129
+ test('returns undefined for empty options', () => {
130
+ expect(pickInitialTemplate([])).toBeUndefined();
131
+ });
132
+
133
+ test('falls back to newest entry when no LTS in catalog', () => {
134
+ const noLts = buildUbuntuOptions([
135
+ {
136
+ template: 'ubuntu-25.04-standard_25.04-1.1_amd64.tar.zst',
137
+ package: 'ubuntu-25.04-standard',
138
+ version: '25.04-1.1',
139
+ section: 'system',
140
+ },
141
+ {
142
+ template: 'ubuntu-24.10-standard_24.10-1_amd64.tar.zst',
143
+ package: 'ubuntu-24.10-standard',
144
+ version: '24.10-1',
145
+ section: 'system',
146
+ },
147
+ ]);
148
+ expect(pickInitialTemplate(noLts)).toBe('ubuntu-25.04-standard_25.04-1.1_amd64.tar.zst');
149
+ });
150
+ });