@tikoci/quickchr 0.4.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +522 -6
- package/MANUAL.md +471 -59
- package/README.md +42 -8
- package/package.json +4 -2
- package/src/cli/flags.ts +208 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +660 -107
- package/src/cli/tips.ts +57 -0
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +12 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/completions.ts +34 -11
- package/src/lib/device-mode.ts +149 -10
- package/src/lib/diagnostics.ts +13 -6
- package/src/lib/images.ts +53 -22
- package/src/lib/lock.ts +497 -0
- package/src/lib/names.ts +96 -0
- package/src/lib/network.ts +199 -27
- package/src/lib/packages.ts +80 -6
- package/src/lib/provision.ts +129 -7
- package/src/lib/provisioning-window.ts +356 -0
- package/src/lib/qemu.ts +93 -14
- package/src/lib/quickchr.ts +1108 -339
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +211 -8
- package/src/lib/types.ts +117 -17
- package/src/lib/versions.ts +54 -0
package/src/lib/device-mode.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { DeviceModeOptions } from "./types.ts";
|
|
6
6
|
import { QuickCHRError } from "./types.ts";
|
|
7
|
+
import { shellQuote } from "./names.ts";
|
|
7
8
|
import { restGet, restPost } from "./rest.ts";
|
|
8
9
|
|
|
9
10
|
export const KNOWN_DEVICE_MODES = ["home", "advanced", "basic", "rose"] as const;
|
|
@@ -63,6 +64,26 @@ function normalizeFeatureList(list: string[] | undefined): string[] {
|
|
|
63
64
|
return [...new Set(values)];
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/** Keys `/system/device-mode` reports that are not feature flags. `mode` is the
|
|
68
|
+
* device-mode itself; the rest are status RouterOS keeps alongside it. */
|
|
69
|
+
export const DEVICE_MODE_NON_FEATURE_KEYS: ReadonlySet<string> = new Set([
|
|
70
|
+
"mode",
|
|
71
|
+
"allowed-versions",
|
|
72
|
+
"attempt-count",
|
|
73
|
+
"flagged",
|
|
74
|
+
"flagging-enabled",
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/** Is this feature on, as RouterOS reports it?
|
|
78
|
+
*
|
|
79
|
+
* RouterOS answers `/system/device-mode` with **strings**: `"container": "true"`, not
|
|
80
|
+
* `true`. A reader that tests `value === true` therefore finds nothing enabled, ever —
|
|
81
|
+
* which is what `quickchr get <name> device-mode` did, printing a bare mode and no
|
|
82
|
+
* feature line however many were on. Normalize before deciding. */
|
|
83
|
+
export function isDeviceModeFeatureEnabled(value: unknown): boolean {
|
|
84
|
+
return toYesNo(value) === "yes";
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
function toYesNo(value: unknown): string {
|
|
67
88
|
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
68
89
|
if (typeof value === "number") return value > 0 ? "yes" : "no";
|
|
@@ -148,20 +169,61 @@ export function formatDeviceModeSelection(options: ResolvedDeviceModeOptions): s
|
|
|
148
169
|
return parts.join(" ");
|
|
149
170
|
}
|
|
150
171
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
172
|
+
/** Factory credentials — `admin` with no password, as a fresh CHR ships.
|
|
173
|
+
*
|
|
174
|
+
* The right auth for device-mode during *first-boot* provisioning, where it runs
|
|
175
|
+
* before any account has been created, and the wrong auth afterwards: a machine
|
|
176
|
+
* provisioned with `--disable-admin` answers 401. Post-boot callers resolve the
|
|
177
|
+
* machine's own credentials instead — see `applyDeviceMode`. */
|
|
178
|
+
export const FACTORY_AUTH_HEADER = `Basic ${btoa("admin:")}`;
|
|
179
|
+
|
|
180
|
+
/** Is this response body actually `/system/device-mode`, and not the post-boot race?
|
|
181
|
+
*
|
|
182
|
+
* RouterOS can answer a non-resource endpoint with resource-shaped or otherwise wrong
|
|
183
|
+
* data for a short window after boot — `provisioning.instructions.md` documents this
|
|
184
|
+
* for exactly this endpoint, and `readDeviceMode()` carries a guard for it. A 2xx is
|
|
185
|
+
* therefore not proof the endpoint is ready; a body with `mode` in it is. */
|
|
186
|
+
function isDeviceModeBody(body: string): boolean {
|
|
187
|
+
let data: unknown;
|
|
188
|
+
try { data = JSON.parse(body); } catch { return false; }
|
|
189
|
+
const record = Array.isArray(data) ? data[0] : data;
|
|
190
|
+
if (!record || typeof record !== "object") return false;
|
|
191
|
+
const keys = record as Record<string, unknown>;
|
|
192
|
+
if ("board-name" in keys || "architecture-name" in keys) return false;
|
|
193
|
+
return "mode" in keys;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Wait until the device-mode REST endpoint is reachable **and answering with
|
|
197
|
+
* device-mode data**.
|
|
198
|
+
*
|
|
199
|
+
* Returning on a bare 2xx was a race the next line then paid for: `readDeviceMode()`
|
|
200
|
+
* rejects a resource-shaped body outright, so a guest that came back mid-race failed
|
|
201
|
+
* the whole operation instead of being waited out for one more second. This is the
|
|
202
|
+
* poll-until-the-keys-are-there pattern `provisioning.instructions.md` prescribes for
|
|
203
|
+
* every non-resource endpoint, applied where the waiting already happens.
|
|
204
|
+
*
|
|
205
|
+
* `auth` defaults to factory admin, which is what a guest being provisioned for the
|
|
206
|
+
* first time has. A machine past that point needs its own credentials passed in. */
|
|
207
|
+
export async function waitForDeviceModeApi(
|
|
208
|
+
httpPort: number,
|
|
209
|
+
timeoutMs: number = 60_000,
|
|
210
|
+
auth: string = FACTORY_AUTH_HEADER,
|
|
211
|
+
): Promise<void> {
|
|
154
212
|
const deadline = Date.now() + timeoutMs;
|
|
213
|
+
let lastBody = "";
|
|
155
214
|
while (Date.now() < deadline) {
|
|
156
215
|
try {
|
|
157
|
-
const { status } = await restGet(
|
|
216
|
+
const { status, body } = await restGet(
|
|
158
217
|
`http://127.0.0.1:${httpPort}/rest/system/device-mode`,
|
|
159
218
|
auth,
|
|
160
219
|
3000,
|
|
161
220
|
);
|
|
162
221
|
|
|
163
222
|
if (status >= 200 && status < 300) {
|
|
164
|
-
return;
|
|
223
|
+
if (isDeviceModeBody(body)) return;
|
|
224
|
+
// Answering, but not with device-mode yet — keep waiting rather than
|
|
225
|
+
// handing the caller a body it will throw on.
|
|
226
|
+
lastBody = body;
|
|
165
227
|
}
|
|
166
228
|
|
|
167
229
|
if (status === 401) {
|
|
@@ -176,7 +238,11 @@ export async function waitForDeviceModeApi(httpPort: number, timeoutMs: number =
|
|
|
176
238
|
await Bun.sleep(1000);
|
|
177
239
|
}
|
|
178
240
|
|
|
179
|
-
throw new QuickCHRError(
|
|
241
|
+
throw new QuickCHRError(
|
|
242
|
+
"BOOT_TIMEOUT",
|
|
243
|
+
`Device-mode API did not become ready on port ${httpPort}` +
|
|
244
|
+
(lastBody ? ` (last response was not device-mode data: ${lastBody.slice(0, 200)})` : ""),
|
|
245
|
+
);
|
|
180
246
|
}
|
|
181
247
|
|
|
182
248
|
/**
|
|
@@ -189,7 +255,11 @@ export async function waitForDeviceModeApi(httpPort: number, timeoutMs: number =
|
|
|
189
255
|
* Uses restPost (node:http + agent:false) to bypass Bun's connection pool.
|
|
190
256
|
* The 300s timeout is a safety limit — the caller is expected to kill QEMU well before then.
|
|
191
257
|
*/
|
|
192
|
-
export function startDeviceModeUpdate(
|
|
258
|
+
export function startDeviceModeUpdate(
|
|
259
|
+
httpPort: number,
|
|
260
|
+
options: ResolvedDeviceModeOptions,
|
|
261
|
+
auth: string = FACTORY_AUTH_HEADER,
|
|
262
|
+
): Promise<{ status: number; body: string }> {
|
|
193
263
|
if (!shouldApplyDeviceMode(options)) {
|
|
194
264
|
return Promise.resolve({ status: 200, body: "" });
|
|
195
265
|
}
|
|
@@ -200,7 +270,6 @@ export function startDeviceModeUpdate(httpPort: number, options: ResolvedDeviceM
|
|
|
200
270
|
payload[name] = value;
|
|
201
271
|
}
|
|
202
272
|
|
|
203
|
-
const auth = `Basic ${btoa("admin:")}`;
|
|
204
273
|
return restPost(
|
|
205
274
|
`http://127.0.0.1:${httpPort}/rest/system/device-mode/update`,
|
|
206
275
|
auth,
|
|
@@ -209,8 +278,10 @@ export function startDeviceModeUpdate(httpPort: number, options: ResolvedDeviceM
|
|
|
209
278
|
);
|
|
210
279
|
}
|
|
211
280
|
|
|
212
|
-
export async function readDeviceMode(
|
|
213
|
-
|
|
281
|
+
export async function readDeviceMode(
|
|
282
|
+
httpPort: number,
|
|
283
|
+
auth: string = FACTORY_AUTH_HEADER,
|
|
284
|
+
): Promise<Record<string, string>> {
|
|
214
285
|
const { status, body } = await restGet(
|
|
215
286
|
`http://127.0.0.1:${httpPort}/rest/system/device-mode`,
|
|
216
287
|
auth,
|
|
@@ -281,3 +352,71 @@ export function verifyDeviceMode(
|
|
|
281
352
|
actual,
|
|
282
353
|
};
|
|
283
354
|
}
|
|
355
|
+
|
|
356
|
+
/** The `quickchr set` flags that would reproduce this selection, shell-quoted.
|
|
357
|
+
*
|
|
358
|
+
* Rendered from the *resolved* options, not the raw ones, so the command it prints
|
|
359
|
+
* is the command that was actually going to run — `mode: "auto"` comes out as the
|
|
360
|
+
* `rose` it resolves to rather than as an alias the reader has to expand.
|
|
361
|
+
*
|
|
362
|
+
* Quoted because `resolveDeviceModeOptions()` deliberately passes unknown modes and
|
|
363
|
+
* features through for forward compatibility (it warns, RouterOS validates), so a
|
|
364
|
+
* value with a space or a `;` in it reaches this function intact. Unquoted, the
|
|
365
|
+
* advertised command would silently become a different command. Ordinary values are
|
|
366
|
+
* returned unquoted, so the common case still reads like something a person typed. */
|
|
367
|
+
export function formatDeviceModeFlags(options: ResolvedDeviceModeOptions): string {
|
|
368
|
+
if (options.skip) return "";
|
|
369
|
+
const parts: string[] = [];
|
|
370
|
+
if (options.mode) parts.push(`--device-mode ${shellQuote(options.mode)}`);
|
|
371
|
+
const enable = Object.entries(options.features).filter(([, v]) => v === "yes").map(([k]) => k);
|
|
372
|
+
const disable = Object.entries(options.features).filter(([, v]) => v === "no").map(([k]) => k);
|
|
373
|
+
if (enable.length > 0) parts.push(`--device-mode-enable ${shellQuote(enable.sort().join(","))}`);
|
|
374
|
+
if (disable.length > 0) parts.push(`--device-mode-disable ${shellQuote(disable.sort().join(","))}`);
|
|
375
|
+
return parts.join(" ");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** What a device-mode update would change, read against the guest's current record.
|
|
379
|
+
*
|
|
380
|
+
* Only the differences — a change that costs a power cycle should say which settings
|
|
381
|
+
* it is buying, and `expected == actual` rows are noise. Empty means nothing would
|
|
382
|
+
* move, which is the caller's cue that no power cycle is needed. */
|
|
383
|
+
export function describeDeviceModeChange(
|
|
384
|
+
options: ResolvedDeviceModeOptions,
|
|
385
|
+
actual: Record<string, string>,
|
|
386
|
+
): string[] {
|
|
387
|
+
// `verifyDeviceMode` stays the single judge of what counts as a mismatch — only the
|
|
388
|
+
// key is read back out of its message, and the values come from the maps it returns.
|
|
389
|
+
const { expected, mismatches } = verifyDeviceMode(options, actual);
|
|
390
|
+
const changed = new Set(mismatches.map((mismatch) => mismatch.split(":")[0]));
|
|
391
|
+
return Object.entries(expected)
|
|
392
|
+
.filter(([key]) => changed.has(key))
|
|
393
|
+
.map(([key, want]) => `${key}: ${actual[key] ?? "(unset)"} -> ${want}`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Fold an applied device-mode selection into what `machine.json` already records.
|
|
397
|
+
*
|
|
398
|
+
* `setDeviceMode()` used to overwrite `state.deviceMode` with the request, so
|
|
399
|
+
* `set <name> --device-mode-enable container` on a machine provisioned with
|
|
400
|
+
* `ipsec=yes` left the record claiming ipsec had never been asked for. The guest
|
|
401
|
+
* still had it: device-mode updates are cumulative, and only the settings named in
|
|
402
|
+
* a request move. The record has to be cumulative too, or it describes a machine
|
|
403
|
+
* that does not exist — and `classifyProvisioningRequest()` reads it. */
|
|
404
|
+
export function mergeDeviceModeOptions(
|
|
405
|
+
current: DeviceModeOptions | undefined,
|
|
406
|
+
applied: ResolvedDeviceModeOptions,
|
|
407
|
+
): DeviceModeOptions {
|
|
408
|
+
if (applied.skip) return current ?? {};
|
|
409
|
+
const enable = new Set(normalizeFeatureList(current?.enable));
|
|
410
|
+
const disable = new Set(normalizeFeatureList(current?.disable));
|
|
411
|
+
for (const [feature, value] of Object.entries(applied.features)) {
|
|
412
|
+
// A feature only ever sits on one side: the newest instruction for it wins.
|
|
413
|
+
enable.delete(feature);
|
|
414
|
+
disable.delete(feature);
|
|
415
|
+
(value === "yes" ? enable : disable).add(feature);
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
mode: applied.mode ?? current?.mode,
|
|
419
|
+
enable: enable.size > 0 ? [...enable].sort() : undefined,
|
|
420
|
+
disable: disable.size > 0 ? [...disable].sort() : undefined,
|
|
421
|
+
};
|
|
422
|
+
}
|
package/src/lib/diagnostics.ts
CHANGED
|
@@ -54,18 +54,22 @@ export type BootProbeOutcome =
|
|
|
54
54
|
| "reset"
|
|
55
55
|
/** Connect or response exceeded the per-probe budget — socket hung. */
|
|
56
56
|
| "probe-timeout"
|
|
57
|
-
/** 401/403
|
|
57
|
+
/** 401/403 from REST, or a login `!trap` from the native API — the service
|
|
58
|
+
* answered and refused the credentials; its auth layer is up. */
|
|
58
59
|
| "unauthorized"
|
|
59
60
|
/** 2xx but not the expected `board-name` singleton — REST still initializing. */
|
|
60
61
|
| "wrong-body"
|
|
61
62
|
/** Any other HTTP status. */
|
|
62
63
|
| "http-error"
|
|
63
|
-
/** 2xx with the expected body
|
|
64
|
+
/** 2xx with the expected body, or a native-API login `!done`. */
|
|
64
65
|
| "ready"
|
|
65
66
|
/** Anything unclassified — `detail` carries the raw text. */
|
|
66
67
|
| "other";
|
|
67
68
|
|
|
68
69
|
export interface BootProbeStats {
|
|
70
|
+
/** Which readiness probe ran — REST by default, the native-API login for a guest
|
|
71
|
+
* without REST (#182). Absent means REST. */
|
|
72
|
+
kind?: "rest" | "api";
|
|
69
73
|
/** Total probe attempts made. */
|
|
70
74
|
attempts: number;
|
|
71
75
|
/** Attempt count per outcome. */
|
|
@@ -90,9 +94,11 @@ export function classifyProbeError(err: unknown): { outcome: BootProbeOutcome; d
|
|
|
90
94
|
const message = err instanceof Error ? err.message : String(err);
|
|
91
95
|
if (code === "ECONNREFUSED") return { outcome: "refused", detail: code };
|
|
92
96
|
if (code === "ECONNRESET" || code === "EPIPE") return { outcome: "reset", detail: code };
|
|
93
|
-
if (code === "ETIMEDOUT" || message.includes("restGet timeout")) {
|
|
97
|
+
if (code === "ETIMEDOUT" || message.includes("restGet timeout") || message.includes("apiLogin timeout")) {
|
|
94
98
|
return { outcome: "probe-timeout", detail: code ?? "probe deadline" };
|
|
95
99
|
}
|
|
100
|
+
// apiLogin(): slirp accepted, then the guest side closed before any reply.
|
|
101
|
+
if (message === "closed before reply") return { outcome: "reset", detail: message };
|
|
96
102
|
return { outcome: "other", detail: code ? `${code}: ${message}` : message };
|
|
97
103
|
}
|
|
98
104
|
|
|
@@ -123,9 +129,10 @@ export function summarizeBootProbe(stats: BootProbeStats): string {
|
|
|
123
129
|
.sort((a, b) => b[1] - a[1])
|
|
124
130
|
.map(([k, v]) => `${k}=${v}`)
|
|
125
131
|
.join(" ");
|
|
132
|
+
const answer = stats.kind === "api" ? "API reply" : "HTTP response";
|
|
126
133
|
const reachedHttp = stats.firstHttpAtMs === null
|
|
127
|
-
?
|
|
128
|
-
: `first
|
|
134
|
+
? `forwarded port never produced an ${answer}`
|
|
135
|
+
: `first ${answer} at ${Math.round(stats.firstHttpAtMs / 1000)}s`;
|
|
129
136
|
const last = stats.last
|
|
130
137
|
? `last: ${stats.last.outcome}${stats.last.detail ? ` (${stats.last.detail})` : ""} at ${Math.round(stats.last.atMs / 1000)}s`
|
|
131
138
|
: "";
|
|
@@ -673,7 +680,7 @@ export async function captureBootFailure(ctx: BootFailureContext): Promise<BootF
|
|
|
673
680
|
lines.push(`Preceding credential transition: ${ctx.trigger.credentialTransition}`);
|
|
674
681
|
}
|
|
675
682
|
}
|
|
676
|
-
if (ctx.probe) lines.push(
|
|
683
|
+
if (ctx.probe) lines.push(`${ctx.probe.kind === "api" ? "API login" : "REST"} probe: ${summarizeBootProbe(ctx.probe)}`);
|
|
677
684
|
lines.push(`QEMU process: ${record.host.qemuProcess}`);
|
|
678
685
|
if (record.hostfwd) lines.push(`hostfwd 127.0.0.1:${record.hostfwd.port}: ${record.hostfwd.tcpConnect}`);
|
|
679
686
|
if (forwardProbe) lines.push(`slirp per-port: ${forwardProbe.summary}`);
|
package/src/lib/images.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Image download, ZIP extraction, and cache management.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { existsSync, copyFileSync, readdirSync, renameSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { existsSync, copyFileSync, mkdtempSync, readdirSync, renameSync, rmSync, unlinkSync } from "node:fs";
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import type { Arch } from "./types.ts";
|
|
8
8
|
import { QuickCHRError } from "./types.ts";
|
|
@@ -20,6 +20,37 @@ function finalizeExtractedImage(zipPath: string, imgPath: string): string {
|
|
|
20
20
|
return imgPath;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Check that a cached raw image has a complete DOS partition table.
|
|
25
|
+
*
|
|
26
|
+
* RouterOS CHR images for both supported architectures use a DOS partition
|
|
27
|
+
* table. Checking its signature and partition extents catches empty and
|
|
28
|
+
* truncated files while avoiding a version-specific image-size assumption.
|
|
29
|
+
*/
|
|
30
|
+
export async function isUsableCachedImage(imgPath: string): Promise<boolean> {
|
|
31
|
+
const file = Bun.file(imgPath);
|
|
32
|
+
if (!(await file.exists()) || file.size < 512) return false;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const sector = new Uint8Array(await file.slice(0, 512).arrayBuffer());
|
|
36
|
+
if (sector[510] !== 0x55 || sector[511] !== 0xaa) return false;
|
|
37
|
+
|
|
38
|
+
let hasPartition = false;
|
|
39
|
+
for (let offset = 446; offset < 510; offset += 16) {
|
|
40
|
+
const start = new DataView(sector.buffer, sector.byteOffset + offset + 8, 8);
|
|
41
|
+
const firstSector = start.getUint32(0, true);
|
|
42
|
+
const sectorCount = start.getUint32(4, true);
|
|
43
|
+
if (sectorCount === 0) continue;
|
|
44
|
+
hasPartition = true;
|
|
45
|
+
const end = (firstSector + sectorCount) * 512;
|
|
46
|
+
if (!Number.isSafeInteger(end) || end > file.size) return false;
|
|
47
|
+
}
|
|
48
|
+
return hasPartition;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
/** Download a CHR image ZIP if not already cached. Returns path to the ZIP. */
|
|
24
55
|
export async function downloadImage(
|
|
25
56
|
version: string,
|
|
@@ -58,39 +89,38 @@ export async function extractImage(
|
|
|
58
89
|
const imgName = basename(zipPath, ".zip");
|
|
59
90
|
const imgPath = join(cache, imgName);
|
|
60
91
|
|
|
61
|
-
if (
|
|
92
|
+
if (await isUsableCachedImage(imgPath)) {
|
|
62
93
|
return finalizeExtractedImage(zipPath, imgPath);
|
|
63
94
|
}
|
|
95
|
+
if (existsSync(imgPath)) unlinkSync(imgPath);
|
|
64
96
|
|
|
65
97
|
const log = logger ?? createLogger();
|
|
66
98
|
log.status(`Extracting: ${basename(zipPath)}`);
|
|
67
99
|
|
|
68
|
-
|
|
100
|
+
const tempDir = mkdtempSync(join(cache, ".quickchr-image-"));
|
|
101
|
+
try {
|
|
102
|
+
extractZip(zipPath, tempDir);
|
|
69
103
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
(f) => f.endsWith(".img") && f.startsWith("chr-"),
|
|
104
|
+
// MikroTik arm64 ZIPs may contain chr-X.Y.Z.img without the arch suffix.
|
|
105
|
+
const files = readdirSync(tempDir).filter((file) => file.endsWith(".img") && file.startsWith("chr-"));
|
|
106
|
+
const base = basename(zipPath, ".img.zip");
|
|
107
|
+
const expected = files.find(
|
|
108
|
+
(file) => file === `${base}.img` || file.replace("-arm64", "") === `${base.replace("-arm64", "")}.img`,
|
|
76
109
|
);
|
|
77
|
-
|
|
78
|
-
const expected = files.find((f) => {
|
|
79
|
-
const base = basename(zipPath, ".img.zip");
|
|
80
|
-
// chr-7.22.1.img matches chr-7.22.1.img.zip
|
|
81
|
-
return f === base + ".img" || f.replace("-arm64", "") === base.replace("-arm64", "") + ".img";
|
|
82
|
-
});
|
|
83
|
-
if (expected) {
|
|
84
|
-
const extractedPath = join(cache, expected);
|
|
85
|
-
if (extractedPath !== imgPath) {
|
|
86
|
-
renameSync(extractedPath, imgPath);
|
|
87
|
-
}
|
|
88
|
-
} else {
|
|
110
|
+
if (!expected) {
|
|
89
111
|
throw new QuickCHRError(
|
|
90
112
|
"PROCESS_FAILED",
|
|
91
113
|
`Expected ${imgPath} after unzip, but not found. Files: ${files.join(", ")}`,
|
|
92
114
|
);
|
|
93
115
|
}
|
|
116
|
+
|
|
117
|
+
const extractedPath = join(tempDir, expected);
|
|
118
|
+
if (!(await isUsableCachedImage(extractedPath))) {
|
|
119
|
+
throw new QuickCHRError("PROCESS_FAILED", `Extracted CHR image is incomplete or invalid: ${expected}`);
|
|
120
|
+
}
|
|
121
|
+
renameSync(extractedPath, imgPath);
|
|
122
|
+
} finally {
|
|
123
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
94
124
|
}
|
|
95
125
|
|
|
96
126
|
return finalizeExtractedImage(zipPath, imgPath);
|
|
@@ -105,11 +135,12 @@ export async function ensureCachedImage(
|
|
|
105
135
|
): Promise<string> {
|
|
106
136
|
const cache = cacheDir ?? getCacheDir();
|
|
107
137
|
const imgPath = join(cache, `${chrImageBasename(version, arch)}.img`);
|
|
108
|
-
if (
|
|
138
|
+
if (await isUsableCachedImage(imgPath)) {
|
|
109
139
|
const log = logger ?? createLogger();
|
|
110
140
|
log.status(` Using cached image: ${chrImageBasename(version, arch)}`);
|
|
111
141
|
return imgPath;
|
|
112
142
|
}
|
|
143
|
+
if (existsSync(imgPath)) unlinkSync(imgPath);
|
|
113
144
|
assertSufficientQuickchrStorage(`cache CHR ${version} (${arch})`);
|
|
114
145
|
const zipPath = await downloadImage(version, arch, cacheDir, logger);
|
|
115
146
|
const extractedImgPath = await extractImage(zipPath, cacheDir, logger);
|