@celilo/e2e 0.19.3 → 0.20.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.
Files changed (50) hide show
  1. package/README.md +30 -13
  2. package/bin/e2e-bake-management +171 -12
  3. package/bin/e2e-infra +0 -1
  4. package/bin/e2e-up +14 -3
  5. package/docker/Dockerfile.observer +12 -1
  6. package/docker/Dockerfile.target-machine +22 -1
  7. package/npm-registry-server/package.json +1 -1
  8. package/package.json +3 -3
  9. package/registry-server/package.json +1 -1
  10. package/scripts/pack-celilo-packages.ts +15 -0
  11. package/src/block-timing.test.ts +559 -0
  12. package/src/block-timing.ts +366 -0
  13. package/src/cli/build.test.ts +54 -4
  14. package/src/cli/build.ts +204 -88
  15. package/src/cli/command-registry.ts +21 -0
  16. package/src/cli/command-tree-parser.ts +11 -2
  17. package/src/cli/completion.ts +9 -0
  18. package/src/cli/host.ts +252 -0
  19. package/src/cli/index.ts +78 -51
  20. package/src/cli/module-discovery.ts +108 -13
  21. package/src/cli/scaffold.ts +18 -26
  22. package/src/container-manager.cleanup.test.ts +284 -0
  23. package/src/container-manager.runner.test.ts +351 -0
  24. package/src/container-manager.test.ts +84 -0
  25. package/src/container-manager.ts +721 -185
  26. package/src/docker-compose-generator.ts +135 -61
  27. package/src/doctor.test.ts +259 -4
  28. package/src/doctor.ts +276 -3
  29. package/src/exit-cleanup.test.ts +83 -1
  30. package/src/fleet-nameserver-gate.test.ts +45 -0
  31. package/src/host-vm.test.ts +156 -0
  32. package/src/host-vm.ts +230 -0
  33. package/src/index.ts +11 -0
  34. package/src/live-stack.test.ts +184 -0
  35. package/src/live-stack.ts +145 -0
  36. package/src/no-unjustified-sleep.test.ts +90 -0
  37. package/src/proxmox-provisioner.test.ts +18 -2
  38. package/src/proxmox-provisioner.ts +22 -0
  39. package/src/public-sim-routes.test.ts +9 -2
  40. package/src/repo-root.ts +33 -0
  41. package/src/run-args.test.ts +76 -0
  42. package/src/run-args.ts +89 -0
  43. package/src/runner.ts +213 -8
  44. package/src/shared-infra.ts +83 -32
  45. package/src/socks-proxy.ts +2 -0
  46. package/src/source-fingerprint.test.ts +213 -0
  47. package/src/source-fingerprint.ts +201 -0
  48. package/src/stage-simulator-inputs.ts +93 -0
  49. package/src/stages.ts +133 -0
  50. package/src/wait-for-run.ts +1 -0
package/src/cli/build.ts CHANGED
@@ -19,13 +19,25 @@
19
19
  */
20
20
 
21
21
  import { execSync, spawnSync } from 'node:child_process';
22
- import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
22
+ import {
23
+ closeSync,
24
+ existsSync,
25
+ mkdirSync,
26
+ openSync,
27
+ readFileSync,
28
+ readSync,
29
+ readdirSync,
30
+ rmSync,
31
+ writeFileSync,
32
+ } from 'node:fs';
23
33
  import { basename, join, resolve } from 'node:path';
24
34
  import { gunzipSync } from 'node:zlib';
25
35
  import { stageAptRepo } from '../../scripts/stage-apt-repo';
26
36
  import { stageLibsignal } from '../../scripts/stage-libsignal';
27
37
  import { explainBuildFailure } from '../doctor';
28
38
  import { ensureRegistryServerBundle, ensureTerraformFakeBundle } from '../registry-bundle';
39
+ import { findMonorepoRoot } from '../repo-root';
40
+ import { packNpmRegistryTarballs, stageWebsiteDist } from '../stage-simulator-inputs';
29
41
 
