@tikoci/quickchr 0.5.0 → 0.5.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/src/cli/index.ts CHANGED
@@ -1390,7 +1390,13 @@ function printReadinessTip(name: string, version: string): void {
1390
1390
  tip(`wait for readiness with: centrs retrieve --quickchr ${name} /system/resource${via} --wait 5m`);
1391
1391
  }
1392
1392
 
1393
- async function cmdStart(argv: string[]) {
1393
+ /** Single-target start's status word: a machine this call created says so (#194). */
1394
+ function startStatusLine(created: boolean, noWait: boolean): string {
1395
+ if (noWait) return `${created ? "created" : "launched"} — not waiting for RouterOS (--no-wait)`;
1396
+ return created ? "created and started" : "started";
1397
+ }
1398
+
1399
+ export async function cmdStart(argv: string[]) {
1394
1400
  const { flags, positional } = parseFlags(argv);
1395
1401
  applyTimeoutExtraShortFlag(argv, flags, positional);
1396
1402
  rejectUnknownFlags(flags, START_FLAGS, "start");
@@ -1474,7 +1480,6 @@ async function cmdStart(argv: string[]) {
1474
1480
  bootSize: flag(flags, "boot-size"),
1475
1481
  extraDisks: flagList(flags, "add-disk").length > 0 ? flagList(flags, "add-disk") : undefined,
1476
1482
  extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
1477
- installDeps: flagBool(flags, "install-deps"),
1478
1483
  dryRun: flagBool(flags, "dry-run"),
1479
1484
  timeoutExtra: await resolveTimeoutExtraMs(flags),
1480
1485
  };
@@ -1505,10 +1510,10 @@ async function cmdStart(argv: string[]) {
1505
1510
  }
1506
1511
 
1507
1512
  opts.disableAdmin = flagOptionalBool(flags, "disable-admin");
1513
+ // No name, or a name no machine has: this call creates the machine (#194).
1514
+ const target = opts.name ? QuickCHR.get(opts.name) : null;
1508
1515
  {
1509
- const { QuickCHR } = await import("../lib/quickchr.ts");
1510
1516
  const { isProvisioningWindowOpen } = await import("../lib/provisioning-window.ts");
1511
- const target = opts.name ? QuickCHR.get(opts.name) : null;
1512
1517
  // No machine yet, or one that was added and never booted: still a first boot,
1513
1518
  // so the setting applies. Only a closed window suppresses it — otherwise a
1514
1519
  // plain `quickchr start <booted-name>` would ask to provision a booted guest
@@ -1573,7 +1578,7 @@ async function cmdStart(argv: string[]) {
1573
1578
  } else {
1574
1579
  const creds = await resolveDisplayCredentials(instance.state);
1575
1580
  const v6 = isRouterOs6(instance.state.version);
1576
- console.log(`${statusIcon("running")} ${bold(instance.name)} ${opts.wait === false ? "launched — not waiting for RouterOS (--no-wait)" : "started"}`);
1581
+ console.log(`${statusIcon("running")} ${bold(instance.name)} ${startStatusLine(!target, opts.wait === false)}`);
1577
1582
  console.log(` Version: ${instance.state.version} (${instance.state.arch})${v6 ? ` ${dim("boot-only: no REST, no provisioning")}` : ""}`);
1578
1583
  console.log(` Network: ${formatNetworks(instance.state.networks ?? [])}`);
1579
1584
  console.log(` Ports: ${formatPorts(instance.state.ports)}`);
@@ -2236,7 +2241,7 @@ async function cmdDisk(argv: string[]) {
2236
2241
 
2237
2242
  const { QuickCHR } = await import("../lib/quickchr.ts");
2238
2243
  const { bold, dim, machineNotFoundMessage } = await import("./format.ts");
2239
- const { getDiskInfo } = await import("../lib/disk.ts");
2244
+ const { bootDiskFormatOf, bootDiskPath, getDiskInfo } = await import("../lib/disk.ts");
2240
2245
  const { join } = await import("node:path");
2241
2246
  const { existsSync } = await import("node:fs");
2242
2247
  const { findQemuImg } = await import("../lib/platform.ts");
@@ -2259,10 +2264,8 @@ async function cmdDisk(argv: string[]) {
2259
2264
  console.log(bold(`Disks for ${name}\n`));
2260
2265
 
2261
2266
  // Boot disk
2262
- const bootPath = state.bootDiskFormat === "qcow2"
2263
- ? join(state.machineDir, "boot.qcow2")
2264
- : join(state.machineDir, "disk.img");
2265
- const bootFormat = state.bootDiskFormat ?? "raw";
2267
+ const bootFormat = bootDiskFormatOf(state);
2268
+ const bootPath = bootDiskPath(state.machineDir, bootFormat);
2266
2269
 
2267
2270
  if (qemuImg && existsSync(bootPath)) {
2268
2271
  try {
@@ -2835,7 +2838,7 @@ async function cmdCache(argv: string[]) {
2835
2838
  return;
2836
2839
  }
2837
2840
 
2838
- const { listCacheEntries, pruneCache, parseSizeString, formatSizeBytes } = await import("../lib/cache.ts");
2841
+ const { cacheInventory, countEntries, pruneCache, parseSizeString, formatSizeBytes } = await import("../lib/cache.ts");
2839
2842
 
2840
2843
  const parseDuration = (s: string): number => {
2841
2844
  const m = s.match(/^(\d+(?:\.\d+)?)([smhdw])$/);
@@ -2847,6 +2850,11 @@ async function cmdCache(argv: string[]) {
2847
2850
  };
2848
2851
  const parseSize = parseSizeString;
2849
2852
  const formatSize = formatSizeBytes;
2853
+ const printFailedDeletes = (failed: Array<{ entry: { basename: string }; error: string }>) => {
2854
+ if (failed.length === 0) return;
2855
+ console.error(`Could not delete ${countEntries(failed.length)} (kept, not counted as freed):`);
2856
+ for (const { entry, error } of failed) console.error(` - ${entry.basename}: ${error}`);
2857
+ };
2850
2858
  const formatAge = (ms: number): string => {
2851
2859
  const s = Math.floor(ms / 1000);
2852
2860
  if (s < 60) return `${s}s`;
@@ -2897,13 +2905,14 @@ async function cmdCache(argv: string[]) {
2897
2905
  }
2898
2906
  case "list":
2899
2907
  case "ls": {
2900
- const entries = listCacheEntries();
2908
+ const { entries, unmanagedBytes } = cacheInventory();
2901
2909
  if (flags.json === true) {
2902
2910
  console.log(JSON.stringify(entries, null, 2));
2903
2911
  return;
2904
2912
  }
2905
2913
  if (entries.length === 0) {
2906
- console.log("No cached images.");
2914
+ console.log("No cache entries.");
2915
+ if (unmanagedBytes > 0) console.log(`Other files: ${formatSize(unmanagedBytes)} (not written by quickchr; never pruned)`);
2907
2916
  return;
2908
2917
  }
2909
2918
  const now = Date.now();
@@ -2930,7 +2939,8 @@ async function cmdCache(argv: string[]) {
2930
2939
  );
2931
2940
  }
2932
2941
  const total = entries.reduce((s, e) => s + e.sizeBytes, 0);
2933
- console.log(`\nTotal: ${entries.length} image(s), ${formatSize(total)}`);
2942
+ console.log(`\nTotal: ${countEntries(entries.length)}, ${formatSize(total)}`);
2943
+ if (unmanagedBytes > 0) console.log(`Other files: ${formatSize(unmanagedBytes)} (not written by quickchr; never pruned)`);
2934
2944
  break;
2935
2945
  }
2936
2946
  case "prune": {
@@ -2944,29 +2954,33 @@ async function cmdCache(argv: string[]) {
2944
2954
  }
2945
2955
  const result = pruneCache(opts);
2946
2956
  const verb = opts.dryRun ? "Would evict" : "Evicted";
2947
- if (result.evicted.length === 0) {
2957
+ if (result.evicted.length === 0 && result.failed.length === 0) {
2948
2958
  console.log("Nothing to evict.");
2949
- } else {
2950
- console.log(`${verb} ${result.evicted.length} image(s):`);
2959
+ } else if (result.evicted.length > 0) {
2960
+ console.log(`${verb} ${countEntries(result.evicted.length)}:`);
2951
2961
  for (const e of result.evicted) {
2952
2962
  const reason = result.reasons[e.basename] ?? "policy";
2953
2963
  console.log(` - ${e.basename} (${formatSize(e.sizeBytes)}) — ${reason}`);
2954
2964
  }
2955
- console.log(`Freed: ${formatSize(result.freedBytes)}`);
2965
+ console.log(`${opts.dryRun ? "Would free" : "Freed"}: ${formatSize(result.freedBytes)}`);
2956
2966
  }
2967
+ printFailedDeletes(result.failed);
2968
+ if (result.failed.length > 0) process.exitCode = 1;
2957
2969
  break;
2958
2970
  }
2959
2971
  case "clear": {
2960
2972
  if (flags.all !== true) {
2961
- console.error("cache clear: refuses without --all (deletes all non-in-use cached images)");
2973
+ console.error("cache clear: refuses without --all (deletes all non-in-use cache entries)");
2962
2974
  process.exit(1);
2963
2975
  }
2964
2976
  const result = pruneCache({ maxSizeBytes: 0, dryRun: flags["dry-run"] === true });
2965
2977
  const verb = flags["dry-run"] === true ? "Would delete" : "Deleted";
2966
- console.log(`${verb} ${result.evicted.length} image(s), freed ${formatSize(result.freedBytes)}`);
2978
+ console.log(`${verb} ${countEntries(result.evicted.length)}, ${flags["dry-run"] === true ? "would free" : "freed"} ${formatSize(result.freedBytes)}`);
2979
+ printFailedDeletes(result.failed);
2980
+ if (result.failed.length > 0) process.exitCode = 1;
2967
2981
  if (result.kept.some((e) => e.inUse)) {
2968
2982
  const inUse = result.kept.filter((e) => e.inUse);
2969
- console.log(`Kept ${inUse.length} in-use image(s):`);
2983
+ console.log(`Kept ${inUse.length} in-use cache ${inUse.length === 1 ? "entry" : "entries"}:`);
2970
2984
  for (const e of inUse) console.log(` - ${e.basename}`);
2971
2985
  }
2972
2986
  break;
@@ -2992,7 +3006,7 @@ async function cmdSettings(argv: string[]) {
2992
3006
 
2993
3007
  const displayValue = (key: string, value: unknown): string => {
2994
3008
  if (value === undefined) return "(unset)";
2995
- if (key === "cache-max-size" && typeof value === "number") return formatSizeBytes(value);
3009
+ if ((key === "cache-max-size" || key === "storage-reserve") && typeof value === "number") return formatSizeBytes(value);
2996
3010
  if (key === "timeout-extra" && typeof value === "number") return `${value}s`;
2997
3011
  return String(value);
2998
3012
  };
@@ -3079,7 +3093,7 @@ async function cmdDoctor(argv: string[] = []) {
3079
3093
  const staleImages = await findStaleCacheImages();
3080
3094
  if (staleImages.length > 0) {
3081
3095
  const longTerm = staleImages[0]?.olderThan;
3082
- console.log(`Note: ${staleImages.length} cached image(s) older than current long-term (${longTerm}):`);
3096
+ console.log(`Note: ${staleImages.length} cache item(s) older than current long-term (${longTerm}):`);
3083
3097
  for (const e of staleImages) console.log(` - ${e.basename} (${e.version})`);
3084
3098
  console.log(" Hint: 'quickchr cache prune --older-than " + longTerm + "' to evict them.");
3085
3099
  console.log();
@@ -3174,6 +3188,7 @@ Commands:
3174
3188
  console <name> Attach to serial console of a running instance
3175
3189
  exec <name> <command> Run a RouterOS CLI command on a running instance
3176
3190
  (raw /rest/execute — see 'quickchr exec --help' for centrs)
3191
+ qga <name> <operation> Query the QEMU Guest Agent (x86 only)
3177
3192
  remove [<name>|--all] Remove instance(s) and disk
3178
3193
  clean [<name>|--all] Reset instance disk to fresh image
3179
3194
  get <name> [group] Show machine config (license, device-mode, admin)
@@ -3182,7 +3197,7 @@ Commands:
3182
3197
  disk <name> Show disk details for an instance
3183
3198
  snapshot <name> [cmd] Manage snapshots (list/save/load/delete)
3184
3199
  networks Network discovery & socket management
3185
- cache [add|key|list|prune|clear] Manage cached CHR images
3200
+ cache [add|key|list|prune|clear] Manage cached CHR images and packages
3186
3201
  settings [print|get|set|reset] Manage quickchr's own global settings (quickchr.env)
3187
3202
  completions Manage shell completions (Tab completion)
3188
3203
  logs <name> Tail the QEMU log for an instance
@@ -3292,6 +3307,13 @@ Examples:
3292
3307
  quickchr exec my-chr ":put [:serialize to=json [/ip/address/print]]"
3293
3308
  quickchr exec my-chr "/log/info message=hello"
3294
3309
 
3310
+ Exit status:
3311
+ 0 means the command was delivered, not that RouterOS accepted it. A rejected
3312
+ command (e.g. "failure: invalid user name") still exits 0, with
3313
+ RouterOS's message as the output. For a pass/fail exit code, use centrs
3314
+ (a write command needs --yes when not run at a TTY):
3315
+ centrs execute --quickchr <name> --yes <command>
3316
+
3295
3317
  ${CENTRS_SEE_ALSO}`);
3296
3318
  break;
3297
3319
  case "start":
@@ -3606,7 +3628,9 @@ Options:
3606
3628
  case "cache":
3607
3629
  console.log(`quickchr cache <add|key|list|prune|clear> [options]
3608
3630
 
3609
- Manage cached CHR images in the data dir cache directory.
3631
+ Manage cached CHR images and package archives in the data dir cache directory.
3632
+ An all-packages archive and its extraction are one entry; an interrupted download
3633
+ is a partial entry. Files quickchr did not write are reported, never deleted.
3610
3634
 
3611
3635
  Subcommands:
3612
3636
  add [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
@@ -3614,14 +3638,16 @@ Subcommands:
3614
3638
  key [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
3615
3639
  Print cache directory, resolved version, and architecture for CI.
3616
3640
  Without --json, output is GITHUB_OUTPUT-compatible key=value lines.
3617
- list [--json] List cached images (basename, version, arch, size, age, in-use).
3618
- prune [options] Evict cached images per policy. Never evicts in-use images.
3619
- --older-than <ver> Evict images with version < <ver>.
3620
- --max-age <dur> Evict images older than duration (e.g. 7d, 24h).
3621
- --max-size <size> Evict oldest-version images until total size <= <size>.
3641
+ list [--json] List cache entries (basename, version, arch, size, age, in-use).
3642
+ prune [options] Evict cache entries per policy. Never evicts in-use entries.
3643
+ --older-than <ver> Evict entries with version < <ver>.
3644
+ --max-age <dur> Evict entries not used for longer than duration (e.g. 7d, 24h).
3645
+ --max-size <size> Evict entries until total size <= <size>: partial downloads
3646
+ first, then oldest RouterOS version.
3622
3647
  --dry-run Show what would be evicted without deleting.
3623
- clear --all Delete all non-in-use cached images.
3648
+ clear --all Delete all non-in-use cache entries.
3624
3649
 
3650
+ A failed delete is listed on stderr, is not counted as freed, and exits 1.
3625
3651
  Duration: <N>(s|m|h|d|w) Size: <N>(K|M|G|T)[i][B] (case-insensitive, IEC binary).
3626
3652
 
3627
3653
  Default auto-prune cap: 2 GiB — override via 'quickchr settings set cache-max-size <size>'
@@ -3641,6 +3667,8 @@ Managed keys:
3641
3667
  auto|tcg|hvf|kvm (default: auto = detect)
3642
3668
  cache-max-size Auto-prune cap applied after each successful start.
3643
3669
  Size string (e.g. 2G, 512M). (default: 2G)
3670
+ storage-reserve Free space creating a machine or filling the cache must
3671
+ leave, beyond what it writes. Size string. (default: 2G)
3644
3672
  timeout-extra Default for 'start's --timeout-extra/-T when omitted.
3645
3673
  Non-negative integer seconds. (default: 0)
3646
3674
  secure-login Default for 'add'/'start's --secure-login when neither
package/src/lib/cache.ts CHANGED
@@ -1,20 +1,27 @@
1
1
  /**
2
- * Cache retention policy for downloaded CHR images.
2
+ * Cache retention policy for downloaded CHR images and package archives.
3
3
  *
4
- * - Default cap: 2 GB total cache size (size-based eviction).
4
+ * - Default cap: 2 GB total cache size (size-based eviction), counting images, package
5
+ * archives with their extractions, and interrupted downloads.
5
6
  * - Eviction order: oldest RouterOS version first, mtime tiebreaker.
6
7
  * - Never evicts an image referenced by any machine (running or stopped).
7
8
  * - Callers can pin extra versions via `protectVersions` (e.g. current long-term).
8
9
  */
9
10
 
10
- import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
11
+ import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
11
12
  import { join } from "node:path";
12
- import { getCacheDir, loadAllMachines } from "./state.ts";
13
+ import { directorySize, getCacheDir, loadAllMachines } from "./state.ts";
14
+ import { isPidAlive, liveLeases, pinnedEntries, withStorageLock } from "./storage-lease.ts";
13
15
  import { chrImageBasename, compareRouterOsVersion, isValidVersion } from "./versions.ts";
14
16
  import { QuickCHRError, type Arch } from "./types.ts";
15
17
 
16
18
  export const DEFAULT_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
17
19
 
20
+ /** Free space quickchr keeps on the data dir's filesystem beyond what an operation
21
+ * writes — the `storage-reserve` setting's default. Lives here beside the cache cap
22
+ * so settings.ts can read both without importing storage.ts (which imports settings). */
23
+ export const DEFAULT_STORAGE_RESERVE_BYTES = 2 * 1024 * 1024 * 1024;
24
+
18
25
  /** Parse a human size string (NK/NM/NG/NT, IEC binary, case-insensitive) to bytes. */
19
26
  export function parseSizeString(s: string): number {
20
27
  const m = s.match(/^(\d+(?:\.\d+)?)([KMGT]?)i?B?$/i);
@@ -25,6 +32,11 @@ export function parseSizeString(s: string): number {
25
32
  return Math.floor(n * (mult[unit] ?? 1));
26
33
  }
27
34
 
35
+ /** "1 entry" / "3 entries". */
36
+ export function countEntries(n: number): string {
37
+ return `${n} ${n === 1 ? "entry" : "entries"}`;
38
+ }
39
+
28
40
  /** Format a byte count as a human size (IEC binary), matching cache list's display. */
29
41
  export function formatSizeBytes(b: number): string {
30
42
  if (b >= 1024 ** 3) return `${(b / 1024 ** 3).toFixed(2)} GiB`;
@@ -33,34 +45,72 @@ export function formatSizeBytes(b: number): string {
33
45
  return `${b} B`;
34
46
  }
35
47
 
48
+ /** What a cache entry is. `packages` is one archive + its extraction, counted and
49
+ * deleted together: the extraction is only a cache hit while the archive it was
50
+ * made from is still there. `partial` is an interrupted download or extraction. */
51
+ export type CacheEntryKind = "image" | "image-zip" | "packages" | "partial";
52
+
36
53
  export interface CacheEntry {
54
+ /** The entry's main file or directory. */
37
55
  path: string;
56
+ /** Everything deleting this entry removes. */
57
+ paths: string[];
38
58
  basename: string;
59
+ kind: CacheEntryKind;
39
60
  version: string;
40
61
  arch: Arch | "unknown";
41
62
  sizeBytes: number;
63
+ /** Last write, or last use: a cache hit touches the image or package archive. */
42
64
  mtimeMs: number;
43
65
  inUse: boolean;
44
66
  }
45
67
 
68
+ export interface CacheInventory {
69
+ entries: CacheEntry[];
70
+ /** Bytes in files quickchr did not write. Listed, never deleted. */
71
+ unmanagedBytes: number;
72
+ }
73
+
46
74
  export interface PruneOptions {
47
75
  cacheDir?: string;
48
76
  maxSizeBytes?: number;
49
77
  maxAgeMs?: number;
50
78
  olderThan?: string;
51
79
  protectVersions?: string[];
80
+ /** Evict eligible entries, oldest version first, until this many bytes are gone. */
81
+ reclaimBytes?: number;
82
+ /** Keep entries written or used this recently. Pins are what protect a reader; this
83
+ * also spares what a just-finished `cache add` fetched for a later process. */
84
+ skipRecentMs?: number;
85
+ /** Leave these entries alone (a reclaim loop's earlier failed deletes). */
86
+ skipEntries?: string[];
52
87
  dryRun?: boolean;
53
88
  }
54
89
 
55
90
  export interface PruneResult {
56
91
  evicted: CacheEntry[];
57
92
  kept: CacheEntry[];
93
+ /** Entries chosen for eviction whose delete failed. Neither evicted nor kept, and not
94
+ * counted in `freedBytes`. */
95
+ failed: Array<{ entry: CacheEntry; error: string }>;
96
+ /** Logical size of what was deleted. Not a measured change in free space: sparse
97
+ * files, clones and concurrent writers all make the two differ. */
58
98
  freedBytes: number;
59
99
  reasons: Record<string, string>;
60
100
  }
61
101
 
102
+ /** Anything an automatic eviction leaves alone because another operation may be using
103
+ * it right now — a create converting from the image, a first boot installing packages. */
104
+ export const RECENT_USE_MS = 30 * 60 * 1000;
105
+
62
106
  /** Parse a cached file basename into version + arch. Returns "unknown" parts when unparseable. */
63
107
  export function parseCacheBasename(basename: string): { version: string; arch: Arch | "unknown" } {
108
+ const packages = basename.match(/^(?:all_)?packages-(x86|arm64)-(.+?)(?:\.zip)?$/);
109
+ if (packages) {
110
+ const version = packages[2] ?? "unknown";
111
+ return { version: isValidVersion(version) ? version : "unknown", arch: packages[1] as Arch };
112
+ }
113
+
64
114
  // Strip recognized cache extensions (.img, .img.zip).
65
115
  let stem = basename;
66
116
  if (stem.endsWith(".img.zip")) stem = stem.slice(0, -".img.zip".length);
@@ -76,6 +126,9 @@ export function parseCacheBasename(basename: string): { version: string; arch: A
76
126
  return { version, arch };
77
127
  }
78
128
 
129
+ /** Cache names machines still need. Every machine keeps its factory image: `clean` and a
130
+ * failed first boot's rollback rebuild from it. A machine keeps its package cache only
131
+ * until its first-boot provisioning is stamped done. */
79
132
  function inUseSet(): Set<string> {
80
133
  const set = new Set<string>();
81
134
  let machines: ReturnType<typeof loadAllMachines>;
@@ -88,16 +141,24 @@ function inUseSet(): Set<string> {
88
141
  if (!m.version || !m.arch) continue;
89
142
  set.add(`${chrImageBasename(m.version, m.arch)}.img`);
90
143
  set.add(`${chrImageBasename(m.version, m.arch)}.img.zip`);
144
+ const wantsPackages = m.installAllPackages === true || (m.packages?.length ?? 0) > 0;
145
+ if (wantsPackages && !m.provisioning) set.add(`packages-${m.arch}-${m.version}`);
91
146
  }
92
147
  return set;
93
148
  }
94
149
 
95
- /** List cache entries with parsed metadata and in-use status. */
96
- export function listCacheEntries(cacheDir?: string): CacheEntry[] {
150
+ /** Inventory the cache: every entry quickchr manages, plus the bytes it does not.
151
+ * An entry is in use when a machine needs it or a live operation has pinned it. */
152
+ export function cacheInventory(cacheDir?: string, pins: Set<string> = pinnedEntries()): CacheInventory {
97
153
  const dir = cacheDir ?? getCacheDir();
98
- if (!existsSync(dir)) return [];
154
+ if (!existsSync(dir)) return { entries: [], unmanagedBytes: 0 };
99
155
  const used = inUseSet();
100
- const out: CacheEntry[] = [];
156
+ for (const pin of pins) used.add(pin);
157
+ const now = Date.now();
158
+ const entries: CacheEntry[] = [];
159
+ const packageGroups = new Map<string, CacheEntry>();
160
+ let unmanagedBytes = 0;
161
+
101
162
  for (const name of readdirSync(dir)) {
102
163
  const full = join(dir, name);
103
164
  let stat: ReturnType<typeof statSync>;
@@ -106,20 +167,94 @@ export function listCacheEntries(cacheDir?: string): CacheEntry[] {
106
167
  } catch {
107
168
  continue;
108
169
  }
109
- if (!stat.isFile()) continue;
110
- if (!name.endsWith(".img") && !name.endsWith(".img.zip")) continue;
170
+ const sizeBytes = stat.isDirectory() ? directorySize(full) : stat.size;
171
+
172
+ // Only exact quickchr names are managed: `all_packages-<arch>-<ver>.zip` (a file)
173
+ // and `packages-<arch>-<ver>` (a directory), with a real RouterOS version.
174
+ const packages = name.match(/^(all_)?packages-(x86|arm64)-(.+?)(\.zip)?$/);
175
+ const isPackageArtifact = packages !== null && isValidVersion(packages[3] ?? "") &&
176
+ (packages[1] && packages[4] ? stat.isFile() : !packages[1] && !packages[4] && stat.isDirectory());
177
+ if (packages && isPackageArtifact) {
178
+ const key = `packages-${packages[2]}-${packages[3]}`;
179
+ const group = packageGroups.get(key);
180
+ if (group) {
181
+ group.paths.push(full);
182
+ group.sizeBytes += sizeBytes;
183
+ group.mtimeMs = Math.max(group.mtimeMs, stat.mtimeMs);
184
+ } else {
185
+ const { version, arch } = parseCacheBasename(key);
186
+ packageGroups.set(key, {
187
+ path: join(dir, key),
188
+ paths: [full],
189
+ basename: key,
190
+ kind: "packages",
191
+ version,
192
+ arch,
193
+ sizeBytes,
194
+ mtimeMs: stat.mtimeMs,
195
+ inUse: used.has(key),
196
+ });
197
+ }
198
+ continue;
199
+ }
200
+
201
+ // `<target>.<pid>-<id>.part` (download.ts) is live while its writer is.
202
+ // `.quickchr-image-*` / `.quickchr-packages-*` extraction dirs have no owner
203
+ // recorded, so they count as live for RECENT_USE_MS after their last write.
204
+ const part = name.match(/^(.+)\.(\d+)-[0-9a-f]+\.part$/);
205
+ const extraction = stat.isDirectory() && (name.startsWith(".quickchr-image-") || name.startsWith(".quickchr-packages-"));
206
+ if ((part && isManagedDownloadTarget(part[1] ?? "")) || extraction) {
207
+ const { version, arch } = parseCacheBasename(part?.[1] ?? name);
208
+ entries.push({
209
+ path: full,
210
+ paths: [full],
211
+ basename: name,
212
+ kind: "partial",
213
+ version,
214
+ arch,
215
+ sizeBytes,
216
+ mtimeMs: stat.mtimeMs,
217
+ inUse: part ? isPidAlive(Number(part[2])) : now - stat.mtimeMs < RECENT_USE_MS,
218
+ });
219
+ continue;
220
+ }
221
+
222
+ const kind: CacheEntryKind | undefined = !stat.isFile()
223
+ ? undefined
224
+ : name.endsWith(".img.zip") ? "image-zip" : name.endsWith(".img") ? "image" : undefined;
111
225
  const { version, arch } = parseCacheBasename(name);
112
- out.push({
226
+ if (!kind || version === "unknown") {
227
+ unmanagedBytes += sizeBytes;
228
+ continue;
229
+ }
230
+ entries.push({
113
231
  path: full,
232
+ paths: [full],
114
233
  basename: name,
234
+ kind,
115
235
  version,
116
236
  arch,
117
- sizeBytes: stat.size,
237
+ sizeBytes,
118
238
  mtimeMs: stat.mtimeMs,
119
239
  inUse: used.has(name),
120
240
  });
121
241
  }
122
- return out;
242
+
243
+ entries.push(...packageGroups.values());
244
+ return { entries, unmanagedBytes };
245
+ }
246
+
247
+ /** True for the files download.ts writes into the cache: an image ZIP or an
248
+ * all-packages ZIP, with a real RouterOS version. Anything else is not ours. */
249
+ function isManagedDownloadTarget(name: string): boolean {
250
+ if (name.endsWith(".img.zip")) return parseCacheBasename(name).version !== "unknown";
251
+ const packages = name.match(/^all_packages-(x86|arm64)-(.+)\.zip$/);
252
+ return packages !== null && isValidVersion(packages[2] ?? "");
253
+ }
254
+
255
+ /** List cache entries with parsed metadata and in-use status. */
256
+ export function listCacheEntries(cacheDir?: string): CacheEntry[] {
257
+ return cacheInventory(cacheDir).entries;
123
258
  }
124
259
 
125
260
  function compareVersionsSafe(a: string, b: string): number {
@@ -140,11 +275,24 @@ function compareVersionsSafe(a: string, b: string): number {
140
275
  * - `maxSizeBytes`: total bytes exceed cap
141
276
  * - `maxAgeMs`: any entry older than threshold
142
277
  * - `olderThan`: any entry's parsed version older than threshold
278
+ * - `reclaimBytes`: evict until this many bytes are gone
143
279
  *
144
- * In-use entries and `protectVersions` entries are never evicted.
280
+ * In-use entries, `protectVersions` entries and (with `skipRecentMs`) recently used
281
+ * entries are never evicted. Interrupted downloads go first, then oldest version.
145
282
  */
146
283
  export function pruneCache(opts: PruneOptions = {}): PruneResult {
147
- const entries = listCacheEntries(opts.cacheDir);
284
+ // A dry run deletes nothing, so it reads the pins without taking the lock.
285
+ return opts.dryRun ? pruneCacheUnderLock(opts, pinnedEntries()) : withStorageLock(() => pruneCacheUnderLock(opts));
286
+ }
287
+
288
+ /** `pruneCache` for a caller already holding the storage lock (storage.ts admission).
289
+ * Entries pinned by a live lease count as in use. */
290
+ export function pruneCacheUnderLock(
291
+ opts: PruneOptions = {},
292
+ pins: Set<string> = new Set(liveLeases().flatMap((lease) => lease.pins)),
293
+ ): PruneResult {
294
+ const entries = cacheInventory(opts.cacheDir, pins).entries;
295
+ const skip = new Set(opts.skipEntries ?? []);
148
296
  const protect = new Set(opts.protectVersions ?? []);
149
297
  const dryRun = opts.dryRun ?? false;
150
298
  const now = Date.now();
@@ -152,6 +300,7 @@ export function pruneCache(opts: PruneOptions = {}): PruneResult {
152
300
  const reasons: Record<string, string> = {};
153
301
  const evicted: CacheEntry[] = [];
154
302
  const kept: CacheEntry[] = [];
303
+ const failed: PruneResult["failed"] = [];
155
304
 
156
305
  const protectedEntries: CacheEntry[] = [];
157
306
  const candidates: CacheEntry[] = [];
@@ -159,27 +308,44 @@ export function pruneCache(opts: PruneOptions = {}): PruneResult {
159
308
  for (const e of entries) {
160
309
  if (e.inUse) {
161
310
  protectedEntries.push(e);
162
- continue;
163
- }
164
- if (protect.has(e.version)) {
311
+ reasons[e.basename] = e.kind === "partial" ? "in progress" : "in use";
312
+ } else if (protect.has(e.version)) {
165
313
  protectedEntries.push(e);
166
- continue;
314
+ reasons[e.basename] = `protected (version ${e.version})`;
315
+ } else if (skip.has(e.basename)) {
316
+ protectedEntries.push(e);
317
+ reasons[e.basename] = "skipped";
318
+ } else if (opts.skipRecentMs !== undefined && now - e.mtimeMs < opts.skipRecentMs) {
319
+ protectedEntries.push(e);
320
+ reasons[e.basename] = "used recently";
321
+ } else {
322
+ candidates.push(e);
167
323
  }
168
- candidates.push(e);
169
324
  }
170
325
 
171
326
  candidates.sort((a, b) => {
327
+ const partial = Number(b.kind === "partial") - Number(a.kind === "partial");
328
+ if (partial !== 0) return partial;
172
329
  const v = compareVersionsSafe(a.version, b.version);
173
330
  if (v !== 0) return v;
174
331
  return a.mtimeMs - b.mtimeMs;
175
332
  });
176
333
 
177
- const evict = (e: CacheEntry, reason: string) => {
178
- evicted.push(e);
179
- reasons[e.basename] = reason;
334
+ /** True when the entry is gone (or, in a dry run, would be). */
335
+ const evict = (e: CacheEntry, reason: string): boolean => {
180
336
  if (!dryRun) {
181
- try { unlinkSync(e.path); } catch { /* best-effort */ }
337
+ try {
338
+ for (const path of e.paths) rmSync(path, { recursive: true, force: true });
339
+ } catch (error) {
340
+ const message = error instanceof Error ? error.message : String(error);
341
+ failed.push({ entry: e, error: message });
342
+ reasons[e.basename] = `delete failed: ${message}`;
343
+ return false;
344
+ }
182
345
  }
346
+ evicted.push(e);
347
+ reasons[e.basename] = reason;
348
+ return true;
183
349
  };
184
350
 
185
351
  const remaining: CacheEntry[] = [];
@@ -212,31 +378,34 @@ export function pruneCache(opts: PruneOptions = {}): PruneResult {
212
378
  }
213
379
 
214
380
  const keptSoFar: CacheEntry[] = [...remainingAfterAge];
381
+ const totalSize = (es: CacheEntry[]) => es.reduce((s, x) => s + x.sizeBytes, 0);
215
382
 
216
383
  if (opts.maxSizeBytes !== undefined) {
217
384
  const cap = opts.maxSizeBytes;
218
- const totalSize = (es: CacheEntry[]) => es.reduce((s, x) => s + x.sizeBytes, 0);
219
- let total = totalSize(protectedEntries) + totalSize(keptSoFar);
385
+ let total = totalSize(protectedEntries) + totalSize(keptSoFar) + totalSize(failed.map((f) => f.entry));
220
386
  while (total > cap && keptSoFar.length > 0) {
221
387
  const victim = keptSoFar.shift();
222
388
  if (!victim) break;
223
- evict(victim, `size cap ${cap} exceeded (oldest version)`);
224
- total -= victim.sizeBytes;
389
+ if (evict(victim, `size cap ${formatSizeBytes(cap)} exceeded (oldest version)`)) total -= victim.sizeBytes;
225
390
  }
226
391
  }
227
392
 
228
- for (const e of protectedEntries) {
229
- kept.push(e);
230
- if (e.inUse) reasons[e.basename] ??= "in use";
231
- else reasons[e.basename] ??= `protected (version ${e.version})`;
393
+ if (opts.reclaimBytes !== undefined) {
394
+ const target = opts.reclaimBytes;
395
+ while (totalSize(evicted) < target && keptSoFar.length > 0) {
396
+ const victim = keptSoFar.shift();
397
+ if (!victim) break;
398
+ evict(victim, `reclaimed for space (oldest version)`);
399
+ }
232
400
  }
401
+
402
+ for (const e of protectedEntries) kept.push(e);
233
403
  for (const e of keptSoFar) {
234
404
  kept.push(e);
235
405
  reasons[e.basename] ??= "kept";
236
406
  }
237
407
 
238
- const freedBytes = evicted.reduce((s, e) => s + e.sizeBytes, 0);
239
- return { evicted, kept, freedBytes, reasons };
408
+ return { evicted, kept, failed, freedBytes: totalSize(evicted), reasons };
240
409
  }
241
410
 
242
411
  /**
@@ -249,21 +418,34 @@ export function autoPruneIfOverCap(opts: { logger?: (msg: string) => void; cache
249
418
  const entries = listCacheEntries(opts.cacheDir);
250
419
  const total = entries.reduce((s, e) => s + e.sizeBytes, 0);
251
420
  if (total <= cap) {
252
- return { evicted: [], kept: entries, freedBytes: 0, reasons: {} };
421
+ return { evicted: [], kept: entries, failed: [], freedBytes: 0, reasons: {} };
253
422
  }
254
423
  const result = pruneCache({
255
424
  cacheDir: opts.cacheDir,
256
425
  maxSizeBytes: cap,
257
426
  protectVersions: opts.protectVersions,
427
+ skipRecentMs: RECENT_USE_MS,
258
428
  });
259
- if (opts.logger && result.evicted.length > 0) {
260
- opts.logger(`cache: evicted ${result.evicted.length} image(s), freed ${(result.freedBytes / 1024 / 1024).toFixed(1)} MiB`);
429
+ if (opts.logger) {
430
+ for (const line of describePrune(result)) opts.logger(line);
261
431
  }
262
432
  return result;
263
433
  } catch (e) {
264
434
  if (opts.logger) {
265
435
  opts.logger(`cache: auto-prune skipped: ${e instanceof Error ? e.message : String(e)}`);
266
436
  }
267
- return { evicted: [], kept: [], freedBytes: 0, reasons: {} };
437
+ return { evicted: [], kept: [], failed: [], freedBytes: 0, reasons: {} };
438
+ }
439
+ }
440
+
441
+ /** One-line log summary of an eviction, naming what went, plus one line per failure. */
442
+ export function describePrune(result: PruneResult): string[] {
443
+ const lines: string[] = [];
444
+ if (result.evicted.length > 0) {
445
+ lines.push(`cache: evicted ${result.evicted.map((e) => e.basename).join(", ")} — freed ${formatSizeBytes(result.freedBytes)}`);
446
+ }
447
+ for (const { entry, error } of result.failed) {
448
+ lines.push(`cache: could not delete ${entry.basename}: ${error}`);
268
449
  }
450
+ return lines;
269
451
  }