@tikoci/quickchr 0.4.6 → 0.4.8

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/lib/state.ts CHANGED
@@ -7,6 +7,7 @@ import { join, } from "node:path";
7
7
  import type { MachineState, } from "./types.ts";
8
8
  import { QuickCHRError } from "./types.ts";
9
9
  import { networkModeToConfigs } from "./network.ts";
10
+ import { assertPathSafeName, isPathSafeName } from "./names.ts";
10
11
 
11
12
  /** Get the quickchr data directory root. Override with QUICKCHR_DATA_DIR env var. */
12
13
  export function getDataDir(): string {
@@ -91,12 +92,72 @@ export function saveMachine(state: MachineState): void {
91
92
  writeFileSync(machineJsonPath(state.name), JSON.stringify(state, null, "\t") + "\n");
92
93
  }
93
94
 
94
- /** Load machine state from disk. Returns undefined if not found. */
95
+ /** The fields every caller dereferences without guarding, with the `typeof` each must
96
+ * answer. Deliberately not a schema: it is the set that turns a bad file into a crash
97
+ * somewhere else, which is the thing being prevented. `cpu`/`mem` are absent because a
98
+ * missing one prints oddly rather than throwing, and a narrow check cannot wrongly
99
+ * condemn a machine that works today. `networks` is absent because `loadMachine()`
100
+ * migrates it just below.
101
+ *
102
+ * Every field listed here has been required on `MachineState` since the commit that
103
+ * introduced it (5cfe052), so no `machine.json` any version of quickchr wrote can be
104
+ * condemned by this check — which is the thing that would make it worse than the bug
105
+ * it fixes. */
106
+ const REQUIRED_STATE_FIELDS: ReadonlyArray<[keyof MachineState, "string" | "number" | "object"]> = [
107
+ ["name", "string"],
108
+ ["version", "string"],
109
+ ["arch", "string"],
110
+ ["status", "string"],
111
+ ["machineDir", "string"],
112
+ ["portBase", "number"],
113
+ ["ports", "object"],
114
+ ["packages", "object"],
115
+ ];
116
+
117
+ /** Why `parsed` is not usable as a `MachineState`, or `undefined` when it is.
118
+ *
119
+ * Parsing successfully is not the same as being state. `{"name":"lab"}` parses, and
120
+ * `JSON.parse` is happy with `null` and `[]` too; casting any of them to `MachineState`
121
+ * only moves the failure — `{"name":"lab"}` reached `formatPorts(m.ports)` and aborted
122
+ * `quickchr list` exactly as an unparseable file used to (#165). */
123
+ function stateShapeProblem(parsed: unknown): string | undefined {
124
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
125
+ return "not a JSON object";
126
+ }
127
+ const record = parsed as Record<string, unknown>;
128
+ const bad = REQUIRED_STATE_FIELDS
129
+ .filter(([key, kind]) => typeof record[key] !== kind || record[key] === null)
130
+ .map(([key]) => key);
131
+ return bad.length > 0 ? `missing or invalid: ${bad.join(", ")}` : undefined;
132
+ }
133
+
134
+ /** Load machine state from disk. Returns undefined if not found.
135
+ *
136
+ * Throws `STATE_ERROR` when the file is there but unusable. A targeted lookup by name
137
+ * has to fail: the caller named *this* machine, and answering `undefined` would report
138
+ * a machine that exists on disk as one that never did. `loadAllMachines()` is the
139
+ * enumeration and takes the other policy — see `tryLoadMachine()` (#165). */
95
140
  export function loadMachine(name: string): MachineState | undefined {
96
141
  const path = machineJsonPath(name);
97
142
  if (!existsSync(path)) return undefined;
98
- const data = readFileSync(path, "utf-8");
99
- const state = JSON.parse(data) as MachineState;
143
+ let state: MachineState;
144
+ try {
145
+ state = JSON.parse(readFileSync(path, "utf-8")) as MachineState;
146
+ } catch (e) {
147
+ throw new QuickCHRError(
148
+ "STATE_ERROR",
149
+ `Machine "${name}" has an unreadable machine.json (${e instanceof Error ? e.message : String(e)})`,
150
+ `Inspect ${path}, or clear the machine with 'quickchr remove ${name}'`,
151
+ );
152
+ }
153
+ const shapeProblem = stateShapeProblem(state);
154
+ if (shapeProblem) {
155
+ throw new QuickCHRError(
156
+ "STATE_ERROR",
157
+ `Machine "${name}" has an unreadable machine.json (${shapeProblem})`,
158
+ `Inspect ${path}, or clear the machine with 'quickchr remove ${name}'`,
159
+ );
160
+ }
100
161
  // Migrate legacy `network` field → `networks` array
101
162
  if (!state.networks && (state as unknown as Record<string, unknown>).network) {
102
163
  const legacy = (state as unknown as Record<string, unknown>).network as MachineState["networks"][0]["specifier"] | "user" | "vmnet-shared" | { type: "vmnet-bridge"; iface: string };
@@ -107,7 +168,33 @@ export function loadMachine(name: string): MachineState | undefined {
107
168
  return state;
108
169
  }
109
170
 
110
- /** Load all machine states. */
171
+ /** The three things a machine directory can turn out to be.
172
+ *
173
+ * `loadMachine()` collapses `missing` and `unreadable` into "undefined or throw", which
174
+ * is the right contract for a lookup and the wrong one for an enumeration. This keeps
175
+ * them apart so each caller can pick (#165). Internal for now: promoting it to public
176
+ * API is a rename if #58 lands. */
177
+ type MachineLoad =
178
+ | { status: "ok"; state: MachineState }
179
+ | { status: "missing" }
180
+ | { status: "unreadable"; error: string };
181
+
182
+ /** `loadMachine()` without the throw. */
183
+ function tryLoadMachine(name: string): MachineLoad {
184
+ try {
185
+ const state = loadMachine(name);
186
+ return state ? { status: "ok", state } : { status: "missing" };
187
+ } catch (e) {
188
+ return { status: "unreadable", error: e instanceof Error ? e.message : String(e) };
189
+ }
190
+ }
191
+
192
+ /** Load all machine states, skipping directories with no usable `machine.json`.
193
+ *
194
+ * An enumeration is best-effort across many: one corrupt file used to abort the whole
195
+ * listing, which loses the state of every healthy machine as well. Nothing is hidden by
196
+ * the skip — `listUnreadableMachines()` names what was left out, and `quickchr list`
197
+ * shows it as a row (#165). */
111
198
  export function loadAllMachines(): MachineState[] {
112
199
  const dir = getMachinesDir();
113
200
  if (!existsSync(dir)) return [];
@@ -117,13 +204,27 @@ export function loadAllMachines(): MachineState[] {
117
204
 
118
205
  for (const entry of entries) {
119
206
  if (!entry.isDirectory()) continue;
120
- const state = loadMachine(entry.name);
121
- if (state) machines.push(state);
207
+ const loaded = tryLoadMachine(entry.name);
208
+ if (loaded.status === "ok") machines.push(loaded.state);
122
209
  }
123
210
 
124
211
  return machines;
125
212
  }
126
213
 
214
+ /** Every machine directory whose `machine.json` exists but cannot be read.
215
+ *
216
+ * Distinct from `listOrphanMachineDirs()`, which also counts a directory with no
217
+ * `machine.json` at all — a create that never finished. This one is a machine that did
218
+ * exist and whose state went bad, so it still holds a disk image worth naming. */
219
+ export function listUnreadableMachines(): Array<{ name: string; error: string }> {
220
+ const out: Array<{ name: string; error: string }> = [];
221
+ for (const name of listMachineNames()) {
222
+ const loaded = tryLoadMachine(name);
223
+ if (loaded.status === "unreadable") out.push({ name, error: loaded.error });
224
+ }
225
+ return out;
226
+ }
227
+
127
228
  /** Get all existing machine names. */
128
229
  export function listMachineNames(): string[] {
129
230
  const dir = getMachinesDir();
@@ -133,13 +234,53 @@ export function listMachineNames(): string[] {
133
234
  .map((e) => e.name);
134
235
  }
135
236
 
237
+ /** True when `name` has a machine directory but no readable `machine.json` — a create
238
+ * that did not finish. `list` cannot show it and `start` cannot boot it, but
239
+ * `listMachineNames()` (directory-based) still counts it, so it blocks re-add (#155). */
240
+ export function isOrphanMachineDir(name: string): boolean {
241
+ // A traversal name resolves to a directory that exists and holds no machine.json —
242
+ // the data dir itself, for ".." — so the path check has to come first or the
243
+ // predicate answers "orphan" about something that is not a machine directory.
244
+ if (!isPathSafeName(name)) return false;
245
+ const dir = getMachineDir(name);
246
+ if (!existsSync(dir)) return false;
247
+ return !isReadableMachine(name);
248
+ }
249
+
250
+ /** Every machine directory with no readable `machine.json`. */
251
+ export function listOrphanMachineDirs(): string[] {
252
+ return listMachineNames().filter((name) => !isReadableMachine(name));
253
+ }
254
+
255
+ /** A truncated or corrupt `machine.json` is as unusable as a missing one, and both make
256
+ * the directory an orphan. */
257
+ function isReadableMachine(name: string): boolean {
258
+ return tryLoadMachine(name).status === "ok";
259
+ }
260
+
136
261
  /** Get all port bases currently in use by existing machines. */
137
262
  export function getUsedPortBases(): number[] {
138
263
  return loadAllMachines().map((m) => m.portBase);
139
264
  }
140
265
 
266
+ /** Every NIC MAC currently assigned across all machines.
267
+ * Passed to `assignMacs()` so a new machine cannot be handed an address another
268
+ * machine already presents on a shared L2 segment (#154). */
269
+ export function getUsedMacs(): Set<string> {
270
+ const macs = new Set<string>();
271
+ for (const m of loadAllMachines()) {
272
+ for (const n of m.networks ?? []) {
273
+ if (n.mac) macs.add(n.mac);
274
+ }
275
+ }
276
+ return macs;
277
+ }
278
+
141
279
  /** Delete a machine and all its files. */
142
280
  export function removeMachine(name: string): void {
281
+ // This is an `rmSync(..., { recursive: true })`; the name must never be able to
282
+ // point outside machines/<name>/.
283
+ assertPathSafeName(name, "machine");
143
284
  const dir = getMachineDir(name);
144
285
  if (!existsSync(dir)) {
145
286
  throw new QuickCHRError("MACHINE_NOT_FOUND", `Machine "${name}" not found`);
package/src/lib/types.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ProgressLogger } from "./log.ts";
6
+ import type { BootProbeStats } from "./diagnostics.ts";
6
7
 
7
8
  // --- Version & Architecture ---
8
9
 
@@ -54,13 +55,14 @@ export type NetworkMode =
54
55
  * - **Host needs to see guest L2 frames** (MNDP, MAC-Telnet, raw Ethernet,
55
56
  * broadcasts) → `{type:"socket-connect", port}` against a host TCP server
56
57
  * (rootless, cross-platform; recipe in `docs/mndp.md`).
57
- * - **L2 link between two VMs** → `{type:"socket", name}` (named pair) or
58
- * `socket-listen`/`socket-connect`/`socket-mcast` (low-level).
58
+ * - **L2 link between two VMs** → `{type:"socket", name}`, a named link whose
59
+ * transport (`dgram`, `listen-connect`, `mcast`) is fixed when the name is created;
60
+ * or `socket-listen`/`socket-connect`/`socket-mcast` for a raw netdev.
59
61
  * - **Real LAN / DHCP-from-host presence** → `"shared"` or `{type:"bridged", iface}`
60
62
  * (rootless via socket_vmnet on macOS / pre-created TAP on Linux).
61
63
  *
62
64
  * Note: receiving guest-*originated* UDP (e.g. a guest replying to `10.0.2.2`)
63
- * needs no extra NIC and no forward — see {@link ChrInstance.tzspGatewayIp}.
65
+ * needs no extra NIC and no forward — see {@link ChrInstance.hostGatewayIp}.
64
66
  */
65
67
  export type NetworkSpecifier =
66
68
  /** SLIRP user-mode NAT + `hostfwd` port mapping. Management default; the only
@@ -105,6 +107,12 @@ export interface NetworkConfig {
105
107
  specifier: NetworkSpecifier;
106
108
  /** QEMU netdev id (net0, net1, ...). */
107
109
  id: string;
110
+ /** Locally-administered MAC, assigned at creation and persisted so it survives
111
+ * restart. Without it QEMU hands every guest the same default sequence and two
112
+ * machines on one L2 segment collide — see `deriveMac` in `network.ts` (#154).
113
+ * Never reassigned: changing it on a booted machine orphans RouterOS's interface
114
+ * identity. Absent on machines created before #154, which keep QEMU's defaults. */
115
+ mac?: string;
108
116
  /** Resolved QEMU args (populated at launch time, not persisted). */
109
117
  resolved?: ResolvedNetwork;
110
118
  }
@@ -169,6 +177,11 @@ export const SERVICE_NAMES = Object.keys(SERVICE_PORTS) as ServiceName[];
169
177
  export const PORTS_PER_BLOCK = 10;
170
178
  export const DEFAULT_PORT_BASE = 9100;
171
179
 
180
+ /** QEMU user-mode (SLIRP) gateway — the host's address as seen from inside a guest.
181
+ * Fixed by QEMU's built-in 10.0.2.0/24 user network; surfaced on a running instance
182
+ * as {@link ChrInstance.hostGatewayIp}. */
183
+ export const HOST_GATEWAY_IP = "10.0.2.2";
184
+
172
185
  /**
173
186
  * TCP endpoint for QEMU IPC channels on Windows.
174
187
  * Used instead of named pipes when QEMU's Winsock bind() can't handle \\.\pipe\ paths.
@@ -340,7 +353,7 @@ export interface StartOptions {
340
353
  * {@link lookupGuestPort} resolve guest port + proto for common service names.
341
354
  *
342
355
  * For *receiving* guest-originated UDP (no forward needed), see
343
- * {@link ChrInstance.tzspGatewayIp} and `docs/networking-recipes.md`.
356
+ * {@link ChrInstance.hostGatewayIp} and `docs/networking-recipes.md`.
344
357
  * @example
345
358
  * extraPorts: [{ name: "snmp", host: 9161, guest: 161, proto: "udp" }]
346
359
  * @example
@@ -590,15 +603,26 @@ export interface ChrInstance {
590
603
  * `docs/networking-recipes.md`, `examples/udp-gateway/`.
591
604
  * @example
592
605
  * // RouterOS TZSP sniffer streaming to the host:
593
- * await instance.exec(`/tool/sniffer/set streaming-server=${instance.tzspGatewayIp}:37008`);
606
+ * await instance.exec(`/tool/sniffer/set streaming-server=${instance.hostGatewayIp}:37008`);
594
607
  */
608
+ hostGatewayIp: string;
609
+ /** @deprecated Renamed to {@link ChrInstance.hostGatewayIp} — the value was never
610
+ * TZSP-specific, it is the host as seen from inside the guest and carries any
611
+ * guest→host UDP (remote syslog, NetFlow, a plain socket). The old name led
612
+ * readers to assume a sniffer-only primitive.
613
+ *
614
+ * Still populated with the identical value, and will be removed no earlier than
615
+ * the next minor. Migration is a rename at the call site — nothing else changes. */
595
616
  tzspGatewayIp: string;
596
617
 
597
618
  /** Check or wait for REST API readiness.
598
619
  * After `QuickCHR.start()` with provisioning options (installAllPackages, license, etc.),
599
620
  * the returned instance is already REST-reachable — this returns immediately.
600
621
  * Call this only when starting without provisioning, or as a safety check after a manual stop/restart. */
601
- waitForBoot(timeoutMs?: number): Promise<boolean>;
622
+ /** @param stats Optional out-param; each REST readiness probe is classified
623
+ * into it (see diagnostics.ts BootProbeStats) so a failed wait can explain
624
+ * whether the forwarded port ever answered. */
625
+ waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean>;
602
626
  /** Poll a condition until it returns true or the timeout elapses.
603
627
  * Retries every 2 s, swallowing errors from the condition function.
604
628
  * Returns `true` when the condition passes, `false` on timeout.
@@ -784,6 +808,16 @@ export type ErrorCode =
784
808
  | "QGA_UNSUPPORTED"
785
809
  | "QGA_TIMEOUT"
786
810
  | "DOWNLOAD_FAILED"
811
+ // A transfer that received nothing for DOWNLOAD_STALL_MS. Retriable, and
812
+ // retried before this surfaces — a wedged socket usually moves on the next
813
+ // attempt (#116).
814
+ | "DOWNLOAD_STALLED"
815
+ // A transfer that kept moving but could not finish inside a budget derived
816
+ // from its content-length and COLD_DOWNLOAD_FLOOR_BYTES_PER_S. Terminal on
817
+ // purpose: retrying re-downloads from zero on a link already measured slower
818
+ // than the floor, which is the "one slow transfer becomes three" behavior
819
+ // #116 exists to remove.
820
+ | "DOWNLOAD_TOO_SLOW"
787
821
  | "MACHINE_EXISTS"
788
822
  | "MACHINE_NOT_FOUND"
789
823
  | "MACHINE_RUNNING"
@@ -791,6 +825,10 @@ export type ErrorCode =
791
825
  | "INVALID_VERSION"
792
826
  | "INVALID_ARCH"
793
827
  | "INVALID_NAME"
828
+ // A flag or argument the CLI cannot honour as written — a removed flag name, or a
829
+ // value it will not accept. Distinct from INVALID_SETTING_VALUE, which is about a
830
+ // stored setting rather than what was typed.
831
+ | "INVALID_ARGUMENT"
794
832
  | "INVALID_DISK_SIZE"
795
833
  | "MACHINE_LOCKED"
796
834
  | "EXEC_FAILED"
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { Arch, Channel } from "./types.ts";
6
6
  import { CHANNELS, QuickCHRError } from "./types.ts";
7
- import { fetchResilient } from "./net.ts";
7
+ import { fetchResilient, isConnectionFailure } from "./net.ts";
8
8
 
9
9
  const UPGRADE_BASE = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
10
10
  const DOWNLOAD_BASE = "https://download.mikrotik.com/routeros";
@@ -65,35 +65,112 @@ export function provisioningSupportHint(minimumVersion = MIN_PROVISION_VERSION):
65
65
  return `Use --channel long-term or --version ${minimumVersion}+ for ${PROVISIONING_FEATURE_SUMMARY}, or keep the older version without provisioning options.`;
66
66
  }
67
67
 
68
- /** Fetch the latest version for a given channel from MikroTik's upgrade server. */
69
- export async function resolveVersion(channel: Channel): Promise<string> {
70
- const url = `${UPGRADE_BASE}.${channel}`;
68
+ /** Attempts for a connection-class failure while resolving a channel version. */
69
+ export const VERSION_RESOLVE_MAX_ATTEMPTS = 3;
70
+
71
+ /** Backoff base: attempt N waits N × this before N+1. Mirrors `downloadToFile`. */
72
+ const VERSION_RESOLVE_RETRY_MS = 2000;
73
+
74
+ export interface ResolveVersionOptions {
75
+ /** Attempts for connection-class failures. Defaults to {@link VERSION_RESOLVE_MAX_ATTEMPTS}. */
76
+ maxAttempts?: number;
77
+ /**
78
+ * Override the backoff base. A test lever, like `downloadToFile`'s `stallMs`:
79
+ * the real backoff spends 6 s of wall clock exhausting the default attempts.
80
+ * Production callers should not set it.
81
+ */
82
+ retryDelayMs?: number;
83
+ }
71
84
 
72
- const response = await fetchResilient(url);
73
- if (!response.ok) {
74
- throw new QuickCHRError(
75
- "DOWNLOAD_FAILED",
76
- `Failed to fetch version for channel "${channel}": HTTP ${response.status}`,
77
- );
78
- }
85
+ /** A whole count of at least 1, or `fallback` for anything that isn't one. */
86
+ function positiveInt(value: number | undefined, fallback: number): number {
87
+ if (value === undefined || !Number.isFinite(value)) return fallback;
88
+ return Math.max(1, Math.floor(value));
89
+ }
90
+
91
+ /** A finite, non-negative delay in ms, or `fallback` for anything that isn't one. */
92
+ function nonNegativeMs(value: number | undefined, fallback: number): number {
93
+ if (value === undefined || !Number.isFinite(value)) return fallback;
94
+ return Math.max(0, value);
95
+ }
79
96
 
80
- const text = await response.text();
81
- // Response format: "7.22.1 1774276515" (version + unix timestamp)
82
- const version = text.trim().split(/\s+/)[0]?.trim();
83
- if (!version || !isValidVersion(version)) {
84
- throw new QuickCHRError(
85
- "INVALID_VERSION",
86
- `Unexpected version format for channel "${channel}": "${text.trim()}"`,
87
- );
97
+ /**
98
+ * Fetch the latest version for a given channel from MikroTik's upgrade server.
99
+ *
100
+ * Retried on a connection-class failure only — a transient refusal at
101
+ * `upgrade.mikrotik.com` used to fail a whole `quickchr start`, since this sits
102
+ * on the critical path of `start()`, the wizard and the CLI while only the
103
+ * download path had a retry policy (#121). An HTTP answer is the server's verdict
104
+ * and stays terminal on the first attempt: a 404 for a bad channel must fail fast.
105
+ */
106
+ export async function resolveVersion(
107
+ channel: Channel,
108
+ opts: ResolveVersionOptions = {},
109
+ ): Promise<string> {
110
+ const url = `${UPGRADE_BASE}.${channel}`;
111
+ // Sanitized, not just clamped: these are public options, and `Math.max(1, NaN)`
112
+ // is NaN — the loop would never run, and the failure would read "all NaN
113
+ // attempts: undefined" instead of doing anything.
114
+ const maxAttempts = positiveInt(opts.maxAttempts, VERSION_RESOLVE_MAX_ATTEMPTS);
115
+ const retryDelayMs = nonNegativeMs(opts.retryDelayMs, VERSION_RESOLVE_RETRY_MS);
116
+ let lastError: unknown;
117
+
118
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
119
+ let text: string;
120
+ try {
121
+ const response = await fetchResilient(url);
122
+ if (!response.ok) {
123
+ // An HTTP answer is the server's verdict. Thrown as a QuickCHRError so
124
+ // the catch below can tell it from a transport failure and let it out.
125
+ throw new QuickCHRError(
126
+ "DOWNLOAD_FAILED",
127
+ `Failed to fetch version for channel "${channel}": HTTP ${response.status}`,
128
+ );
129
+ }
130
+ // Inside the try: the body can fail *after* the headers arrive (a reset
131
+ // mid-response), which is a transport failure like any other and must be
132
+ // retried rather than escaping the loop unwrapped.
133
+ text = await response.text();
134
+ } catch (err) {
135
+ // Only "could not reach the host at all" is retried. An HTTP verdict, an
136
+ // abort (the caller gave up) or a real bug surfaces immediately.
137
+ if (err instanceof QuickCHRError) throw err;
138
+ if (!isConnectionFailure(err)) throw err;
139
+ lastError = err;
140
+ if (attempt < maxAttempts) await Bun.sleep(attempt * retryDelayMs);
141
+ continue;
142
+ }
143
+
144
+ // Parsing is terminal: the server answered, and answering with something
145
+ // unparseable is not a condition another attempt can improve.
146
+ // Response format: "7.22.1 1774276515" (version + unix timestamp)
147
+ const version = text.trim().split(/\s+/)[0]?.trim();
148
+ if (!version || !isValidVersion(version)) {
149
+ throw new QuickCHRError(
150
+ "INVALID_VERSION",
151
+ `Unexpected version format for channel "${channel}": "${text.trim()}"`,
152
+ );
153
+ }
154
+
155
+ return version;
88
156
  }
89
157
 
90
- return version;
158
+ // Says "1 attempt" rather than "all 1 attempts" — these land in CI logs.
159
+ const tries = maxAttempts === 1 ? "1 attempt" : `all ${maxAttempts} attempts`;
160
+ throw new QuickCHRError(
161
+ "DOWNLOAD_FAILED",
162
+ `Failed to fetch version for channel "${channel}" on ${tries}: ` +
163
+ `${lastError instanceof Error ? lastError.message : String(lastError)}`,
164
+ `Check network access to ${new URL(url).hostname}, or pass an explicit --version to skip channel resolution.`,
165
+ );
91
166
  }
92
167
 
93
168
  /** Fetch latest versions for all channels in parallel. */
94
- export async function resolveAllVersions(): Promise<Record<Channel, string>> {
169
+ export async function resolveAllVersions(
170
+ opts: ResolveVersionOptions = {},
171
+ ): Promise<Record<Channel, string>> {
95
172
  const results = await Promise.all(
96
- CHANNELS.map(async (ch) => [ch, await resolveVersion(ch)] as const),
173
+ CHANNELS.map(async (ch) => [ch, await resolveVersion(ch, opts)] as const),
97
174
  );
98
175
  return Object.fromEntries(results) as Record<Channel, string>;
99
176
  }