@tikoci/quickchr 0.4.1 → 0.4.2

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/DESIGN.md CHANGED
@@ -46,13 +46,13 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
46
46
 
47
47
  How to tell it's firing: grep CI `qemu.log`/run logs for `respawning QEMU once`. A frequent occurrence, or a `BOOT_TIMEOUT` that *survives* the respawn (or appears on the `_launchExisting` path), is the signal to stop treating it as a flake and find the real cause. Tracked in `BACKLOG.md` (boot-respawn watch-item).
48
48
 
49
- 9. **Resilient downloads via public DNS** — All fetches to MikroTik's `upgrade`/`download` hosts go through `fetchResilient()` (`src/lib/net.ts`), never bare `fetch()`. The real failure mode, confirmed by an on-runner probe (2026-06-16), is that **GitHub-hosted runners' system resolver returns `ESERVFAIL` for `*.mikrotik.com`** — slowly (2–26 s) — via *both* `getaddrinfo` and c-ares-over-`resolv.conf`. So a plain `fetch` either times out resolving or (when the stub hands back only the unreachable AAAA) fails with Bun's `errno: 0` `ConnectionRefused` / `FailedToOpenSocket`. A **direct query to a public resolver (1.1.1.1 / 8.8.8.8) answers in ~10 ms**, and the hosts have a reachable IPv4. The earlier "IPv6 happy-eyeballs" theory was a red herring — the primary fault is DNS resolution at the runner's stub, not IPv6 egress.
49
+ 9. **Resilient downloads: normal `fetch` first, public-DNS IPv4 as a failback** — All fetches to MikroTik's `upgrade`/`download` hosts go through `fetchResilient()` (`src/lib/net.ts`), never bare `fetch()`. The intent is narrow: a **plain `fetch` is the path** — dual-stack (happy eyeballs), on par with curl/most tools, and honoring local DNS (`/etc/hosts` pins, VPN/split-horizon, mirror redirects, IPv6-only egress) — and we layer one **failback** beneath it to ride out *DNS misconfiguration somewhere in the environment*, wherever it comes from. We deliberately do **not** invert this (public-DNS+IPv4 first) — that would override local DNS on every machine and could introduce its own failures (e.g. an IPv4-only connect on an IPv6-only network). Normal first keeps us on par with most tools; the failback only adds smarts when the normal path actually breaks.
50
50
 
51
- Measured on the CI runner: `lookup({family:4})` → `ESERVFAIL` ~9 s; `lookup({all})` → `ESERVFAIL`/`ETIMEOUT` 22–26 s; `resolve4` (resolv.conf) → `ESERVFAIL` 2–22 s; `Resolver([1.1.1.1,8.8.8.8]).resolve4` → **OK ~10 ms**.
51
+ The trigger is precise: only when the normal `fetch` throws a **connection-class error** (`isConnectionFailure()` — the `ConnectionRefused`/`FailedToOpenSocket`/`ECONNREFUSED`-family codes, or a `TypeError` carrying Bun's `errno: 0` connect-failure marker; *not* aborts or any HTTP response) does `fetchResilient` retry. It resolves the A record by querying public DNS **directly** (a `dns.Resolver` with `setServers([1.1.1.1, 8.8.8.8, 1.0.0.1])`, bounded by a 3 s timeout so a blocked resolver doesn't stall), then connects to the IPv4 literal preserving the `Host` header and TLS SNI so certificate validation still passes. If public DNS also has no answer it surfaces the original failure. HTTP responses (incl. 5xx) and aborts (`AbortError`, e.g. from `AbortSignal.timeout`) pass through unchanged, never retried; a `TypeError` retries only with Bun's `errno: 0` connect-failure marker, so an unrelated `TypeError` (a real bug) surfaces immediately.
52
52
 
