@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 +1 -1
- package/package.json +1 -1
- package/src/cli/index.ts +182 -4
- package/src/index.ts +8 -1
- package/src/lib/auth.ts +21 -0
- package/src/lib/cache.ts +251 -0
- package/src/lib/forward-spec.ts +92 -0
- package/src/lib/guest-ports.ts +67 -0
- package/src/lib/network.ts +73 -0
- package/src/lib/packages.ts +8 -55
- package/src/lib/quickchr.ts +83 -5
- package/src/lib/scp.ts +97 -0
- package/src/lib/socket-registry.ts +23 -4
- package/src/lib/types.ts +82 -4
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
|
|
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
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
|
+
}
|
package/src/lib/cache.ts
ADDED
|
@@ -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
|
+
}
|