@celilo/e2e 0.5.1 → 0.6.1

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 (59) hide show
  1. package/README.md +2 -1
  2. package/bin/cele2e +14 -0
  3. package/bin/e2e-cache-images +57 -0
  4. package/bin/e2e-shell +36 -13
  5. package/bin/e2e-status +8 -2
  6. package/bin/e2e-up +5 -5
  7. package/completions/install-zsh-completion.sh +78 -0
  8. package/config/dns/celilo.computer.zone +16 -0
  9. package/config/dns/iamtheinternet.org.zone +7 -6
  10. package/config/dns/knot-namecheap.conf +8 -0
  11. package/config/dns/knot-tld.conf +4 -0
  12. package/config/dns/lunacycle.net.zone +16 -0
  13. package/config/dns/net.zone +11 -0
  14. package/config/dns/org.zone +5 -0
  15. package/config/dns/root.zone +2 -0
  16. package/config/dns/templates/iamtheinternet.org.zone +16 -0
  17. package/config/dns/templates/lunacycle.net.zone +16 -0
  18. package/config/resolver/unbound.conf +10 -0
  19. package/config/routing/docker-image-preload.service +14 -0
  20. package/config/routing/docker-image-preload.sh +24 -0
  21. package/config/routing/management-routes.sh +24 -2
  22. package/config/routing/resolver-internal-routes.sh +59 -0
  23. package/docker/Dockerfile.internal-resolver +16 -0
  24. package/docker/Dockerfile.isitup +37 -0
  25. package/docker/Dockerfile.management +2 -0
  26. package/docker/Dockerfile.registry +34 -0
  27. package/docker/Dockerfile.target-machine +20 -0
  28. package/docker/Dockerfile.target-machine-docker +11 -19
  29. package/docker/Dockerfile.target-technitium +97 -0
  30. package/netapps/authentik.netapp +0 -0
  31. package/netapps/caddy.netapp +0 -0
  32. package/netapps/greenwave.netapp +0 -0
  33. package/netapps/homebridge.netapp +0 -0
  34. package/netapps/iptables.netapp +0 -0
  35. package/netapps/knot-unbound-internal.netapp +0 -0
  36. package/netapps/namecheap.netapp +0 -0
  37. package/netapps/technitium.netapp +0 -0
  38. package/netapps/test-ansible-output.netapp +0 -0
  39. package/package.json +8 -1
  40. package/simulators/greenwave/state.ts +7 -12
  41. package/simulators/isitup/Caddyfile +11 -0
  42. package/simulators/isitup/entrypoint.sh +47 -0
  43. package/simulators/isitup/server.ts +112 -0
  44. package/simulators/namecheap-ddns/server.ts +31 -2
  45. package/src/cli/build.ts +214 -0
  46. package/src/cli/command-registry.ts +100 -0
  47. package/src/cli/command-tree-parser.ts +52 -0
  48. package/src/cli/completion.ts +195 -0
  49. package/src/cli/index.ts +273 -0
  50. package/src/cli/module-discovery.ts +120 -0
  51. package/src/cli/scaffold.ts +108 -0
  52. package/src/container-manager.ts +373 -76
  53. package/src/docker-compose-generator.ts +127 -23
  54. package/src/network-builder.ts +21 -0
  55. package/src/parse-line.ts +147 -0
  56. package/src/runner.ts +197 -247
  57. package/src/simulator-ips.ts +42 -0
  58. package/src/types.ts +28 -0
  59. package/bin/cache-docker-images +0 -46
@@ -1,4 +1,5 @@
1
1
  import { stringify } from 'yaml';
2
+ import { SIMULATOR_IPS } from './simulator-ips';
2
3
  import type { MachineSpec, NetworkConfig, TopologyPreset } from './types';
3
4
  import { ZONE_GATEWAYS } from './types';
4
5
 