53
- `fetchResilient` resolves the A record by querying public DNS **directly** (a `dns.Resolver` with `setServers([1.1.1.1, 8.8.8.8, 1.0.0.1])`, bounded by a 3 s timeout so a blocked resolver doesn't stall), then connects to the IPv4 literal preserving the `Host` header and TLS SNI so certificate validation still passes. It falls back to a normal `fetch` (system resolver, dual-stack) only when public DNS is unavailable — e.g. a network that blocks public resolvers — so it stays correct off CI. HTTP responses and aborts/timeouts pass through unchanged, never retried. `dns.resolve4`/`getaddrinfo` are deliberately **not** used for the primary path because they inherit the runner's broken stub.
53
+ *Motivating incident (don't over-fit to it):* the failback was prompted by GitHub-hosted runners whose system resolver returned `ESERVFAIL` for `*.mikrotik.com` — slowly (2–26 s) — via *both* `getaddrinfo` and c-ares-over-`resolv.conf`, so a plain `fetch` either timed out resolving or (when the stub handed back only the unreachable AAAA) failed with Bun's `errno: 0` `ConnectionRefused` / `FailedToOpenSocket`; a direct public-resolver query answered in ~10 ms. That was one observed symptom of a broken resolver, and its deeper root cause is still unknown — so the design is framed as *generic resilience to a misconfigured/transient resolver*, not a CI-specific patch. The earlier "IPv6 happy-eyeballs" theory was a red herring (the symptom was DNS, not IPv6 egress). On-runner probe (2026-06-16): `lookup({family:4})` → `ESERVFAIL` ~9 s; `lookup({all})` → `ESERVFAIL`/`ETIMEOUT` 22–26 s; `resolve4` (resolv.conf) → `ESERVFAIL` 2–22 s; `Resolver([1.1.1.1,8.8.8.8]).resolve4` → OK ~10 ms.
54
54
 
55
- ⚠️ **Sister-project routing.** A download/version-resolve failure that only appears on CI (slow `ESERVFAIL`, or `errno: 0` from `resolveVersion`) is a **quickchr** concern, not the consuming project's — do not paper over it downstream with `/etc/hosts` pins or IPv6 toggles in the sister repo's workflow. The historical `getent ahostsv4` `/etc/hosts` workaround was both in the wrong layer and non-functional (it hit the same broken stub resolver, returning empty). Covered by `test/unit/net.test.ts`.
55
+ **Sister-project routing.** Because `fetchResilient` already absorbs a flaky resolver, a download/version-resolve failure should not be papered over downstream with `/etc/hosts` pins or IPv6 toggles in a consuming repo's workflow — fix it (or extend the failback) here in quickchr. The historical `getent ahostsv4` `/etc/hosts` workaround was both in the wrong layer and non-functional (it hit the same broken stub resolver, returning empty). The failback is best-effort, not a guarantee: it recovers connection-class DNS failures, not arbitrary network breakage. Covered by `test/unit/net.test.ts`.
56
56
 
57
57
  ## Port Layout
58
58
 
package/README.md CHANGED
@@ -331,6 +331,25 @@ console.log(chr.state.bootDiskFormat); // "qcow2"
331
331
  console.log(chr.state.extraDisks); // ["512M", "2G"]
332
332
  ```
333
333
 
334
+ ### Version & channel API
335
+
336
+ Pick which CHRs are worth booting (handy for CI matrices) without scraping CLI text:
337
+
338
+ ```typescript
339
+ import { resolveActiveChannels, resolveAllVersions } from "@tikoci/quickchr";
340
+
341
+ await resolveAllVersions();
342
+ // { stable: "7.23.1", "long-term": "7.21.4", testing: "7.23rc4", development: "7.24beta2" }
343
+
344
+ await resolveActiveChannels();
345
+ // ["stable", "long-term", "development"]
346
+ // released channels always, plus any pre-release at/ahead of stable
347
+ ```
348
+
349
+ `compareRouterOsVersion` orders pre-release suffixes (`7.24beta2` < `7.24rc1` <
350
+ `7.24` < `7.24.1`). The same map is available from the CLI via
351
+ `quickchr version --json`. See [MANUAL.md](./MANUAL.md) → *Version & channel helpers*.
352
+
334
353
  ### Use in Tests
335
354
 
336
355
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/quickchr",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "CLI and library to download, launch, and manage MikroTik CHR virtual machines via QEMU",
5
5
  "module": "src/index.ts",
6
6
  "main": "src/index.ts",
package/src/cli/index.ts CHANGED
@@ -179,7 +179,7 @@ async function main() {
179
179
  await cmdCompletions(args.slice(1));
180
180
  break;
181
181
  case "doctor":
182
- await cmdDoctor();
182
+ await cmdDoctor(args.slice(1));
183
183
  break;
184
184
  case "logs":
185
185
  await cmdLogs(args.slice(1));
@@ -194,7 +194,7 @@ async function main() {
194
194
  case "version":
195
195
  case "--version":
196
196
  case "-v":
197
- await cmdVersion();
197
+ await cmdVersion(args.slice(1));
198
198
  break;
199
199
  case "help":
200
200
  case "--help":
@@ -2429,38 +2429,37 @@ async function cmdCache(argv: string[]) {
2429
2429
  }
2430
2430
  }
2431
2431
 
2432
- async function cmdDoctor() {
2432
+ async function cmdDoctor(argv: string[] = []) {
2433
+ const { flags } = parseFlags(argv);
2434
+ const asJson = flagBool(flags, "json");
2433
2435
  const { QuickCHR } = await import("../lib/quickchr.ts");
2434
- const { statusIcon, bold } = await import("./format.ts");
2435
2436
 
2436
2437
  const result = await QuickCHR.doctor();
2437
2438
 
2439
+ if (asJson) {
2440
+ // Best-effort: cache entries older than current long-term (offline → empty).
2441
+ const staleImages = await findStaleCacheImages();
2442
+ console.log(JSON.stringify({ ok: result.ok, checks: result.checks, staleImages }, null, 2));
2443
+ if (!result.ok) process.exit(1);
2444
+ return;
2445
+ }
2446
+
2447
+ const { statusIcon, bold } = await import("./format.ts");
2438
2448
  console.log(bold("quickchr doctor\n"));
2439
2449
  for (const check of result.checks) {
2440
2450
  console.log(`${statusIcon(check.status)} ${check.label}: ${check.detail}`);
2441
2451
  }
2442
-
2443
2452
  console.log();
2444
2453
 
2445
- // Best-effort: warn about cache entries older than current long-term.
2446
- try {
2447
- const { listCacheEntries } = await import("../lib/cache.ts");
2448
- const { resolveVersion, compareRouterOsVersion } = await import("../lib/versions.ts");
2449
- const entries = listCacheEntries();
2450
- if (entries.length > 0) {
2451
- const longTerm = await resolveVersion("long-term");
2452
- const stale = entries.filter((e) => {
2453
- if (e.version === "unknown" || e.inUse) return false;
2454
- try { return compareRouterOsVersion(e.version, longTerm) < 0; } catch { return false; }
2455
- });
2456
- if (stale.length > 0) {
2457
- console.log(`Note: ${stale.length} cached image(s) older than current long-term (${longTerm}):`);
2458
- for (const e of stale) console.log(` - ${e.basename} (${e.version})`);
2459
- console.log(" Hint: 'quickchr cache prune --older-than " + longTerm + "' to evict them.");
2460
- console.log();
2461
- }
2462
- }
2463
- } catch { /* offline or no cache — silent */ }
2454
+ // Best-effort, after the checks print so the network lookup never delays them.
2455
+ const staleImages = await findStaleCacheImages();
2456
+ if (staleImages.length > 0) {
2457
+ const longTerm = staleImages[0]?.olderThan;
2458
+ console.log(`Note: ${staleImages.length} cached image(s) older than current long-term (${longTerm}):`);
2459
+ for (const e of staleImages) console.log(` - ${e.basename} (${e.version})`);
2460
+ console.log(" Hint: 'quickchr cache prune --older-than " + longTerm + "' to evict them.");
2461
+ console.log();
2462
+ }
2464
2463
 
2465
2464
  if (result.ok) {
2466
2465
  console.log("All checks passed.");
@@ -2470,12 +2469,50 @@ async function cmdDoctor() {
2470
2469
  }
2471
2470
  }
2472
2471
 
2473
- async function cmdVersion() {
2472
+ interface StaleCacheImage {
2473
+ basename: string;
2474
+ version: string;
2475
+ /** The current long-term version this image is older than. */
2476
+ olderThan: string;
2477
+ }
2478
+
2479
+ /** Cached images older than the current long-term release. Empty when offline or no cache. */
2480
+ async function findStaleCacheImages(): Promise<StaleCacheImage[]> {
2481
+ try {
2482
+ const { listCacheEntries } = await import("../lib/cache.ts");
2483
+ const { resolveVersion, compareRouterOsVersion } = await import("../lib/versions.ts");
2484
+ const entries = listCacheEntries();
2485
+ if (entries.length === 0) return [];
2486
+ const longTerm = await resolveVersion("long-term");
2487
+ return entries
2488
+ .filter((e) => {
2489
+ if (e.version === "unknown" || e.inUse) return false;
2490
+ try { return compareRouterOsVersion(e.version, longTerm) < 0; } catch { return false; }
2491
+ })
2492
+ .map((e) => ({ basename: e.basename, version: e.version, olderThan: longTerm }));
2493
+ } catch {
2494
+ return []; // offline or no cache
2495
+ }
2496
+ }
2497
+
2498
+ async function cmdVersion(argv: string[] = []) {
2499
+ const { flags } = parseFlags(argv);
2500
+ const asJson = flagBool(flags, "json");
2501
+ const { resolveAllVersions } = await import("../lib/versions.ts");
2502
+
2503
+ if (asJson) {
2504
+ // Stable machine-readable contract: { channel: version }. Offline → {}.
2505
+ let versions: Record<string, string> = {};
2506
+ try {
2507
+ versions = await resolveAllVersions();
2508
+ } catch { /* offline — emit empty object */ }
2509
+ console.log(JSON.stringify(versions));
2510
+ return;
2511
+ }
2512
+
2474
2513
  const pkg = await import("../../package.json");
2475
2514
  console.log(`quickchr ${pkg.version}`);
2476
-
2477
2515
  try {
2478
- const { resolveAllVersions } = await import("../lib/versions.ts");
2479
2516
  const versions = await resolveAllVersions();
2480
2517
  console.log("\nLatest RouterOS versions:");
2481
2518
  for (const [channel, version] of Object.entries(versions)) {
@@ -2522,8 +2559,8 @@ Commands:
2522
2559
  cache [list|prune|clear] Manage cached CHR images
2523
2560
  completions Manage shell completions (Tab completion)
2524
2561
  logs <name> Tail the QEMU log for an instance
2525
- doctor Check prerequisites
2526
- version Show version info
2562
+ doctor [--json] Check prerequisites
2563
+ version [--json] Show version info
2527
2564
  help [command] Show help
2528
2565
 
2529
2566
  Environment:
@@ -2707,11 +2744,14 @@ Print environment variables for subprocesses that connect to a running CHR.
2707
2744
  --json Output the env map as JSON instead of KEY=value lines.`);
2708
2745
  break;
2709
2746
  case "doctor":
2710
- console.log(`quickchr doctor
2747
+ console.log(`quickchr doctor [--json]
2711
2748
 
2712
2749
  Check system prerequisites: QEMU binaries, firmware, acceleration,
2713
2750
  sshpass (for package upload), qemu-img (for disk operations), data directories,
2714
- and cached images. Also reports the current shell and completion install status.`);
2751
+ and cached images. Also reports the current shell and completion install status.
2752
+
2753
+ Options:
2754
+ --json Emit { ok, checks, staleImages } as JSON. Exit code still reflects ok.`);
2715
2755
  break;
2716
2756
  case "logs":
2717
2757
  console.log(`quickchr logs <name> [options]
@@ -2842,6 +2882,16 @@ Credential resolution order (highest priority first):
2842
2882
  Omit to see a list of instances.
2843
2883
  --all Remove all instances.`);
2844
2884
  break;
2885
+ case "version":
2886
+ console.log(`quickchr version [--json]
2887
+
2888
+ Show the quickchr version and the latest RouterOS version per channel.
2889
+
2890
+ Options:
2891
+ --json Emit the latest RouterOS versions as a { channel: version } object
2892
+ (stable, long-term, testing, development). Offline emits {}.
2893
+ Omits the quickchr version line — query package.json for that.`);
2894
+ break;
2845
2895
  case "networks":
2846
2896
  case "net":
2847
2897
  showNetworksHelp();
package/src/index.ts CHANGED
@@ -130,3 +130,20 @@ export { parseForwardSpec } from "./lib/forward-spec.ts";
130
130
  // Logging
131
131
  export { createLogger } from "./lib/log.ts";
132
132
  export type { ProgressLogger } from "./lib/log.ts";
133
+
134
+ // Version & channel utilities
135
+ export { CHANNELS } from "./lib/types.ts";
136
+ export {
137
+ resolveVersion,
138
+ resolveAllVersions,
139
+ isValidVersion,
140
+ parseVersionParts,
141
+ compareRouterOsVersion,
142
+ isProvisioningSupportedVersion,
143
+ channelMaturity,
144
+ classifyChannels,
145
+ selectActiveChannels,
146
+ resolveChannelStatuses,
147
+ resolveActiveChannels,
148
+ } from "./lib/versions.ts";
149
+ export type { ChannelMaturity, ChannelStatus } from "./lib/versions.ts";
package/src/lib/net.ts CHANGED
@@ -1,22 +1,30 @@
1
1
  /**
2
2
  * Resilient HTTP fetch for MikroTik's download hosts.
3
3
  *
4
- * On GitHub-hosted CI runners the system DNS resolver returns `ESERVFAIL`
5
- * (slowly — 2 to 26 s) for `upgrade.mikrotik.com` / `download.mikrotik.com` via
6
- * both `getaddrinfo` and c-ares-over-resolv.conf, so a plain `fetch` either
7
- * times out or (when the stub hands back only the unreachable AAAA) fails with
8
- * Bun's `errno: 0` ConnectionRefused / FailedToOpenSocket. A direct query to a
9
- * public resolver answers in ~10 ms, and the hosts are dual-stack with a
10
- * reachable IPv4. quickchr is expected to run unattended in CI, so it resolves
11
- * these hosts itself rather than trusting the runner's broken resolver.
4
+ * Primary path: a plain `fetch` on the system resolver. This is tried first,
5
+ * accepting the latency cost when it fails — usually small, but up to the
6
+ * resolver's own failure time (the broken CI stub below took 2–26 s). It is
7
+ * dual-stack (happy eyeballs) and on par with curl/most tools, and it honors
8
+ * local DNS configuration —
9
+ * `/etc/hosts` pins, VPN/split-horizon DNS, mirror redirects, IPv6-only egress —
10
+ * which a forced public-DNS+IPv4 path would silently override and could itself
11
+ * turn into a fresh failure mode. So we stay "normal" unless the normal path
12
+ * actually breaks.
12
13
  *
13
- * Strategy: resolve the A record by querying a public DNS server directly
14
- * (bypassing the host's resolv.conf), then connect to the IPv4 literal,
15
- * preserving the `Host` header and TLS SNI so certificate validation still
16
- * passes. Fall back to a normal `fetch` (system resolver, dual-stack) when the
17
- * public-DNS path is unavailable — e.g. a network that blocks public resolvers
18
- * — so this stays correct off CI. HTTP responses and aborts/timeouts pass
19
- * through unchanged; they are never retried.
14
+ * Fallback: some environments (notably GitHub-hosted CI runners, observed
15
+ * 2026-06) have a broken/slow stub resolver that returns `ESERVFAIL` — slowly,
16
+ * 2 to 26 s — for `upgrade.mikrotik.com` / `download.mikrotik.com`, or hands back
17
+ * only an unreachable AAAA, surfacing as Bun's `errno: 0` ConnectionRefused /
18
+ * FailedToOpenSocket. When the normal fetch fails *that* way (a connection-class
19
+ * error), we retry by resolving the A record against a public DNS server
20
+ * directly (~10 ms), bypassing the host's resolv.conf, and connecting to the
21
+ * IPv4 literal with the `Host` header and TLS SNI preserved so certificate
22
+ * validation still passes.
23
+ *
24
+ * Only connection-class failures trigger the fallback; HTTP responses (incl.
25
+ * 5xx) and aborts (`AbortError`, e.g. from `AbortSignal.timeout`) pass through
26
+ * unchanged and are never retried. (A low-level connect `ETIMEDOUT` is a
27
+ * connection-class failure and *is* retried — see `isConnectionFailure`.)
20
28
  */
21
29
 
22
30
  import { promises as dns } from "node:dns";
@@ -28,13 +36,14 @@ const DNS_TIMEOUT_MS = 3000;
28
36
  * True for the connection-class failures raised when a socket cannot be opened
29
37
  * (e.g. IPv4 blocked on an IPv6-only network, or Bun's `errno: 0`
30
38
  * ConnectionRefused / FailedToOpenSocket against an unreachable address), plus
31
- * the standard connect errors. Excludes aborts/timeouts and HTTP-level outcomes
32
- * (those carry a Response and never throw here). Used to decide whether to fall
33
- * back from the IPv4 attempt to a normal dual-stack fetch.
39
+ * the standard connect errors. Excludes `AbortError` (aborts/`AbortSignal`
40
+ * timeouts) and HTTP-level outcomes (those carry a Response and never throw
41
+ * here). Used to decide whether to fall back from the normal fetch to the
42
+ * public-DNS IPv4 attempt.
34
43
  */
35
44
  export function isConnectionFailure(err: unknown): boolean {
36
45
  if (!err || typeof err !== "object") return false;
37
- const e = err as { name?: string; code?: string; cause?: { code?: string } };
46
+ const e = err as { name?: string; code?: string; errno?: number; cause?: { code?: string } };
38
47
  if (e.name === "AbortError") return false;
39
48
  const code = e.code ?? e.cause?.code;
40
49
  if (
@@ -49,10 +58,12 @@ export function isConnectionFailure(err: unknown): boolean {
49
58
  ) {
50
59
  return true;
51
60
  }
52
- // Bun wraps low-level connect failures as a bare TypeError ("Unable to
53
- // connect…"). Treat those as retriable too; a wrong guess only costs one
54
- // normal fetch, which then surfaces the real error if it also fails.
55
- return e.name === "TypeError";
61
+ // Bun has also surfaced low-level connect failures as a `TypeError` carrying
62
+ // `errno: 0` (its connect-failure marker; the typed `code`s above catch the
63
+ // current form — Bun 1.3 throws an `Error` with `code: "ConnectionRefused"`,
64
+ // confirmed by probe). Gate on that marker so an unrelated `TypeError` (a real
65
+ // bug) surfaces immediately instead of triggering a pointless DNS retry.
66
+ return e.name === "TypeError" && e.errno === 0;
56
67
  }
57
68
 
58
69
  /** Rewrite a URL to target an explicit IPv4 literal, preserving port/path/query. */
@@ -64,10 +75,11 @@ export function toIpv4Url(url: string, address: string): string {
64
75
 
65
76
  /**
66
77
  * Resolve a host's A record via public DNS servers directly, bypassing the
67
- * host's resolv.conf (which is broken for these names on CI runners). Returns
68
- * undefined — never throws — when public DNS is unreachable or has no answer,
69
- * so the caller can fall back to a normal fetch. Bounded by DNS_TIMEOUT_MS so a
70
- * blocked resolver does not stall the request.
78
+ * host's resolv.conf. Used only as the fallback path, after a normal fetch has
79
+ * already failed with a connection-class error (e.g. a broken stub resolver on
80
+ * CI runners). Returns undefined — never throws — when public DNS is unreachable
81
+ * or has no answer, so the caller can surface the original failure. Bounded by
82
+ * DNS_TIMEOUT_MS so a blocked resolver does not stall the request.
71
83
  */
72
84
  async function resolveIpv4(host: string): Promise<string | undefined> {
73
85
  const resolver = new dns.Resolver();
@@ -88,6 +100,12 @@ async function resolveIpv4(host: string): Promise<string | undefined> {
88
100
  }
89
101
  }
90
102
 
103
+ /**
104
+ * Fetch `url` but connect to an explicit IPv4 `address`, keeping HTTP and TLS
105
+ * pointed at the original host: the `Host` header and TLS SNI (`serverName`) are
106
+ * set to the URL's hostname so virtual-hosting routing and certificate
107
+ * validation still pass against the IP literal. The failback transport.
108
+ */
91
109
  async function fetchOverIpv4(
92
110
  url: string,
93
111
  address: string,
@@ -99,25 +117,31 @@ async function fetchOverIpv4(
99
117
  return fetch(toIpv4Url(url, address), {
100
118
  ...init,
101
119
  headers,
102
- tls: { serverName: host, ...init?.tls },
120
+ // serverName last: it must pin to the real host for cert validation against
121
+ // the IP literal — a caller-provided init.tls.serverName must not override it.
122
+ tls: { ...init?.tls, serverName: host },
103
123
  });
104
124
  }
105
125
 
106
126
  /**
107
- * `fetch()` that resolves dual-stack hosts via public DNS and connects over
108
- * IPv4, so it survives a broken/slow system resolver (e.g. GitHub-hosted CI
109
- * runners). Falls back to a normal `fetch` when public DNS is unavailable.
110
- * Behaves like `fetch` for HTTP responses and aborts.
127
+ * `fetch()` that tries the system resolver first (a normal dual-stack fetch)
128
+ * and, only when that fails with a connection-class error, falls back to
129
+ * resolving via public DNS and connecting over the IPv4 literal — so it survives
130
+ * a broken/slow system resolver (e.g. GitHub-hosted CI runners) without
131
+ * overriding local DNS in the common case. Behaves like `fetch` for HTTP
132
+ * responses and aborts.
111
133
  */
112
134
  export async function fetchResilient(url: string, init?: BunFetchRequestInit): Promise<Response> {
113
- const address = await resolveIpv4(new URL(url).hostname);
114
- if (address) {
115
- try {
116
- return await fetchOverIpv4(url, address, init);
117
- } catch (err) {
118
- if (!isConnectionFailure(err)) throw err;
119
- // IPv4 unreachable (e.g. IPv6-only egress) — fall through to a normal fetch.
120
- }
135
+ try {
136
+ return await fetch(url, init);
137
+ } catch (err) {
138
+ // Only a connection-class failure (e.g. a broken/slow stub resolver, or an
139
+ // unreachable AAAA) is worth the public-DNS workaround. Aborts (AbortError)
140
+ // and everything else propagate unchanged.
141
+ if (!isConnectionFailure(err)) throw err;
142
+ const address = await resolveIpv4(new URL(url).hostname);
143
+ // Public DNS can't help (unreachable / no answer) — surface the original failure.
144
+ if (address === undefined) throw err;
145
+ return fetchOverIpv4(url, address, init);
121
146
  }
122
- return fetch(url, init);
123
147
  }
@@ -1169,7 +1169,7 @@ export class QuickCHR {
1169
1169
  // Resolve version
1170
1170
  let version: string;
1171
1171
  if (opts.version) {
1172
- if ((CHANNELS as string[]).includes(opts.version)) {
1172
+ if ((CHANNELS as readonly string[]).includes(opts.version)) {
1173
1173
  logger.warn(
1174
1174
  `'${opts.version}' is a channel name, not a version — passed as 'version' but resolved as a channel. Use 'channel: "${opts.version}"' instead to make this explicit.`,
1175
1175
  );
package/src/lib/types.ts CHANGED
@@ -7,7 +7,8 @@ import type { ProgressLogger } from "./log.ts";
7
7
  // --- Version & Architecture ---
8
8
 
9
9
  export type Channel = "stable" | "long-term" | "testing" | "development";
10
- export const CHANNELS: Channel[] = ["stable", "long-term", "testing", "development"];
10
+ /** Canonical channel order. Frozen so neither library code nor consumers can mutate it. */
11
+ export const CHANNELS: readonly Channel[] = Object.freeze(["stable", "long-term", "testing", "development"] as Channel[]);
11
12
 
12
13
  export type Arch = "arm64" | "x86";
13
14
  export const ARCHES: Arch[] = ["arm64", "x86"];
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { Arch, Channel } from "./types.ts";
6
- import { QuickCHRError } from "./types.ts";
6
+ import { CHANNELS, QuickCHRError } from "./types.ts";
7
7
  import { fetchResilient } from "./net.ts";
8
8
 
9
9
  const UPGRADE_BASE = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
@@ -92,9 +92,8 @@ export async function resolveVersion(channel: Channel): Promise<string> {
92
92
 
93
93
  /** Fetch latest versions for all channels in parallel. */
94
94
  export async function resolveAllVersions(): Promise<Record<Channel, string>> {
95
- const channels: Channel[] = ["stable", "long-term", "testing", "development"];
96
95
  const results = await Promise.all(
97
- channels.map(async (ch) => [ch, await resolveVersion(ch)] as const),
96
+ CHANNELS.map(async (ch) => [ch, await resolveVersion(ch)] as const),
98
97
  );
99
98
  return Object.fromEntries(results) as Record<Channel, string>;
100
99
  }
@@ -104,27 +103,58 @@ export function isValidVersion(version: string): boolean {
104
103
  return /^\d+\.\d+(\.\d+)?(beta\d+|rc\d+)?$/.test(version);
105
104
  }
106
105
 
107
- /** Parse a RouterOS version into numeric parts. beta/rc suffixes are ignored for ordering. */
108
- export function parseVersionParts(version: string): [number, number, number] {
109
- const match = version.match(/^(\d+)\.(\d+)(?:\.(\d+))?(?:beta\d+|rc\d+)?$/);
106
+ /**
107
+ * RouterOS pre-release ordering rank. For a given X.Y, a beta is older than an rc,
108
+ * which is older than the final release: beta < rc < release.
109
+ */
110
+ const STAGE_RANK = { beta: 0, rc: 1, release: 2 } as const;
111
+
112
+ interface ParsedVersion {
113
+ major: number;
114
+ minor: number;
115
+ patch: number;
116
+ /** Maturity rank within X.Y: beta=0 < rc=1 < release=2. */
117
+ stage: (typeof STAGE_RANK)[keyof typeof STAGE_RANK];
118
+ /** The N in betaN/rcN (0 for release builds). */
119
+ stageNum: number;
120
+ }
121
+
122
+ /** Parse a RouterOS version into numeric parts plus pre-release stage. */
123
+ function parseVersion(version: string): ParsedVersion {
124
+ const match = version.match(/^(\d+)\.(\d+)(?:\.(\d+))?(?:(beta|rc)(\d+))?$/);
110
125
  if (!match) {
111
126
  throw new QuickCHRError("INVALID_VERSION", `Invalid version: ${version}`);
112
127
  }
113
128
 
114
- const major = Number.parseInt(match[1] ?? "0", 10);
115
- const minor = Number.parseInt(match[2] ?? "0", 10);
116
- const patch = Number.parseInt(match[3] ?? "0", 10);
129
+ const stageName = match[4] as "beta" | "rc" | undefined;
130
+ return {
131
+ major: Number.parseInt(match[1] ?? "0", 10),
132
+ minor: Number.parseInt(match[2] ?? "0", 10),
133
+ patch: Number.parseInt(match[3] ?? "0", 10),
134
+ stage: stageName ? STAGE_RANK[stageName] : STAGE_RANK.release,
135
+ stageNum: match[5] ? Number.parseInt(match[5], 10) : 0,
136
+ };
137
+ }
138
+
139
+ /** Parse a RouterOS version into numeric parts. beta/rc suffixes are ignored. */
140
+ export function parseVersionParts(version: string): [number, number, number] {
141
+ const { major, minor, patch } = parseVersion(version);
117
142
  return [major, minor, patch];
118
143
  }
119
144
 
120
- /** Compare two RouterOS versions semantically. */
145
+ /**
146
+ * Compare two RouterOS versions semantically, including pre-release suffixes:
147
+ * `7.24beta2` < `7.24rc1` < `7.24` < `7.24.1`. Returns <0, 0, or >0.
148
+ */
121
149
  export function compareRouterOsVersion(left: string, right: string): number {
122
- const [leftMajor, leftMinor, leftPatch] = parseVersionParts(left);
123
- const [rightMajor, rightMinor, rightPatch] = parseVersionParts(right);
150
+ const a = parseVersion(left);
151
+ const b = parseVersion(right);
124
152
 
125
- if (leftMajor !== rightMajor) return leftMajor - rightMajor;
126
- if (leftMinor !== rightMinor) return leftMinor - rightMinor;
127
- return leftPatch - rightPatch;
153
+ if (a.major !== b.major) return a.major - b.major;
154
+ if (a.minor !== b.minor) return a.minor - b.minor;
155
+ if (a.patch !== b.patch) return a.patch - b.patch;
156
+ if (a.stage !== b.stage) return a.stage - b.stage;
157
+ return a.stageNum - b.stageNum;
128
158
  }
129
159
 
130
160
  /** True if RouterOS version is supported for quickchr provisioning mutations. */
@@ -180,3 +210,65 @@ export function generateMachineName(
180
210
  }
181
211
  return `${base}-${n}`;
182
212
  }
213
+
214
+ // --- Channel recency ---
215
+
216
+ /** Channels that ship as finished releases vs. moving pre-release builds. */
217
+ const RELEASED_CHANNELS: readonly Channel[] = ["stable", "long-term"];
218
+
219
+ export type ChannelMaturity = "released" | "prerelease";
220
+
221
+ /** Classify a channel as a finished release line or a pre-release line. */
222
+ export function channelMaturity(channel: Channel): ChannelMaturity {
223
+ return RELEASED_CHANNELS.includes(channel) ? "released" : "prerelease";
224
+ }
225
+
226
+ export interface ChannelStatus {
227
+ channel: Channel;
228
+ /** Resolved version for the channel, e.g. "7.24beta2". */
229
+ version: string;
230
+ maturity: ChannelMaturity;
231
+ /** True if this channel's version is at or ahead of the current stable release. */
232
+ aheadOfStable: boolean;
233
+ }
234
+
235
+ /**
236
+ * Classify each channel by current recency (pure — no network).
237
+ * `aheadOfStable` is always measured against the `stable` version in `versions`.
238
+ */
239
+ export function classifyChannels(versions: Record<Channel, string>): ChannelStatus[] {
240
+ const stableVersion = versions.stable;
241
+ return CHANNELS.map((channel) => ({
242
+ channel,
243
+ version: versions[channel],
244
+ maturity: channelMaturity(channel),
245
+ aheadOfStable: compareRouterOsVersion(versions[channel], stableVersion) >= 0,
246
+ }));
247
+ }
248
+
249
+ /**
250
+ * Select the channels currently worth booting (pure — no network): every released
251
+ * channel unconditionally, plus any pre-release channel at or ahead of the reference
252
+ * channel's version (default reference: `stable`). This answers "what's worth booting,"
253
+ * never "what must pass" — gate policy stays with the consumer.
254
+ */
255
+ export function selectActiveChannels(
256
+ versions: Record<Channel, string>,
257
+ opts: { aheadOf?: Channel } = {},
258
+ ): Channel[] {
259
+ const referenceVersion = versions[opts.aheadOf ?? "stable"];
260
+ return CHANNELS.filter((channel) => {
261
+ if (channelMaturity(channel) === "released") return true;
262
+ return compareRouterOsVersion(versions[channel], referenceVersion) >= 0;
263
+ });
264
+ }
265
+
266
+ /** Fetch all channel versions and classify each by current recency. */
267
+ export async function resolveChannelStatuses(): Promise<ChannelStatus[]> {
268
+ return classifyChannels(await resolveAllVersions());
269
+ }
270
+
271
+ /** Fetch all channel versions and return the channels currently worth booting. */
272
+ export async function resolveActiveChannels(opts: { aheadOf?: Channel } = {}): Promise<Channel[]> {
273
+ return selectActiveChannels(await resolveAllVersions(), opts);
274
+ }