@tikoci/quickchr 0.4.8 → 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/DESIGN.md +219 -14
- package/MANUAL.md +396 -71
- package/README.md +33 -8
- package/package.json +2 -2
- package/src/cli/flags.ts +22 -3
- package/src/cli/index.ts +461 -104
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +2 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/cache.ts +220 -38
- package/src/lib/completions.ts +20 -11
- package/src/lib/device-mode.ts +149 -10
- package/src/lib/diagnostics.ts +13 -6
- package/src/lib/disk.ts +90 -33
- package/src/lib/download.ts +3 -2
- package/src/lib/images.ts +34 -15
- package/src/lib/lock.ts +498 -0
- package/src/lib/names.ts +24 -0
- package/src/lib/network.ts +16 -16
- package/src/lib/packages.ts +66 -40
- package/src/lib/provision.ts +3 -2
- package/src/lib/provisioning-window.ts +356 -0
- package/src/lib/qemu.ts +54 -2
- package/src/lib/quickchr.ts +950 -359
- package/src/lib/rename.ts +41 -0
- package/src/lib/settings.ts +20 -9
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +2 -2
- package/src/lib/state.ts +89 -2
- package/src/lib/storage-lease.ts +169 -0
- package/src/lib/storage.ts +223 -66
- package/src/lib/types.ts +109 -23
- package/src/lib/versions.ts +54 -0
package/src/cli/index.ts
CHANGED
|
@@ -3,18 +3,20 @@
|
|
|
3
3
|
* quickchr CLI entry point — command router.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { StartOptions, Arch, Channel, ServiceName, NetworkSpecifier } from "../lib/types.ts";
|
|
6
|
+
import type { StartOptions, Arch, Channel, DeviceModeOptions, ServiceName, NetworkSpecifier } from "../lib/types.ts";
|
|
7
7
|
import { parseNetworkSpecifier } from "../lib/network.ts";
|
|
8
8
|
import { expandForwardSpec } from "../lib/forward-spec.ts";
|
|
9
|
-
import { ADD_FLAGS, REMOVED_FLAGS, START_FLAGS, VALUE_FLAGS, unknownFlags, unknownFlagMessage, valuelessFlags, valuelessFlagMessage } from "./flags.ts";
|
|
9
|
+
import { ADD_FLAGS, REMOVED_FLAGS, SET_FLAGS, START_FLAGS, VALUE_FLAGS, unknownFlags, unknownFlagMessage, valuelessFlags, valuelessFlagMessage } from "./flags.ts";
|
|
10
10
|
import { QuickCHRError } from "../lib/types.ts";
|
|
11
11
|
import { CENTRS_EXEC_TIP, CENTRS_SEE_ALSO, tip, tipsForError } from "./tips.ts";
|
|
12
12
|
import {
|
|
13
|
+
isRouterOs6,
|
|
13
14
|
MIN_PROVISION_VERSION,
|
|
14
15
|
PROVISIONING_BOOT_ONLY_SUMMARY,
|
|
15
16
|
PROVISIONING_FEATURE_SUMMARY,
|
|
16
17
|
provisioningSupportHint,
|
|
17
18
|
provisioningSupportSummary,
|
|
19
|
+
ROUTEROS6_BOOT_ONLY,
|
|
18
20
|
} from "../lib/versions.ts";
|
|
19
21
|
|
|
20
22
|
const args = process.argv.slice(2);
|
|
@@ -103,6 +105,20 @@ function flagBool(flags: Record<string, string | boolean | string[]>, key: strin
|
|
|
103
105
|
return flags[key] === true || flags[key] === "true";
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
/** A boolean flag that keeps "not given" apart from `--no-<key>`: `true`, `false`, or
|
|
109
|
+
* `undefined` when the flag is absent.
|
|
110
|
+
*
|
|
111
|
+
* `flagBool()` collapses absent into `false`, and that is the wrong answer for any
|
|
112
|
+
* option `start()` merges with stored intent (`opts.x ?? existing.x`): `false ?? true`
|
|
113
|
+
* is `false`, so `quickchr add --install-all-packages` followed by a plain
|
|
114
|
+
* `quickchr start <name>` booted without the packages and said nothing (#185). Same
|
|
115
|
+
* rule as `--no-device-mode`: silence and refusal are different instructions, and only
|
|
116
|
+
* silence may fall through to what `add` recorded. */
|
|
117
|
+
export function flagOptionalBool(flags: Record<string, string | boolean | string[]>, key: string): boolean | undefined {
|
|
118
|
+
if (flags[key] === undefined) return undefined;
|
|
119
|
+
return flags[key] !== false && flags[key] !== "false";
|
|
120
|
+
}
|
|
121
|
+
|
|
106
122
|
function flagList(flags: Record<string, string | boolean | string[]>, key: string): string[] {
|
|
107
123
|
const val = flags[key];
|
|
108
124
|
if (Array.isArray(val)) return val;
|
|
@@ -114,6 +130,49 @@ function csvList(values: string[]): string[] {
|
|
|
114
130
|
return [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean))];
|
|
115
131
|
}
|
|
116
132
|
|
|
133
|
+
/** The device-mode request the `--device-mode*` flags describe, or `undefined` when
|
|
134
|
+
* they describe nothing.
|
|
135
|
+
*
|
|
136
|
+
* One reader for all three verbs that take these flags. `add` and `start` had
|
|
137
|
+
* separate copies and they had already drifted: only `start` honoured
|
|
138
|
+
* `--no-device-mode`, so `quickchr add --no-device-mode --device-mode-enable container`
|
|
139
|
+
* enabled container while the same flags on `start` skipped device-mode with a
|
|
140
|
+
* warning. Two spellings of one intent producing opposite results is the silent-drop
|
|
141
|
+
* failure in another costume (#176).
|
|
142
|
+
*
|
|
143
|
+
* `--no-device-mode` means *skip device-mode entirely*, so it wins over the feature
|
|
144
|
+
* lists and says so rather than quietly discarding them.
|
|
145
|
+
*
|
|
146
|
+
* It returns an explicit `{ mode: "skip" }` rather than `undefined`, because
|
|
147
|
+
* `undefined` means *the user said nothing* and the two are not the same instruction.
|
|
148
|
+
* `start()` resolves a silent request against stored intent — `opts.deviceMode ??
|
|
149
|
+
* existing.deviceMode` — so a machine created with `add --device-mode-enable
|
|
150
|
+
* container` and then started with `--no-device-mode` provisioned container anyway,
|
|
151
|
+
* power cycle included. Measured on CHR 7.24.4: 48 s and `container=yes`, from a flag
|
|
152
|
+
* whose documented job is to skip the step. A flag that is accepted and does nothing
|
|
153
|
+
* is the defect this whole area exists to remove (#176). */
|
|
154
|
+
export function deviceModeFromFlags(
|
|
155
|
+
flags: Record<string, string | boolean | string[]>,
|
|
156
|
+
): DeviceModeOptions | undefined {
|
|
157
|
+
const mode = flag(flags, "device-mode");
|
|
158
|
+
const enable = csvList(flagList(flags, "device-mode-enable"));
|
|
159
|
+
const disable = csvList(flagList(flags, "device-mode-disable"));
|
|
160
|
+
|
|
161
|
+
if (flags["device-mode"] === false) {
|
|
162
|
+
if (enable.length > 0 || disable.length > 0) {
|
|
163
|
+
console.warn("Warning: --device-mode-enable/--device-mode-disable ignored because --no-device-mode was set.");
|
|
164
|
+
}
|
|
165
|
+
return { mode: "skip" };
|
|
166
|
+
}
|
|
167
|
+
if (mode === undefined && enable.length === 0 && disable.length === 0) return undefined;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
mode: mode ?? "auto",
|
|
171
|
+
enable: enable.length > 0 ? enable : undefined,
|
|
172
|
+
disable: disable.length > 0 ? disable : undefined,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
117
176
|
/** Build networks array from --add-network and --no-network. */
|
|
118
177
|
function buildNetworks(flags: Record<string, string | boolean | string[]>): NetworkSpecifier[] | undefined {
|
|
119
178
|
const specs: NetworkSpecifier[] = [];
|
|
@@ -222,10 +281,27 @@ export async function applyAccelFlag(flags: Record<string, string | boolean | st
|
|
|
222
281
|
|
|
223
282
|
/** Resolve --secure-login/--no-secure-login, falling back to the secure-login setting
|
|
224
283
|
* only when neither flag was passed. Shared by cmdAdd, cmdStart, and cmdStart's
|
|
225
|
-
* --all bulk-restart path.
|
|
226
|
-
|
|
284
|
+
* --all bulk-restart path.
|
|
285
|
+
*
|
|
286
|
+
* `applySetting` is **required**, and deliberately so. The setting is a *first-boot*
|
|
287
|
+
* default: carrying it into the start of a machine whose provisioning window has
|
|
288
|
+
* closed turns `quickchr start <name>` — and every such machine in `start --all` —
|
|
289
|
+
* into a request to provision a booted guest, which now refuses out loud (#176).
|
|
290
|
+
* Callers pass the target's own window state, not "does this machine exist": a
|
|
291
|
+
* machine that was `add`ed and never started is still on its first boot and keeps
|
|
292
|
+
* the setting. An explicit flag always wins over both.
|
|
293
|
+
*
|
|
294
|
+
* Making the argument required is the guard. An earlier pass left `cmdStart`'s
|
|
295
|
+
* single-target call on the defaulted form while believing it had been updated, so
|
|
296
|
+
* the restart regression stayed live behind a fix that had landed in `cmdAdd`
|
|
297
|
+
* instead. A missing argument is now a type error rather than a silent default. */
|
|
298
|
+
export async function resolveSecureLoginFlag(
|
|
299
|
+
flags: Record<string, string | boolean | string[]>,
|
|
300
|
+
applySetting: boolean,
|
|
301
|
+
): Promise<boolean | undefined> {
|
|
227
302
|
if (flags["secure-login"] === false) return false;
|
|
228
303
|
if (flagBool(flags, "secure-login")) return true;
|
|
304
|
+
if (!applySetting) return undefined;
|
|
229
305
|
const { resolveSetting } = await import("../lib/settings.ts");
|
|
230
306
|
return resolveSetting("secure-login").value === true ? true : undefined;
|
|
231
307
|
}
|
|
@@ -264,6 +340,9 @@ async function main() {
|
|
|
264
340
|
case "stop":
|
|
265
341
|
await cmdStop(args.slice(1));
|
|
266
342
|
break;
|
|
343
|
+
case "shutdown":
|
|
344
|
+
await cmdShutdown(args.slice(1));
|
|
345
|
+
break;
|
|
267
346
|
case "list":
|
|
268
347
|
case "ls":
|
|
269
348
|
case "status":
|
|
@@ -360,7 +439,9 @@ async function main() {
|
|
|
360
439
|
console.error(` Hint: ${err.installHint}`);
|
|
361
440
|
}
|
|
362
441
|
for (const line of tipsForError(err.code, command)) tip(line);
|
|
363
|
-
|
|
442
|
+
// The 7.x baseline explanation; a RouterOS 6 refusal already says why, and
|
|
443
|
+
// "for older 7.x" would point a 6.x user at the wrong fix.
|
|
444
|
+
if (err.code === "PROVISIONING_VERSION_UNSUPPORTED" && !err.message.includes(ROUTEROS6_BOOT_ONLY)) {
|
|
364
445
|
console.error(` Why this happened: ${provisioningSupportSummary(MIN_PROVISION_VERSION)}`);
|
|
365
446
|
console.error(` ${PROVISIONING_BOOT_ONLY_SUMMARY}`);
|
|
366
447
|
console.error(" Try this:");
|
|
@@ -375,6 +456,12 @@ async function main() {
|
|
|
375
456
|
|
|
376
457
|
// --- Commands ---
|
|
377
458
|
|
|
459
|
+
/** RouterOS 6 in `add`/`start --help` (#182): what it is for, and what it is not. */
|
|
460
|
+
const ROUTEROS6_HELP = `RouterOS 6.x is a best-effort boot-only target (x86 only): quickchr downloads,
|
|
461
|
+
boots and tracks it, and reports its ports and login. It is there for testing v6→v7
|
|
462
|
+
migration tooling, not as supported RouterOS. Readiness is a native-API login, every
|
|
463
|
+
provisioning flag is refused, and REST-based commands (exec, get, set) say so.`;
|
|
464
|
+
|
|
378
465
|
/** Print QEMU keyboard shortcuts before foreground launch. Screen clears after QEMU starts. */
|
|
379
466
|
function printForegroundTips(hasProvisioning = false) {
|
|
380
467
|
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
|
|
@@ -626,13 +713,15 @@ async function cmdAdd(argv: string[]) {
|
|
|
626
713
|
|
|
627
714
|
const opts: StartOptions = {
|
|
628
715
|
version: flag(flags, "version"),
|
|
629
|
-
|
|
716
|
+
// The default channel only stands in for a missing --version; applied alongside
|
|
717
|
+
// one, it reads to start()/add() as a caller passing both (#178).
|
|
718
|
+
channel: (flag(flags, "channel") as Channel | undefined) ?? (flag(flags, "version") ? undefined : resolveSetting("default-channel").value as Channel | undefined),
|
|
630
719
|
arch: (flag(flags, "arch") as Arch | "auto" | undefined) ?? (resolveSetting("default-arch").value as Arch | "auto" | undefined),
|
|
631
720
|
name: flag(flags, "name") ?? positional[0],
|
|
632
721
|
cpu: flag(flags, "cpu") ? Number(flag(flags, "cpu")) : undefined,
|
|
633
722
|
mem: flag(flags, "mem") ? Number(flag(flags, "mem")) : undefined,
|
|
634
723
|
packages: csvList(flagList(flags, "add-package")),
|
|
635
|
-
installAllPackages:
|
|
724
|
+
installAllPackages: flagOptionalBool(flags, "install-all-packages"),
|
|
636
725
|
portBase: flag(flags, "port-base") ? Number(flag(flags, "port-base")) : undefined,
|
|
637
726
|
excludePorts: [
|
|
638
727
|
...(flags.winbox === false ? ["winbox" as ServiceName] : []),
|
|
@@ -645,24 +734,18 @@ async function cmdAdd(argv: string[]) {
|
|
|
645
734
|
extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
|
|
646
735
|
};
|
|
647
736
|
|
|
648
|
-
|
|
649
|
-
const deviceModeEnable = csvList(flagList(flags, "device-mode-enable"));
|
|
650
|
-
const deviceModeDisable = csvList(flagList(flags, "device-mode-disable"));
|
|
651
|
-
if (deviceModeValue !== undefined || deviceModeEnable.length > 0 || deviceModeDisable.length > 0) {
|
|
652
|
-
opts.deviceMode = {
|
|
653
|
-
mode: deviceModeValue ?? "auto",
|
|
654
|
-
enable: deviceModeEnable.length > 0 ? deviceModeEnable : undefined,
|
|
655
|
-
disable: deviceModeDisable.length > 0 ? deviceModeDisable : undefined,
|
|
656
|
-
};
|
|
657
|
-
}
|
|
737
|
+
opts.deviceMode = deviceModeFromFlags(flags);
|
|
658
738
|
|
|
659
739
|
const userStr = flag(flags, "add-user");
|
|
660
740
|
if (userStr) {
|
|
661
741
|
const [uname = "", password] = userStr.split(":");
|
|
662
742
|
opts.user = { name: uname, password: password ?? "" };
|
|
663
743
|
}
|
|
664
|
-
opts.disableAdmin =
|
|
665
|
-
|
|
744
|
+
opts.disableAdmin = flagOptionalBool(flags, "disable-admin");
|
|
745
|
+
// `add` always creates a machine, so its provisioning window is open by definition.
|
|
746
|
+
// A RouterOS 6 machine takes no provisioning at all, so the setting — a default, not
|
|
747
|
+
// a request — stays out of it; only an explicit --secure-login is refused.
|
|
748
|
+
opts.secureLogin = await resolveSecureLoginFlag(flags, !isRouterOs6(opts.version ?? ""));
|
|
666
749
|
|
|
667
750
|
const state = await QuickCHR.add(opts);
|
|
668
751
|
|
|
@@ -673,10 +756,26 @@ async function cmdAdd(argv: string[]) {
|
|
|
673
756
|
console.log(` CPU/Mem: ${state.cpu} vCPU${state.cpu > 1 ? "s" : ""}, ${state.mem} MB`);
|
|
674
757
|
console.log(` Network: ${formatNetworks(state.networks ?? [])}`);
|
|
675
758
|
console.log(` Ports: ${formatPorts(state.ports)}`);
|
|
676
|
-
|
|
759
|
+
if (isRouterOs6(state.version)) {
|
|
760
|
+
console.log(` API: 127.0.0.1:${ports.api} ${dim("(after start; RouterOS 6 is boot-only and has no REST)")}`);
|
|
761
|
+
} else {
|
|
762
|
+
console.log(` REST: http://127.0.0.1:${ports.http} ${dim("(after start)")}`);
|
|
763
|
+
}
|
|
677
764
|
console.log(` Dir: ${dim(state.machineDir)}`);
|
|
678
|
-
if (state.
|
|
679
|
-
if (state.
|
|
765
|
+
if (state.installAllPackages) console.log(` Packages: all extra packages ${dim("(applied on first start)")}`);
|
|
766
|
+
else if (state.packages.length > 0) console.log(` Packages: ${state.packages.join(", ")} ${dim("(applied on first start)")}`);
|
|
767
|
+
const login = [
|
|
768
|
+
...(state.user ? [`add user "${state.user.name}"`] : []),
|
|
769
|
+
...(state.secureLogin === true ? ["managed quickchr account"] : []),
|
|
770
|
+
...(state.disableAdmin ? ["disable admin"] : []),
|
|
771
|
+
];
|
|
772
|
+
if (login.length > 0) console.log(` Login: ${login.join(", ")} ${dim("(applied on first start)")}`);
|
|
773
|
+
if (state.deviceMode) {
|
|
774
|
+
const { shouldApplyDeviceMode: willApplyDm, resolveDeviceModeOptions: resolveDm } = await import("../lib/device-mode.ts");
|
|
775
|
+
console.log(willApplyDm(resolveDm(state.deviceMode))
|
|
776
|
+
? ` Device-mode: ${state.deviceMode.mode ?? "auto"} ${dim("(applied on first start)")}`
|
|
777
|
+
: ` Device-mode: ${dim("skipped (--no-device-mode)")}`);
|
|
778
|
+
}
|
|
680
779
|
console.log();
|
|
681
780
|
console.log(`${dim("tip:")} quickchr start ${state.name}`);
|
|
682
781
|
}
|
|
@@ -1261,29 +1360,81 @@ async function cmdSetup() {
|
|
|
1261
1360
|
}
|
|
1262
1361
|
}
|
|
1263
1362
|
|
|
1264
|
-
|
|
1363
|
+
/** Whether the `secure-login` *setting* reaches this start. The setting is a default,
|
|
1364
|
+
* not a request, so it stays out of every start that cannot provision: a closed
|
|
1365
|
+
* window, a `--no-wait` start (#159), and a RouterOS 6 machine (#182). Otherwise a
|
|
1366
|
+
* user with `secure-login=true` configured could never use either. An explicit
|
|
1367
|
+
* `--secure-login` is still passed through, and refused by name. */
|
|
1368
|
+
export function secureLoginSettingApplies(
|
|
1369
|
+
flags: Record<string, string | boolean | string[]>,
|
|
1370
|
+
windowOpen: boolean,
|
|
1371
|
+
version: string | undefined,
|
|
1372
|
+
): boolean {
|
|
1373
|
+
return windowOpen && flags.wait !== false && !isRouterOs6(version ?? "");
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/** `--bg` never did what it reads as (#159): background is the default, and the flag
|
|
1377
|
+
* only ever chose where the serial console went. It stays accepted so existing
|
|
1378
|
+
* scripts keep working, and says once that it does nothing. */
|
|
1379
|
+
function warnDeprecatedBackgroundFlag(flags: Record<string, string | boolean | string[]>): void {
|
|
1380
|
+
const given = flags.bg === true ? "--bg" : flags.background === true ? "--background" : undefined;
|
|
1381
|
+
if (!given) return;
|
|
1382
|
+
console.error(`Warning: ${given} is deprecated and does nothing — background is already the default, and it never made start return early. Use --no-wait to return as soon as QEMU is running.`);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** After a `--no-wait` start the caller owns readiness; name the command that does it. */
|
|
1386
|
+
function printReadinessTip(name: string, version: string): void {
|
|
1387
|
+
// 6.x: native API, and no validation — centrs checks paths against the 7.x tree
|
|
1388
|
+
// and rejects /system/resource on 6.49 (checked 2026-10-21).
|
|
1389
|
+
const via = isRouterOs6(version) ? " --via native-api --no-validate" : "";
|
|
1390
|
+
tip(`wait for readiness with: centrs retrieve --quickchr ${name} /system/resource${via} --wait 5m`);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
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[]) {
|
|
1265
1400
|
const { flags, positional } = parseFlags(argv);
|
|
1266
1401
|
applyTimeoutExtraShortFlag(argv, flags, positional);
|
|
1267
1402
|
rejectUnknownFlags(flags, START_FLAGS, "start");
|
|
1268
1403
|
// Before the --all branch: both start paths must see the same override.
|
|
1269
1404
|
await applyAccelFlag(flags);
|
|
1405
|
+
warnDeprecatedBackgroundFlag(flags);
|
|
1406
|
+
const wait = flags.wait === false ? false : undefined;
|
|
1270
1407
|
|
|
1271
1408
|
// --all: start every stopped machine in background
|
|
1272
1409
|
if (flagBool(flags, "all")) {
|
|
1410
|
+
// Batch starts only accept options this branch actually forwards. In particular,
|
|
1411
|
+
// a provisioning flag must never disappear on a --no-wait start.
|
|
1412
|
+
const batchFlags = new Set(["all", "wait", "bg", "background", "accel", "timeout-extra", "secure-login"]);
|
|
1413
|
+
const unsupported = Object.keys(flags).filter((key) => !batchFlags.has(key));
|
|
1414
|
+
if (unsupported.length > 0 || flags.bg === false || flags.background === false) {
|
|
1415
|
+
throw new QuickCHRError("INVALID_ARGUMENT", "start --all runs in background and accepts only --no-wait, --bg, --background, --accel, --timeout-extra, --secure-login and --no-secure-login. Start an individual machine to use other options.");
|
|
1416
|
+
}
|
|
1273
1417
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
1274
1418
|
const { statusIcon, link, bold, resolveDisplayCredentials, formatRestUrl, formatSshCommand } = await import("./format.ts");
|
|
1275
1419
|
const timeoutExtra = await resolveTimeoutExtraMs(flags);
|
|
1276
|
-
const secureLogin = await resolveSecureLoginFlag(flags);
|
|
1277
1420
|
const stopped = QuickCHR.list().filter((m) => m.status !== "running");
|
|
1278
1421
|
if (stopped.length === 0) {
|
|
1279
1422
|
console.log("No stopped instances.");
|
|
1280
1423
|
return;
|
|
1281
1424
|
}
|
|
1425
|
+
const { isProvisioningWindowOpen } = await import("../lib/provisioning-window.ts");
|
|
1282
1426
|
for (const m of stopped) {
|
|
1283
1427
|
console.log(`Starting ${bold(m.name)}...`);
|
|
1284
|
-
|
|
1428
|
+
// Per machine, not per invocation: a never-started machine in this batch is
|
|
1429
|
+
// still entitled to the secure-login setting on its first boot.
|
|
1430
|
+
const secureLogin = await resolveSecureLoginFlag(flags, secureLoginSettingApplies(flags, isProvisioningWindowOpen(m), m.version));
|
|
1431
|
+
const instance = await QuickCHR.start({ name: m.name, background: true, wait, timeoutExtra, secureLogin });
|
|
1285
1432
|
const creds = await resolveDisplayCredentials(instance.state);
|
|
1286
|
-
|
|
1433
|
+
const endpoint = isRouterOs6(instance.state.version)
|
|
1434
|
+
? `API: 127.0.0.1:${instance.ports.api}`
|
|
1435
|
+
: `REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`;
|
|
1436
|
+
console.log(`${statusIcon("running")} ${bold(instance.name)} ${endpoint} SSH: ${formatSshCommand(creds.user, instance.sshPort)}${wait === false ? " (not waited for)" : ""}`);
|
|
1437
|
+
if (wait === false) printReadinessTip(instance.name, instance.state.version);
|
|
1287
1438
|
}
|
|
1288
1439
|
return;
|
|
1289
1440
|
}
|
|
@@ -1310,13 +1461,15 @@ async function cmdStart(argv: string[]) {
|
|
|
1310
1461
|
|
|
1311
1462
|
const opts: StartOptions = {
|
|
1312
1463
|
version: flag(flags, "version"),
|
|
1313
|
-
|
|
1464
|
+
// The default channel only stands in for a missing --version; applied alongside
|
|
1465
|
+
// one, it reads to start()/add() as a caller passing both (#178).
|
|
1466
|
+
channel: (flag(flags, "channel") as Channel | undefined) ?? (flag(flags, "version") ? undefined : resolveSetting("default-channel").value as Channel | undefined),
|
|
1314
1467
|
arch: (flag(flags, "arch") as Arch | "auto" | undefined) ?? (resolveSetting("default-arch").value as Arch | "auto" | undefined),
|
|
1315
1468
|
name: flag(flags, "name") ?? positional[0],
|
|
1316
1469
|
cpu: flag(flags, "cpu") ? Number(flag(flags, "cpu")) : undefined,
|
|
1317
1470
|
mem: flag(flags, "mem") ? Number(flag(flags, "mem")) : undefined,
|
|
1318
|
-
packages: flagList(flags, "add-package"),
|
|
1319
|
-
installAllPackages:
|
|
1471
|
+
packages: csvList(flagList(flags, "add-package")),
|
|
1472
|
+
installAllPackages: flagOptionalBool(flags, "install-all-packages"),
|
|
1320
1473
|
portBase: flag(flags, "port-base") ? Number(flag(flags, "port-base")) : undefined,
|
|
1321
1474
|
excludePorts: [
|
|
1322
1475
|
...(flags.winbox === false ? ["winbox" as ServiceName] : []),
|
|
@@ -1327,26 +1480,11 @@ async function cmdStart(argv: string[]) {
|
|
|
1327
1480
|
bootSize: flag(flags, "boot-size"),
|
|
1328
1481
|
extraDisks: flagList(flags, "add-disk").length > 0 ? flagList(flags, "add-disk") : undefined,
|
|
1329
1482
|
extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
|
|
1330
|
-
installDeps: flagBool(flags, "install-deps"),
|
|
1331
1483
|
dryRun: flagBool(flags, "dry-run"),
|
|
1332
1484
|
timeoutExtra: await resolveTimeoutExtraMs(flags),
|
|
1333
1485
|
};
|
|
1334
1486
|
|
|
1335
|
-
|
|
1336
|
-
const deviceModeEnable = csvList(flagList(flags, "device-mode-enable"));
|
|
1337
|
-
const deviceModeDisable = csvList(flagList(flags, "device-mode-disable"));
|
|
1338
|
-
const noDeviceMode = flags["device-mode"] === false;
|
|
1339
|
-
if (noDeviceMode) {
|
|
1340
|
-
if (deviceModeEnable.length > 0 || deviceModeDisable.length > 0) {
|
|
1341
|
-
console.warn("Warning: --device-mode-enable/--device-mode-disable ignored because --no-device-mode was set.");
|
|
1342
|
-
}
|
|
1343
|
-
} else if (deviceModeValue !== undefined || deviceModeEnable.length > 0 || deviceModeDisable.length > 0) {
|
|
1344
|
-
opts.deviceMode = {
|
|
1345
|
-
mode: deviceModeValue ?? "auto",
|
|
1346
|
-
enable: deviceModeEnable.length > 0 ? deviceModeEnable : undefined,
|
|
1347
|
-
disable: deviceModeDisable.length > 0 ? deviceModeDisable : undefined,
|
|
1348
|
-
};
|
|
1349
|
-
}
|
|
1487
|
+
opts.deviceMode = deviceModeFromFlags(flags);
|
|
1350
1488
|
|
|
1351
1489
|
// --license-* flags (credentials from env if not supplied)
|
|
1352
1490
|
const licenseLevel = flag(flags, "license-level");
|
|
@@ -1371,10 +1509,25 @@ async function cmdStart(argv: string[]) {
|
|
|
1371
1509
|
opts.user = { name, password: password ?? "" };
|
|
1372
1510
|
}
|
|
1373
1511
|
|
|
1374
|
-
opts.disableAdmin =
|
|
1375
|
-
|
|
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;
|
|
1515
|
+
{
|
|
1516
|
+
const { isProvisioningWindowOpen } = await import("../lib/provisioning-window.ts");
|
|
1517
|
+
// No machine yet, or one that was added and never booted: still a first boot,
|
|
1518
|
+
// so the setting applies. Only a closed window suppresses it — otherwise a
|
|
1519
|
+
// plain `quickchr start <booted-name>` would ask to provision a booted guest
|
|
1520
|
+
// and be refused.
|
|
1521
|
+
opts.secureLogin = await resolveSecureLoginFlag(flags, secureLoginSettingApplies(
|
|
1522
|
+
flags,
|
|
1523
|
+
!target || isProvisioningWindowOpen(target.state),
|
|
1524
|
+
target?.state.version ?? opts.version,
|
|
1525
|
+
));
|
|
1526
|
+
}
|
|
1527
|
+
opts.wait = wait;
|
|
1376
1528
|
|
|
1377
1529
|
// Background default: true. Explicitly foreground only with --fg / --foreground / --no-background / --no-bg.
|
|
1530
|
+
// `--bg` / `--background` are accepted and change nothing (see warnDeprecatedBackgroundFlag).
|
|
1378
1531
|
const wantFg =
|
|
1379
1532
|
flagBool(flags, "fg") ||
|
|
1380
1533
|
flagBool(flags, "foreground") ||
|
|
@@ -1387,8 +1540,9 @@ async function cmdStart(argv: string[]) {
|
|
|
1387
1540
|
return;
|
|
1388
1541
|
}
|
|
1389
1542
|
|
|
1390
|
-
// --fg on already-running machine: attach to its serial console
|
|
1391
|
-
|
|
1543
|
+
// --fg on already-running machine: attach to its serial console.
|
|
1544
|
+
// `--fg --no-wait` skips both foreground blocks so start() refuses the pair before any I/O.
|
|
1545
|
+
if (!opts.background && opts.wait !== false && opts.name) {
|
|
1392
1546
|
const runningMachine = QuickCHR.list().find((m) => m.name === opts.name && m.status === "running");
|
|
1393
1547
|
if (runningMachine) {
|
|
1394
1548
|
await attachSerial(runningMachine.name, runningMachine.machineDir);
|
|
@@ -1396,7 +1550,7 @@ async function cmdStart(argv: string[]) {
|
|
|
1396
1550
|
}
|
|
1397
1551
|
}
|
|
1398
1552
|
|
|
1399
|
-
if (!opts.background) {
|
|
1553
|
+
if (!opts.background && opts.wait !== false) {
|
|
1400
1554
|
const wantsDeviceMode = !!opts.deviceMode && !["skip", "none", "off", "disabled"].includes((opts.deviceMode.mode ?? "auto").toLowerCase());
|
|
1401
1555
|
const hasProv = !!(
|
|
1402
1556
|
opts.installAllPackages ||
|
|
@@ -1423,13 +1577,19 @@ async function cmdStart(argv: string[]) {
|
|
|
1423
1577
|
console.log(` ${dim("Tip: run background")} quickchr start ${instance.name}`);
|
|
1424
1578
|
} else {
|
|
1425
1579
|
const creds = await resolveDisplayCredentials(instance.state);
|
|
1426
|
-
|
|
1427
|
-
console.log(
|
|
1580
|
+
const v6 = isRouterOs6(instance.state.version);
|
|
1581
|
+
console.log(`${statusIcon("running")} ${bold(instance.name)} ${startStatusLine(!target, opts.wait === false)}`);
|
|
1582
|
+
console.log(` Version: ${instance.state.version} (${instance.state.arch})${v6 ? ` ${dim("boot-only: no REST, no provisioning")}` : ""}`);
|
|
1428
1583
|
console.log(` Network: ${formatNetworks(instance.state.networks ?? [])}`);
|
|
1429
1584
|
console.log(` Ports: ${formatPorts(instance.state.ports)}`);
|
|
1430
|
-
|
|
1585
|
+
if (v6) {
|
|
1586
|
+
console.log(` API: 127.0.0.1:${instance.ports.api} ${dim(`(native API, user ${creds.user})`)}`);
|
|
1587
|
+
} else {
|
|
1588
|
+
console.log(` REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`);
|
|
1589
|
+
}
|
|
1431
1590
|
console.log(` SSH: ${formatSshCommand(creds.user, instance.sshPort)}`);
|
|
1432
1591
|
console.log(` WinBox: 127.0.0.1:${instance.ports.winbox}`);
|
|
1592
|
+
if (opts.wait === false) printReadinessTip(instance.name, instance.state.version);
|
|
1433
1593
|
if (creds.user !== "admin") {
|
|
1434
1594
|
console.log(` Login: ${creds.user} / ${creds.password || "(no password)"}`);
|
|
1435
1595
|
console.log(` Run: quickchr exec ${instance.name} /system/resource/print`);
|
|
@@ -1437,6 +1597,32 @@ async function cmdStart(argv: string[]) {
|
|
|
1437
1597
|
}
|
|
1438
1598
|
}
|
|
1439
1599
|
|
|
1600
|
+
async function cmdShutdown(argv: string[]) {
|
|
1601
|
+
const { flags, positional } = parseFlags(argv);
|
|
1602
|
+
const invalid = unknownFlags(flags, ["timeout"]);
|
|
1603
|
+
if (invalid.length) throw new QuickCHRError("INVALID_ARGUMENT", "shutdown accepts only <name> and --timeout <seconds>");
|
|
1604
|
+
if (positional.length !== 1 || positional[0] === undefined) throw new QuickCHRError("INVALID_ARGUMENT", "Usage: quickchr shutdown <name> [--timeout <seconds>]");
|
|
1605
|
+
const raw = flags.timeout === undefined ? (process.env.QUICKCHR_SHUTDOWN_TIMEOUT ?? "10") : flag(flags, "timeout");
|
|
1606
|
+
if (raw === undefined || !/^[0-9]+$/.test(raw) || Number(raw) <= 0) {
|
|
1607
|
+
throw new QuickCHRError("INVALID_ARGUMENT", "shutdown timeout must be a positive integer number of seconds");
|
|
1608
|
+
}
|
|
1609
|
+
const { validateShutdownTimeout } = await import("../lib/shutdown.ts");
|
|
1610
|
+
const timeoutMs = validateShutdownTimeout(Number(raw) * 1000);
|
|
1611
|
+
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
1612
|
+
const name = positional[0];
|
|
1613
|
+
const instance = QuickCHR.get(name);
|
|
1614
|
+
if (!instance) throw new QuickCHRError("MACHINE_NOT_FOUND", `Machine "${name}" not found`);
|
|
1615
|
+
const result = await instance.shutdown({ timeoutMs });
|
|
1616
|
+
if (result.outcome === "forced") {
|
|
1617
|
+
console.log(`${name}: forced power cut — guest shutdown not confirmed`);
|
|
1618
|
+
if (result.requestError) console.error(`Shutdown request: ${result.requestError}`);
|
|
1619
|
+
} else if (result.outcome === "already-stopped") {
|
|
1620
|
+
console.log(`${name}: already stopped`);
|
|
1621
|
+
} else {
|
|
1622
|
+
console.log(`${name}: guest exited after shutdown request`);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1440
1626
|
async function cmdStop(argv: string[]) {
|
|
1441
1627
|
const { flags, positional } = parseFlags(argv);
|
|
1442
1628
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
@@ -1700,6 +1886,11 @@ async function cmdGet(argv: string[]) {
|
|
|
1700
1886
|
return;
|
|
1701
1887
|
}
|
|
1702
1888
|
|
|
1889
|
+
// Every live group is a REST read. Refused here rather than left to fetchGroup(),
|
|
1890
|
+
// which swallows errors and would print an empty report for a v6 machine (#182).
|
|
1891
|
+
const { assertRestAvailable } = await import("../lib/versions.ts");
|
|
1892
|
+
assertRestAvailable(s, "'quickchr get' (live properties)");
|
|
1893
|
+
|
|
1703
1894
|
const { resolveAuth } = await import("../lib/auth.ts");
|
|
1704
1895
|
const auth = resolveAuth(s);
|
|
1705
1896
|
const base = `http://127.0.0.1:${instance.ports.http}/rest`;
|
|
@@ -1747,14 +1938,20 @@ async function cmdGet(argv: string[]) {
|
|
|
1747
1938
|
}
|
|
1748
1939
|
|
|
1749
1940
|
if (results["device-mode"]) {
|
|
1941
|
+
const { DEVICE_MODE_NON_FEATURE_KEYS, isDeviceModeFeatureEnabled } = await import("../lib/device-mode.ts");
|
|
1750
1942
|
const dm = results["device-mode"] as Record<string, unknown>;
|
|
1751
1943
|
const mode = dm.mode ?? "(not set)";
|
|
1752
1944
|
console.log(`\n ${bold("Device Mode")}`);
|
|
1753
1945
|
console.log(` Mode: ${mode}`);
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1946
|
+
// RouterOS reports these as strings ("true"/"false"), so the old `v === true`
|
|
1947
|
+
// test matched nothing and the feature lines never printed — `get` could not
|
|
1948
|
+
// read back what `set` had just written. Both lines are shown: after
|
|
1949
|
+
// `--device-mode-disable smb` the interesting answer is the one that is off.
|
|
1950
|
+
const features = Object.entries(dm).filter(([k]) => !DEVICE_MODE_NON_FEATURE_KEYS.has(k));
|
|
1951
|
+
const enabled = features.filter(([, v]) => isDeviceModeFeatureEnabled(v)).map(([k]) => k);
|
|
1952
|
+
const disabled = features.filter(([, v]) => !isDeviceModeFeatureEnabled(v)).map(([k]) => k);
|
|
1953
|
+
if (enabled.length > 0) console.log(` Enabled: ${enabled.join(", ")}`);
|
|
1954
|
+
if (disabled.length > 0) console.log(` Disabled: ${disabled.join(", ")}`);
|
|
1758
1955
|
}
|
|
1759
1956
|
|
|
1760
1957
|
if (results.admin) {
|
|
@@ -1883,6 +2080,10 @@ async function applyLicense(argv: string[]) {
|
|
|
1883
2080
|
console.error(`Machine "${name}" is not running.`);
|
|
1884
2081
|
process.exit(1);
|
|
1885
2082
|
}
|
|
2083
|
+
// Before any credential prompt: instance.license() refuses RouterOS 6 too, but only
|
|
2084
|
+
// after the user has typed a MikroTik password for nothing.
|
|
2085
|
+
const { assertProvisioningSupportedVersion } = await import("../lib/versions.ts");
|
|
2086
|
+
assertProvisioningSupportedVersion(instance.state.version, "apply a license");
|
|
1886
2087
|
|
|
1887
2088
|
let account = flag(flags, "account") ?? process.env.MIKROTIK_WEB_ACCOUNT;
|
|
1888
2089
|
let password = flag(flags, "password") ?? process.env.MIKROTIK_WEB_PASSWORD;
|
|
@@ -1928,28 +2129,100 @@ async function applyLicense(argv: string[]) {
|
|
|
1928
2129
|
console.log(`License applied: ${before} → ${level}`);
|
|
1929
2130
|
}
|
|
1930
2131
|
|
|
2132
|
+
/** `set` applies a change to a machine that has already booted.
|
|
2133
|
+
*
|
|
2134
|
+
* It is the post-boot half of the provisioning flags, and deliberately narrow: only
|
|
2135
|
+
* the steps that are safe to run against a guest whose configuration quickchr no
|
|
2136
|
+
* longer knows get a home here (#176). `--license` is account-level. `--device-mode`
|
|
2137
|
+
* is a capability flag — it gates whether a feature *can* run and describes nothing
|
|
2138
|
+
* about how the guest is configured, so applying it later cannot clobber working
|
|
2139
|
+
* config; the config it was blocking could not have been applied while it was off.
|
|
2140
|
+
*
|
|
2141
|
+
* `--add-user`, `--disable-admin` and `--secure-login` are excluded on purpose. They
|
|
2142
|
+
* are working config rather than a capability, they collide with whatever has been
|
|
2143
|
+
* created since, and they are the steps that drift. Their route stays
|
|
2144
|
+
* `quickchr clean <name>` and a fresh provisioning run. */
|
|
1931
2145
|
async function cmdSet(argv: string[]) {
|
|
1932
2146
|
const { flags, positional } = parseFlags(argv);
|
|
1933
|
-
|
|
2147
|
+
// Positional first, `--name` as the fallback — the same order `applyLicense` uses,
|
|
2148
|
+
// which `set --license` delegates to. Without this `quickchr set --name lab
|
|
2149
|
+
// --license` printed a usage error while `quickchr license --name lab` worked.
|
|
2150
|
+
const name = positional[0] ?? flag(flags, "name");
|
|
1934
2151
|
|
|
2152
|
+
const usage = "Usage: quickchr set <name> [--license [--level=p1|p10|unlimited]] [--device-mode <mode>] [--device-mode-enable <features>] [--device-mode-disable <features>]";
|
|
1935
2153
|
if (!name) {
|
|
1936
|
-
console.error(
|
|
2154
|
+
console.error(usage);
|
|
2155
|
+
process.exit(1);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
const valueless = valuelessFlags(flags, SET_FLAGS);
|
|
2159
|
+
if (valueless.length > 0) {
|
|
2160
|
+
console.error(valuelessFlagMessage(valueless, "set"));
|
|
2161
|
+
process.exit(1);
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
if (flags["device-mode"] === false) {
|
|
2165
|
+
// `--no-device-mode` means "skip device-mode provisioning" on add/start. There is
|
|
2166
|
+
// nothing to skip in an imperative set, so honouring it would be accepting a flag
|
|
2167
|
+
// and doing nothing — the failure this whole area exists to stop.
|
|
2168
|
+
console.error("--no-device-mode is a provisioning flag for 'add'/'start' (skip the step); 'set' applies a change, so pass a mode or a feature list instead.");
|
|
1937
2169
|
process.exit(1);
|
|
1938
2170
|
}
|
|
1939
2171
|
|
|
1940
2172
|
const wantsLicense = flagBool(flags, "license");
|
|
2173
|
+
const deviceMode = deviceModeFromFlags(flags);
|
|
1941
2174
|
|
|
1942
|
-
if (!wantsLicense) {
|
|
1943
|
-
console.error("Nothing to set. Available flags: --license");
|
|
2175
|
+
if (!wantsLicense && !deviceMode) {
|
|
2176
|
+
console.error("Nothing to set. Available flags: --license, --device-mode, --device-mode-enable, --device-mode-disable");
|
|
1944
2177
|
process.exit(1);
|
|
1945
2178
|
}
|
|
1946
2179
|
|
|
2180
|
+
// Device-mode first: it power-cycles the guest, and a license applied beforehand
|
|
2181
|
+
// would be read back across a reboot it did not expect.
|
|
2182
|
+
if (deviceMode) await applyDeviceModeChange(name, deviceMode);
|
|
1947
2183
|
if (wantsLicense) {
|
|
1948
2184
|
const subcmdArgs = [name, ...Object.entries(flags).flatMap(([k, v]) => v === true ? [`--${k}`] : [`--${k}=${v}`]), ...positional.slice(1)];
|
|
1949
2185
|
await applyLicense(subcmdArgs);
|
|
1950
2186
|
}
|
|
1951
2187
|
}
|
|
1952
2188
|
|
|
2189
|
+
/** `set <name> --device-mode…` — the post-boot route the refusal names.
|
|
2190
|
+
*
|
|
2191
|
+
* Device-mode needs a **power cycle**: RouterOS will not apply it otherwise, and
|
|
2192
|
+
* `/system/device-mode/update` issued from inside the guest simply never returns,
|
|
2193
|
+
* because it is waiting for a power cycle it cannot perform on itself. quickchr owns
|
|
2194
|
+
* the power button, which is why this lives here and not in a tool that talks to a
|
|
2195
|
+
* router over the network. */
|
|
2196
|
+
async function applyDeviceModeChange(name: string, deviceMode: DeviceModeOptions) {
|
|
2197
|
+
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
2198
|
+
const { bold, dim, machineNotFoundMessage } = await import("./format.ts");
|
|
2199
|
+
const { formatDeviceModeSelection, resolveDeviceModeOptions } = await import("../lib/device-mode.ts");
|
|
2200
|
+
const { shellQuote } = await import("../lib/names.ts");
|
|
2201
|
+
|
|
2202
|
+
const instance = QuickCHR.get(name);
|
|
2203
|
+
if (!instance) {
|
|
2204
|
+
console.error(machineNotFoundMessage(name));
|
|
2205
|
+
process.exit(1);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// Before announcing a change that cannot happen (setDeviceMode() refuses it as well).
|
|
2209
|
+
const { assertProvisioningSupportedVersion } = await import("../lib/versions.ts");
|
|
2210
|
+
assertProvisioningSupportedVersion(instance.state.version, "set device-mode");
|
|
2211
|
+
|
|
2212
|
+
const selection = formatDeviceModeSelection(resolveDeviceModeOptions(deviceMode));
|
|
2213
|
+
console.log(`Setting device-mode on ${bold(name)}: ${selection}`);
|
|
2214
|
+
// Hedged on purpose: a request the guest already satisfies costs nothing, and
|
|
2215
|
+
// setDeviceMode() skips the power cycle in that case. Promising a restart before
|
|
2216
|
+
// reading the guest would make the quiet no-op look like a failure to restart.
|
|
2217
|
+
console.log(dim(" RouterOS only applies device-mode across a power cycle, so any setting that moves restarts the machine."));
|
|
2218
|
+
// setDeviceMode() checks the preconditions (running, user-mode NIC), prints the
|
|
2219
|
+
// settings that will actually move, and skips the power cycle when none would.
|
|
2220
|
+
await instance.setDeviceMode(deviceMode);
|
|
2221
|
+
// Quoted for the same reason the refusal routes are: a legacy machine name can
|
|
2222
|
+
// contain a space, and an unquoted read-back hint would name a different machine.
|
|
2223
|
+
console.log(`Device-mode set. Read it back with: quickchr get ${shellQuote(name)} device-mode`);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
1953
2226
|
async function cmdLicense(argv: string[]) {
|
|
1954
2227
|
await applyLicense(argv);
|
|
1955
2228
|
}
|
|
@@ -1968,7 +2241,7 @@ async function cmdDisk(argv: string[]) {
|
|
|
1968
2241
|
|
|
1969
2242
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
1970
2243
|
const { bold, dim, machineNotFoundMessage } = await import("./format.ts");
|
|
1971
|
-
const { getDiskInfo } = await import("../lib/disk.ts");
|
|
2244
|
+
const { bootDiskFormatOf, bootDiskPath, getDiskInfo } = await import("../lib/disk.ts");
|
|
1972
2245
|
const { join } = await import("node:path");
|
|
1973
2246
|
const { existsSync } = await import("node:fs");
|
|
1974
2247
|
const { findQemuImg } = await import("../lib/platform.ts");
|
|
@@ -1991,10 +2264,8 @@ async function cmdDisk(argv: string[]) {
|
|
|
1991
2264
|
console.log(bold(`Disks for ${name}\n`));
|
|
1992
2265
|
|
|
1993
2266
|
// Boot disk
|
|
1994
|
-
const
|
|
1995
|
-
|
|
1996
|
-
: join(state.machineDir, "disk.img");
|
|
1997
|
-
const bootFormat = state.bootDiskFormat ?? "raw";
|
|
2267
|
+
const bootFormat = bootDiskFormatOf(state);
|
|
2268
|
+
const bootPath = bootDiskPath(state.machineDir, bootFormat);
|
|
1998
2269
|
|
|
1999
2270
|
if (qemuImg && existsSync(bootPath)) {
|
|
2000
2271
|
try {
|
|
@@ -2567,7 +2838,7 @@ async function cmdCache(argv: string[]) {
|
|
|
2567
2838
|
return;
|
|
2568
2839
|
}
|
|
2569
2840
|
|
|
2570
|
-
const {
|
|
2841
|
+
const { cacheInventory, countEntries, pruneCache, parseSizeString, formatSizeBytes } = await import("../lib/cache.ts");
|
|
2571
2842
|
|
|
2572
2843
|
const parseDuration = (s: string): number => {
|
|
2573
2844
|
const m = s.match(/^(\d+(?:\.\d+)?)([smhdw])$/);
|
|
@@ -2579,6 +2850,11 @@ async function cmdCache(argv: string[]) {
|
|
|
2579
2850
|
};
|
|
2580
2851
|
const parseSize = parseSizeString;
|
|
2581
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
|
+
};
|
|
2582
2858
|
const formatAge = (ms: number): string => {
|
|
2583
2859
|
const s = Math.floor(ms / 1000);
|
|
2584
2860
|
if (s < 60) return `${s}s`;
|
|
@@ -2629,13 +2905,14 @@ async function cmdCache(argv: string[]) {
|
|
|
2629
2905
|
}
|
|
2630
2906
|
case "list":
|
|
2631
2907
|
case "ls": {
|
|
2632
|
-
const entries =
|
|
2908
|
+
const { entries, unmanagedBytes } = cacheInventory();
|
|
2633
2909
|
if (flags.json === true) {
|
|
2634
2910
|
console.log(JSON.stringify(entries, null, 2));
|
|
2635
2911
|
return;
|
|
2636
2912
|
}
|
|
2637
2913
|
if (entries.length === 0) {
|
|
2638
|
-
console.log("No
|
|
2914
|
+
console.log("No cache entries.");
|
|
2915
|
+
if (unmanagedBytes > 0) console.log(`Other files: ${formatSize(unmanagedBytes)} (not written by quickchr; never pruned)`);
|
|
2639
2916
|
return;
|
|
2640
2917
|
}
|
|
2641
2918
|
const now = Date.now();
|
|
@@ -2662,7 +2939,8 @@ async function cmdCache(argv: string[]) {
|
|
|
2662
2939
|
);
|
|
2663
2940
|
}
|
|
2664
2941
|
const total = entries.reduce((s, e) => s + e.sizeBytes, 0);
|
|
2665
|
-
console.log(`\nTotal: ${entries.length}
|
|
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)`);
|
|
2666
2944
|
break;
|
|
2667
2945
|
}
|
|
2668
2946
|
case "prune": {
|
|
@@ -2676,29 +2954,33 @@ async function cmdCache(argv: string[]) {
|
|
|
2676
2954
|
}
|
|
2677
2955
|
const result = pruneCache(opts);
|
|
2678
2956
|
const verb = opts.dryRun ? "Would evict" : "Evicted";
|
|
2679
|
-
if (result.evicted.length === 0) {
|
|
2957
|
+
if (result.evicted.length === 0 && result.failed.length === 0) {
|
|
2680
2958
|
console.log("Nothing to evict.");
|
|
2681
|
-
} else {
|
|
2682
|
-
console.log(`${verb} ${result.evicted.length}
|
|
2959
|
+
} else if (result.evicted.length > 0) {
|
|
2960
|
+
console.log(`${verb} ${countEntries(result.evicted.length)}:`);
|
|
2683
2961
|
for (const e of result.evicted) {
|
|
2684
2962
|
const reason = result.reasons[e.basename] ?? "policy";
|
|
2685
2963
|
console.log(` - ${e.basename} (${formatSize(e.sizeBytes)}) — ${reason}`);
|
|
2686
2964
|
}
|
|
2687
|
-
console.log(
|
|
2965
|
+
console.log(`${opts.dryRun ? "Would free" : "Freed"}: ${formatSize(result.freedBytes)}`);
|
|
2688
2966
|
}
|
|
2967
|
+
printFailedDeletes(result.failed);
|
|
2968
|
+
if (result.failed.length > 0) process.exitCode = 1;
|
|
2689
2969
|
break;
|
|
2690
2970
|
}
|
|
2691
2971
|
case "clear": {
|
|
2692
2972
|
if (flags.all !== true) {
|
|
2693
|
-
console.error("cache clear: refuses without --all (deletes all non-in-use
|
|
2973
|
+
console.error("cache clear: refuses without --all (deletes all non-in-use cache entries)");
|
|
2694
2974
|
process.exit(1);
|
|
2695
2975
|
}
|
|
2696
2976
|
const result = pruneCache({ maxSizeBytes: 0, dryRun: flags["dry-run"] === true });
|
|
2697
2977
|
const verb = flags["dry-run"] === true ? "Would delete" : "Deleted";
|
|
2698
|
-
console.log(`${verb} ${result.evicted.length}
|
|
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;
|
|
2699
2981
|
if (result.kept.some((e) => e.inUse)) {
|
|
2700
2982
|
const inUse = result.kept.filter((e) => e.inUse);
|
|
2701
|
-
console.log(`Kept ${inUse.length} in-use
|
|
2983
|
+
console.log(`Kept ${inUse.length} in-use cache ${inUse.length === 1 ? "entry" : "entries"}:`);
|
|
2702
2984
|
for (const e of inUse) console.log(` - ${e.basename}`);
|
|
2703
2985
|
}
|
|
2704
2986
|
break;
|
|
@@ -2724,7 +3006,7 @@ async function cmdSettings(argv: string[]) {
|
|
|
2724
3006
|
|
|
2725
3007
|
const displayValue = (key: string, value: unknown): string => {
|
|
2726
3008
|
if (value === undefined) return "(unset)";
|
|
2727
|
-
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);
|
|
2728
3010
|
if (key === "timeout-extra" && typeof value === "number") return `${value}s`;
|
|
2729
3011
|
return String(value);
|
|
2730
3012
|
};
|
|
@@ -2811,7 +3093,7 @@ async function cmdDoctor(argv: string[] = []) {
|
|
|
2811
3093
|
const staleImages = await findStaleCacheImages();
|
|
2812
3094
|
if (staleImages.length > 0) {
|
|
2813
3095
|
const longTerm = staleImages[0]?.olderThan;
|
|
2814
|
-
console.log(`Note: ${staleImages.length}
|
|
3096
|
+
console.log(`Note: ${staleImages.length} cache item(s) older than current long-term (${longTerm}):`);
|
|
2815
3097
|
for (const e of staleImages) console.log(` - ${e.basename} (${e.version})`);
|
|
2816
3098
|
console.log(" Hint: 'quickchr cache prune --older-than " + longTerm + "' to evict them.");
|
|
2817
3099
|
console.log();
|
|
@@ -2897,7 +3179,8 @@ Commands:
|
|
|
2897
3179
|
setup Interactive setup wizard (TTY only)
|
|
2898
3180
|
add [options] Create a new CHR machine (without starting)
|
|
2899
3181
|
start [<name>|options] Start or restart a CHR instance
|
|
2900
|
-
stop [<name>|--all]
|
|
3182
|
+
stop [<name>|--all] Power cut — print list if no name
|
|
3183
|
+
shutdown <name> Request guest shutdown, then bounded hard-stop fallback
|
|
2901
3184
|
list [<name>] [--json] List all instances, or detail for one
|
|
2902
3185
|
status Alias for list
|
|
2903
3186
|
inspect <name> [--json] Print running machine descriptor as JSON
|
|
@@ -2905,15 +3188,16 @@ Commands:
|
|
|
2905
3188
|
console <name> Attach to serial console of a running instance
|
|
2906
3189
|
exec <name> <command> Run a RouterOS CLI command on a running instance
|
|
2907
3190
|
(raw /rest/execute — see 'quickchr exec --help' for centrs)
|
|
3191
|
+
qga <name> <operation> Query the QEMU Guest Agent (x86 only)
|
|
2908
3192
|
remove [<name>|--all] Remove instance(s) and disk
|
|
2909
3193
|
clean [<name>|--all] Reset instance disk to fresh image
|
|
2910
|
-
get <name> [group]
|
|
2911
|
-
set <name>
|
|
3194
|
+
get <name> [group] Show machine config (license, device-mode, admin)
|
|
3195
|
+
set <name> [flags] Change a booted machine (license, device-mode)
|
|
2912
3196
|
license <name> Deprecated — use 'set <name> --license'
|
|
2913
3197
|
disk <name> Show disk details for an instance
|
|
2914
3198
|
snapshot <name> [cmd] Manage snapshots (list/save/load/delete)
|
|
2915
3199
|
networks Network discovery & socket management
|
|
2916
|
-
cache [add|key|list|prune|clear] Manage cached CHR images
|
|
3200
|
+
cache [add|key|list|prune|clear] Manage cached CHR images and packages
|
|
2917
3201
|
settings [print|get|set|reset] Manage quickchr's own global settings (quickchr.env)
|
|
2918
3202
|
completions Manage shell completions (Tab completion)
|
|
2919
3203
|
logs <name> Tail the QEMU log for an instance
|
|
@@ -2946,6 +3230,8 @@ Post-boot provisioning flags in this command (--add-package, --install-all-packa
|
|
|
2946
3230
|
RouterOS ${MIN_PROVISION_VERSION}+ only. Older 7.x remains boot-only. Disk and network
|
|
2947
3231
|
options still work on older versions when the required host tools are installed.
|
|
2948
3232
|
|
|
3233
|
+
${ROUTEROS6_HELP}
|
|
3234
|
+
|
|
2949
3235
|
Options:
|
|
2950
3236
|
--name <name> Instance name (required)
|
|
2951
3237
|
--version <ver> RouterOS version (e.g., 7.22.1)
|
|
@@ -2956,9 +3242,9 @@ Options:
|
|
|
2956
3242
|
--accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
|
|
2957
3243
|
Anything but 'auto' bypasses detection, including the
|
|
2958
3244
|
arm64-on-Apple-Silicon TCG fallback (tikoci/quickchr#97).
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
3245
|
+
--boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
|
|
3246
|
+
--boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
|
|
3247
|
+
--add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
|
|
2962
3248
|
--add-package <pkg> Extra package to install on first boot (repeatable)
|
|
2963
3249
|
--forward <spec> Add an extra hostfwd port (repeatable). Spec:
|
|
2964
3250
|
name[:host[:guest]][/proto], or a port range
|
|
@@ -2976,9 +3262,18 @@ Options:
|
|
|
2976
3262
|
--no-winbox Exclude WinBox port mapping
|
|
2977
3263
|
--no-api-ssl Exclude API-SSL port mapping
|
|
2978
3264
|
--device-mode <m> Set device-mode on first boot: rose|advanced|basic|home|auto|skip
|
|
3265
|
+
--device-mode-enable <f> Set one or more device-mode flags to yes (e.g. container)
|
|
3266
|
+
--device-mode-disable <f> Set one or more device-mode flags to no
|
|
3267
|
+
--no-device-mode Skip device-mode provisioning entirely
|
|
2979
3268
|
--add-network <spec> Add a network NIC (repeatable). Specs: user, shared, bridged:<if>,
|
|
2980
3269
|
socket::<name>, tap:<if>. Default: single user NIC.
|
|
2981
|
-
--no-network Start with no NICs (headless)
|
|
3270
|
+
--no-network Start with no NICs (headless)
|
|
3271
|
+
|
|
3272
|
+
Provisioning flags apply on the machine's first boot — whether they are given
|
|
3273
|
+
here or on that first 'start'. After the guest has booted, a flag the machine already
|
|
3274
|
+
has is accepted as a no-op; any other is refused rather than ignored, naming where the
|
|
3275
|
+
change can still be made (often 'quickchr set <name>'). 'quickchr clean <name>' also
|
|
3276
|
+
reopens the window, but resets the disk to factory and erases the guest's configuration.`);
|
|
2982
3277
|
break;
|
|
2983
3278
|
case "setup":
|
|
2984
3279
|
console.log(`quickchr setup
|
|
@@ -3012,6 +3307,13 @@ Examples:
|
|
|
3012
3307
|
quickchr exec my-chr ":put [:serialize to=json [/ip/address/print]]"
|
|
3013
3308
|
quickchr exec my-chr "/log/info message=hello"
|
|
3014
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
|
+
|
|
3015
3317
|
${CENTRS_SEE_ALSO}`);
|
|
3016
3318
|
break;
|
|
3017
3319
|
case "start":
|
|
@@ -3028,10 +3330,23 @@ validated on RouterOS ${MIN_PROVISION_VERSION}+ only. Older 7.x remains boot-onl
|
|
|
3028
3330
|
Disk and network options still work on older versions when the required host tools
|
|
3029
3331
|
are installed.
|
|
3030
3332
|
|
|
3333
|
+
${ROUTEROS6_HELP}
|
|
3334
|
+
|
|
3335
|
+
By default 'start' returns once RouterOS is ready (REST answers; on 6.x, a native-API
|
|
3336
|
+
login). --no-wait returns as soon as QEMU is running and leaves readiness to you:
|
|
3337
|
+
centrs retrieve --quickchr <name> /system/resource --wait 5m
|
|
3338
|
+
QEMU runs detached either way. --no-wait is refused with --fg, and with provisioning
|
|
3339
|
+
flags, including ones 'add' recorded for the first boot — provisioning needs a booted
|
|
3340
|
+
guest.
|
|
3341
|
+
|
|
3031
3342
|
Options:
|
|
3032
|
-
--all Start all stopped machines
|
|
3033
|
-
|
|
3343
|
+
--all Start all stopped machines, in background. Accepts only
|
|
3344
|
+
--no-wait, --accel, --timeout-extra and --[no-]secure-login
|
|
3345
|
+
(plus the deprecated no-op --bg / --background); any other
|
|
3346
|
+
option is refused rather than ignored
|
|
3347
|
+
--no-wait Return once QEMU is running; do not wait for RouterOS (not with --fg)
|
|
3034
3348
|
--fg / --foreground Run in foreground — serial console on stdio
|
|
3349
|
+
--bg / --background Deprecated, does nothing: background is already the default
|
|
3035
3350
|
--version <ver> RouterOS version (e.g., 7.22.1)
|
|
3036
3351
|
--channel <ch> Channel: stable, long-term, testing, development
|
|
3037
3352
|
--arch <arch> Architecture: arm64, x86, auto (default: host native)
|
|
@@ -3041,9 +3356,9 @@ Options:
|
|
|
3041
3356
|
--accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
|
|
3042
3357
|
Anything but 'auto' bypasses detection, including the
|
|
3043
3358
|
arm64-on-Apple-Silicon TCG fallback (tikoci/quickchr#97).
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3359
|
+
--boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
|
|
3360
|
+
--boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
|
|
3361
|
+
--add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
|
|
3047
3362
|
--add-package <pkg> Extra package to install (repeatable)
|
|
3048
3363
|
--forward <spec> Add an extra hostfwd port (repeatable). Spec:
|
|
3049
3364
|
name[:host[:guest]][/proto], or a range
|
|
@@ -3068,15 +3383,35 @@ Options:
|
|
|
3068
3383
|
--device-mode-disable <f> Set one or more device-mode flags to no
|
|
3069
3384
|
--no-device-mode Skip device-mode provisioning entirely
|
|
3070
3385
|
--timeout-extra <s>, -T <s> Add extra seconds to the auto-computed boot timeout
|
|
3071
|
-
--dry-run Print what would run without starting
|
|
3386
|
+
--dry-run Print what would run without starting
|
|
3387
|
+
|
|
3388
|
+
Provisioning flags (packages, login, device-mode, license) apply only on a machine's
|
|
3389
|
+
first boot. On a machine that has booted, a flag the machine already has (same
|
|
3390
|
+
packages, user, license, device-mode) is accepted as a no-op. Any other is refused,
|
|
3391
|
+
naming where the change can still be made — often 'quickchr set <name>'. Only reach for
|
|
3392
|
+
'quickchr clean <name>' if you mean it: it resets the disk to factory, erasing the
|
|
3393
|
+
guest's configuration, and reopens the first-boot window.`);
|
|
3072
3394
|
break;
|
|
3073
3395
|
case "stop":
|
|
3074
3396
|
console.log(`quickchr stop [<name>] [--all]
|
|
3075
3397
|
|
|
3398
|
+
Power cut: QEMU SIGTERM, then SIGKILL if needed. Pending guest changes may be lost.
|
|
3399
|
+
Use shutdown to request /system/shutdown first.
|
|
3400
|
+
|
|
3076
3401
|
<name> Stop a specific running instance.
|
|
3077
3402
|
Omit to see a list of running instances.
|
|
3078
3403
|
--all Stop all running instances.`);
|
|
3079
3404
|
break;
|
|
3405
|
+
case "shutdown":
|
|
3406
|
+
console.log(`quickchr shutdown <name> [--timeout <seconds>]
|
|
3407
|
+
|
|
3408
|
+
Request /system/shutdown over REST, then wait for QEMU to exit.
|
|
3409
|
+
If it is still running at the deadline, force a power cut and report it.
|
|
3410
|
+
--timeout <seconds> Positive integer seconds (default 10).
|
|
3411
|
+
Overrides QUICKCHR_SHUTDOWN_TIMEOUT.
|
|
3412
|
+
Lock wait and hard-stop escalation are separate bounded waits.
|
|
3413
|
+
Preserves disks and credentials. Use stop for immediate teardown.`);
|
|
3414
|
+
break;
|
|
3080
3415
|
case "status":
|
|
3081
3416
|
console.log(`quickchr status [<name>] [--json]
|
|
3082
3417
|
|
|
@@ -3195,14 +3530,30 @@ When no group is given, all properties are shown.
|
|
|
3195
3530
|
The machine must be running to query live properties.`);
|
|
3196
3531
|
break;
|
|
3197
3532
|
case "set":
|
|
3198
|
-
console.log(`quickchr set <name> --license [options]
|
|
3533
|
+
console.log(`quickchr set <name> [--license | --device-mode...] [options]
|
|
3199
3534
|
|
|
3200
|
-
|
|
3535
|
+
Change a property on a machine that has already booted. This is the post-boot
|
|
3536
|
+
half of the provisioning flags: 'add'/'start' apply them at first boot, 'set'
|
|
3537
|
+
applies them now.
|
|
3201
3538
|
|
|
3202
3539
|
<name> Name of a running CHR instance (required).
|
|
3203
3540
|
|
|
3204
3541
|
Flags:
|
|
3205
3542
|
--license Apply or renew a CHR trial license.
|
|
3543
|
+
--device-mode <m> Set device-mode: rose|advanced|basic|home|auto
|
|
3544
|
+
--device-mode-enable <f> Set one or more device-mode flags to yes (e.g. container)
|
|
3545
|
+
--device-mode-disable <f> Set one or more device-mode flags to no
|
|
3546
|
+
|
|
3547
|
+
Device-mode notes:
|
|
3548
|
+
RouterOS only applies device-mode across a power cycle, so 'set' restarts the
|
|
3549
|
+
machine (roughly double a plain start). Enabling a feature without naming a
|
|
3550
|
+
mode resolves the mode to 'rose'; quickchr prints every setting that moves
|
|
3551
|
+
before it restarts. The machine must be running and have a user-mode NIC.
|
|
3552
|
+
Read it back with 'quickchr get <name> device-mode'.
|
|
3553
|
+
|
|
3554
|
+
Not available here: --add-user, --disable-admin and --secure-login. Those rewrite
|
|
3555
|
+
working config rather than unlock a capability, so their route stays
|
|
3556
|
+
'quickchr clean <name>' followed by the original add/start command.
|
|
3206
3557
|
|
|
3207
3558
|
License options (used with --license):
|
|
3208
3559
|
--level <level> License level: p1 (1 Gbps), p10 (10 Gbps), unlimited (default: p1)
|
|
@@ -3213,7 +3564,7 @@ Credential resolution order:
|
|
|
3213
3564
|
1. --account / --password flags
|
|
3214
3565
|
2. MIKROTIK_WEB_ACCOUNT / MIKROTIK_WEB_PASSWORD environment variables
|
|
3215
3566
|
3. OS native secret store (macOS Keychain / Linux Keyring)
|
|
3216
|
-
4. ~/.config/quickchr/
|
|
3567
|
+
4. ~/.config/quickchr/com.quickchr.mikrotik-web.json (fallback)`);
|
|
3217
3568
|
break;
|
|
3218
3569
|
case "license":
|
|
3219
3570
|
console.log(`quickchr license <name> [options]
|
|
@@ -3235,7 +3586,7 @@ Credential resolution order (highest priority first):
|
|
|
3235
3586
|
1. --account / --password flags
|
|
3236
3587
|
2. MIKROTIK_WEB_ACCOUNT / MIKROTIK_WEB_PASSWORD environment variables
|
|
3237
3588
|
3. OS native secret store (macOS Keychain, Linux GNOME Keyring, Windows Credential Manager)
|
|
3238
|
-
4. ~/.config/quickchr/
|
|
3589
|
+
4. ~/.config/quickchr/com.quickchr.mikrotik-web.json (fallback)`);
|
|
3239
3590
|
break;
|
|
3240
3591
|
case "qga":
|
|
3241
3592
|
console.log(`quickchr qga <name> <operation> [options]
|
|
@@ -3277,7 +3628,9 @@ Options:
|
|
|
3277
3628
|
case "cache":
|
|
3278
3629
|
console.log(`quickchr cache <add|key|list|prune|clear> [options]
|
|
3279
3630
|
|
|
3280
|
-
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.
|
|
3281
3634
|
|
|
3282
3635
|
Subcommands:
|
|
3283
3636
|
add [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
|
|
@@ -3285,14 +3638,16 @@ Subcommands:
|
|
|
3285
3638
|
key [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
|
|
3286
3639
|
Print cache directory, resolved version, and architecture for CI.
|
|
3287
3640
|
Without --json, output is GITHUB_OUTPUT-compatible key=value lines.
|
|
3288
|
-
list [--json] List
|
|
3289
|
-
prune [options] Evict
|
|
3290
|
-
--older-than <ver> Evict
|
|
3291
|
-
--max-age <dur> Evict
|
|
3292
|
-
--max-size <size> Evict
|
|
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.
|
|
3293
3647
|
--dry-run Show what would be evicted without deleting.
|
|
3294
|
-
clear --all Delete all non-in-use
|
|
3648
|
+
clear --all Delete all non-in-use cache entries.
|
|
3295
3649
|
|
|
3650
|
+
A failed delete is listed on stderr, is not counted as freed, and exits 1.
|
|
3296
3651
|
Duration: <N>(s|m|h|d|w) Size: <N>(K|M|G|T)[i][B] (case-insensitive, IEC binary).
|
|
3297
3652
|
|
|
3298
3653
|
Default auto-prune cap: 2 GiB — override via 'quickchr settings set cache-max-size <size>'
|
|
@@ -3312,6 +3667,8 @@ Managed keys:
|
|
|
3312
3667
|
auto|tcg|hvf|kvm (default: auto = detect)
|
|
3313
3668
|
cache-max-size Auto-prune cap applied after each successful start.
|
|
3314
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)
|
|
3315
3672
|
timeout-extra Default for 'start's --timeout-extra/-T when omitted.
|
|
3316
3673
|
Non-negative integer seconds. (default: 0)
|
|
3317
3674
|
secure-login Default for 'add'/'start's --secure-login when neither
|