@@ -46,6 +47,7 @@ function baseService(opts: {
46
47
  environment?: Record<string, string>;
47
48
  tmpfs?: string[];
48
49
  privileged?: boolean;
50
+ dns?: string[];
49
51
  }) {
50
52
  // Auto-assign an image tag from the Dockerfile name so Docker can
51
53
  // skip builds when pre-built images exist.
@@ -162,7 +164,7 @@ export function generateSharedInfraYaml(): string {
162
164
 
163
165
  services['root-dns'] = baseService({
164
166
  build: { context: '.', dockerfile: 'docker/Dockerfile.authoritative-dns' },
165
- networks: { 'internet-external': { ipv4_address: '100.64.0.53' } },
167
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.ROOT_DNS } },
166
168
  cap_add: ['NET_ADMIN'],
167
169
  volumes: [
168
170
  './config/dns/knot-root.conf:/config/knot.conf:ro',
@@ -172,22 +174,31 @@ export function generateSharedInfraYaml(): string {
172
174
 
173
175
  services['tld-dns'] = baseService({
174
176
  build: { context: '.', dockerfile: 'docker/Dockerfile.authoritative-dns' },
175
- networks: { 'internet-external': { ipv4_address: '100.64.0.54' } },
177
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.TLD_DNS } },
176
178
  cap_add: ['NET_ADMIN'],
177
179
  volumes: [
178
180
  './config/dns/knot-tld.conf:/config/knot.conf:ro',
179
181
  './config/dns/com.zone:/config/com.zone:ro',
180
182
  './config/dns/org.zone:/config/org.zone:ro',
183
+ './config/dns/net.zone:/config/net.zone:ro',
181
184
  ],
182
185
  });
183
186
 
184
187
  services['namecheap-dns'] = baseService({
185
188
  build: { context: '.', dockerfile: 'docker/Dockerfile.namecheap-dns' },
186
- networks: { 'internet-external': { ipv4_address: '100.64.0.55' } },
189
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.NAMECHEAP_DNS } },
187
190
  cap_add: ['NET_ADMIN'],
191
+ // Per-domain DDNS passwords for the cross-domain test. Fallback
192
+ // `test123` still applies to any domain not listed.
193
+ environment: {
194
+ DDNS_PASSWORDS:
195
+ '{"iamtheinternet.org":"test123","celilo.computer":"test456","lunacycle.net":"test789"}',
196
+ },
188
197
  volumes: [
189
198
  './config/dns/knot-namecheap.conf:/config/knot.conf:ro',
190
199
  './config/dns/iamtheinternet.org.zone:/config/iamtheinternet.org.zone',
200
+ './config/dns/celilo.computer.zone:/config/celilo.computer.zone',
201
+ './config/dns/lunacycle.net.zone:/config/lunacycle.net.zone',
191
202
  './config/dns/park-your-domain.com.zone:/config/park-your-domain.com.zone',
192
203
  ],
193
204
  });
@@ -204,7 +215,7 @@ export function generateSharedInfraYaml(): string {
204
215
 
205
216
  services.letsencrypt = baseService({
206
217
  build: { context: '.', dockerfile: 'docker/Dockerfile.pebble' },
207
- networks: { 'internet-external': { ipv4_address: '100.64.0.100' } },
218
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.PEBBLE } },
208
219
  cap_add: ['NET_ADMIN'],
209
220
  command: '-config /config/pebble-config.json -dnsserver 100.100.0.1:53',
210
221
  volumes: [
@@ -214,6 +225,28 @@ export function generateSharedInfraYaml(): string {
214
225
  ],
215
226
  });
216
227
 
228
+ // Registry stand-in. Context widened to `..` (the `packages/` dir) so the
229
+ // Dockerfile can COPY from packages/registry-server (single source of truth
230
+ // shared with the celilo-registry module).
231
+ services.registry = baseService({
232
+ build: { context: '..', dockerfile: 'e2e/docker/Dockerfile.registry' },
233
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.REGISTRY } },
234
+ cap_add: ['NET_ADMIN'],
235
+ volumes: ['../../modules:/modules:ro'],
236
+ });
237
+
238
+ // External website prober simulator — mimics isitup.org's /api.json endpoint.
239
+ // Sits on internet-external so probe requests exercise the real public-facing
240
+ // path (target domain → DNS → ISP → firewall → Caddy). Uses comcast-resolver
241
+ // at 100.100.0.1 as DNS so it can recursively resolve the simulated public
242
+ // zones (e.g. iamtheinternet.org).
243
+ services.isitup = baseService({
244
+ build: { context: '.', dockerfile: 'docker/Dockerfile.isitup' },
245
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.ISITUP } },
246
+ cap_add: ['NET_ADMIN'],
247
+ dns: ['100.100.0.1'],
248
+ });
249
+
217
250
  const compose = { networks, volumes, services };
218
251
  return stringify(compose, { lineWidth: 120 });
219
252
  }
@@ -252,7 +285,8 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
252
285
  const services: Record<string, unknown> = {};
