@celilo/e2e 0.8.0 → 0.9.0

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/src/cli/build.ts CHANGED
@@ -19,10 +19,11 @@
19
19
  */
20
20
 
21
21
  import { execSync, spawnSync } from 'node:child_process';
22
- import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
22
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
23
23
  import { basename, join, resolve } from 'node:path';
24
- import { ensureRegistryServerBundle } from '../registry-bundle';
24
+ import { gunzipSync } from 'node:zlib';
25
25
  import { stageAptRepo } from '../../scripts/stage-apt-repo';
26
+ import { ensureRegistryServerBundle } from '../registry-bundle';
26
27
 
27
28
  /** Walk up from pkgDir to find the monorepo root (contains a `modules/` dir). */
28
29
  function findMonorepoRoot(pkgDir: string): string | null {
@@ -56,6 +57,13 @@ interface BuildOptions {
56
57
  moduleDirs: string[];
57
58
  save: boolean;
58
59
  skipModules: boolean;
60
+ /**
61
+ * Bake `management:latest` from the PUBLISHED @celilo/cli on real npm
62
+ * instead of the monorepo DEV cli served by the sim registry. Runs a
63
+ * standalone container on the default docker network (real internet) —
64
+ * the simulated topology has no route to npmjs.org.
65
+ */
66
+ published: boolean;
59
67
  }
60
68
 
61
69
  /**
@@ -75,7 +83,7 @@ interface BuildOptions {
75
83
  * keeps build-infra working in npm-installed @celilo/e2e, where these
76
84
  * staging dirs aren't relevant.
77
85
  */
78
- function stageSimulatorInputs(pkgDir: string): void {
86
+ async function stageSimulatorInputs(pkgDir: string): Promise<void> {
79
87
  // Try CWD first — when cele2e is installed globally, `pkgDir` is
80
88
  // somewhere like `~/.bun/install/global/node_modules/@celilo/e2e`,
81
89
  // which doesn't have a `modules/` sibling. Walking up from CWD
@@ -84,7 +92,13 @@ function stageSimulatorInputs(pkgDir: string): void {
84
92
  // Mirrors the same CWD-then-pkgDir pattern used for module
85
93
  // discovery further down.
86
94
  const repoRoot = findMonorepoRoot(process.cwd()) ?? findMonorepoRoot(pkgDir);
87
- if (!repoRoot) return;
95
+ if (!repoRoot) {
96
+ // No monorepo (npm-installed @celilo/e2e) → fetch the same caches from
97
+ // the public celilo sources instead of building them from workspace
98
+ // source, so build-infra still works for a monorepo-free consumer (ce-i2i).
99
+ await stageFromPublic(pkgDir);
100
+ return;
101
+ }
88
102
 
89
103
  const websiteSrc = join(repoRoot, 'modules', 'celilo-website', 'site');
90
104
  const websiteCache = join(pkgDir, '.celilo-website-cache');
@@ -154,6 +168,201 @@ function stageSimulatorInputs(pkgDir: string): void {
154
168
  console.log('');
155
169
  }
156
170
 
171
+ /**
172
+ * The published @celilo/* packages the npm-registry-sim must serve so
173
+ * install.sh's `bun add -g @celilo/cli @celilo/event-bus @celilo/e2e`
174
+ * resolves its full transitive closure against the sim. Mirrors
175
+ * PACKAGES_TO_PACK in scripts/pack-celilo-packages.ts (the monorepo
176
+ * equivalent that packs from workspace source instead of fetching).
177
+ */
178
+ const PUBLISHED_CELILO_PACKAGES = [
179
+ '@celilo/cli',
180
+ '@celilo/capabilities',
181
+ '@celilo/cli-display',
182
+ '@celilo/e2e',
183
+ '@celilo/event-bus',
184
+ ] as const;
185
+
186
+ /**
187
+ * npm-consumer counterpart to the monorepo staging above (ce-i2i). With no
188
+ * `modules/` source tree to build from, fetch the sim-content caches from
189
+ * the public celilo sources so `cele2e build-infra` still produces working
190
+ * simulator images. Everything is pulled over the public internet, which
191
+ * build-infra already requires (docker base images, the caddy apt repo, ACME):
192
+ *
193
+ * .npm-registry-cache/ ← published @celilo/* tarballs (npm registry)
194
+ * .celilo-website-cache/ ← install.sh from https://celilo.computer (all the
195
+ * bake step's `curl celilo.computer/install.sh` needs)
196
+ * .apt-repo-cache/pool/ ← real celilo + celilo-bootstrap .debs fetched from
197
+ * apt.celilo.computer (only bootstrap-apt needs them).
198
+ *
199
+ * Sources are overridable via CELILO_E2E_NPM_REGISTRY / CELILO_E2E_WEBSITE_BASE
200
+ * / CELILO_E2E_APT_BASE (used by the monorepo-free CI smoke test, ce-1pe).
201
+ */
202
+ async function stageFromPublic(pkgDir: string): Promise<void> {
203
+ console.log(`${bold}Staging simulator inputs (npm-consumer mode)...${reset}\n`);
204
+
205
+ const registry = (process.env.CELILO_E2E_NPM_REGISTRY ?? 'https://registry.npmjs.org').replace(
206
+ /\/+$/,
207
+ '',
208
+ );
209
+ const websiteBase = (process.env.CELILO_E2E_WEBSITE_BASE ?? 'https://celilo.computer').replace(
210
+ /\/+$/,
211
+ '',
212
+ );
213
+
214
+ // (a) npm-registry-cache: the published @celilo/* tarballs.
215
+ const npmCache = join(pkgDir, '.npm-registry-cache');
216
+ rmSync(npmCache, { recursive: true, force: true });
217
+ mkdirSync(npmCache, { recursive: true });
218
+ process.stdout.write(` ${'npm-registry (fetch)'.padEnd(28)} `);
219
+ const t0 = Date.now();
220
+ for (const pkg of PUBLISHED_CELILO_PACKAGES) {
221
+ await fetchPublishedTarball(pkg, npmCache, registry);
222
+ }
223
+ console.log(
224
+ `${green}✔${reset} ${dim}${Math.round((Date.now() - t0) / 1000)}s, ${PUBLISHED_CELILO_PACKAGES.length} tarball(s)${reset}`,
225
+ );
226
+
227
+ // (b) celilo-website-cache: the bake step's `curl celilo.computer/install.sh`
228
+ // only needs install.sh served, so fetch it (bootstrap.sh best-effort — it's
229
+ // referenced by install.sh's help text but not needed for the bake path).
230
+ const websiteCache = join(pkgDir, '.celilo-website-cache');
231
+ rmSync(websiteCache, { recursive: true, force: true });
232
+ mkdirSync(websiteCache, { recursive: true });
233
+ process.stdout.write(` ${'celilo-website (fetch)'.padEnd(28)} `);
234
+ const t1 = Date.now();
235
+ await fetchSiteFile(websiteBase, 'install.sh', websiteCache, { required: true });
236
+ await fetchSiteFile(websiteBase, 'bootstrap.sh', websiteCache, { required: false });
237
+ console.log(`${green}✔${reset} ${dim}${Math.round((Date.now() - t1) / 1000)}s${reset}`);
238
+
239
+ // (c) apt-repo-cache/pool: the real celilo + celilo-bootstrap .debs, fetched
240
+ // from apt.celilo.computer so the bootstrap-apt test's `apt install
241
+ // celilo-bootstrap` resolves. Only that test exercises this path; if the
242
+ // fetch fails we leave the pool empty (build-infra still succeeds for
243
+ // everyone) and bootstrap-apt fails loudly with the sim's "empty repo".
244
+ const aptBase = (process.env.CELILO_E2E_APT_BASE ?? 'https://apt.celilo.computer').replace(
245
+ /\/+$/,
246
+ '',
247
+ );
248
+ const pool = join(pkgDir, '.apt-repo-cache', 'pool');
249
+ rmSync(join(pkgDir, '.apt-repo-cache'), { recursive: true, force: true });
250
+ mkdirSync(pool, { recursive: true });
251
+ process.stdout.write(` ${'apt-repo (fetch)'.padEnd(28)} `);
252
+ const t2 = Date.now();
253
+ try {
254
+ const count = await fetchAptPool(aptBase, pool);
255
+ console.log(
256
+ `${green}✔${reset} ${dim}${Math.round((Date.now() - t2) / 1000)}s, ${count} deb(s)${reset}`,
257
+ );
258
+ } catch (err) {
259
+ console.log(
260
+ `${yellow}empty${reset} ${dim}(${err instanceof Error ? err.message : String(err)})${reset}`,
261
+ );
262
+ }
263
+
264
+ console.log('');
265
+ }
266
+
267
+ /**
268
+ * Fetch the celilo + celilo-bootstrap .debs from a real apt repo laid out like
269
+ * apt.celilo.computer (`deb <base>/ stable main`) into `pool`. Parses the
270
+ * per-arch Packages index for its `Filename: pool/...` entries and downloads
271
+ * each referenced .deb (deduped across arches, so the arch-all bootstrap deb is
272
+ * fetched once). Returns the number of debs staged; throws if none were found.
273
+ */
274
+ export async function fetchAptPool(base: string, pool: string): Promise<number> {
275
+ const filenames = new Set<string>();
276
+ for (const arch of ['amd64', 'arm64']) {
277
+ const dir = `${base}/dists/stable/main/binary-${arch}`;
278
+ const packages = await fetchPackagesIndex(dir);
279
+ for (const line of packages.split('\n')) {
280
+ const m = line.match(/^Filename:\s*(\S+)/);
281
+ if (m) filenames.add(m[1]);
282
+ }
283
+ }
284
+ if (filenames.size === 0) {
285
+ throw new Error(`no debs in ${base} stable/main — repo layout changed?`);
286
+ }
287
+ for (const filename of filenames) {
288
+ const url = `${base}/${filename}`;
289
+ const res = await fetch(url);
290
+ if (!res.ok) throw new Error(`fetching ${url}: HTTP ${res.status}`);
291
+ writeFileSync(join(pool, basename(filename)), Buffer.from(await res.arrayBuffer()));
292
+ }
293
+ return filenames.size;
294
+ }
295
+
296
+ /** Fetch a Packages index from an apt binary-<arch> dir, uncompressed or .gz. */
297
+ async function fetchPackagesIndex(dir: string): Promise<string> {
298
+ const plain = await fetch(`${dir}/Packages`);
299
+ if (plain.ok) return await plain.text();
300
+ const gz = await fetch(`${dir}/Packages.gz`);
301
+ if (!gz.ok) throw new Error(`fetching ${dir}/Packages(.gz): HTTP ${gz.status}`);
302
+ return gunzipSync(Buffer.from(await gz.arrayBuffer())).toString('utf8');
303
+ }
304
+
305
+ /**
306
+ * Download a package's `latest` published tarball from the npm registry into
307
+ * destDir. Uses the registry's own tarball filename so the npm-registry-sim
308
+ * (which indexes tarballs by their embedded package.json) serves it verbatim.
309
+ */
310
+ export async function fetchPublishedTarball(
311
+ pkg: string,
312
+ destDir: string,
313
+ registry: string,
314
+ ): Promise<void> {
315
+ const packumentUrl = `${registry}/${pkg}`;
316
+ const packumentRes = await fetch(packumentUrl);
317
+ if (!packumentRes.ok) {
318
+ throw new Error(
319
+ `Fetching packument for ${pkg} (${packumentUrl}): HTTP ${packumentRes.status}. ` +
320
+ `Is ${pkg} published to ${registry}?`,
321
+ );
322
+ }
323
+ const packument = (await packumentRes.json()) as {
324
+ 'dist-tags'?: Record<string, string>;
325
+ versions?: Record<string, { dist?: { tarball?: string } }>;
326
+ };
327
+ const latest = packument['dist-tags']?.latest;
328
+ const tarballUrl = latest ? packument.versions?.[latest]?.dist?.tarball : undefined;
329
+ if (!latest || !tarballUrl) {
330
+ throw new Error(`${pkg}: registry ${registry} has no dist-tags.latest tarball`);
331
+ }
332
+ const tgzRes = await fetch(tarballUrl);
333
+ if (!tgzRes.ok) {
334
+ throw new Error(`Fetching tarball for ${pkg}@${latest} (${tarballUrl}): HTTP ${tgzRes.status}`);
335
+ }
336
+ const filename =
337
+ tarballUrl.split('/').pop() ?? `${pkg.replace('@', '').replace('/', '-')}-${latest}.tgz`;
338
+ writeFileSync(join(destDir, filename), Buffer.from(await tgzRes.arrayBuffer()));
339
+ }
340
+
341
+ /**
342
+ * Download a single file from the published site into destDir. Throws on a
343
+ * missing required file; silently skips an absent optional one.
344
+ */
345
+ export async function fetchSiteFile(
346
+ base: string,
347
+ name: string,
348
+ destDir: string,
349
+ opts: { required: boolean },
350
+ ): Promise<void> {
351
+ const url = `${base}/${name}`;
352
+ let res: Response;
353
+ try {
354
+ res = await fetch(url);
355
+ } catch (err) {
356
+ if (opts.required) throw err;
357
+ return;
358
+ }
359
+ if (!res.ok) {
360
+ if (opts.required) throw new Error(`Fetching ${url}: HTTP ${res.status}`);
361
+ return;
362
+ }
363
+ writeFileSync(join(destDir, name), Buffer.from(await res.arrayBuffer()));
364
+ }
365
+
157
366
  function packageNetapp(moduleDir: string, netappsDir: string): void {
158
367
  const absDir = resolve(moduleDir);
159
368
  if (!existsSync(absDir)) {
@@ -201,6 +410,58 @@ function packageNetapp(moduleDir: string, netappsDir: string): void {
201
410
  console.log(`${green}✔${reset} ${dim}${elapsed}s ${size}${reset}`);
202
411
  }
203
412
 
413
+ /**
414
+ * Consumer (npm-installed) staging: fetch standard-module .netapps from the
415
+ * public celilo registry into `netappsDir`. In the monorepo we package these
416
+ * from source (packageNetapp); an npm consumer has no `modules/` source, so
417
+ * getRegistryVolumes() binds `./netapps` into the sim registry and we fill it
418
+ * here at build-infra time (which has network — the test RUN stays hermetic
419
+ * against the local sim). No version-pinning: the capability system governs
420
+ * compatibility at import time (validateCapabilityAccess), surfacing a
421
+ * mismatch as an actionable import error rather than a brittle lockfile.
422
+ */
423
+ export async function stageNetappsFromRegistry(netappsDir: string): Promise<void> {
424
+ const base = (process.env.CELILO_REGISTRY_URL ?? 'https://celilo.computer/registry').replace(
425
+ /\/+$/,
426
+ '',
427
+ );
428
+ console.log(`${bold}Fetching standard-module netapps from registry...${reset}`);
429
+ console.log(` ${dim}${base}${reset}\n`);
430
+
431
+ // ponytail: single page (per_page=100). Paginate if >100 modules ever ship.
432
+ const listUrl = `${base}/api/v1/modules?per_page=100`;
433
+ const listResp = await fetch(listUrl, { signal: AbortSignal.timeout(30_000) });
434
+ if (!listResp.ok) {
435
+ console.error(`${red}Registry list failed: HTTP ${listResp.status} ${listUrl}${reset}`);
436
+ process.exit(1);
437
+ }
438
+ const { modules } = (await listResp.json()) as {
439
+ modules: Array<{ name: string; max_version: string }>;
440
+ };
441
+ if (!modules || modules.length === 0) {
442
+ console.error(`${red}Registry returned no modules — nothing to stage${reset}`);
443
+ process.exit(1);
444
+ }
445
+
446
+ for (const { name, max_version } of modules) {
447
+ process.stdout.write(` ${name.padEnd(20)} `);
448
+ const t0 = Date.now();
449
+ const dlUrl = `${base}/api/v1/modules/${encodeURIComponent(name)}/${encodeURIComponent(max_version)}/download`;
450
+ const resp = await fetch(dlUrl, { signal: AbortSignal.timeout(60_000) });
451
+ if (!resp.ok) {
452
+ console.log(`${red}✗${reset}`);
453
+ console.error(`${red} download failed: HTTP ${resp.status} ${dlUrl}${reset}`);
454
+ process.exit(1);
455
+ }
456
+ const bytes = new Uint8Array(await resp.arrayBuffer());
457
+ writeFileSync(join(netappsDir, `${name}.netapp`), bytes);
458
+ console.log(
459
+ `${green}✔${reset} ${dim}${max_version} ${Math.round((Date.now() - t0) / 1000)}s ${(bytes.length / 1024).toFixed(0)}KB${reset}`,
460
+ );
461
+ }
462
+ console.log('');
463
+ }
464
+
204
465
  function buildDockerImages(pkgDir: string): void {
205
466
  // Refresh the bundled registry-server source before docker build —
206
467
  // Dockerfile.registry copies from <pkgDir>/registry-server, which is
@@ -295,7 +556,7 @@ function tagVanillaManagement(): void {
295
556
  * build-infra so the operator can't accidentally proceed with a
296
557
  * non-functional default management image.
297
558
  */
298
- function bakeManagement(pkgDir: string): void {
559
+ function bakeManagement(pkgDir: string, published: boolean): void {
299
560
  const bakeScript = join(pkgDir, 'bin', 'e2e-bake-management');
300
561
  if (!existsSync(bakeScript)) {
301
562
  console.error(
@@ -305,9 +566,14 @@ function bakeManagement(pkgDir: string): void {
305
566
  process.exit(1);
306
567
  }
307
568
 
308
- console.log(`${bold}Baking celilo into management:latest via install.sh...${reset}\n`);
569
+ console.log(
570
+ published
571
+ ? `${bold}Baking PUBLISHED @celilo/cli into management:latest from real npm...${reset}\n`
572
+ : `${bold}Baking celilo into management:latest via install.sh...${reset}\n`,
573
+ );
309
574
  const start = Date.now();
310
- const result = spawnSync('bun', ['run', bakeScript], { stdio: 'inherit' });
575
+ const bakeArgs = ['run', bakeScript, ...(published ? ['--published'] : [])];
576
+ const result = spawnSync('bun', bakeArgs, { stdio: 'inherit' });
311
577
  const elapsed = Math.round((Date.now() - start) / 1000);
312
578
 
313
579
  if (result.status !== 0) {
@@ -381,8 +647,8 @@ function saveImages(pkgDir: string): void {
381
647
  console.log('\nTo restore after colima restart:\n cele2e load');
382
648
  }
383
649
 
384
- export function runBuild(options: BuildOptions): void {
385
- const { pkgDir, moduleDirs, save, skipModules } = options;
650
+ export async function runBuild(options: BuildOptions): Promise<void> {
651
+ const { pkgDir, moduleDirs, save, skipModules, published } = options;
386
652
  const netappsDir = join(pkgDir, 'netapps');
387
653
  mkdirSync(netappsDir, { recursive: true });
388
654
 
@@ -390,7 +656,7 @@ export function runBuild(options: BuildOptions): void {
390
656
  // and npm-registry-sim COPY from the staged dirs at docker-build time,
391
657
  // so they have to exist (and reflect current workspace state) before
392
658
  // buildDockerImages() runs.
393
- stageSimulatorInputs(pkgDir);
659
+ await stageSimulatorInputs(pkgDir);
394
660
 
395
661
  if (!skipModules) {
396
662
  // Explicit dirs take precedence; fall back to auto-discovering all
@@ -412,6 +678,12 @@ export function runBuild(options: BuildOptions): void {
412
678
  packageNetapp(dir, netappsDir);
413
679
  }
414
680
  console.log('');
681
+ } else {
682
+ // npm consumer: no `modules/` source to package from — fetch the
683
+ // standard-module netapps from the registry so the sim registry
684
+ // (which binds ./netapps) has something to serve. Consumers need
685
+ // zero vendored module binaries.
686
+ await stageNetappsFromRegistry(netappsDir);
415
687
  }
416
688
  }
417
689
 
@@ -429,7 +701,7 @@ export function runBuild(options: BuildOptions): void {
429
701
  // simulated celilo.computer / npm registry. This is what makes the
430
702
  // default management image every test uses an actual product of
431
703
  // install.sh, rather than a parallel `bun add -g` pinning.
432
- bakeManagement(pkgDir);
704
+ bakeManagement(pkgDir, published);
433
705
 
434
706
  // Pre-seed heavy app images so app-zone deploys load them from the cache
435
707
  // instead of pulling GB through the sim (ISS-0154).
@@ -34,6 +34,8 @@ export const COMMANDS: CommandDef[] = [
34
34
  { name: '--live', description: 'Use live (non-simulated) internet' },
35
35
  { name: '--published', description: 'Use published .netapp packages' },
36
36
  { name: '--notify', description: 'Desktop notification when the run finishes' },
37
+ { name: '--shuffle', description: 'Randomize test order to surface order-dependent bugs (seed is logged)' },
38
+ { name: '--seed=', description: 'Replay a specific --shuffle order (e.g. --seed=12345)' },
37
39
  ],
38
40
  },
39
41
  {
@@ -111,4 +113,4 @@ export const RUN_FLAGS = ['--all', '--complete', '--ci-safe', '--all-modules', '
111
113
 
112
114
  export const DOWN_FLAGS = ['--keep', '--all'];
113
115
 
114
- export const BUILD_INFRA_FLAGS = ['--save'];
116
+ export const BUILD_INFRA_FLAGS = ['--save', '--skip-modules', '--published'];
@@ -138,6 +138,8 @@ _cele2e() {
138
138
  build-infra)
139
139
  _arguments \\
140
140
  '--save[Save Docker images to tarball after build]' \\
141
+ '--skip-modules[Skip .netapp packaging (only rebuild Docker images)]' \\
142
+ '--published[Bake management:latest with the published @celilo/cli from real npm]' \\
141
143
  '*:module directory:_directories'
142
144
  ;;
143
145
  shell)
package/src/cli/index.ts CHANGED
@@ -157,6 +157,8 @@ Options for \`down\`:
157
157
  Options for \`build-infra\`:
158
158
  --save Save Docker images to tarball after build
159
159
  --skip-modules Skip .netapp packaging (only rebuild Docker images)
160
+ --published Bake management:latest with the PUBLISHED @celilo/cli from
161
+ real npm (default: bake the monorepo DEV cli via the sim)
160
162
 
161
163
  Examples:
162
164
  cele2e run --all # FULL regression: every module suite + top-level e2e/tests/
@@ -373,6 +375,7 @@ switch (command) {
373
375
  case 'build-infra': {
374
376
  const save = args.includes('--save');
375
377
  const skipModules = args.includes('--skip-modules');
378
+ const published = args.includes('--published');
376
379
  const moduleDirs = args.filter((a) => !a.startsWith('--'));
377
380
  // build-infra mutates the shared Docker images — serialize against runs.
378
381
  // Released via the lock's exit handler (runBuild may process.exit on failure).
@@ -385,7 +388,7 @@ switch (command) {
385
388
  }
386
389
  throw err;
387
390
  }
388
- runBuild({ pkgDir: PKG_DIR, moduleDirs, save, skipModules });
391
+ await runBuild({ pkgDir: PKG_DIR, moduleDirs, save, skipModules, published });
389
392
  break;
390
393
  }
391
394
 
@@ -0,0 +1,22 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { checkCliVersion } from './cli-version-contract';
3
+
4
+ // ce-5qp: harness<->CLI version contract fails fast on a stale management image.
5
+
6
+ test('accepts a CLI at or above the minimum', () => {
7
+ expect(checkCliVersion('celilo 0.9.1', '0.9.0')).toBeNull();
8
+ expect(checkCliVersion('celilo 0.9.0', '0.9.0')).toBeNull();
9
+ expect(checkCliVersion('celilo 1.2.3', '0.9.0')).toBeNull();
10
+ });
11
+
12
+ test('rejects a stale CLI with an actionable message', () => {
13
+ const msg = checkCliVersion('celilo 0.5.0-alpha.5', '0.9.0');
14
+ expect(msg).toContain('celilo 0.5.0-alpha.5');
15
+ expect(msg).toContain('>=0.9.0');
16
+ expect(msg).toContain('build-infra');
17
+ });
18
+
19
+ test('reports unparseable version output instead of silently passing', () => {
20
+ expect(checkCliVersion('command not found', '0.9.0')).toContain('Could not parse');
21
+ expect(checkCliVersion('', '0.9.0')).toContain('Could not parse');
22
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Harness<->CLI version contract (ce-5qp). The management image ships a
3
+ * baked-in `celilo` CLI; a published harness run against a stale image
4
+ * (e.g. an old npm consumer's `cele2e build-infra`) used to hang ~90s in
5
+ * `system init` and surface only "canceled". Instead we check the baked CLI
6
+ * version up front and fail fast (<10s) with an actionable message.
7
+ *
8
+ * Bump MIN_CLI_VERSION whenever the harness starts relying on a CLI
9
+ * feature/flag that older CLIs lack. It's the MINIMUM the harness needs,
10
+ * not an exact pin.
11
+ */
12
+ export const MIN_CLI_VERSION = '0.9.0';
13
+
14
+ /**
15
+ * Pure core of the contract: given the raw `celilo --version` stdout, decide
16
+ * whether it satisfies `minVersion`. Returns an actionable error string when
17
+ * it doesn't, or `null` when OK. No I/O, so it's unit-testable without a
18
+ * running management container.
19
+ */
20
+ export function checkCliVersion(versionStdout: string, minVersion = MIN_CLI_VERSION): string | null {
21
+ // Output is `celilo <version>` (apps/celilo displayVersion). Grab the token.
22
+ const version = versionStdout.match(/celilo\s+(\S+)/)?.[1];
23
+ if (!version) {
24
+ return (
25
+ `Could not parse celilo version from "${versionStdout.trim()}" ` +
26
+ `(harness requires >=${minVersion}). Is the management image's celilo CLI intact?`
27
+ );
28
+ }
29
+ if (Bun.semver.order(version, minVersion) < 0) {
30
+ return (
31
+ `Management image has celilo ${version}, harness requires >=${minVersion}. ` +
32
+ `Rebuild the management image against a newer @celilo/cli ` +
33
+ `(e.g. \`cele2e build-infra\`, or \`--published\` after publishing).`
34
+ );
35
+ }
36
+ return null;
37
+ }