@tikoci/quickchr 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -150,7 +150,7 @@ quickchr doctor
150
150
  |------|-------------|---------|
151
151
  | `--version <ver>` | RouterOS version (e.g., 7.22.1) | Latest stable |
152
152
  | `--channel <ch>` | stable, long-term, testing, development | stable |
153
- | `--arch <arch>` | arm64 or x86 | Host native |
153
+ | `--arch <arch>` | arm64, x86, or auto | Host native |
154
154
  | `--name <name>` | Instance name | Auto-generated |
155
155
  | `--cpu <n>` | vCPU count | 1 |
156
156
  | `--mem <mb>` | Memory in MB | 512 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/quickchr",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { StartOptions, Arch, Channel, ServiceName, NetworkSpecifier } from "../lib/types.ts";
7
7
  import { parseNetworkSpecifier } from "../lib/network.ts";
8
+ import { parseForwardSpec } from "../lib/forward-spec.ts";
8
9
  import {
9
10
  MIN_PROVISION_VERSION,
10
11
  PROVISIONING_BOOT_ONLY_SUMMARY,
@@ -181,6 +182,9 @@ async function main() {
181
182
  case "net":
182
183
  await cmdNetworks(args.slice(1));
183
184
  break;
185
+ case "cache":
186
+ await cmdCache(args.slice(1));
187
+ break;
184
188
  case "version":
185
189
  case "--version":
186
190
  case "-v":
@@ -474,7 +478,7 @@ async function cmdAdd(argv: string[]) {
474
478
  const opts: StartOptions = {
475
479
  version: flag(flags, "version"),
476
480
  channel: flag(flags, "channel") as Channel | undefined,
477
- arch: flag(flags, "arch") as Arch | undefined,
481
+ arch: flag(flags, "arch") as Arch | "auto" | undefined,
478
482
  name: flag(flags, "name") ?? positional[0],
479
483
  cpu: flag(flags, "cpu") ? Number(flag(flags, "cpu")) : undefined,
480
484
  mem: flag(flags, "mem") ? Number(flag(flags, "mem")) : undefined,
@@ -489,6 +493,7 @@ async function cmdAdd(argv: string[]) {
489
493
  bootDiskFormat: (flag(flags, "boot-disk-format") as StartOptions["bootDiskFormat"] | undefined) ?? "qcow2",
490
494
  bootSize: flag(flags, "boot-size"),
491
495
  extraDisks: flagList(flags, "add-disk").length > 0 ? flagList(flags, "add-disk") : undefined,
496
+ extraPorts: flagList(flags, "forward").map(parseForwardSpec),
492
497
  };
493
498
 
494
499
  const deviceModeValue = flag(flags, "device-mode");
@@ -1155,7 +1160,7 @@ async function cmdStart(argv: string[]) {
1155
1160
  const opts: StartOptions = {
1156
1161
  version: flag(flags, "version"),
1157
1162
  channel: flag(flags, "channel") as Channel | undefined,
1158
- arch: flag(flags, "arch") as Arch | undefined,
1163
+ arch: flag(flags, "arch") as Arch | "auto" | undefined,
1159
1164
  name: flag(flags, "name") ?? positional[0],
1160
1165
  cpu: flag(flags, "cpu") ? Number(flag(flags, "cpu")) : undefined,
1161
1166
  mem: flag(flags, "mem") ? Number(flag(flags, "mem")) : undefined,
@@ -1170,6 +1175,7 @@ async function cmdStart(argv: string[]) {
1170
1175
  bootDiskFormat: (flag(flags, "boot-disk-format") as StartOptions["bootDiskFormat"] | undefined) ?? "qcow2",
1171
1176
  bootSize: flag(flags, "boot-size"),
1172
1177
  extraDisks: flagList(flags, "add-disk").length > 0 ? flagList(flags, "add-disk") : undefined,
1178
+ extraPorts: flagList(flags, "forward").map(parseForwardSpec),
1173
1179
  installDeps: flagBool(flags, "install-deps"),
1174
1180
  dryRun: flagBool(flags, "dry-run"),
1175
1181
  timeoutExtra: parseInt(flag(flags, "timeout-extra") ?? flag(flags, "T") ?? "0", 10) * 1000 || undefined,
@@ -2249,6 +2255,130 @@ async function cmdLogs(argv: string[]) {
2249
2255
  }
2250
2256
  }
2251
2257
 
2258
+ async function cmdCache(argv: string[]) {
2259
+ const { flags, positional } = parseFlags(argv);
2260
+ const sub = positional[0] ?? "list";
2261
+
2262
+ if (sub === "help" || sub === "--help" || sub === "-h") {
2263
+ printCommandHelp("cache");
2264
+ return;
2265
+ }
2266
+
2267
+ const { listCacheEntries, pruneCache } = await import("../lib/cache.ts");
2268
+
2269
+ const parseDuration = (s: string): number => {
2270
+ const m = s.match(/^(\d+(?:\.\d+)?)([smhdw])$/);
2271
+ if (!m) throw new Error(`Invalid duration "${s}" (use Ns/Nm/Nh/Nd/Nw, e.g. 7d)`);
2272
+ const n = Number.parseFloat(m[1] ?? "0");
2273
+ const unit = m[2];
2274
+ const mult: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
2275
+ return Math.floor(n * (mult[unit ?? ""] ?? 0));
2276
+ };
2277
+ const parseSize = (s: string): number => {
2278
+ const m = s.match(/^(\d+(?:\.\d+)?)([KMGT]?)i?B?$/i);
2279
+ if (!m) throw new Error(`Invalid size "${s}" (use NK/NM/NG/NT, e.g. 2G)`);
2280
+ const n = Number.parseFloat(m[1] ?? "0");
2281
+ const unit = (m[2] ?? "").toUpperCase();
2282
+ const mult: Record<string, number> = { "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
2283
+ return Math.floor(n * (mult[unit] ?? 1));
2284
+ };
2285
+ const formatSize = (b: number): string => {
2286
+ if (b >= 1024 ** 3) return `${(b / 1024 ** 3).toFixed(2)} GiB`;
2287
+ if (b >= 1024 ** 2) return `${(b / 1024 ** 2).toFixed(1)} MiB`;
2288
+ if (b >= 1024) return `${(b / 1024).toFixed(1)} KiB`;
2289
+ return `${b} B`;
2290
+ };
2291
+ const formatAge = (ms: number): string => {
2292
+ const s = Math.floor(ms / 1000);
2293
+ if (s < 60) return `${s}s`;
2294
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
2295
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
2296
+ return `${Math.floor(s / 86400)}d`;
2297
+ };
2298
+
2299
+ switch (sub) {
2300
+ case "list":
2301
+ case "ls": {
2302
+ const entries = listCacheEntries();
2303
+ if (flags.json === true) {
2304
+ console.log(JSON.stringify(entries, null, 2));
2305
+ return;
2306
+ }
2307
+ if (entries.length === 0) {
2308
+ console.log("No cached images.");
2309
+ return;
2310
+ }
2311
+ const now = Date.now();
2312
+ const rows = entries.map((e) => ({
2313
+ basename: e.basename,
2314
+ version: e.version,
2315
+ arch: e.arch,
2316
+ size: formatSize(e.sizeBytes),
2317
+ age: formatAge(now - e.mtimeMs),
2318
+ inUse: e.inUse ? "yes" : "no",
2319
+ }));
2320
+ const widths = {
2321
+ basename: Math.max(8, ...rows.map((r) => r.basename.length)),
2322
+ version: Math.max(7, ...rows.map((r) => r.version.length)),
2323
+ arch: Math.max(4, ...rows.map((r) => r.arch.length)),
2324
+ size: Math.max(8, ...rows.map((r) => r.size.length)),
2325
+ age: Math.max(3, ...rows.map((r) => r.age.length)),
2326
+ };
2327
+ const header = `${"BASENAME".padEnd(widths.basename)} ${"VERSION".padEnd(widths.version)} ${"ARCH".padEnd(widths.arch)} ${"SIZE".padEnd(widths.size)} ${"AGE".padEnd(widths.age)} IN-USE`;
2328
+ console.log(header);
2329
+ for (const r of rows) {
2330
+ console.log(
2331
+ `${r.basename.padEnd(widths.basename)} ${r.version.padEnd(widths.version)} ${r.arch.padEnd(widths.arch)} ${r.size.padEnd(widths.size)} ${r.age.padEnd(widths.age)} ${r.inUse}`,
2332
+ );
2333
+ }
2334
+ const total = entries.reduce((s, e) => s + e.sizeBytes, 0);
2335
+ console.log(`\nTotal: ${entries.length} image(s), ${formatSize(total)}`);
2336
+ break;
2337
+ }
2338
+ case "prune": {
2339
+ const opts: Parameters<typeof pruneCache>[0] = { dryRun: flags["dry-run"] === true };
2340
+ if (typeof flags["older-than"] === "string") opts.olderThan = flags["older-than"];
2341
+ if (typeof flags["max-age"] === "string") opts.maxAgeMs = parseDuration(flags["max-age"]);
2342
+ if (typeof flags["max-size"] === "string") opts.maxSizeBytes = parseSize(flags["max-size"]);
2343
+ if (opts.olderThan === undefined && opts.maxAgeMs === undefined && opts.maxSizeBytes === undefined) {
2344
+ console.error("cache prune: provide at least one of --older-than, --max-age, --max-size");
2345
+ process.exit(1);
2346
+ }
2347
+ const result = pruneCache(opts);
2348
+ const verb = opts.dryRun ? "Would evict" : "Evicted";
2349
+ if (result.evicted.length === 0) {
2350
+ console.log("Nothing to evict.");
2351
+ } else {
2352
+ console.log(`${verb} ${result.evicted.length} image(s):`);
2353
+ for (const e of result.evicted) {
2354
+ const reason = result.reasons[e.basename] ?? "policy";
2355
+ console.log(` - ${e.basename} (${formatSize(e.sizeBytes)}) — ${reason}`);
2356
+ }
2357
+ console.log(`Freed: ${formatSize(result.freedBytes)}`);
2358
+ }
2359
+ break;
2360
+ }
2361
+ case "clear": {
2362
+ if (flags.all !== true) {
2363
+ console.error("cache clear: refuses without --all (deletes all non-in-use cached images)");
2364
+ process.exit(1);
2365
+ }
2366
+ const result = pruneCache({ maxSizeBytes: 0, dryRun: flags["dry-run"] === true });
2367
+ const verb = flags["dry-run"] === true ? "Would delete" : "Deleted";
2368
+ console.log(`${verb} ${result.evicted.length} image(s), freed ${formatSize(result.freedBytes)}`);
2369
+ if (result.kept.some((e) => e.inUse)) {
2370
+ const inUse = result.kept.filter((e) => e.inUse);
2371
+ console.log(`Kept ${inUse.length} in-use image(s):`);
2372
+ for (const e of inUse) console.log(` - ${e.basename}`);
2373
+ }
2374
+ break;
2375
+ }
2376
+ default:
2377
+ console.error(`Unknown cache subcommand: ${sub}\nRun 'quickchr help cache' for usage.`);
2378
+ process.exit(1);
2379
+ }
2380
+ }
2381
+
2252
2382
  async function cmdDoctor() {
2253
2383
  const { QuickCHR } = await import("../lib/quickchr.ts");
2254
2384
  const { statusIcon, bold } = await import("./format.ts");
@@ -2261,6 +2391,27 @@ async function cmdDoctor() {
2261
2391
  }
2262
2392
 
2263
2393
  console.log();
2394
+
2395
+ // Best-effort: warn about cache entries older than current long-term.
2396
+ try {
2397
+ const { listCacheEntries } = await import("../lib/cache.ts");
2398
+ const { resolveVersion, compareRouterOsVersion } = await import("../lib/versions.ts");
2399
+ const entries = listCacheEntries();
2400
+ if (entries.length > 0) {
2401
+ const longTerm = await resolveVersion("long-term");
2402
+ const stale = entries.filter((e) => {
2403
+ if (e.version === "unknown" || e.inUse) return false;
2404
+ try { return compareRouterOsVersion(e.version, longTerm) < 0; } catch { return false; }
2405
+ });
2406
+ if (stale.length > 0) {
2407
+ console.log(`Note: ${stale.length} cached image(s) older than current long-term (${longTerm}):`);
2408
+ for (const e of stale) console.log(` - ${e.basename} (${e.version})`);
2409
+ console.log(" Hint: 'quickchr cache prune --older-than " + longTerm + "' to evict them.");
2410
+ console.log();
2411
+ }
2412
+ }
2413
+ } catch { /* offline or no cache — silent */ }
2414
+
2264
2415
  if (result.ok) {
2265
2416
  console.log("All checks passed.");
2266
2417
  } else {
@@ -2316,6 +2467,7 @@ Commands:
2316
2467
  disk <name> Show disk details for an instance
2317
2468
  snapshot <name> [cmd] Manage snapshots (list/save/load/delete)
2318
2469
  networks Network discovery & socket management
2470
+ cache [list|prune|clear] Manage cached CHR images
2319
2471
  completions Manage shell completions (Tab completion)
2320
2472
  logs <name> Tail the QEMU log for an instance
2321
2473
  doctor Check prerequisites
@@ -2351,13 +2503,18 @@ Options:
2351
2503
  --name <name> Instance name (required)
2352
2504
  --version <ver> RouterOS version (e.g., 7.22.1)
2353
2505
  --channel <ch> Channel: stable, long-term, testing, development
2354
- --arch <arch> Architecture: arm64, x86 (default: host native)
2506
+ --arch <arch> Architecture: arm64, x86, auto (default: host native)
2355
2507
  --cpu <n> vCPU count (default: 1)
2356
2508
  --mem <mb> RAM in MB (default: 512)
2357
2509
  --boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
2358
2510
  --boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
2359
2511
  --add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
2360
2512
  --add-package <pkg> Extra package to install on first boot (repeatable)
2513
+ --forward <spec> Add an extra hostfwd port (repeatable). Spec:
2514
+ name[:host[:guest]][/proto]. Examples:
2515
+ --forward smb (host auto, guest 445/tcp from registry)
2516
+ --forward winbox:9300 (host pinned, guest from registry)
2517
+ --forward myapp:9200:7777/udp (fully explicit)
2361
2518
  --install-all-packages Install all packages on first boot
2362
2519
  --add-user <u:p> Create user on first boot (name:password)
2363
2520
  --disable-admin Disable the default admin account on first boot
@@ -2424,7 +2581,7 @@ Options:
2424
2581
  --fg / --foreground Run in foreground — serial console on stdio
2425
2582
  --version <ver> RouterOS version (e.g., 7.22.1)
2426
2583
  --channel <ch> Channel: stable, long-term, testing, development
2427
- --arch <arch> Architecture: arm64, x86 (default: host native)
2584
+ --arch <arch> Architecture: arm64, x86, auto (default: host native)
2428
2585
  --name <name> Instance name
2429
2586
  --cpu <n> vCPU count (default: 1)
2430
2587
  --mem <mb> RAM in MB (default: 512)
@@ -2432,6 +2589,9 @@ Options:
2432
2589
  --boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
2433
2590
  --add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
2434
2591
  --add-package <pkg> Extra package to install (repeatable)
2592
+ --forward <spec> Add an extra hostfwd port (repeatable). Spec:
2593
+ name[:host[:guest]][/proto]. Examples: smb,
2594
+ winbox:9300, myapp:9200:7777/udp.
2435
2595
  --add-user <u:p> Create user with name:password
2436
2596
  --disable-admin Disable the default admin account
2437
2597
  --no-secure-login Keep admin with no password (skip managed account)
@@ -2617,6 +2777,24 @@ Credential resolution order (highest priority first):
2617
2777
  case "net":
2618
2778
  showNetworksHelp();
2619
2779
  break;
2780
+ case "cache":
2781
+ console.log(`quickchr cache <list|prune|clear> [options]
2782
+
2783
+ Manage cached CHR images in the data dir cache directory.
2784
+
2785
+ Subcommands:
2786
+ list [--json] List cached images (basename, version, arch, size, age, in-use).
2787
+ prune [options] Evict cached images per policy. Never evicts in-use images.
2788
+ --older-than <ver> Evict images with version < <ver>.
2789
+ --max-age <dur> Evict images older than duration (e.g. 7d, 24h).
2790
+ --max-size <size> Evict oldest-version images until total size <= <size>.
2791
+ --dry-run Show what would be evicted without deleting.
2792
+ clear --all Delete all non-in-use cached images.
2793
+
2794
+ Duration: <N>(s|m|h|d|w) Size: <N>(K|M|G|T)[i][B] (case-insensitive, IEC binary).
2795
+
2796
+ Default auto-prune cap: 2 GiB (applied after each successful start).`);
2797
+ break;
2620
2798
  default:
2621
2799
  console.log(`No detailed help for '${command}'.`);
2622
2800
  }
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * ## Key exports
17
17
  * - {@link QuickCHR} — Main class with static methods: `start()`, `add()`, `list()`, `get()`, `doctor()`
18
- * - {@link ChrInstance} — Runtime handle returned by `start()`: `stop()`, `remove()`, `rest()`, `exec()`, `snapshot`, etc.
18
+ * - {@link ChrInstance} — Runtime handle returned by `start()`: `stop()`, `remove()`, `rest()`, `exec()`, `upload()`, `download()`, `snapshot`, etc.
19
19
  * - {@link StartOptions} — All options for creating/starting a CHR instance
20
20
  * - {@link QuickCHRError} — Typed errors with codes like `MISSING_QEMU`, `TIMEOUT`, `EXEC_FAILED`
21
21
  *
@@ -119,6 +119,13 @@ export type { MikrotikCredentials, InstanceCredentials } from "./lib/credentials
119
119
  // Package utilities
120
120
  export { downloadPackages, listAvailablePackages, downloadAndListPackages, installPackages, installAllPackages } from "./lib/packages.ts";
121
121
 
122
+ // Guest port registry
123
+ export { WELL_KNOWN_GUEST_PORTS, lookupGuestPort } from "./lib/guest-ports.ts";
124
+ export type { GuestPortDef } from "./lib/guest-ports.ts";
125
+
126
+ // Forward spec parsing
127
+ export { parseForwardSpec } from "./lib/forward-spec.ts";
128
+
122
129
  // Logging
123
130
  export { createLogger } from "./lib/log.ts";
124
131
  export type { ProgressLogger } from "./lib/log.ts";
package/src/lib/auth.ts CHANGED
@@ -17,6 +17,11 @@ export interface ResolvedAuth {
17
17
  user: string;
18
18
  }
19
19
 
20
+ export interface ResolvedCreds {
21
+ user: string;
22
+ password: string;
23
+ }
24
+
20
25
  /**
21
26
  * Resolve the best credentials for a machine.
22
27
  *
@@ -66,3 +71,19 @@ export function resolveAuth(
66
71
  user: "admin",
67
72
  };
68
73
  }
74
+
75
+ /**
76
+ * Resolve raw username + password for a machine (same priority as {@link resolveAuth}).
77
+ * Used by transports that need the password in cleartext — SCP, SSH, SFTP.
78
+ */
79
+ export function resolveCreds(
80
+ state: Pick<MachineState, "name" | "user" | "disableAdmin">,
81
+ user?: string,
82
+ password?: string,
83
+ ): ResolvedCreds {
84
+ if (user !== undefined) return { user, password: password ?? "" };
85
+ if (state.user) return { user: state.user.name, password: state.user.password };
86
+ const stored = getInstanceCredentials(state.name);
87
+ if (stored) return { user: stored.user, password: stored.password };
88
+ return { user: "admin", password: "" };
89
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Cache retention policy for downloaded CHR images.
3
+ *
4
+ * - Default cap: 2 GB total cache size (size-based eviction).
5
+ * - Eviction order: oldest RouterOS version first, mtime tiebreaker.
6
+ * - Never evicts an image referenced by any machine (running or stopped).
7
+ * - Callers can pin extra versions via `protectVersions` (e.g. current long-term).
8
+ */
9
+
10
+ import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { getCacheDir, loadAllMachines } from "./state.ts";
13
+ import { chrImageBasename, compareRouterOsVersion, isValidVersion } from "./versions.ts";
14
+ import type { Arch } from "./types.ts";
15
+
16
+ export const DEFAULT_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
17
+
18
+ export interface CacheEntry {
19
+ path: string;
20
+ basename: string;
21
+ version: string;
22
+ arch: Arch | "unknown";
23
+ sizeBytes: number;
24
+ mtimeMs: number;
25
+ inUse: boolean;
26
+ }
27
+
28
+ export interface PruneOptions {
29
+ cacheDir?: string;
30
+ maxSizeBytes?: number;
31
+ maxAgeMs?: number;
32
+ olderThan?: string;
33
+ protectVersions?: string[];
34
+ dryRun?: boolean;
35
+ }
36
+
37
+ export interface PruneResult {
38
+ evicted: CacheEntry[];
39
+ kept: CacheEntry[];
40
+ freedBytes: number;
41
+ reasons: Record<string, string>;
42
+ }
43
+
44
+ /** Parse a cached file basename into version + arch. Returns "unknown" parts when unparseable. */
45
+ export function parseCacheBasename(basename: string): { version: string; arch: Arch | "unknown" } {
46
+ // Strip recognized cache extensions (.img, .img.zip).
47
+ let stem = basename;
48
+ if (stem.endsWith(".img.zip")) stem = stem.slice(0, -".img.zip".length);
49
+ else if (stem.endsWith(".zip")) stem = stem.slice(0, -".zip".length);
50
+ else if (stem.endsWith(".img")) stem = stem.slice(0, -".img".length);
51
+
52
+ // chr-<version>[-arm64]
53
+ const m = stem.match(/^chr-(\d+\.\d+(?:\.\d+)?(?:beta\d+|rc\d+)?)(-arm64)?$/);
54
+ if (!m) return { version: "unknown", arch: "unknown" };
55
+ const version = m[1] ?? "unknown";
56
+ const arch: Arch = m[2] ? "arm64" : "x86";
57
+ if (!isValidVersion(version)) return { version: "unknown", arch };
58
+ return { version, arch };
59
+ }
60
+
61
+ function inUseSet(): Set<string> {
62
+ const set = new Set<string>();
63
+ let machines: ReturnType<typeof loadAllMachines>;
64
+ try {
65
+ machines = loadAllMachines();
66
+ } catch {
67
+ return set;
68
+ }
69
+ for (const m of machines) {
70
+ if (!m.version || !m.arch) continue;
71
+ set.add(`${chrImageBasename(m.version, m.arch)}.img`);
72
+ set.add(`${chrImageBasename(m.version, m.arch)}.img.zip`);
73
+ }
74
+ return set;
75
+ }
76
+
77
+ /** List cache entries with parsed metadata and in-use status. */
78
+ export function listCacheEntries(cacheDir?: string): CacheEntry[] {
79
+ const dir = cacheDir ?? getCacheDir();
80
+ if (!existsSync(dir)) return [];
81
+ const used = inUseSet();
82
+ const out: CacheEntry[] = [];
83
+ for (const name of readdirSync(dir)) {
84
+ const full = join(dir, name);
85
+ let stat: ReturnType<typeof statSync>;
86
+ try {
87
+ stat = statSync(full);
88
+ } catch {
89
+ continue;
90
+ }
91
+ if (!stat.isFile()) continue;
92
+ if (!name.endsWith(".img") && !name.endsWith(".img.zip")) continue;
93
+ const { version, arch } = parseCacheBasename(name);
94
+ out.push({
95
+ path: full,
96
+ basename: name,
97
+ version,
98
+ arch,
99
+ sizeBytes: stat.size,
100
+ mtimeMs: stat.mtimeMs,
101
+ inUse: used.has(name),
102
+ });
103
+ }
104
+ return out;
105
+ }
106
+
107
+ function compareVersionsSafe(a: string, b: string): number {
108
+ if (a === b) return 0;
109
+ if (a === "unknown") return -1;
110
+ if (b === "unknown") return 1;
111
+ try {
112
+ return compareRouterOsVersion(a, b);
113
+ } catch {
114
+ return 0;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Apply the cache retention policy. Returns what was (or would be) evicted.
120
+ *
121
+ * Eviction proceeds only when at least one of these conditions is set and met:
122
+ * - `maxSizeBytes`: total bytes exceed cap
123
+ * - `maxAgeMs`: any entry older than threshold
124
+ * - `olderThan`: any entry's parsed version older than threshold
125
+ *
126
+ * In-use entries and `protectVersions` entries are never evicted.
127
+ */
128
+ export function pruneCache(opts: PruneOptions = {}): PruneResult {
129
+ const entries = listCacheEntries(opts.cacheDir);
130
+ const protect = new Set(opts.protectVersions ?? []);
131
+ const dryRun = opts.dryRun ?? false;
132
+ const now = Date.now();
133
+
134
+ const reasons: Record<string, string> = {};
135
+ const evicted: CacheEntry[] = [];
136
+ const kept: CacheEntry[] = [];
137
+
138
+ const protectedEntries: CacheEntry[] = [];
139
+ const candidates: CacheEntry[] = [];
140
+
141
+ for (const e of entries) {
142
+ if (e.inUse) {
143
+ protectedEntries.push(e);
144
+ continue;
145
+ }
146
+ if (protect.has(e.version)) {
147
+ protectedEntries.push(e);
148
+ continue;
149
+ }
150
+ candidates.push(e);
151
+ }
152
+
153
+ candidates.sort((a, b) => {
154
+ const v = compareVersionsSafe(a.version, b.version);
155
+ if (v !== 0) return v;
156
+ return a.mtimeMs - b.mtimeMs;
157
+ });
158
+
159
+ const evict = (e: CacheEntry, reason: string) => {
160
+ evicted.push(e);
161
+ reasons[e.basename] = reason;
162
+ if (!dryRun) {
163
+ try { unlinkSync(e.path); } catch { /* best-effort */ }
164
+ }
165
+ };
166
+
167
+ const remaining: CacheEntry[] = [];
168
+
169
+ if (opts.olderThan) {
170
+ const threshold = opts.olderThan;
171
+ for (const e of candidates) {
172
+ if (e.version !== "unknown" && compareVersionsSafe(e.version, threshold) < 0) {
173
+ evict(e, `version ${e.version} < ${threshold}`);
174
+ } else {
175
+ remaining.push(e);
176
+ }
177
+ }
178
+ } else {
179
+ remaining.push(...candidates);
180
+ }
181
+
182
+ const remainingAfterAge: CacheEntry[] = [];
183
+ if (opts.maxAgeMs !== undefined) {
184
+ const maxAge = opts.maxAgeMs;
185
+ for (const e of remaining) {
186
+ if (now - e.mtimeMs > maxAge) {
187
+ evict(e, `age ${(Math.floor((now - e.mtimeMs) / 1000))}s > ${Math.floor(maxAge / 1000)}s`);
188
+ } else {
189
+ remainingAfterAge.push(e);
190
+ }
191
+ }
192
+ } else {
193
+ remainingAfterAge.push(...remaining);
194
+ }
195
+
196
+ const keptSoFar: CacheEntry[] = [...remainingAfterAge];
197
+
198
+ if (opts.maxSizeBytes !== undefined) {
199
+ const cap = opts.maxSizeBytes;
200
+ const totalSize = (es: CacheEntry[]) => es.reduce((s, x) => s + x.sizeBytes, 0);
201
+ let total = totalSize(protectedEntries) + totalSize(keptSoFar);
202
+ while (total > cap && keptSoFar.length > 0) {
203
+ const victim = keptSoFar.shift();
204
+ if (!victim) break;
205
+ evict(victim, `size cap ${cap} exceeded (oldest version)`);
206
+ total -= victim.sizeBytes;
207
+ }
208
+ }
209
+
210
+ for (const e of protectedEntries) {
211
+ kept.push(e);
212
+ if (e.inUse) reasons[e.basename] ??= "in use";
213
+ else reasons[e.basename] ??= `protected (version ${e.version})`;
214
+ }
215
+ for (const e of keptSoFar) {
216
+ kept.push(e);
217
+ reasons[e.basename] ??= "kept";
218
+ }
219
+
220
+ const freedBytes = evicted.reduce((s, e) => s + e.sizeBytes, 0);
221
+ return { evicted, kept, freedBytes, reasons };
222
+ }
223
+
224
+ /**
225
+ * Best-effort auto-prune used by `start()` after a successful boot.
226
+ * Never throws — failures are swallowed and logged.
227
+ */
228
+ export function autoPruneIfOverCap(opts: { logger?: (msg: string) => void; cacheDir?: string; maxSizeBytes?: number; protectVersions?: string[] } = {}): PruneResult {
229
+ const cap = opts.maxSizeBytes ?? DEFAULT_CACHE_MAX_BYTES;
230
+ try {
231
+ const entries = listCacheEntries(opts.cacheDir);
232
+ const total = entries.reduce((s, e) => s + e.sizeBytes, 0);
233
+ if (total <= cap) {
234
+ return { evicted: [], kept: entries, freedBytes: 0, reasons: {} };
235
+ }
236
+ const result = pruneCache({
237
+ cacheDir: opts.cacheDir,
238
+ maxSizeBytes: cap,
239
+ protectVersions: opts.protectVersions,
240
+ });
241
+ if (opts.logger && result.evicted.length > 0) {
242
+ opts.logger(`cache: evicted ${result.evicted.length} image(s), freed ${(result.freedBytes / 1024 / 1024).toFixed(1)} MiB`);
243
+ }
244
+ return result;
245
+ } catch (e) {
246
+ if (opts.logger) {
247
+ opts.logger(`cache: auto-prune skipped: ${e instanceof Error ? e.message : String(e)}`);
248
+ }
249
+ return { evicted: [], kept: [], freedBytes: 0, reasons: {} };
250
+ }
251
+ }