@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/DESIGN.md +604 -3
- package/MANUAL.md +222 -19
- package/README.md +29 -2
- package/package.json +4 -2
- package/src/cli/flags.ts +194 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +258 -34
- package/src/cli/tips.ts +57 -0
- package/src/index.ts +11 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/channels.ts +23 -2
- package/src/lib/completions.ts +17 -3
- package/src/lib/console.ts +206 -75
- package/src/lib/diagnostics.ts +740 -0
- package/src/lib/download.ts +433 -0
- package/src/lib/guest-snapshot.ts +474 -0
- package/src/lib/images.ts +56 -61
- package/src/lib/names.ts +72 -0
- package/src/lib/net.ts +59 -3
- package/src/lib/network.ts +191 -19
- package/src/lib/packages.ts +85 -17
- package/src/lib/provision.ts +126 -5
- package/src/lib/qemu.ts +62 -16
- package/src/lib/quickchr.ts +504 -64
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +147 -6
- package/src/lib/types.ts +44 -6
- package/src/lib/versions.ts +99 -22
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
|
-
/**
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
/**
|
|
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
|
|
121
|
-
if (
|
|
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}
|
|
58
|
-
* `
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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"
|
package/src/lib/versions.ts
CHANGED
|
@@ -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
|
-
/**
|
|
69
|
-
export
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|