@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
@@ -0,0 +1,377 @@
1
+ /**
2
+ * System prerequisite detection.
3
+ *
4
+ * Single source of truth for "what tools must be on the management
5
+ * server's PATH for celilo (and the modules it manages) to function."
6
+ * See `apps/celilo/designs/PREREQ_DETECTION.md` for the full design,
7
+ * including the layered platform contract for module developers.
8
+ *
9
+ * The PREREQUISITES table here is the ONLY place a tool gets added to
10
+ * the platform contract. `system doctor`, `system init`, and the
11
+ * runtime invokers (ansible/terraform shell-outs) all consume it from
12
+ * here.
13
+ *
14
+ * All entries are universally required — no required-vs-recommended
15
+ * distinction. The platform is the platform; an operator who currently
16
+ * doesn't use Terraform still installs Terraform, on the theory that
17
+ * predictability beats marginal install-friction savings.
18
+ */
19
+
20
+ import { spawnSync } from 'node:child_process';
21
+
22
+ // ── Types ─────────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Package managers we can produce install hints for. `none` means
26
+ * we couldn't detect a recognized package manager (rare —
27
+ * default-link-to-docs fallback).
28
+ */
29
+ export type PackageManager = 'apt' | 'dnf' | 'yum' | 'pacman' | 'apk' | 'brew' | 'none';
30
+
31
+ /**
32
+ * Static description of a prerequisite. Lives in the PREREQUISITES
33
+ * table below; consumers don't construct these themselves.
34
+ */
35
+ export interface PrerequisiteSpec {
36
+ /** Binary name as invoked on the command line (e.g. 'ansible'). */
37
+ name: string;
38
+ /** One-line "what this is for" text used in doctor output. */
39
+ description: string;
40
+ /** Flag that prints the version (`--version` for most; `version`
41
+ * for terraform; `-V` for ssh; `-v` for unzip). */
42
+ versionFlag: string;
43
+ /** Captures the version string from the tool's version output.
44
+ * First capture group must be the bare version (e.g. `2.16.3`). */
45
+ versionRegex: RegExp;
46
+ /** Minimum semver. `null` means presence-only — any version OK. */
47
+ minVersion: string | null;
48
+ }
49
+
50
+ /**
51
+ * Result of checking one prerequisite. Non-throwing; failure modes
52
+ * surface via `present: false` or `meetsMinimum: false`.
53
+ */
54
+ export interface PrereqCheck {
55
+ name: string;
56
+ description: string;
57
+ /** `command -v <name>` succeeded. */
58
+ present: boolean;
59
+ /** Resolved path on PATH (or null when absent). */
60
+ binaryPath: string | null;
61
+ /** Version captured from `--version` output, or null on
62
+ * parse-fail / not-present. */
63
+ version: string | null;
64
+ /** True when the spec has no minimum, OR the captured version
65
+ * satisfies it. False when missing, parse-failed-with-minimum,
66
+ * or below-minimum. */
67
+ meetsMinimum: boolean;
68
+ /** OS-aware install instruction (always populated; falls back to
69
+ * generic guidance when the package manager isn't recognized). */
70
+ installHint: string;
71
+ }
72
+
73
+ // ── PREREQUISITES table ───────────────────────────────────────────────
74
+
75
+ /**
76
+ * The platform contract's "operator-installed tools" layer. Edit this
77
+ * list to add/remove tools from the contract. Per-OS install hints
78
+ * live in INSTALL_HINTS below.
79
+ */
80
+ export const PREREQUISITES: PrerequisiteSpec[] = [
81
+ {
82
+ name: 'bun',
83
+ description: 'Celilo runtime',
84
+ versionFlag: '--version',
85
+ versionRegex: /(\d+\.\d+\.\d+)/,
86
+ minVersion: null,
87
+ },
88
+ {
89
+ name: 'ansible',
90
+ description: 'Configures hosts via playbooks',
91
+ versionFlag: '--version',
92
+ // Modern ansible: "ansible [core 2.16.3]"; older: "ansible 2.9.27".
93
+ // Pick the first X.Y.Z triple in the output; both shapes work.
94
+ versionRegex: /(\d+\.\d+\.\d+)/,
95
+ minVersion: '2.15.0',
96
+ },
97
+ {
98
+ name: 'ansible-galaxy',
99
+ description: 'Installs Ansible collection deps',
100
+ versionFlag: '--version',
101
+ versionRegex: /(\d+\.\d+\.\d+)/,
102
+ minVersion: '2.15.0',
103
+ },
104
+ {
105
+ name: 'terraform',
106
+ description: 'Provisions container infrastructure',
107
+ // terraform's flag is `version` (no leading --), unique among our prereqs.
108
+ versionFlag: 'version',
109
+ versionRegex: /Terraform\s+v(\d+\.\d+\.\d+)/,
110
+ minVersion: '1.6.0',
111
+ },
112
+ {
113
+ name: 'ssh',
114
+ description: 'Reaches managed machines',
115
+ // ssh -V prints to stderr, e.g. "OpenSSH_9.6p1, LibreSSL 3.3.6".
116
+ // checkPrerequisite captures both streams so this Just Works.
117
+ versionFlag: '-V',
118
+ versionRegex: /OpenSSH[_\s]+(\d+\.\d+)/,
119
+ minVersion: null,
120
+ },
121
+ {
122
+ name: 'git',
123
+ description: 'Stale-version checks; module build scripts',
124
+ versionFlag: '--version',
125
+ versionRegex: /git version (\d+\.\d+\.\d+)/,
126
+ minVersion: null,
127
+ },
128
+ {
129
+ name: 'curl',
130
+ description: 'HTTP fetches in install.sh and module hooks',
131
+ versionFlag: '--version',
132
+ versionRegex: /curl\s+(\d+\.\d+\.\d+)/,
133
+ minVersion: null,
134
+ },
135
+ {
136
+ name: 'unzip',
137
+ description: "Used by Bun's installer (bootstrap-only)",
138
+ versionFlag: '-v',
139
+ versionRegex: /UnZip\s+(\d+\.\d+)/,
140
+ minVersion: null,
141
+ },
142
+ ];
143
+
144
+ // ── Per-OS install hints ──────────────────────────────────────────────
145
+
146
+ /**
147
+ * Per-tool, per-package-manager install commands. A missing entry
148
+ * falls back to FALLBACK_HINTS (for tools with non-standard install
149
+ * paths like bun and terraform) or to a generic guidance string.
150
+ */
151
+ const INSTALL_HINTS: Record<string, Partial<Record<PackageManager, string>>> = {
152
+ bun: {
153
+ pacman: 'sudo pacman -S bun',
154
+ brew: 'brew install oven-sh/bun/bun',
155
+ // apt/dnf/yum/apk: no native package; fall through to FALLBACK_HINTS.
156
+ },
157
+ ansible: {
158
+ apt: 'sudo apt-get install ansible',
159
+ dnf: 'sudo dnf install ansible',
160
+ yum: 'sudo yum install ansible',
161
+ pacman: 'sudo pacman -S ansible',
162
+ apk: 'sudo apk add ansible',
163
+ brew: 'brew install ansible',
164
+ },
165
+ 'ansible-galaxy': {
166
+ // ansible-galaxy ships in the same tarball as ansible. If the
167
+ // operator is missing it, the fix is "install ansible." Phrase the
168
+ // hint so the operator's eye lands on the ansible row, not on this
169
+ // one.
170
+ apt: '(installed alongside ansible — install ansible)',
171
+ dnf: '(installed alongside ansible — install ansible)',
172
+ yum: '(installed alongside ansible — install ansible)',
173
+ pacman: '(installed alongside ansible — install ansible)',
174
+ apk: '(installed alongside ansible — install ansible)',
175
+ brew: '(installed alongside ansible — install ansible)',
176
+ },
177
+ terraform: {
178
+ // The HashiCorp apt/dnf/yum repos require a multi-step GPG-key +
179
+ // apt-source setup that's too long for a single-line hint.
180
+ // Link to HashiCorp's docs and let the operator follow the recipe.
181
+ apt: 'See https://developer.hashicorp.com/terraform/install',
182
+ dnf: 'See https://developer.hashicorp.com/terraform/install',
183
+ yum: 'See https://developer.hashicorp.com/terraform/install',
184
+ pacman: 'sudo pacman -S terraform',
185
+ apk: 'sudo apk add terraform',
186
+ brew: 'brew install terraform',
187
+ },
188
+ ssh: {
189
+ apt: 'sudo apt-get install openssh-client',
190
+ dnf: 'sudo dnf install openssh-clients',
191
+ yum: 'sudo yum install openssh-clients',
192
+ pacman: 'sudo pacman -S openssh',
193
+ apk: 'sudo apk add openssh-client',
194
+ // macOS ships /usr/bin/ssh built-in; missing it means a busted
195
+ // system, not a missing brew formula. Fall through.
196
+ },
197
+ git: {
198
+ apt: 'sudo apt-get install git',
199
+ dnf: 'sudo dnf install git',
200
+ yum: 'sudo yum install git',
201
+ pacman: 'sudo pacman -S git',
202
+ apk: 'sudo apk add git',
203
+ brew: 'brew install git',
204
+ },
205
+ curl: {
206
+ apt: 'sudo apt-get install curl',
207
+ dnf: 'sudo dnf install curl',
208
+ yum: 'sudo yum install curl',
209
+ pacman: 'sudo pacman -S curl',
210
+ apk: 'sudo apk add curl',
211
+ // macOS ships /usr/bin/curl built-in.
212
+ },
213
+ unzip: {
214
+ apt: 'sudo apt-get install unzip',
215
+ dnf: 'sudo dnf install unzip',
216
+ yum: 'sudo yum install unzip',
217
+ pacman: 'sudo pacman -S unzip',
218
+ apk: 'sudo apk add unzip',
219
+ // macOS ships /usr/bin/unzip built-in.
220
+ },
221
+ };
222
+
223
+ /**
224
+ * Tools whose canonical install path isn't a system package manager.
225
+ * Used when no package-manager-specific hint is available.
226
+ */
227
+ const FALLBACK_HINTS: Record<string, string> = {
228
+ bun: 'See https://bun.sh/install',
229
+ terraform: 'See https://developer.hashicorp.com/terraform/install',
230
+ };
231
+
232
+ // ── Detection ─────────────────────────────────────────────────────────
233
+
234
+ /**
235
+ * Detect the operator's primary package manager.
236
+ *
237
+ * On Darwin, brew wins if installed (otherwise we report 'none' and
238
+ * the install hints fall back to docs links). On Linux/other Unix,
239
+ * we probe for apt-get → dnf → yum → pacman → apk in priority order.
240
+ *
241
+ * The ordering matters: a Debian-derived box that has both apt-get
242
+ * and (somehow) dnf still gets 'apt'. Distros with multiple package
243
+ * managers in PATH are rare in operator scenarios.
244
+ */
245
+ export function detectPackageManager(): PackageManager {
246
+ if (process.platform === 'darwin') {
247
+ return Bun.which('brew') ? 'brew' : 'none';
248
+ }
249
+ if (Bun.which('apt-get')) return 'apt';
250
+ if (Bun.which('dnf')) return 'dnf';
251
+ if (Bun.which('yum')) return 'yum';
252
+ if (Bun.which('pacman')) return 'pacman';
253
+ if (Bun.which('apk')) return 'apk';
254
+ return 'none';
255
+ }
256
+
257
+ /**
258
+ * Look up the OS-appropriate install command for a tool. Always
259
+ * returns a non-empty string — falls through to docs links and
260
+ * generic guidance when there's no exact match.
261
+ */
262
+ export function getInstallHint(toolName: string, pm: PackageManager): string {
263
+ const pmHint = INSTALL_HINTS[toolName]?.[pm];
264
+ if (pmHint) return pmHint;
265
+ const fallback = FALLBACK_HINTS[toolName];
266
+ if (fallback) return fallback;
267
+ return `Install '${toolName}' via your package manager`;
268
+ }
269
+
270
+ /**
271
+ * Compare two semver triples (MAJOR.MINOR.PATCH). Missing components
272
+ * default to 0 so '2.16' compares as '2.16.0'. Returns -1 / 0 / 1.
273
+ *
274
+ * Exported for testing.
275
+ */
276
+ export function compareVersions(a: string, b: string): number {
277
+ const aParts = a.split('.').map((n) => Number.parseInt(n, 10) || 0);
278
+ const bParts = b.split('.').map((n) => Number.parseInt(n, 10) || 0);
279
+ const len = Math.max(aParts.length, bParts.length);
280
+ for (let i = 0; i < len; i++) {
281
+ const av = aParts[i] ?? 0;
282
+ const bv = bParts[i] ?? 0;
283
+ if (av < bv) return -1;
284
+ if (av > bv) return 1;
285
+ }
286
+ return 0;
287
+ }
288
+
289
+ /**
290
+ * Run a single prerequisite check. Two-phase:
291
+ * 1. Presence: `Bun.which(name)` (POSIX `command -v` semantics).
292
+ * 2. Version: spawn `<tool> <flag>` and regex-match the output.
293
+ * Both stdout and stderr are scanned because some tools
294
+ * (notably ssh -V) write the version banner to stderr.
295
+ *
296
+ * Errors are non-throwing: any failure short-circuits to a
297
+ * `present: false` or `meetsMinimum: false` result. Callers decide
298
+ * what to do with that.
299
+ *
300
+ * Per-call timeout: 5s. A version probe that hangs longer than that
301
+ * is a misbehaving binary or a stuck PATH lookup; either way we
302
+ * shouldn't wedge the doctor command waiting on it.
303
+ */
304
+ export function checkPrerequisite(
305
+ spec: PrerequisiteSpec,
306
+ pm: PackageManager = detectPackageManager(),
307
+ ): PrereqCheck {
308
+ const installHint = getInstallHint(spec.name, pm);
309
+
310
+ const binaryPath = Bun.which(spec.name);
311
+ if (!binaryPath) {
312
+ return {
313
+ name: spec.name,
314
+ description: spec.description,
315
+ present: false,
316
+ binaryPath: null,
317
+ version: null,
318
+ meetsMinimum: false,
319
+ installHint,
320
+ };
321
+ }
322
+
323
+ let version: string | null = null;
324
+ try {
325
+ const result = spawnSync(spec.name, [spec.versionFlag], {
326
+ encoding: 'utf-8',
327
+ timeout: 5000,
328
+ // Some tools (notably git on macOS) refuse to run with a
329
+ // cleared HOME or empty env; inherit the parent process env so
330
+ // we get the same PATH the runtime invokers will use later
331
+ // (PATH-gotcha smoke-test from the design doc).
332
+ env: process.env,
333
+ });
334
+ const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
335
+ const match = output.match(spec.versionRegex);
336
+ version = match?.[1] ?? null;
337
+ } catch {
338
+ // spawnSync threw — broken binary, denied permissions, etc.
339
+ // Treat as present-but-version-unknown.
340
+ version = null;
341
+ }
342
+
343
+ const meetsMinimum = (() => {
344
+ if (!spec.minVersion) return true; // no minimum → always passes
345
+ if (!version) return false; // had a minimum but couldn't read version
346
+ return compareVersions(version, spec.minVersion) >= 0;
347
+ })();
348
+
349
+ return {
350
+ name: spec.name,
351
+ description: spec.description,
352
+ present: true,
353
+ binaryPath,
354
+ version,
355
+ meetsMinimum,
356
+ installHint,
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Run every check in the PREREQUISITES table and return the
362
+ * results in declaration order (which is the order the doctor /
363
+ * init output renders them — `bun` first, then runtime tools).
364
+ */
365
+ export function checkAllPrerequisites(): PrereqCheck[] {
366
+ const pm = detectPackageManager();
367
+ return PREREQUISITES.map((spec) => checkPrerequisite(spec, pm));
368
+ }
369
+
370
+ /**
371
+ * Convenience: subset of checks that failed (missing or below-min).
372
+ * The doctor / init / runtime-guard callers all want this filter
373
+ * before deciding whether to block.
374
+ */
375
+ export function failingPrerequisites(checks: PrereqCheck[]): PrereqCheck[] {
376
+ return checks.filter((c) => !c.present || !c.meetsMinimum);
377
+ }