253
286
 
254
287
  // --- Management (always present) ---
255
- const managementDefaultGw = config.topology === 'direct-internet' ? '192.168.0.254' : '192.168.0.1';
288
+ const managementDefaultGw =
289
+ config.topology === 'direct-internet' ? '192.168.0.254' : '192.168.0.1';
256
290
 
257
291
  services.management = baseService({
258
292
  build: { context: '.', dockerfile: 'docker/Dockerfile.management' },
@@ -264,7 +298,28 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
264
298
  ...(config.managementVolumes ?? []),
265
299
  ],
266
300
  environment: {
267
- DDNS_ENDPOINT: 'http://100.64.0.55:8080/update',
301
+ DDNS_ENDPOINT: `http://${SIMULATOR_IPS.NAMECHEAP_DNS}:8080/update`,
302
+ DEFAULT_GATEWAY: managementDefaultGw,
303
+ CELILO_REGISTRY_URL: 'http://e2e-registry.lab',
304
+ },
305
+ });
306
+
307
+ // --- Internal split-horizon DNS resolver ---
308
+ // Resolves the test domain to caddy's internal DMZ IP so management can
309
+ // reach it via a direct route rather than through the external interface
310
+ // (hairpin NAT would be needed otherwise). Management already has
311
+ // nameserver 192.168.0.10 as its primary resolver in /etc/resolv.conf.
312
+ const caddyIp =
313
+ config.dmzMachines.find((m) => m.name === 'caddy')?.ip ??
314
+ config.dmzMachines[0]?.ip ??
315
+ '10.0.10.10';
316
+ services['dns-int'] = baseService({
317
+ build: { context: '.', dockerfile: 'docker/Dockerfile.internal-resolver' },
318
+ networks: { internal: { ipv4_address: '192.168.0.10' } },
319
+ cap_add: ['NET_ADMIN'],
320
+ environment: {
321
+ CADDY_IP: caddyIp,
322
+ DOMAIN: config.domain ?? 'iamtheinternet.org',
268
323
  DEFAULT_GATEWAY: managementDefaultGw,
269
324
  },
270
325
  });
@@ -306,13 +361,18 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
306
361
  const zoneNetwork = machine.zone;
307
362
  const gateway = ZONE_GATEWAYS[machine.zone];
308
363
 
309
- const isAppZone = machine.zone === 'app';
310
- const dockerfile = isAppZone
311
- ? 'docker/Dockerfile.target-machine-docker'
312
- : 'docker/Dockerfile.target-machine';
364
+ const needsDocker = machine.zone === 'app' || machine.docker === true;
365
+ const dockerfile =
366
+ machine.dockerfile ||
367
+ (needsDocker
368
+ ? 'docker/Dockerfile.target-machine-docker'
369
+ : 'docker/Dockerfile.target-machine');
313
370
 
314
371
  const machineVolumes = ['ssh-keys:/ssh-keys:ro'];
315
- if (isAppZone) {
372
+
373
+ // Mount the host image cache into Docker-capable machines so the
374
+ // docker-image-preload service can load them at startup without pulling.
375
+ if (needsDocker) {
316
376
  machineVolumes.push('./docker-image-cache:/docker-image-cache:ro');
317
377
  }
318
378
 
@@ -364,7 +424,8 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
364
424
 
365
425
  const services: Record<string, unknown> = {};
366
426
 
367
- const managementDefaultGw = config.topology === 'direct-internet' ? '192.168.0.254' : '192.168.0.1';
427
+ const managementDefaultGw =
428
+ config.topology === 'direct-internet' ? '192.168.0.254' : '192.168.0.1';
368
429
 
369
430
  services.management = baseService({
370
431
  build: { context: '.', dockerfile: 'docker/Dockerfile.management' },
@@ -376,14 +437,30 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
376
437
  ...(config.managementVolumes ?? []),
377
438
  ],
378
439
  environment: {
379
- DDNS_ENDPOINT: 'http://100.64.0.55:8080/update',
440
+ DDNS_ENDPOINT: `http://${SIMULATOR_IPS.NAMECHEAP_DNS}:8080/update`,
380
441
  DEFAULT_GATEWAY: managementDefaultGw,
442
+ CELILO_REGISTRY_URL: 'http://e2e-registry.lab',
381
443
  },
382
444
  });
383
445
 
384
446
  const topologyFn = TOPOLOGY_SERVICES[config.topology] || TOPOLOGY_SERVICES.default;