30
42
  /**
31
43
  * Ceiling on a single `docker build`. The slowest image here is a few minutes
@@ -35,27 +47,15 @@ import { ensureRegistryServerBundle, ensureTerraformFakeBundle } from '../regist
35
47
  const PER_IMAGE_BUILD_TIMEOUT_MS = 15 * 60_000;
36
48
 
37
49
  /**
38
- * Walk up from pkgDir to find the celilo monorepo root.
39
- *
40
- * The marker is `apps/celilo/package.json` — a file that only a real celilo
41
- * checkout has — NOT the presence of a `modules/` directory. A bare `modules/`
42
- * is too weak a signal: a stray empty `node_modules/modules` (left by a botched
43
- * `bun add -g`) made this return the global node_modules as "the monorepo root",
44
- * which skipped `stageFromPublic()` and crashed the npm-consumer path in the
45
- * workspace packer (ce-dbc). Matches the marker `container-manager.ts`'s
46
- * findCeliloRoot already uses. Returns null in any consumer install, so callers
47
- * correctly fall through to the published-source path.
50
+ * Ceiling on a single `celilo module package` run. The slowest module takes
51
+ * a couple of minutes; 10 leaves generous headroom. Without it a wedged
52
+ * packaging step sat for two hours (2026-09-04, technitium: all samples in
53
+ * kevent64, artifact truncated at 59 percent, no error, no exit).
48
54
  */
49
- export function findMonorepoRoot(pkgDir: string): string | null {
50
- let dir = pkgDir;
51
- for (let i = 0; i < 10; i++) {
52
- if (existsSync(join(dir, 'apps', 'celilo', 'package.json'))) return dir;
53
- const parent = resolve(dir, '..');
54
- if (parent === dir) break;
55
- dir = parent;
56
- }
57
- return null;
58
- }
55
+ const PER_MODULE_PACKAGE_TIMEOUT_MS = 10 * 60_000;
56
+
57
+ /** Re-exported so this module's existing callers and its recurrence test keep their import path. */
58
+ export { findMonorepoRoot };
59
59
 
60
60
  /** Return paths to all modules that have a manifest.yml. */
