@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.
- package/package.json +1 -1
- package/src/api-clients/proxmox.ts +77 -45
- package/src/cli/command-registry.ts +12 -12
- package/src/cli/commands/completion.ts +12 -11
- package/src/cli/commands/module-check.ts +158 -0
- package/src/cli/commands/module-import.ts +5 -5
- package/src/cli/commands/module-publish.test.ts +3 -90
- package/src/cli/commands/module-publish.ts +14 -118
- package/src/cli/commands/proxmox-template-selection.test.ts +150 -0
- package/src/cli/commands/proxmox-template-selection.ts +258 -0
- package/src/cli/commands/service-add-proxmox.ts +49 -127
- package/src/cli/commands/service-reconfigure.ts +36 -79
- package/src/cli/commands/service-verify.ts +20 -79
- package/src/cli/commands/{doctor.test.ts → system-doctor.test.ts} +1 -1
- package/src/cli/commands/{doctor.ts → system-doctor.ts} +93 -18
- package/src/cli/completion.ts +29 -2
- package/src/cli/index.ts +16 -7
- package/src/module/import.ts +4 -2
- package/src/registry/client.ts +14 -1
- package/src/services/module-deploy.ts +19 -1
- package/src/services/module-validator/capability-versions.test.ts +90 -0
- package/src/services/module-validator/capability-versions.ts +115 -0
- package/src/services/module-validator/contract-version.test.ts +24 -0
- package/src/services/module-validator/contract-version.ts +69 -0
- package/src/services/module-validator/git-hygiene.test.ts +141 -0
- package/src/services/module-validator/git-hygiene.ts +144 -0
- package/src/services/module-validator/index.test.ts +67 -0
- package/src/services/module-validator/index.ts +74 -0
- package/src/services/module-validator/manifest-schema.ts +42 -0
- package/src/services/module-validator/types.ts +43 -0
- package/src/services/module-validator/typescript-build.test.ts +58 -0
- package/src/services/module-validator/typescript-build.ts +115 -0
- package/src/services/module-validator/workspace-deps.test.ts +137 -0
- package/src/services/module-validator/workspace-deps.ts +187 -0
- package/src/system/prereqs.test.ts +374 -0
- package/src/system/prereqs.ts +377 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the prerequisite-detection module.
|
|
3
|
+
*
|
|
4
|
+
* Coverage:
|
|
5
|
+
* - Version regex parses real-world output for each tool in the table
|
|
6
|
+
* - compareVersions handles MAJOR.MINOR.PATCH (and MAJOR.MINOR) edge cases
|
|
7
|
+
* - getInstallHint returns the right string per tool/pm combination,
|
|
8
|
+
* including fallback paths for tools without a native package
|
|
9
|
+
* - checkPrerequisite returns the documented shape for present + missing
|
|
10
|
+
* binaries, and exercises the "binary present but version unparseable"
|
|
11
|
+
* edge case
|
|
12
|
+
* - PREREQUISITES table is internally consistent (no duplicate names,
|
|
13
|
+
* every entry has the fields the type requires)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import {
|
|
18
|
+
PREREQUISITES,
|
|
19
|
+
checkPrerequisite,
|
|
20
|
+
compareVersions,
|
|
21
|
+
detectPackageManager,
|
|
22
|
+
failingPrerequisites,
|
|
23
|
+
getInstallHint,
|
|
24
|
+
} from './prereqs';
|
|
25
|
+
|
|
26
|
+
// ── PREREQUISITES table consistency ───────────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe('PREREQUISITES table', () => {
|
|
29
|
+
test('has no duplicate tool names', () => {
|
|
30
|
+
const names = PREREQUISITES.map((p) => p.name);
|
|
31
|
+
expect(new Set(names).size).toBe(names.length);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('every entry has the required fields', () => {
|
|
35
|
+
for (const spec of PREREQUISITES) {
|
|
36
|
+
expect(spec.name).toBeTruthy();
|
|
37
|
+
expect(spec.description).toBeTruthy();
|
|
38
|
+
expect(spec.versionFlag).toBeTruthy();
|
|
39
|
+
expect(spec.versionRegex).toBeInstanceOf(RegExp);
|
|
40
|
+
// minVersion may be null; just check the property exists
|
|
41
|
+
expect('minVersion' in spec).toBe(true);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('contains the tools the design doc requires', () => {
|
|
46
|
+
const names = PREREQUISITES.map((p) => p.name);
|
|
47
|
+
expect(names).toContain('bun');
|
|
48
|
+
expect(names).toContain('ansible');
|
|
49
|
+
expect(names).toContain('ansible-galaxy');
|
|
50
|
+
expect(names).toContain('terraform');
|
|
51
|
+
expect(names).toContain('ssh');
|
|
52
|
+
expect(names).toContain('git');
|
|
53
|
+
expect(names).toContain('curl');
|
|
54
|
+
expect(names).toContain('unzip');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── Version regex per tool ────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe('version regex', () => {
|
|
61
|
+
function probe(toolName: string, sampleOutput: string): string | null {
|
|
62
|
+
const spec = PREREQUISITES.find((p) => p.name === toolName);
|
|
63
|
+
if (!spec) throw new Error(`No spec for ${toolName}`);
|
|
64
|
+
const match = sampleOutput.match(spec.versionRegex);
|
|
65
|
+
return match?.[1] ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('bun --version', () => {
|
|
69
|
+
expect(probe('bun', '1.3.3')).toBe('1.3.3');
|
|
70
|
+
expect(probe('bun', '1.3.3\n')).toBe('1.3.3');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('ansible --version (modern, "core 2.16.3")', () => {
|
|
74
|
+
expect(
|
|
75
|
+
probe(
|
|
76
|
+
'ansible',
|
|
77
|
+
'ansible [core 2.16.3]\n config file = None\n configured module search path = ...',
|
|
78
|
+
),
|
|
79
|
+
).toBe('2.16.3');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('ansible --version (legacy, "ansible 2.9.27")', () => {
|
|
83
|
+
expect(probe('ansible', 'ansible 2.9.27\n config file = None')).toBe('2.9.27');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('ansible-galaxy --version', () => {
|
|
87
|
+
expect(probe('ansible-galaxy', 'ansible-galaxy [core 2.16.3]\n python version = 3.11.4')).toBe(
|
|
88
|
+
'2.16.3',
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('terraform version', () => {
|
|
93
|
+
expect(
|
|
94
|
+
probe(
|
|
95
|
+
'terraform',
|
|
96
|
+
'Terraform v1.6.6\non darwin_arm64\n\nYour version of Terraform is out of date!',
|
|
97
|
+
),
|
|
98
|
+
).toBe('1.6.6');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('ssh -V (writes to stderr)', () => {
|
|
102
|
+
expect(probe('ssh', 'OpenSSH_9.6p1, LibreSSL 3.3.6')).toBe('9.6');
|
|
103
|
+
// Older OpenSSH formats with space rather than underscore
|
|
104
|
+
expect(probe('ssh', 'OpenSSH 8.2p1 Ubuntu-4ubuntu0.11')).toBe('8.2');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('git --version', () => {
|
|
108
|
+
expect(probe('git', 'git version 2.45.2')).toBe('2.45.2');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('curl --version (multi-line, version is on first line)', () => {
|
|
112
|
+
expect(
|
|
113
|
+
probe(
|
|
114
|
+
'curl',
|
|
115
|
+
'curl 8.6.0 (x86_64-pc-linux-gnu) libcurl/8.6.0 OpenSSL/3.1.4\nRelease-Date: 2024-01-31',
|
|
116
|
+
),
|
|
117
|
+
).toBe('8.6.0');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('unzip -v', () => {
|
|
121
|
+
expect(
|
|
122
|
+
probe(
|
|
123
|
+
'unzip',
|
|
124
|
+
'UnZip 6.00 of 20 April 2009, by Debian. Original by Info-ZIP.\n\nLatest sources...',
|
|
125
|
+
),
|
|
126
|
+
).toBe('6.00');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('returns null when output is unrecognized', () => {
|
|
130
|
+
const spec = PREREQUISITES.find((p) => p.name === 'terraform');
|
|
131
|
+
expect(spec?.versionRegex.exec('completely unrelated output')?.[1]).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── compareVersions ───────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
describe('compareVersions', () => {
|
|
138
|
+
test('equal triples → 0', () => {
|
|
139
|
+
expect(compareVersions('2.16.3', '2.16.3')).toBe(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('strictly less → -1', () => {
|
|
143
|
+
expect(compareVersions('2.15.0', '2.16.0')).toBe(-1);
|
|
144
|
+
expect(compareVersions('2.16.2', '2.16.3')).toBe(-1);
|
|
145
|
+
expect(compareVersions('1.6.5', '2.0.0')).toBe(-1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('strictly greater → 1', () => {
|
|
149
|
+
expect(compareVersions('2.17.0', '2.16.0')).toBe(1);
|
|
150
|
+
expect(compareVersions('2.16.4', '2.16.3')).toBe(1);
|
|
151
|
+
expect(compareVersions('3.0.0', '2.16.99')).toBe(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('handles missing components (treats as 0)', () => {
|
|
155
|
+
expect(compareVersions('2.16', '2.16.0')).toBe(0);
|
|
156
|
+
expect(compareVersions('2.16', '2.16.1')).toBe(-1);
|
|
157
|
+
expect(compareVersions('2.16.1', '2.16')).toBe(1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('handles non-numeric segments by treating as 0', () => {
|
|
161
|
+
// We don't try to parse pre-release tags. "2.16.3-rc1" and "2.16.3"
|
|
162
|
+
// compare equal — that's intentional. Ansible's pre-releases are
|
|
163
|
+
// rare in operator-installed boxes.
|
|
164
|
+
expect(compareVersions('2.16.3-rc1', '2.16.3')).toBe(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('realistic ansible version comparison (2.16.3 satisfies 2.15.0 minimum)', () => {
|
|
168
|
+
expect(compareVersions('2.16.3', '2.15.0')).toBe(1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('realistic terraform version comparison (1.5.7 below 1.6.0 minimum)', () => {
|
|
172
|
+
expect(compareVersions('1.5.7', '1.6.0')).toBe(-1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── getInstallHint ────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
describe('getInstallHint', () => {
|
|
179
|
+
test('apt: standard tools resolve to "sudo apt-get install <pkg>"', () => {
|
|
180
|
+
expect(getInstallHint('ansible', 'apt')).toBe('sudo apt-get install ansible');
|
|
181
|
+
expect(getInstallHint('git', 'apt')).toBe('sudo apt-get install git');
|
|
182
|
+
expect(getInstallHint('curl', 'apt')).toBe('sudo apt-get install curl');
|
|
183
|
+
expect(getInstallHint('unzip', 'apt')).toBe('sudo apt-get install unzip');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('apt: ssh maps to openssh-client (Debian package name differs)', () => {
|
|
187
|
+
expect(getInstallHint('ssh', 'apt')).toBe('sudo apt-get install openssh-client');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('apt: terraform falls through to HashiCorp docs link', () => {
|
|
191
|
+
expect(getInstallHint('terraform', 'apt')).toBe(
|
|
192
|
+
'See https://developer.hashicorp.com/terraform/install',
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('apt: bun has no native package — falls through to docs', () => {
|
|
197
|
+
expect(getInstallHint('bun', 'apt')).toBe('See https://bun.sh/install');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('apt: ansible-galaxy points the operator at ansible', () => {
|
|
201
|
+
expect(getInstallHint('ansible-galaxy', 'apt')).toContain('install ansible');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('brew: tools available via brew', () => {
|
|
205
|
+
expect(getInstallHint('ansible', 'brew')).toBe('brew install ansible');
|
|
206
|
+
expect(getInstallHint('terraform', 'brew')).toBe('brew install terraform');
|
|
207
|
+
expect(getInstallHint('bun', 'brew')).toBe('brew install oven-sh/bun/bun');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('brew: ssh/curl/unzip fall through (built into macOS)', () => {
|
|
211
|
+
expect(getInstallHint('ssh', 'brew')).toBe("Install 'ssh' via your package manager");
|
|
212
|
+
expect(getInstallHint('curl', 'brew')).toBe("Install 'curl' via your package manager");
|
|
213
|
+
expect(getInstallHint('unzip', 'brew')).toBe("Install 'unzip' via your package manager");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('pacman: terraform IS available via pacman (unlike apt)', () => {
|
|
217
|
+
expect(getInstallHint('terraform', 'pacman')).toBe('sudo pacman -S terraform');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('apk: ssh maps to openssh-client', () => {
|
|
221
|
+
expect(getInstallHint('ssh', 'apk')).toBe('sudo apk add openssh-client');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('none: unrecognized package manager falls back to generic guidance', () => {
|
|
225
|
+
expect(getInstallHint('ansible', 'none')).toBe("Install 'ansible' via your package manager");
|
|
226
|
+
// bun and terraform have docs links that work regardless of pm
|
|
227
|
+
expect(getInstallHint('bun', 'none')).toBe('See https://bun.sh/install');
|
|
228
|
+
expect(getInstallHint('terraform', 'none')).toBe(
|
|
229
|
+
'See https://developer.hashicorp.com/terraform/install',
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('unknown tool name: generic fallback', () => {
|
|
234
|
+
expect(getInstallHint('totally-fictional-tool', 'apt')).toBe(
|
|
235
|
+
"Install 'totally-fictional-tool' via your package manager",
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ── checkPrerequisite ─────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
describe('checkPrerequisite', () => {
|
|
243
|
+
test('present binary returns present:true with a path', () => {
|
|
244
|
+
// bun is the celilo runtime; if these tests are running, bun is there.
|
|
245
|
+
const spec = PREREQUISITES.find((p) => p.name === 'bun');
|
|
246
|
+
if (!spec) throw new Error('bun spec missing');
|
|
247
|
+
const result = checkPrerequisite(spec);
|
|
248
|
+
expect(result.present).toBe(true);
|
|
249
|
+
expect(result.binaryPath).toBeTruthy();
|
|
250
|
+
expect(result.version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
251
|
+
expect(result.meetsMinimum).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('missing binary returns present:false with installHint populated', () => {
|
|
255
|
+
const spec = {
|
|
256
|
+
name: 'definitely-not-installed-aaaaaa',
|
|
257
|
+
description: 'fictional',
|
|
258
|
+
versionFlag: '--version',
|
|
259
|
+
versionRegex: /(\d+\.\d+\.\d+)/,
|
|
260
|
+
minVersion: null,
|
|
261
|
+
};
|
|
262
|
+
const result = checkPrerequisite(spec);
|
|
263
|
+
expect(result.present).toBe(false);
|
|
264
|
+
expect(result.binaryPath).toBeNull();
|
|
265
|
+
expect(result.version).toBeNull();
|
|
266
|
+
expect(result.meetsMinimum).toBe(false);
|
|
267
|
+
expect(result.installHint).toBeTruthy(); // generic fallback at minimum
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('present binary with minVersion → meetsMinimum reflects comparison', () => {
|
|
271
|
+
// Synthesize a spec that demands an impossibly-high bun version
|
|
272
|
+
const spec = {
|
|
273
|
+
name: 'bun',
|
|
274
|
+
description: 'celilo runtime',
|
|
275
|
+
versionFlag: '--version',
|
|
276
|
+
versionRegex: /(\d+\.\d+\.\d+)/,
|
|
277
|
+
minVersion: '999.0.0',
|
|
278
|
+
};
|
|
279
|
+
const result = checkPrerequisite(spec);
|
|
280
|
+
expect(result.present).toBe(true);
|
|
281
|
+
expect(result.version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
282
|
+
expect(result.meetsMinimum).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('present binary with parse-fail leaves version null', () => {
|
|
286
|
+
// Force a regex that won't match bun's version output
|
|
287
|
+
const spec = {
|
|
288
|
+
name: 'bun',
|
|
289
|
+
description: 'celilo runtime',
|
|
290
|
+
versionFlag: '--version',
|
|
291
|
+
versionRegex: /THIS_WILL_NEVER_MATCH_(\w+)/,
|
|
292
|
+
minVersion: null,
|
|
293
|
+
};
|
|
294
|
+
const result = checkPrerequisite(spec);
|
|
295
|
+
expect(result.present).toBe(true);
|
|
296
|
+
expect(result.version).toBeNull();
|
|
297
|
+
// No minimum, parse-fail still passes
|
|
298
|
+
expect(result.meetsMinimum).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('present binary with parse-fail AND minimum → meetsMinimum:false', () => {
|
|
302
|
+
const spec = {
|
|
303
|
+
name: 'bun',
|
|
304
|
+
description: 'celilo runtime',
|
|
305
|
+
versionFlag: '--version',
|
|
306
|
+
versionRegex: /THIS_WILL_NEVER_MATCH_(\w+)/,
|
|
307
|
+
minVersion: '1.0.0',
|
|
308
|
+
};
|
|
309
|
+
const result = checkPrerequisite(spec);
|
|
310
|
+
expect(result.present).toBe(true);
|
|
311
|
+
expect(result.version).toBeNull();
|
|
312
|
+
expect(result.meetsMinimum).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ── failingPrerequisites ──────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
describe('failingPrerequisites', () => {
|
|
319
|
+
test('returns only entries that are missing or below-minimum', () => {
|
|
320
|
+
const checks = [
|
|
321
|
+
{
|
|
322
|
+
name: 'a',
|
|
323
|
+
description: '',
|
|
324
|
+
present: true,
|
|
325
|
+
binaryPath: '/bin/a',
|
|
326
|
+
version: '1.0.0',
|
|
327
|
+
meetsMinimum: true,
|
|
328
|
+
installHint: '',
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: 'b',
|
|
332
|
+
description: '',
|
|
333
|
+
present: false,
|
|
334
|
+
binaryPath: null,
|
|
335
|
+
version: null,
|
|
336
|
+
meetsMinimum: false,
|
|
337
|
+
installHint: '',
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: 'c',
|
|
341
|
+
description: '',
|
|
342
|
+
present: true,
|
|
343
|
+
binaryPath: '/bin/c',
|
|
344
|
+
version: '0.5.0',
|
|
345
|
+
meetsMinimum: false,
|
|
346
|
+
installHint: '',
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
name: 'd',
|
|
350
|
+
description: '',
|
|
351
|
+
present: true,
|
|
352
|
+
binaryPath: '/bin/d',
|
|
353
|
+
version: null,
|
|
354
|
+
meetsMinimum: true,
|
|
355
|
+
installHint: '',
|
|
356
|
+
},
|
|
357
|
+
];
|
|
358
|
+
const failing = failingPrerequisites(checks);
|
|
359
|
+
expect(failing.map((c) => c.name)).toEqual(['b', 'c']);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('empty input → empty result', () => {
|
|
363
|
+
expect(failingPrerequisites([])).toEqual([]);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ── detectPackageManager (smoke test only) ───────────────────────────
|
|
368
|
+
|
|
369
|
+
describe('detectPackageManager', () => {
|
|
370
|
+
test('returns one of the known PackageManager values', () => {
|
|
371
|
+
const pm = detectPackageManager();
|
|
372
|
+
expect(['apt', 'dnf', 'yum', 'pacman', 'apk', 'brew', 'none']).toContain(pm);
|
|
373
|
+
});
|
|
374
|
+
});
|