385
447
  Object.assign(services, topologyFn());
386
448
 
449
+ const legacyCaddyIp =
450
+ config.dmzMachines.find((m) => m.name === 'caddy')?.ip ??
451
+ config.dmzMachines[0]?.ip ??
452
+ '10.0.10.10';
453
+ services['dns-int'] = baseService({
454
+ build: { context: '.', dockerfile: 'docker/Dockerfile.internal-resolver' },
455
+ networks: { internal: { ipv4_address: '192.168.0.10' } },
456
+ cap_add: ['NET_ADMIN'],
457
+ environment: {
458
+ CADDY_IP: legacyCaddyIp,
459
+ DOMAIN: config.domain ?? 'iamtheinternet.org',
460
+ DEFAULT_GATEWAY: managementDefaultGw,
461
+ },
462
+ });
463
+
387
464
  services['fw-ext'] = baseService({
388
465
  build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
389
466
  networks: {
@@ -406,7 +483,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
406
483
 
407
484
  services['root-dns'] = baseService({
408
485
  build: { context: '.', dockerfile: 'docker/Dockerfile.authoritative-dns' },
409
- networks: { 'internet-external': { ipv4_address: '100.64.0.53' } },
486
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.ROOT_DNS } },
410
487
  cap_add: ['NET_ADMIN'],
411
488
  volumes: [
412
489
  './config/dns/knot-root.conf:/config/knot.conf:ro',
@@ -416,22 +493,31 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
416
493
 
417
494
  services['tld-dns'] = baseService({
418
495
  build: { context: '.', dockerfile: 'docker/Dockerfile.authoritative-dns' },
419
- networks: { 'internet-external': { ipv4_address: '100.64.0.54' } },
496
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.TLD_DNS } },
420
497
  cap_add: ['NET_ADMIN'],
421
498
  volumes: [
422
499
  './config/dns/knot-tld.conf:/config/knot.conf:ro',
423
500
  './config/dns/com.zone:/config/com.zone:ro',
424
501
  './config/dns/org.zone:/config/org.zone:ro',
502
+ './config/dns/net.zone:/config/net.zone:ro',
425
503
  ],
426
504
  });
427
505
 
428
506
  services['namecheap-dns'] = baseService({
429
507
  build: { context: '.', dockerfile: 'docker/Dockerfile.namecheap-dns' },
430
- networks: { 'internet-external': { ipv4_address: '100.64.0.55' } },
508
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.NAMECHEAP_DNS } },
431
509
  cap_add: ['NET_ADMIN'],
510
+ // Per-domain DDNS passwords for the cross-domain test. Fallback
511
+ // `test123` still applies to any domain not listed.
512
+ environment: {
513
+ DDNS_PASSWORDS:
514
+ '{"iamtheinternet.org":"test123","celilo.computer":"test456","lunacycle.net":"test789"}',
515
+ },
432
516
  volumes: [
433
517
  './config/dns/knot-namecheap.conf:/config/knot.conf:ro',
434
518
  './config/dns/iamtheinternet.org.zone:/config/iamtheinternet.org.zone',
519
+ './config/dns/celilo.computer.zone:/config/celilo.computer.zone',
520
+ './config/dns/lunacycle.net.zone:/config/lunacycle.net.zone',
435
521
  './config/dns/park-your-domain.com.zone:/config/park-your-domain.com.zone',
436
522
  ],
437
523
  });
@@ -448,7 +534,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
448
534
 
449
535
  services.letsencrypt = baseService({
450
536
  build: { context: '.', dockerfile: 'docker/Dockerfile.pebble' },
451
- networks: { 'internet-external': { ipv4_address: '100.64.0.100' } },
537
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.PEBBLE } },
452
538
  cap_add: ['NET_ADMIN'],
453
539
  command: '-config /config/pebble-config.json -dnsserver 100.100.0.1:53',
454
540
  volumes: [
@@ -458,6 +544,16 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
458
544
  ],
459
545
  });
460
546
 