61
61
  function discoverModuleDirs(repoRoot: string): string[] {
@@ -72,6 +72,84 @@ const red = '\x1b[31m';
72
72
  const yellow = '\x1b[33m';
73
73
  const reset = '\x1b[0m';
74
74
 
75
+ /**
76
+ * Validate that a file is a complete gzip stream (full gunzip pass). This is
77
+ * the check a .netapp or .tgz needs: a stream truncated mid-write fails here
78
+ * with 'unexpected end of file' instead of surviving to the consumer, where
79
+ * the identical zlib error reads as a truncated DOWNLOAD and sends the
80
+ * debugging at the network while the artifact was born broken.
81
+ *
82
+ * Throws with an actionable message naming the file.
83
+ */
84
+ export function assertGzipValid(filePath: string): void {
85
+ try {
86
+ gunzipSync(readFileSync(filePath));
87
+ return;
88
+ } catch (err) {
89
+ const reason = err instanceof Error ? err.message : String(err);
90
+ throw new Error(
91
+ `${filePath} is not a valid gzip stream (${reason}). The artifact was written truncated or corrupted at rest; serving it would surface as 'zlib: unexpected end of file' on the consumer side, indistinguishable from a truncated download.`,
92
+ );
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Validate that a file is a real .deb (ar archive). A full dpkg parse is out
98
+ * of scope; the magic bytes catch the truncated-at-write case, which is the
99
+ * one that silently ships.
100
+ */
101
+ function assertDebValid(filePath: string): void {
102
+ const fd = openSync(filePath, 'r');
103
+ try {
104
+ const magic = Buffer.alloc(8);
105
+ const read = readSync(fd, magic, 0, 8, 0);
106
+ if (read < 8 || magic.toString('latin1') !== '!<arch>\n') {
107
+ throw new Error(
108
+ `${filePath} is not a valid ar archive (missing !<arch> magic). The .deb was written truncated or corrupted at rest.`,
109
+ );
110
+ }
111
+ } finally {
112
+ closeSync(fd);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Verify a produced or downloaded .netapp is a complete gzip stream. Throws
118
+ * with an actionable message; callers fail the build rather than stage the
119
+ * artifact for the sim registry to serve.
120
+ */
121
+ export function verifyNetapp(filePath: string): void {
122
+ if (!existsSync(filePath)) {
123
+ throw new Error(`${filePath} was not produced — packaging step exited 0 but left no artifact`);
124
+ }
125
+ assertGzipValid(filePath);
126
+ }
127
+
128
+ /**
129
+ * Verify every .netapp in the staging dir before declaring the stage done.
130
+ * The sim registry binds this directory and serves whatever is in it, so a
131
+ * corrupt file here becomes a 'zlib: unexpected end of file' on some consumer
132
+ * that looks like a network fault. Catches stale artifacts from a previous
133
+ * run too — the dir is not cleaned between builds.
134
+ */
135
+ export function verifyStagedNetapps(netappsDir: string): void {
136
+ if (!existsSync(netappsDir)) return;
137
+ const netapps = readdirSync(netappsDir).filter((f) => f.endsWith('.netapp'));
138
+ const failures: string[] = [];
139
+ for (const netapp of netapps) {
140
+ try {
141
+ verifyNetapp(join(netappsDir, netapp));
142
+ } catch (err) {
143
+ failures.push(err instanceof Error ? err.message : String(err));
144
+ }
145
+ }
146
+ if (failures.length > 0) {
147
+ throw new Error(
148
+ `${failures.length} of ${netapps.length} staged .netapp file(s) failed gzip integrity check:\n ${failures.join('\n ')}`,
149
+ );
150
+ }
151
+ }
152
+
75
153
  interface BuildOptions {
76
154
  pkgDir: string;
77
155
  moduleDirs: string[];
@@ -87,19 +165,16 @@ interface BuildOptions {
87
165
  }
88
166
 
89
167
  /**
90
- * Stage two build-time inputs that the celilo-website-sim and
91
- * npm-registry-sim images COPY at docker-build time:
92
- *
93
- * .celilo-website-cache/ ← modules/celilo-website/site/dist/
94
- * .npm-registry-cache/ ← bun pm pack of each @celilo/* workspace pkg
168
+ * Stage the build-time inputs that the simulator images COPY at docker-build
169
+ * time: the website dist and the @celilo/* tarballs (via
170
+ * src/stage-simulator-inputs.ts), then the apt-repo and libsignal debs.
95
171
  *
96
- * Both are .gitignored — they're build outputs, not source. Without
97
- * this step, Dockerfile.celilo-website-sim and Dockerfile.npm-registry-sim
98
- * fail at COPY (or worse, ship stale tarballs that don't match the
99
- * current workspace versions, which is what the version-mismatch
100
- * regression on 2026-05-06 caught).
172
+ * Both cache dirs are .gitignored — they're build outputs, not source. Without
173
+ * this step, the simulator Dockerfiles fail at COPY (or worse, ship stale
174
+ * tarballs that don't match the current workspace versions, which is what the
175
+ * version-mismatch regression on 2026-05-06 caught).
101
176
  *
102
- * Pure no-op when the workspace doesn't have either cache target —
177
+ * Pure no-op when the workspace doesn't have the cache targets —
103
178
  * keeps build-infra working in npm-installed @celilo/e2e, where these
104
179
  * staging dirs aren't relevant.
105
180
  */
@@ -121,8 +196,6 @@ async function stageSimulatorInputs(pkgDir: string): Promise<void> {
121
196
  }
122
197
 
123
198
  const websiteSrc = join(repoRoot, 'modules', 'celilo-website', 'site');
124
- const websiteCache = join(pkgDir, '.celilo-website-cache');
125
- const npmCache = join(pkgDir, '.npm-registry-cache');
126
199
  const packScript = join(pkgDir, 'scripts', 'pack-celilo-packages.ts');
127
200
 
128
201
  // Skip silently if neither dir exists — we're in an npm-installed
@@ -131,61 +204,16 @@ async function stageSimulatorInputs(pkgDir: string): Promise<void> {
131
204
 
132
205
  console.log(`${bold}Staging simulator inputs...${reset}\n`);
133
206
 
134
- // (a) Build the celilo-website static site and stage its dist/.
135
- if (existsSync(websiteSrc)) {
136
- process.stdout.write(` ${'celilo-website (build)'.padEnd(28)} `);
137
- const t0 = Date.now();
138
- let result = spawnSync('bun', ['install'], { cwd: websiteSrc, stdio: 'pipe' });
139
- if (result.status !== 0) {
140
- console.log(`${red}✗${reset}`);
141
- console.error(result.stderr?.toString());
142
- process.exit(1);
143
- }
144
- result = spawnSync('bun', ['run', 'build'], { cwd: websiteSrc, stdio: 'pipe' });
145
- if (result.status !== 0) {
146
- console.log(`${red}✗${reset}`);
147
- console.error(result.stderr?.toString());
148
- process.exit(1);
149
- }
150
- console.log(`${green}✔${reset} ${dim}${Math.round((Date.now() - t0) / 1000)}s${reset}`);
151
-
152
- process.stdout.write(` ${'celilo-website (stage)'.padEnd(28)} `);
153
- rmSync(websiteCache, { recursive: true, force: true });
154
- mkdirSync(websiteCache, { recursive: true });
155
- cpSync(join(websiteSrc, 'dist'), websiteCache, { recursive: true });
156
- console.log(`${green}✔${reset}`);
157
- }
207
+ stageWebsiteDist(repoRoot, pkgDir);
208
+ packNpmRegistryTarballs(repoRoot, pkgDir);
158
209
 
159
- // (b) Pack the @celilo/* workspace packages so they ship in
160
- // npm-registry-sim. Tarball versions match current workspace
161
- // package.json — re-runs every build-infra to stay fresh.
162
- if (existsSync(packScript)) {
163
- process.stdout.write(` ${'npm-registry (pack)'.padEnd(28)} `);
164
- const t0 = Date.now();
165
- const result = spawnSync('bun', ['run', packScript], {
166
- cwd: repoRoot,
167
- stdio: 'pipe',
168
- });
169
- if (result.status !== 0) {
170
- console.log(`${red}✗${reset}`);
171
- console.error(result.stderr?.toString());
172
- process.exit(1);
173
- }
174
- const tarballCount = existsSync(npmCache)
175
- ? readdirSync(npmCache).filter((f) => f.endsWith('.tgz')).length
176
- : 0;
177
- console.log(
178
- `${green}✔${reset} ${dim}${Math.round((Date.now() - t0) / 1000)}s, ${tarballCount} tarball(s)${reset}`,
179
- );
180
- }
181
-
182
- // (c) Build + stage the celilo/celilo-bootstrap .debs for apt-repo-sim.
210
+ // Build + stage the celilo/celilo-bootstrap .debs for apt-repo-sim.
183
211
  // Graceful no-op when packaging/ or nfpm is absent (npm-installed e2e).
184
212
  // Dockerfile.apt-repo-sim COPYs the staged .apt-repo-cache/pool at build
185
213
  // time, so this must run before buildDockerImages().
186
214
  stageAptRepo(repoRoot, pkgDir);
187
215
 
188
- // (d) Build + stage the libsignal aarch64 native as a .deb, into the SAME
216
+ // Build + stage the libsignal aarch64 native as a .deb, into the SAME
189
217
  // pool stageAptRepo just populated (so it must run after it — that function
190
218
  // recreates the pool empty). Compiled here on the host's own network rather
191
219
  // than inside the sealed e2e network; see stage-libsignal.ts.
@@ -323,7 +351,9 @@ export async function fetchAptPool(base: string, pool: string): Promise<number>
323
351
  const url = `${base}/${filename}`;
324
352
  const res = await fetch(url);
325
353
  if (!res.ok) throw new Error(`fetching ${url}: HTTP ${res.status}`);
326
- writeFileSync(join(pool, basename(filename)), Buffer.from(await res.arrayBuffer()));
354
+ const debPath = join(pool, basename(filename));
355
+ writeFileSync(debPath, Buffer.from(await res.arrayBuffer()));
356
+ assertDebValid(debPath);
327
357
  }
328
358
  return filenames.size;
329
359
  }
@@ -370,7 +400,12 @@ export async function fetchPublishedTarball(
370
400
  }
371
401
  const filename =
372
402
  tarballUrl.split('/').pop() ?? `${pkg.replace('@', '').replace('/', '-')}-${latest}.tgz`;
373
- writeFileSync(join(destDir, filename), Buffer.from(await tgzRes.arrayBuffer()));
403
+ const tgzPath = join(destDir, filename);
404
+ writeFileSync(tgzPath, Buffer.from(await tgzRes.arrayBuffer()));
405
+ // A truncated tarball in .npm-registry-cache fails the consumer's bun add
406
+ // with a zlib error that reads like a network fault. Check here where the
407
+ // cause (a bad fetch) is still visible.
408
+ assertGzipValid(tgzPath);
374
409
  }
375
410
 
376
411
  /**
@@ -418,10 +453,14 @@ function packageNetapp(moduleDir: string, netappsDir: string): void {
418
453
 
419
454
  let result: ReturnType<typeof spawnSync>;
420
455
  if (celiloWrapper && existsSync(celiloWrapper)) {
421
- result = spawnSync(celiloWrapper, ['package', absDir, '--output', out], { stdio: 'pipe' });
456
+ result = spawnSync(celiloWrapper, ['package', absDir, '--output', out], {
457
+ stdio: 'pipe',
458
+ timeout: PER_MODULE_PACKAGE_TIMEOUT_MS,
459
+ });
422
460
  } else if (celiloTs && existsSync(celiloTs)) {
423
461
  result = spawnSync('bun', ['run', celiloTs, 'package', absDir, '--output', out], {
424
462
  stdio: 'pipe',
463
+ timeout: PER_MODULE_PACKAGE_TIMEOUT_MS,
425
464
  });
426
465
  } else {
427
466
  console.error(` ${red}skip${reset} ${name} ${dim}(celilo CLI not found)${reset}`);
@@ -429,9 +468,32 @@ function packageNetapp(moduleDir: string, netappsDir: string): void {
429
468
  }
430
469
 
431
470
  const elapsed = Math.round((Date.now() - start) / 1000);
471
+ if (result.error && (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
472
+ console.log(`${red}✗ TIMED OUT${reset} ${dim}${elapsed}s${reset}`);
473
+ console.error(
474
+ `\n${red}Packaging ${name} exceeded ${Math.round(PER_MODULE_PACKAGE_TIMEOUT_MS / 60_000)} minutes and was killed.${reset}`,
475
+ );
476
+ console.error(
477
+ `${dim}The stall is usually transient (2026-09-04: technitium wedged two hours, artifact truncated mid-write). Delete ${out} and re-run \`cele2e build-infra\`.${reset}`,
478
+ );
479
+ process.exit(1);
480
+ }
432
481
  if (result.status !== 0) {
433
482
  console.log(`${red}✗${reset} ${dim}${result.stderr?.toString().trim()}${reset}`);
434
- return;
483
+ // A failed package must fail the build, not silently shrink the module
484
+ // set: the sim registry binds netapps/ as-is, so the omission surfaces
485
+ // later as a confusing deploy failure instead of a nameable one.
486
+ process.exit(1);
487
+ }
488
+
489
+ // Verify what we just wrote before declaring the step done. A wedge in the
490
+ // packaging stream leaves a truncated file that still exits 0 (celilo#1257).
491
+ try {
492
+ verifyNetapp(out);
493
+ } catch (err) {
494
+ console.log(`${red}✗ CORRUPT${reset} ${dim}${elapsed}s${reset}`);
495
+ console.error(`\n${red}${err instanceof Error ? err.message : String(err)}${reset}`);
496
+ process.exit(1);
435
497
  }
436
498
 
437
499
  const size = (() => {
@@ -489,7 +551,15 @@ export async function stageNetappsFromRegistry(netappsDir: string): Promise<void
489
551
  process.exit(1);
490
552
  }
491
553
  const bytes = new Uint8Array(await resp.arrayBuffer());
492
- writeFileSync(join(netappsDir, `${name}.netapp`), bytes);
554
+ const netappPath = join(netappsDir, `${name}.netapp`);
555
+ writeFileSync(netappPath, bytes);
556
+ try {
557
+ verifyNetapp(netappPath);
558
+ } catch (err) {
559
+ console.log(`${red}✗ CORRUPT${reset}`);
560
+ console.error(`${red} ${err instanceof Error ? err.message : String(err)}${reset}`);
561
+ process.exit(1);
562
+ }
493
563
  console.log(
494
564
  `${green}✔${reset} ${dim}${max_version} ${Math.round((Date.now() - t0) / 1000)}s ${(bytes.length / 1024).toFixed(0)}KB${reset}`,
495
565
  );
@@ -705,6 +775,34 @@ function saveImages(pkgDir: string): void {
705
775
  console.log('\nTo restore after colima restart:\n cele2e load');
706
776
  }
707
777
 
778
+ /**
779
+ * Collect the untagged images this build (and every build before it) left
780
+ * behind.
781
+ *
782
+ * `docker image prune -f` — no `-a`, no `system prune` — removes only images
783
+ * that carry no tag AND no container. The tagged base images every Dockerfile
784
+ * starts `FROM` are therefore untouched, which is the whole distinction: the
785
+ * warning in this repo's operating notes is about `-a` and `system prune`,
786
+ * which DO delete `ubuntu:22.04` and cost a full 27-image rebuild that then
787
+ * gets misread as a network failure (`failed to solve: … TLS handshake
788
+ * timeout`). The bare form is the opposite — it is what stops the pile that
789
+ * drives people to reach for the destructive one.
790
+ *
791
+ * Measured 2026-09-05 before this existed: 3178 dangling images, 25.8 GB, 97%
792
+ * of the local image store, mostly management images superseded by a bake.
793
+ */
794
+ function pruneSupersededImages(): void {
795
+ process.stdout.write(`${bold}Removing superseded (untagged) images...${reset} `);
796
+ try {
797
+ const out = execSync('docker image prune -f', { encoding: 'utf-8', timeout: 300_000 });
798
+ const reclaimed = out.match(/Total reclaimed space:\s*(.+)/)?.[1]?.trim() ?? '0B';
799
+ console.log(`${green}✔${reset} ${dim}${reclaimed} reclaimed${reset}`);
800
+ } catch {
801
+ // Never fail a build over housekeeping.
802
+ console.log(`${yellow}⚠ skipped${reset}`);
803
+ }
804
+ }
805
+
708
806
  export async function runBuild(options: BuildOptions): Promise<void> {
709
807
  const { pkgDir, moduleDirs, save, skipModules, published } = options;
710
808
  const netappsDir = join(pkgDir, 'netapps');
@@ -745,6 +843,21 @@ export async function runBuild(options: BuildOptions): Promise<void> {
745
843
  }
746
844
  }
747
845
 
846
+ // Final gate before anything serves these files: the sim registry binds
847
+ // netapps/ verbatim, so every artifact in it (fresh or stale from a prior
848
+ // run) must be a complete gzip stream. A truncated .netapp served to a
849
+ // consumer reports 'zlib: unexpected end of file', byte-identical to a
850
+ // truncated download, which sends the debugging at the network (celilo#1257).
851
+ try {
852
+ verifyStagedNetapps(netappsDir);
853
+ } catch (err) {
854
+ console.error(`\n${red}${err instanceof Error ? err.message : String(err)}${reset}`);
855
+ console.error(
856
+ `${dim}Delete the corrupt file(s) and re-run \`cele2e build-infra\`. If packaging reproduced the corruption, the stall was likely transient — re-run before deeper debugging.${reset}`,
857
+ );
858
+ process.exit(1);
859
+ }
860
+
748
861
  console.log(`${bold}Building E2E Docker images...${reset}\n`);
749
862
  buildDockerImages(pkgDir);
750
863
 
@@ -768,4 +881,7 @@ export async function runBuild(options: BuildOptions): Promise<void> {
768
881
  if (save) {
769
882
  saveImages(pkgDir);
770
883
  }
884
+
885
+ // Last, so it also collects the image the bake just superseded.
886
+ pruneSupersededImages();
771
887
  }
@@ -46,6 +46,11 @@ export const COMMANDS: CommandDef[] = [
46
46
  { name: '--live', description: 'Use live (non-simulated) internet' },
47
47
  { name: '--published', description: 'Use published .netapp packages' },
48
48
  { name: '--notify', description: 'Desktop notification when the run finishes' },
49
+ {
50
+ name: '--source-cli',
51
+ description:
52
+ 'Run celilo from the mounted workspace instead of the baked image (roughly doubles each command start-up)',
53
+ },
49
54
  {
50
55
  name: '--shuffle',
51
56
  description: 'Randomize test order to surface order-dependent bugs (seed is logged)',
@@ -105,6 +110,19 @@ export const COMMANDS: CommandDef[] = [
105
110
  "Check everything a run needs (baked images, base images, lock, disk) — run's implicit preflight",
106
111
  flags: [{ name: '--json', description: 'Emit the full report as JSON' }],
107
112
  },
113
+ {
114
+ name: 'host',
115
+ description: 'Bring the Docker host VM up/down in the shape the rig needs',
116
+ subcommands: [
117
+ { name: 'status', description: 'Report the VM against the policy (exit 1 if out of policy)' },
118
+ { name: 'up', description: 'Start it, or say why a running one is out of policy' },
119
+ { name: 'down', description: 'Stop it (refuses while the e2e run-lock is held)' },
120
+ {
121
+ name: 'reset',
122
+ description: 'Recreate it — the only way to change mount type. DESTROYS every image',
123
+ },
124
+ ],
125
+ },
108
126
  {
109
127
  name: 'last',
110
128
  description: "Show the most recent run's results dir and counts",
@@ -153,8 +171,11 @@ export const RUN_FLAGS = [
153
171
  '--live',
154
172
  '--published',
155
173
  '--notify',
174
+ '--source-cli',
156
175
  ];
157
176
 
177
+ export const HOST_VERBS = ['status', 'up', 'down', 'reset'];
178
+
158
179
  export const DOWN_FLAGS = ['--keep', '--all'];
159
180
 
160
181
  /** Read-only reporters whose only flag is `--json`. */
@@ -16,7 +16,7 @@ function runHelp(args = ''): string {
16
16
  }
17
17
  }
18
18
 
19
- function parseCommandsFromHelp(text: string): string[] {
19
+ export function parseCommandsFromHelp(text: string): string[] {
20
20
  const commands: string[] = [];
21
21
  let inCommandsSection = false;
22
22
 
@@ -25,7 +25,16 @@ function parseCommandsFromHelp(text: string): string[] {
25
25
  inCommandsSection = true;
26
26
  continue;
27
27
  }
28
- if (inCommandsSection && /^\s*(Options|Examples|Usage):/i.test(line)) {
28
+ // Any UNINDENTED heading ends the list. This used to name three headings
29
+ // literally (`Options:`, `Examples:`, `Usage:`), none of which the help
30
+ // actually prints — it writes `Options for \`run\`:` — so the section never
31
+ // closed and every 2-space-indented word after it was read as a command.
32
+ // Nothing showed that, because option lines start with `-` and the only
33
+ // other candidate block happened to sit after `Examples:`. It surfaced the
34
+ // day a command grew a verb list: `reset` was reported as a top-level
35
+ // command that the registry was missing. Keying on the help's real
36
+ // structure fixes the class rather than that instance.
37
+ if (inCommandsSection && /^\S.*:\s*$/.test(line)) {
29
38
  inCommandsSection = false;
30
39
  continue;
31
40
  }
@@ -7,6 +7,7 @@ import {
7
7
  BUILD_INFRA_FLAGS,
8
8
  COMMANDS,
9
9
  DOWN_FLAGS,
10
+ HOST_VERBS,
10
11
  JSON_FLAG_COMMANDS,
11
12
  RUN_FLAGS,
12
13
  UP_PRESETS,
@@ -65,6 +66,14 @@ export function getCompletions(words: string[], current: number): string[] {
65
66
  );
66
67
  }
67
68
 
69
+ if (command === 'host' && currentIndex === 1) {
70
+ return filterSuggestions(HOST_VERBS, args[1] || '');
71
+ }
72
+
73
+ if (command === 'host' && args[1] === 'reset' && currentIndex === 2) {
74
+ return filterSuggestions(['--yes'], args[2] || '');
75
+ }
76
+
68
77
  if (command === 'completion' && currentIndex === 1) {
69
78
  return filterSuggestions(['zsh', 'bash'], args[1] || '');
70
79
  }