547
+ // Registry stand-in. Context widened to `..` (the `packages/` dir) so the
548
+ // Dockerfile can COPY from packages/registry-server (single source of truth
549
+ // shared with the celilo-registry module).
550
+ services.registry = baseService({
551
+ build: { context: '..', dockerfile: 'e2e/docker/Dockerfile.registry' },
552
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.REGISTRY } },
553
+ cap_add: ['NET_ADMIN'],
554
+ volumes: ['../../modules:/modules:ro'],
555
+ });
556
+
461
557
  const allMachines = [
462
558
  ...config.dmzMachines,
463
559
  ...config.appMachines,
@@ -469,13 +565,16 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
469
565
  const zoneNetwork = machine.zone;
470
566
  const gateway = ZONE_GATEWAYS[machine.zone];
471
567
 
472
- const isAppZone = machine.zone === 'app';
473
- const dockerfile = isAppZone
474
- ? 'docker/Dockerfile.target-machine-docker'
475
- : 'docker/Dockerfile.target-machine';
568
+ const needsDocker = machine.zone === 'app' || machine.docker === true;
569
+ const dockerfile =
570
+ machine.dockerfile ||
571
+ (needsDocker
572
+ ? 'docker/Dockerfile.target-machine-docker'
573
+ : 'docker/Dockerfile.target-machine');
476
574
 
477
575
  const machineVolumes = ['ssh-keys:/ssh-keys:ro'];
478
- if (isAppZone) {
576
+
577
+ if (needsDocker) {
479
578
  machineVolumes.push('./docker-image-cache:/docker-image-cache:ro');
480
579
  }
481
580
 
@@ -502,5 +601,10 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
502
601
  }
503
602
 
504
603
  export function getAllMachines(config: NetworkConfig): MachineSpec[] {
505
- return [...config.dmzMachines, ...config.appMachines, ...config.secureMachines, ...config.internalMachines];
604
+ return [
605
+ ...config.dmzMachines,
606
+ ...config.appMachines,
607
+ ...config.secureMachines,
608
+ ...config.internalMachines,
609
+ ];
506
610
  }
@@ -78,6 +78,27 @@ export class NetworkBuilder {
78
78
  return this;
79
79
  }
80
80
 
81
+ /** Add internal-zone machines that need the Docker-capable image (e.g., Technitium) */
82
+ internalDocker(machines: Record<string, string>): this {
83
+ for (const [name, ip] of Object.entries(machines)) {
84
+ this.config.internalMachines.push({ name, ip, zone: 'internal', docker: true });
85
+ }
86
+ return this;
87
+ }
88
+
89
+ /** Add a Technitium DNS machine using the dedicated pre-installed image */
90
+ technitium(machines: Record<string, string>): this {
91
+ for (const [name, ip] of Object.entries(machines)) {
92
+ this.config.internalMachines.push({
93
+ name,
94
+ ip,
95
+ zone: 'internal',
96
+ dockerfile: 'docker/Dockerfile.target-technitium',
97
+ });
98
+ }
99
+ return this;
100
+ }
101
+
81
102
  withDhcpClient(): this {
82
103
  this.config.dhcpClient = true;
83
104
  return this;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Line parser for the e2e test runner.
3
+ *
4
+ * Translates celilo CLI output lines into progress display calls.
5
+ * Extracted as a separate module so it can be unit-tested without
6
+ * spinning up Docker or a full ProgressDisplay.
7
+ */
8
+
9
+ const dim = '\x1b[2m';
10
+ const green = '\x1b[32m';
11
+ const red = '\x1b[31m';
12
+ const reset = '\x1b[0m';
13
+
14
+ export interface DisplaySink {
15
+ startStep(doing: string, done: string): void;
16
+ doneStep(override?: string): void;
17
+ failStep(message: string): void;
18
+ instantEvent(message: string): void;
19
+ subEvent(message: string): void;
20
+ }
21
+
22
+ export function parseLine(line: string, display: DisplaySink): void {
23
+ // Protocol: [progress:start] doing | done
24
+ const startMatch = line.match(/\[progress:start\] (.*?) \| (.*)/);
25
+ if (startMatch) {
26
+ display.startStep(startMatch[1], startMatch[2]);
27
+ return;
28
+ }
29
+
30
+ // Protocol: [progress:done] optional message
31
+ const doneMatch = line.match(/\[progress:done\](.*)/);
32
+ if (doneMatch) {
33
+ const msg = doneMatch[1].trim();
34
+ display.doneStep(msg || undefined);
35
+ return;
36
+ }
37
+
38
+ // Protocol: [progress:fail] message
39
+ const failMatch = line.match(/\[progress:fail\] (.*)/);
40
+ if (failMatch) {
41
+ display.failStep(failMatch[1]);
42
+ return;
43
+ }
44
+
45
+ // Protocol: [progress:sub] message (sub-event under the current step)
46
+ const subMatch = line.match(/\[progress:sub\] (.*)/);
47
+ if (subMatch) {
48
+ display.subEvent(subMatch[1]);
49
+ return;
50
+ }
51
+
52
+ // Legacy: [progress] message (treat as instant event)
53
+ const legacyMatch = line.match(/\[progress\] (.*)/);
54
+ if (legacyMatch) {
55
+ display.instantEvent(legacyMatch[1]);
56
+ return;
57
+ }
58
+
59
+ const deployMatch = line.match(/Module '([^']+)' deployed successfully/);
60
+ if (deployMatch) {
61
+ display.subEvent(`${green}✔${reset} ${deployMatch[1]} deployed`);
62
+ return;
63
+ }
64
+
65
+ const importMatch = line.match(/Successfully imported module: (\S+)/);
66
+ if (importMatch) {
67
+ display.subEvent(`${green}✔${reset} ${importMatch[1]} imported`);
68
+ return;
69
+ }
70
+
71
+ if (line.includes('Deploying software')) {
72
+ display.subEvent('running Ansible');
73
+ return;
74
+ }
75
+ if (line.includes('Running on_install hook') || line.includes('on_install hook')) {
76
+ display.subEvent('running on_install hook');
77
+ return;
78
+ }
79
+ if (line.includes('Software deployed') || line.includes('Software deployed successfully')) {
80
+ display.subEvent(`${green}✔${reset} Ansible done`);
81
+ return;
82
+ }
83
+ if (line.includes('Infrastructure selected')) {
84
+ display.subEvent('infrastructure selected');
85
+ return;
86
+ }
87
+ if (line.includes('Terraform skipped')) {
88
+ display.subEvent('terraform skipped (existing machine)');
89
+ return;
90
+ }
91
+ if (line.includes('terraform init') || line.includes('Terraform init')) {
92
+ display.subEvent('running terraform init');
93
+ return;
94
+ }
95
+ if (line.includes('terraform apply') || line.includes('Terraform apply')) {
96
+ display.subEvent('running terraform apply');
97
+ return;
98
+ }
99
+
100
+ const retryMatch = line.match(/RETRYING.*?(\d+) retries left/);
101
+ if (retryMatch) {
102
+ display.subEvent(`health check (${retryMatch[1]} retries left)`);
103
+ return;
104
+ }
105
+
106
+ // Ansible progress markers emitted via process.stderr in deploy-ansible.ts
107
+ if (line.startsWith('[ansible:play] ')) {
108
+ display.subEvent(`${dim}▶${reset} ${line.slice(15)}`);
109
+ return;
110
+ }
111
+ if (line.startsWith('[ansible:task] ')) {
112
+ display.subEvent(` • ${line.slice(15)}`);
113
+ return;
114
+ }
115
+ if (line.startsWith('[ansible:handler] ')) {
116
+ display.subEvent(` ↺ ${line.slice(18)}`);
117
+ return;
118
+ }
119
+ if (line.startsWith('[ansible:recap] ')) {
120
+ const recap = line.slice(16);
121
+ const failed = recap.match(/failed=(\d+)/)?.[1];
122
+ const icon = failed && failed !== '0' ? `${red}✗${reset}` : `${green}✔${reset}`;
123
+ display.subEvent(`${icon} ${recap}`);
124
+ return;
125
+ }
126
+
127
+ // Hook log output: "[moduleId:hookName] message"
128
+ const hookLogMatch = line.match(/^\[[\w-]+:[\w_-]+\] (.*)/);
129
+ if (hookLogMatch) {
130
+ display.subEvent(`${dim}hook:${reset} ${hookLogMatch[1]}`);
131
+ return;
132
+ }
133
+
134
+ // Error lines. Require the identifier ending in "Error:" to be either
135
+ // standalone ("Error:") or PascalCase ("CeliloCommandError:") — NOT a
136
+ // camelCase variable like "stageError:" that bun prints as source context
137
+ // for failing tests.
138
+ const errorMatch = line.match(/\b(?:[A-Z]\w*)?Error: (.*)/);
139
+ if (
140
+ errorMatch &&
141
+ !line.includes('bun test') &&
142
+ !line.includes('ipv4: Address already')
143
+ ) {
144
+ const err = errorMatch[1].slice(0, 70);
145
+ display.subEvent(`${red}✗${reset} ${err}`);
146
+ }
147
+ }