@tikoci/quickchr 0.5.1 → 0.5.3
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/.bun-version +1 -0
- package/DESIGN.md +39 -1
- package/MANUAL.md +97 -21
- package/README.md +7 -10
- package/package.json +2 -1
- package/src/cli/flags.ts +4 -1
- package/src/cli/index.ts +148 -14
- package/src/cli/wizard.ts +54 -58
- package/src/index.ts +1 -0
- package/src/lib/completions.ts +9 -6
- package/src/lib/lock.ts +1 -0
- package/src/lib/platform.ts +13 -1
- package/src/lib/qemu.ts +31 -4
- package/src/lib/quickchr.ts +179 -15
- package/src/lib/state.ts +1 -0
- package/src/lib/types.ts +48 -1
package/src/cli/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { StartOptions, Arch, Channel, DeviceModeOptions, ServiceName, NetworkSpecifier } from "../lib/types.ts";
|
|
7
|
+
import { join } from "node:path";
|
|
7
8
|
import { parseNetworkSpecifier } from "../lib/network.ts";
|
|
8
9
|
import { expandForwardSpec } from "../lib/forward-spec.ts";
|
|
9
10
|
import { ADD_FLAGS, REMOVED_FLAGS, SET_FLAGS, START_FLAGS, VALUE_FLAGS, unknownFlags, unknownFlagMessage, valuelessFlags, valuelessFlagMessage } from "./flags.ts";
|
|
@@ -337,6 +338,9 @@ async function main() {
|
|
|
337
338
|
case "start":
|
|
338
339
|
await cmdStart(args.slice(1));
|
|
339
340
|
break;
|
|
341
|
+
case "continue":
|
|
342
|
+
await cmdContinue(args.slice(1));
|
|
343
|
+
break;
|
|
340
344
|
case "stop":
|
|
341
345
|
await cmdStop(args.slice(1));
|
|
342
346
|
break;
|
|
@@ -732,6 +736,7 @@ async function cmdAdd(argv: string[]) {
|
|
|
732
736
|
bootSize: flag(flags, "boot-size"),
|
|
733
737
|
extraDisks: flagList(flags, "add-disk").length > 0 ? flagList(flags, "add-disk") : undefined,
|
|
734
738
|
extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
|
|
739
|
+
qemuArgs: flagList(flags, "qemu-arg").length > 0 ? flagList(flags, "qemu-arg") : undefined,
|
|
735
740
|
};
|
|
736
741
|
|
|
737
742
|
opts.deviceMode = deviceModeFromFlags(flags);
|
|
@@ -1362,15 +1367,16 @@ async function cmdSetup() {
|
|
|
1362
1367
|
|
|
1363
1368
|
/** Whether the `secure-login` *setting* reaches this start. The setting is a default,
|
|
1364
1369
|
* not a request, so it stays out of every start that cannot provision: a closed
|
|
1365
|
-
* window, a `--no-wait` start (#159),
|
|
1366
|
-
* user with `secure-login=true` configured could never
|
|
1367
|
-
* `--secure-login` is still passed through, and
|
|
1370
|
+
* window, a `--no-wait` start (#159), a `--frozen` start (#214), and a RouterOS 6
|
|
1371
|
+
* machine (#182). Otherwise a user with `secure-login=true` configured could never
|
|
1372
|
+
* use any of them. An explicit `--secure-login` is still passed through, and
|
|
1373
|
+
* refused by name. */
|
|
1368
1374
|
export function secureLoginSettingApplies(
|
|
1369
1375
|
flags: Record<string, string | boolean | string[]>,
|
|
1370
1376
|
windowOpen: boolean,
|
|
1371
1377
|
version: string | undefined,
|
|
1372
1378
|
): boolean {
|
|
1373
|
-
return windowOpen && flags.wait !== false && !isRouterOs6(version ?? "");
|
|
1379
|
+
return windowOpen && flags.wait !== false && flags.frozen !== true && !isRouterOs6(version ?? "");
|
|
1374
1380
|
}
|
|
1375
1381
|
|
|
1376
1382
|
/** `--bg` never did what it reads as (#159): background is the default, and the flag
|
|
@@ -1482,6 +1488,7 @@ export async function cmdStart(argv: string[]) {
|
|
|
1482
1488
|
extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
|
|
1483
1489
|
dryRun: flagBool(flags, "dry-run"),
|
|
1484
1490
|
timeoutExtra: await resolveTimeoutExtraMs(flags),
|
|
1491
|
+
qemuArgs: flagList(flags, "qemu-arg").length > 0 ? flagList(flags, "qemu-arg") : undefined,
|
|
1485
1492
|
};
|
|
1486
1493
|
|
|
1487
1494
|
opts.deviceMode = deviceModeFromFlags(flags);
|
|
@@ -1525,6 +1532,7 @@ export async function cmdStart(argv: string[]) {
|
|
|
1525
1532
|
));
|
|
1526
1533
|
}
|
|
1527
1534
|
opts.wait = wait;
|
|
1535
|
+
opts.frozen = flagBool(flags, "frozen") || undefined;
|
|
1528
1536
|
|
|
1529
1537
|
// Background default: true. Explicitly foreground only with --fg / --foreground / --no-background / --no-bg.
|
|
1530
1538
|
// `--bg` / `--background` are accepted and change nothing (see warnDeprecatedBackgroundFlag).
|
|
@@ -1541,8 +1549,9 @@ export async function cmdStart(argv: string[]) {
|
|
|
1541
1549
|
}
|
|
1542
1550
|
|
|
1543
1551
|
// --fg on already-running machine: attach to its serial console.
|
|
1544
|
-
// `--fg --no-wait`
|
|
1545
|
-
|
|
1552
|
+
// `--fg --no-wait` and `--fg --frozen` skip both foreground blocks so start()
|
|
1553
|
+
// refuses the pair before any I/O.
|
|
1554
|
+
if (!opts.background && opts.wait !== false && !opts.frozen && opts.name) {
|
|
1546
1555
|
const runningMachine = QuickCHR.list().find((m) => m.name === opts.name && m.status === "running");
|
|
1547
1556
|
if (runningMachine) {
|
|
1548
1557
|
await attachSerial(runningMachine.name, runningMachine.machineDir);
|
|
@@ -1550,7 +1559,7 @@ export async function cmdStart(argv: string[]) {
|
|
|
1550
1559
|
}
|
|
1551
1560
|
}
|
|
1552
1561
|
|
|
1553
|
-
if (!opts.background && opts.wait !== false) {
|
|
1562
|
+
if (!opts.background && opts.wait !== false && !opts.frozen) {
|
|
1554
1563
|
const wantsDeviceMode = !!opts.deviceMode && !["skip", "none", "off", "disabled"].includes((opts.deviceMode.mode ?? "auto").toLowerCase());
|
|
1555
1564
|
const hasProv = !!(
|
|
1556
1565
|
opts.installAllPackages ||
|
|
@@ -1571,7 +1580,12 @@ export async function cmdStart(argv: string[]) {
|
|
|
1571
1580
|
|
|
1572
1581
|
const instance = await QuickCHR.start(opts);
|
|
1573
1582
|
|
|
1574
|
-
|
|
1583
|
+
// The marker describes the machine, not the request: --frozen on an
|
|
1584
|
+
// already-running normal machine returns that handle unfrozen.
|
|
1585
|
+
if (instance.state.frozen) {
|
|
1586
|
+
console.log(`${statusIcon("running")} ${bold(instance.name)} frozen at reset — patch RAM, then ${bold(`quickchr continue ${instance.name}`)}`);
|
|
1587
|
+
console.log(` GDB: ${dim(join(instance.state.machineDir, "gdb.sock"))}`);
|
|
1588
|
+
} else if (!opts.background) {
|
|
1575
1589
|
console.log(`\n${bold(instance.name)} session ended`);
|
|
1576
1590
|
console.log(` ${dim("Tip: resume session")} quickchr start ${instance.name} --fg`);
|
|
1577
1591
|
console.log(` ${dim("Tip: run background")} quickchr start ${instance.name}`);
|
|
@@ -1597,6 +1611,39 @@ export async function cmdStart(argv: string[]) {
|
|
|
1597
1611
|
}
|
|
1598
1612
|
}
|
|
1599
1613
|
|
|
1614
|
+
export async function cmdContinue(argv: string[]) {
|
|
1615
|
+
const { flags, positional } = parseFlags(argv);
|
|
1616
|
+
applyTimeoutExtraShortFlag(argv, flags, positional);
|
|
1617
|
+
const invalid = unknownFlags(flags, ["wait", "timeout-extra"]);
|
|
1618
|
+
if (invalid.length) throw new QuickCHRError("INVALID_ARGUMENT", "continue accepts only <name>, --no-wait and --timeout-extra <s>");
|
|
1619
|
+
const name = positional[0];
|
|
1620
|
+
if (!name || positional.length !== 1) throw new QuickCHRError("INVALID_ARGUMENT", "Usage: quickchr continue <name> [--no-wait] [--timeout-extra <s>]");
|
|
1621
|
+
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
1622
|
+
const { machineNotFoundMessage } = await import("./format.ts");
|
|
1623
|
+
const instance = QuickCHR.get(name);
|
|
1624
|
+
if (!instance) {
|
|
1625
|
+
console.error(machineNotFoundMessage(name));
|
|
1626
|
+
process.exit(1);
|
|
1627
|
+
}
|
|
1628
|
+
const wait = flags.wait !== false;
|
|
1629
|
+
const timeoutExtra = await resolveTimeoutExtraMs(flags);
|
|
1630
|
+
await instance.continue({ wait, timeoutExtra });
|
|
1631
|
+
const { statusIcon, bold, link, resolveDisplayCredentials, formatRestUrl, formatSshCommand } = await import("./format.ts");
|
|
1632
|
+
if (!wait) {
|
|
1633
|
+
console.log(`${statusIcon("running")} ${bold(name)} resumed — not waiting for RouterOS (--no-wait)`);
|
|
1634
|
+
printReadinessTip(name, instance.state.version);
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
const creds = await resolveDisplayCredentials(instance.state);
|
|
1638
|
+
console.log(`${statusIcon("running")} ${bold(name)} resumed`);
|
|
1639
|
+
if (isRouterOs6(instance.state.version)) {
|
|
1640
|
+
console.log(` API: 127.0.0.1:${instance.ports.api}`);
|
|
1641
|
+
} else {
|
|
1642
|
+
console.log(` REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`);
|
|
1643
|
+
}
|
|
1644
|
+
console.log(` SSH: ${formatSshCommand(creds.user, instance.sshPort)}`);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1600
1647
|
async function cmdShutdown(argv: string[]) {
|
|
1601
1648
|
const { flags, positional } = parseFlags(argv);
|
|
1602
1649
|
const invalid = unknownFlags(flags, ["timeout"]);
|
|
@@ -1691,6 +1738,8 @@ async function cmdList(argv: string[] = []) {
|
|
|
1691
1738
|
networks: s.networks,
|
|
1692
1739
|
ports: s.ports,
|
|
1693
1740
|
pid: s.pid ?? null,
|
|
1741
|
+
frozen: s.frozen ?? false,
|
|
1742
|
+
qemuArgs: s.qemuArgs ?? [],
|
|
1694
1743
|
packages: s.packages,
|
|
1695
1744
|
machineDir: s.machineDir,
|
|
1696
1745
|
createdAt: s.createdAt,
|
|
@@ -1706,6 +1755,10 @@ async function cmdList(argv: string[] = []) {
|
|
|
1706
1755
|
console.log(` Network: ${formatNetworks(s.networks)}`);
|
|
1707
1756
|
console.log(` Ports: ${formatPorts(s.ports)}`);
|
|
1708
1757
|
console.log(` Status: ${s.status}${s.pid ? ` (PID ${s.pid})` : ""}`);
|
|
1758
|
+
if (s.frozen) {
|
|
1759
|
+
console.log(` Frozen: yes — awaiting quickchr continue`);
|
|
1760
|
+
console.log(` GDB: ${dim(join(s.machineDir, "gdb.sock"))}`);
|
|
1761
|
+
}
|
|
1709
1762
|
console.log(` Created: ${new Date(s.createdAt).toLocaleString()}`);
|
|
1710
1763
|
if (s.lastStartedAt) console.log(` Started: ${new Date(s.lastStartedAt).toLocaleString()}`);
|
|
1711
1764
|
if (s.packages.length > 0) console.log(` Packages: ${s.packages.join(", ")}`);
|
|
@@ -1715,6 +1768,7 @@ async function cmdList(argv: string[] = []) {
|
|
|
1715
1768
|
if (s.status === "running") {
|
|
1716
1769
|
const creds = await resolveDisplayCredentials(s);
|
|
1717
1770
|
console.log();
|
|
1771
|
+
if (s.frozen) console.log(` ${dim(`(endpoints answer after 'quickchr continue ${s.name}')`)}`);
|
|
1718
1772
|
console.log(` REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`);
|
|
1719
1773
|
console.log(` WinBox: 127.0.0.1:${instance.ports.winbox}`);
|
|
1720
1774
|
console.log(` SSH: ${formatSshCommand(creds.user, instance.sshPort)}`);
|
|
@@ -1760,7 +1814,7 @@ async function cmdList(argv: string[] = []) {
|
|
|
1760
1814
|
const headers = ["", "Name", "Version", "Arch", "Network", "Ports", "PID"];
|
|
1761
1815
|
const rows = machines.map((m) => [
|
|
1762
1816
|
statusIcon(m.status),
|
|
1763
|
-
m.name,
|
|
1817
|
+
m.frozen ? `${m.name} (frozen)` : m.name,
|
|
1764
1818
|
m.version,
|
|
1765
1819
|
m.arch,
|
|
1766
1820
|
formatNetworks(m.networks ?? []),
|
|
@@ -2149,7 +2203,7 @@ async function cmdSet(argv: string[]) {
|
|
|
2149
2203
|
// --license` printed a usage error while `quickchr license --name lab` worked.
|
|
2150
2204
|
const name = positional[0] ?? flag(flags, "name");
|
|
2151
2205
|
|
|
2152
|
-
const usage = "Usage: quickchr set <name> [--license [--level=p1|p10|unlimited]] [--device-mode <mode>] [--device-mode-enable <features>] [--device-mode-disable <features>]";
|
|
2206
|
+
const usage = "Usage: quickchr set <name> [--license [--level=p1|p10|unlimited]] [--device-mode <mode>] [--device-mode-enable <features>] [--device-mode-disable <features>] [--qemu-arg <arg>] [--clear-qemu-args]";
|
|
2153
2207
|
if (!name) {
|
|
2154
2208
|
console.error(usage);
|
|
2155
2209
|
process.exit(1);
|
|
@@ -2171,12 +2225,22 @@ async function cmdSet(argv: string[]) {
|
|
|
2171
2225
|
|
|
2172
2226
|
const wantsLicense = flagBool(flags, "license");
|
|
2173
2227
|
const deviceMode = deviceModeFromFlags(flags);
|
|
2228
|
+
const qemuArgs = flagList(flags, "qemu-arg");
|
|
2229
|
+
const clearQemuArgs = flagBool(flags, "clear-qemu-args");
|
|
2230
|
+
const wantsQemuArgs = qemuArgs.length > 0 || clearQemuArgs;
|
|
2231
|
+
if (qemuArgs.length > 0 && clearQemuArgs) {
|
|
2232
|
+
throw new QuickCHRError("INVALID_ARGUMENT", "--qemu-arg and --clear-qemu-args conflict: --qemu-arg already replaces the recorded args wholesale");
|
|
2233
|
+
}
|
|
2174
2234
|
|
|
2175
|
-
if (!wantsLicense && !deviceMode) {
|
|
2176
|
-
console.error("Nothing to set. Available flags: --license, --device-mode, --device-mode-enable, --device-mode-disable");
|
|
2235
|
+
if (!wantsLicense && !deviceMode && !wantsQemuArgs) {
|
|
2236
|
+
console.error("Nothing to set. Available flags: --license, --device-mode, --device-mode-enable, --device-mode-disable, --qemu-arg, --clear-qemu-args");
|
|
2177
2237
|
process.exit(1);
|
|
2178
2238
|
}
|
|
2179
2239
|
|
|
2240
|
+
// Recorded args first: they need a stopped machine, and failing fast here
|
|
2241
|
+
// keeps a running-machine refusal from racing a device-mode power cycle.
|
|
2242
|
+
if (wantsQemuArgs) await applyQemuArgsChange(name, qemuArgs, clearQemuArgs);
|
|
2243
|
+
|
|
2180
2244
|
// Device-mode first: it power-cycles the guest, and a license applied beforehand
|
|
2181
2245
|
// would be read back across a reboot it did not expect.
|
|
2182
2246
|
if (deviceMode) await applyDeviceModeChange(name, deviceMode);
|
|
@@ -2186,6 +2250,43 @@ async function cmdSet(argv: string[]) {
|
|
|
2186
2250
|
}
|
|
2187
2251
|
}
|
|
2188
2252
|
|
|
2253
|
+
/** `set <name> --qemu-arg…` — the recovery route for recorded passthrough.
|
|
2254
|
+
*
|
|
2255
|
+
* Boot config, not provisioning: it takes effect on the next start, so the
|
|
2256
|
+
* machine must be stopped — accepting it on a running machine would promise a
|
|
2257
|
+
* change that cannot happen until reboot. Replaces the recorded args wholesale
|
|
2258
|
+
* (one element per flag, like `add`); `--clear-qemu-args` drops them. A bad
|
|
2259
|
+
* recorded arg otherwise fails every start until `machine.json` is hand-edited;
|
|
2260
|
+
* this is the supported way back. */
|
|
2261
|
+
async function applyQemuArgsChange(name: string, qemuArgs: string[], clear: boolean) {
|
|
2262
|
+
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
2263
|
+
const { bold, machineNotFoundMessage } = await import("./format.ts");
|
|
2264
|
+
const { loadMachine, saveMachine } = await import("../lib/state.ts");
|
|
2265
|
+
|
|
2266
|
+
const instance = QuickCHR.get(name);
|
|
2267
|
+
if (!instance) {
|
|
2268
|
+
console.error(machineNotFoundMessage(name));
|
|
2269
|
+
process.exit(1);
|
|
2270
|
+
}
|
|
2271
|
+
if (instance.state.status === "running") {
|
|
2272
|
+
console.error(`Machine "${name}" is running — stop it first: qemuArgs apply at boot, so 'set' only records them on a stopped machine.`);
|
|
2273
|
+
process.exit(1);
|
|
2274
|
+
}
|
|
2275
|
+
const current = loadMachine(name);
|
|
2276
|
+
if (!current) {
|
|
2277
|
+
console.error(machineNotFoundMessage(name));
|
|
2278
|
+
process.exit(1);
|
|
2279
|
+
}
|
|
2280
|
+
current.qemuArgs = clear ? undefined : [...qemuArgs];
|
|
2281
|
+
saveMachine(current);
|
|
2282
|
+
instance.state.qemuArgs = current.qemuArgs;
|
|
2283
|
+
if (clear) {
|
|
2284
|
+
console.log(`Cleared recorded QEMU args on ${bold(name)} — the next start boots without passthrough.`);
|
|
2285
|
+
} else {
|
|
2286
|
+
console.log(`Recorded QEMU args on ${bold(name)}: ${qemuArgs.join(" ")}`);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2189
2290
|
/** `set <name> --device-mode…` — the post-boot route the refusal names.
|
|
2190
2291
|
*
|
|
2191
2292
|
* Device-mode needs a **power cycle**: RouterOS will not apply it otherwise, and
|
|
@@ -3179,6 +3280,7 @@ Commands:
|
|
|
3179
3280
|
setup Interactive setup wizard (TTY only)
|
|
3180
3281
|
add [options] Create a new CHR machine (without starting)
|
|
3181
3282
|
start [<name>|options] Start or restart a CHR instance
|
|
3283
|
+
continue <name> Resume a machine started with --frozen
|
|
3182
3284
|
stop [<name>|--all] Power cut — print list if no name
|
|
3183
3285
|
shutdown <name> Request guest shutdown, then bounded hard-stop fallback
|
|
3184
3286
|
list [<name>] [--json] List all instances, or detail for one
|
|
@@ -3233,7 +3335,7 @@ options still work on older versions when the required host tools are installed.
|
|
|
3233
3335
|
${ROUTEROS6_HELP}
|
|
3234
3336
|
|
|
3235
3337
|
Options:
|
|
3236
|
-
--name <name> Instance name (
|
|
3338
|
+
--name <name> Instance name (auto-generated if omitted)
|
|
3237
3339
|
--version <ver> RouterOS version (e.g., 7.22.1)
|
|
3238
3340
|
--channel <ch> Channel: stable, long-term, testing, development
|
|
3239
3341
|
--arch <arch> Architecture: arm64, x86, auto (default: host native)
|
|
@@ -3257,6 +3359,7 @@ Options:
|
|
|
3257
3359
|
--install-all-packages Install all packages on first boot
|
|
3258
3360
|
--add-user <u:p> Create user on first boot (name:password)
|
|
3259
3361
|
--disable-admin Disable the default admin account on first boot
|
|
3362
|
+
--secure-login Create a managed login; use --disable-admin to disable admin
|
|
3260
3363
|
--no-secure-login Keep admin with no password (skip managed account)
|
|
3261
3364
|
--port-base <port> Starting port number (default: auto-allocated from 9100)
|
|
3262
3365
|
--no-winbox Exclude WinBox port mapping
|
|
@@ -3268,6 +3371,10 @@ Options:
|
|
|
3268
3371
|
--add-network <spec> Add a network NIC (repeatable). Specs: user, shared, bridged:<if>,
|
|
3269
3372
|
socket::<name>, tap:<if>. Default: single user NIC.
|
|
3270
3373
|
--no-network Start with no NICs (headless)
|
|
3374
|
+
--qemu-arg <arg> Extra QEMU argv element, appended verbatim after quickchr's
|
|
3375
|
+
own args (repeatable, space-form only: one element per
|
|
3376
|
+
flag). Recorded here and reused every boot; a start-time
|
|
3377
|
+
value replaces it for that boot only.
|
|
3271
3378
|
|
|
3272
3379
|
Provisioning flags apply on the machine's first boot — whether they are given
|
|
3273
3380
|
here or on that first 'start'. After the guest has booted, a flag the machine already
|
|
@@ -3319,7 +3426,7 @@ ${CENTRS_SEE_ALSO}`);
|
|
|
3319
3426
|
case "start":
|
|
3320
3427
|
console.log(`quickchr start [<name>] [options]
|
|
3321
3428
|
|
|
3322
|
-
<name> Restart
|
|
3429
|
+
<name> Restart a stopped machine; an unknown name creates a fresh machine.
|
|
3323
3430
|
Omit to see a list of stopped machines.
|
|
3324
3431
|
|
|
3325
3432
|
Creation flags below apply when using 'start' to create a new machine in one step.
|
|
@@ -3345,6 +3452,8 @@ Options:
|
|
|
3345
3452
|
(plus the deprecated no-op --bg / --background); any other
|
|
3346
3453
|
option is refused rather than ignored
|
|
3347
3454
|
--no-wait Return once QEMU is running; do not wait for RouterOS (not with --fg)
|
|
3455
|
+
--frozen Boot frozen at reset (-S) with a gdbstub on <machineDir>/gdb.sock;
|
|
3456
|
+
resume with 'quickchr continue' (not with --fg, --all or provisioning)
|
|
3348
3457
|
--fg / --foreground Run in foreground — serial console on stdio
|
|
3349
3458
|
--bg / --background Deprecated, does nothing: background is already the default
|
|
3350
3459
|
--version <ver> RouterOS version (e.g., 7.22.1)
|
|
@@ -3367,6 +3476,7 @@ Options:
|
|
|
3367
3476
|
btest:9200-9210:2000-2010/udp.
|
|
3368
3477
|
--add-user <u:p> Create user with name:password
|
|
3369
3478
|
--disable-admin Disable the default admin account
|
|
3479
|
+
--secure-login Create a managed login; use --disable-admin to disable admin
|
|
3370
3480
|
--no-secure-login Keep admin with no password (skip managed account)
|
|
3371
3481
|
--port-base <port> Starting port number (default: auto-allocated from 9100)
|
|
3372
3482
|
--no-winbox Exclude WinBox port mapping
|
|
@@ -3384,6 +3494,10 @@ Options:
|
|
|
3384
3494
|
--no-device-mode Skip device-mode provisioning entirely
|
|
3385
3495
|
--timeout-extra <s>, -T <s> Add extra seconds to the auto-computed boot timeout
|
|
3386
3496
|
--dry-run Print what would run without starting
|
|
3497
|
+
--qemu-arg <arg> Extra QEMU argv element, appended verbatim after quickchr's
|
|
3498
|
+
own args (repeatable, space-form only: one element per
|
|
3499
|
+
flag). Recorded when creating; on an existing machine it
|
|
3500
|
+
replaces recorded args for this boot only.
|
|
3387
3501
|
|
|
3388
3502
|
Provisioning flags (packages, login, device-mode, license) apply only on a machine's
|
|
3389
3503
|
first boot. On a machine that has booted, a flag the machine already has (same
|
|
@@ -3392,6 +3506,22 @@ naming where the change can still be made — often 'quickchr set <name>'. Only
|
|
|
3392
3506
|
'quickchr clean <name>' if you mean it: it resets the disk to factory, erasing the
|
|
3393
3507
|
guest's configuration, and reopens the first-boot window.`);
|
|
3394
3508
|
break;
|
|
3509
|
+
case "continue":
|
|
3510
|
+
console.log(`quickchr continue <name> [--no-wait] [--timeout-extra <s>]
|
|
3511
|
+
|
|
3512
|
+
Resume a machine started with 'start --frozen' (held at reset with a gdbstub
|
|
3513
|
+
on <machineDir>/gdb.sock). Sends one monitor 'cont', then waits for RouterOS
|
|
3514
|
+
to become ready — the same promise 'start' makes.
|
|
3515
|
+
|
|
3516
|
+
<name> Name of a frozen (or otherwise paused) running machine.
|
|
3517
|
+
--no-wait Return right after resuming; do not wait for RouterOS.
|
|
3518
|
+
--timeout-extra <s>, -T <s>
|
|
3519
|
+
Add extra seconds to the auto-computed resume timeout.
|
|
3520
|
+
|
|
3521
|
+
Resuming is idempotent: when the guest is already running (e.g. gdb continued
|
|
3522
|
+
it), this just clears the frozen marker. A wait that never reaches readiness
|
|
3523
|
+
is a BOOT_TIMEOUT; the machine is stopped and preserved.`);
|
|
3524
|
+
break;
|
|
3395
3525
|
case "stop":
|
|
3396
3526
|
console.log(`quickchr stop [<name>] [--all]
|
|
3397
3527
|
|
|
@@ -3543,6 +3673,10 @@ Flags:
|
|
|
3543
3673
|
--device-mode <m> Set device-mode: rose|advanced|basic|home|auto
|
|
3544
3674
|
--device-mode-enable <f> Set one or more device-mode flags to yes (e.g. container)
|
|
3545
3675
|
--device-mode-disable <f> Set one or more device-mode flags to no
|
|
3676
|
+
--qemu-arg <arg> Replace the recorded QEMU passthrough args (repeatable,
|
|
3677
|
+
space-form only). The machine must be stopped; takes
|
|
3678
|
+
effect on the next start.
|
|
3679
|
+
--clear-qemu-args Drop the recorded QEMU passthrough args. Stopped only.
|
|
3546
3680
|
|
|
3547
3681
|
Device-mode notes:
|
|
3548
3682
|
RouterOS only applies device-mode across a power cycle, so 'set' restarts the
|
package/src/cli/wizard.ts
CHANGED
|
@@ -37,6 +37,9 @@ export function resolveUserChoiceOptions(
|
|
|
37
37
|
|
|
38
38
|
/** Run the interactive wizard using @clack/prompts. */
|
|
39
39
|
export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<void> {
|
|
40
|
+
const { requireQemuImg } = await import("../lib/platform.ts");
|
|
41
|
+
requireQemuImg();
|
|
42
|
+
|
|
40
43
|
// Dynamic import — only loaded when wizard is actually used
|
|
41
44
|
const clack = await import("@clack/prompts");
|
|
42
45
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
@@ -148,75 +151,68 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
148
151
|
});
|
|
149
152
|
if (clack.isCancel(mem)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
150
153
|
|
|
151
|
-
// 4b. Disk configuration —
|
|
154
|
+
// 4b. Disk configuration — qemu-img was checked before any prompts
|
|
152
155
|
let bootSize: string | undefined;
|
|
153
156
|
let bootDiskFormat: "qcow2" | "raw" = "qcow2";
|
|
154
157
|
const extraDisks: string[] = [];
|
|
155
158
|
|
|
156
|
-
const { findQemuImg, getQemuInstallHint } = await import("../lib/platform.ts");
|
|
157
159
|
const { isValidDiskSize } = await import("../lib/disk.ts");
|
|
158
|
-
const
|
|
160
|
+
const diskFormatChoice = await clack.select({
|
|
161
|
+
message: "Boot disk format:",
|
|
162
|
+
options: [
|
|
163
|
+
{ value: "qcow2", label: "qcow2 (recommended)", hint: "supports snapshots and resize" },
|
|
164
|
+
{ value: "raw", label: "raw", hint: "no resize support" },
|
|
165
|
+
],
|
|
166
|
+
initialValue: "qcow2",
|
|
167
|
+
});
|
|
168
|
+
if (clack.isCancel(diskFormatChoice)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
169
|
+
bootDiskFormat = diskFormatChoice as "qcow2" | "raw";
|
|
159
170
|
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
message: "Boot disk format:",
|
|
165
|
-
options: [
|
|
166
|
-
{ value: "qcow2", label: "qcow2 (recommended)", hint: "supports snapshots and resize" },
|
|
167
|
-
{ value: "raw", label: "raw", hint: "no resize support" },
|
|
168
|
-
],
|
|
169
|
-
initialValue: "qcow2",
|
|
171
|
+
if (bootDiskFormat === "qcow2") {
|
|
172
|
+
const wantBootResize = await clack.confirm({
|
|
173
|
+
message: "Resize the boot disk? (default is ~128 MB)",
|
|
174
|
+
initialValue: false,
|
|
170
175
|
});
|
|
171
|
-
if (clack.isCancel(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
if (clack.isCancel(wantBootResize)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
177
|
+
|
|
178
|
+
if (wantBootResize) {
|
|
179
|
+
const size = await clack.text({
|
|
180
|
+
message: "Boot disk size (e.g., 512M, 1G, 2G):",
|
|
181
|
+
validate: (v) => {
|
|
182
|
+
if (!isValidDiskSize(v)) return "Size like 512M, 1G, or 2048";
|
|
183
|
+
},
|
|
178
184
|
});
|
|
179
|
-
if (clack.isCancel(
|
|
180
|
-
|
|
181
|
-
if (wantBootResize) {
|
|
182
|
-
const size = await clack.text({
|
|
183
|
-
message: "Boot disk size (e.g., 512M, 1G, 2G):",
|
|
184
|
-
validate: (v) => {
|
|
185
|
-
if (!isValidDiskSize(v)) return "Size like 512M, 1G, or 2048";
|
|
186
|
-
},
|
|
187
|
-
});
|
|
188
|
-
if (clack.isCancel(size)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
189
|
-
bootSize = size.trim();
|
|
190
|
-
}
|
|
191
|
-
} else {
|
|
192
|
-
clack.log.info("Raw boot disk selected — boot resize is disabled.");
|
|
185
|
+
if (clack.isCancel(size)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
186
|
+
bootSize = size.trim();
|
|
193
187
|
}
|
|
188
|
+
} else {
|
|
189
|
+
clack.log.info("Raw boot disk selected — boot resize is disabled.");
|
|
190
|
+
}
|
|
194
191
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
192
|
+
const wantExtraDisks = await clack.confirm({
|
|
193
|
+
message: "Add extra blank disks?",
|
|
194
|
+
initialValue: false,
|
|
195
|
+
});
|
|
196
|
+
if (clack.isCancel(wantExtraDisks)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
197
|
+
|
|
198
|
+
if (wantExtraDisks) {
|
|
199
|
+
let addMore = true;
|
|
200
|
+
while (addMore) {
|
|
201
|
+
const diskSize = await clack.text({
|
|
202
|
+
message: `Extra disk ${extraDisks.length + 1} size (e.g., 64M, 512M, 1G):`,
|
|
203
|
+
validate: (v) => {
|
|
204
|
+
if (!isValidDiskSize(v)) return "Size like 64M, 512M, or 1G";
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
if (clack.isCancel(diskSize)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
208
|
+
extraDisks.push(diskSize.trim());
|
|
212
209
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
210
|
+
const another = await clack.confirm({
|
|
211
|
+
message: "Add another disk?",
|
|
212
|
+
initialValue: false,
|
|
213
|
+
});
|
|
214
|
+
if (clack.isCancel(another)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
215
|
+
addMore = another;
|
|
220
216
|
}
|
|
221
217
|
}
|
|
222
218
|
|
package/src/index.ts
CHANGED
package/src/lib/completions.ts
CHANGED
|
@@ -328,7 +328,7 @@ _quickchr() {
|
|
|
328
328
|
cword=${D}COMP_CWORD
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
local commands="add start stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help"
|
|
331
|
+
local commands="add start continue stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help"
|
|
332
332
|
|
|
333
333
|
if [[ ${D}cword -eq 1 ]]; then
|
|
334
334
|
COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
|
|
@@ -337,7 +337,7 @@ _quickchr() {
|
|
|
337
337
|
|
|
338
338
|
local cmd="${D}{words[1]}"
|
|
339
339
|
case "${D}cmd" in
|
|
340
|
-
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
|
|
340
|
+
start|continue|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
|
|
341
341
|
local machines
|
|
342
342
|
machines=$(quickchr completions --machines 2>/dev/null)
|
|
343
343
|
COMPREPLY=( $(compgen -W "${D}machines" -- "${D}cur") )
|
|
@@ -387,6 +387,7 @@ _quickchr() {
|
|
|
387
387
|
local commands=(
|
|
388
388
|
'add:Create a new CHR machine (without starting)'
|
|
389
389
|
'start:Start or restart a CHR instance'
|
|
390
|
+
'continue:Resume a machine started with --frozen'
|
|
390
391
|
'stop:Power cut instance(s)'
|
|
391
392
|
'shutdown:Request guest shutdown'
|
|
392
393
|
'list:List all instances'
|
|
@@ -421,7 +422,7 @@ _quickchr() {
|
|
|
421
422
|
args)
|
|
422
423
|
local cmd="${D}{words[2]}"
|
|
423
424
|
case ${D}cmd in
|
|
424
|
-
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
|
|
425
|
+
start|continue|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
|
|
425
426
|
local machines
|
|
426
427
|
machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
|
|
427
428
|
_describe 'machine' machines
|
|
@@ -447,7 +448,7 @@ _quickchr() {
|
|
|
447
448
|
_describe 'cache subcommand' subcommands
|
|
448
449
|
;;
|
|
449
450
|
help)
|
|
450
|
-
local commands=(add start stop shutdown list status inspect env get set remove clean console exec qga license disk snapshot networks cache doctor settings setup completions version)
|
|
451
|
+
local commands=(add start continue stop shutdown list status inspect env get set remove clean console exec qga license disk snapshot networks cache doctor settings setup completions version)
|
|
451
452
|
_describe 'command' commands
|
|
452
453
|
;;
|
|
453
454
|
esac
|
|
@@ -463,7 +464,7 @@ function generateFishCompletion(): string {
|
|
|
463
464
|
return `# quickchr fish completion
|
|
464
465
|
# Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
|
|
465
466
|
|
|
466
|
-
set -l quickchr_commands add start stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help
|
|
467
|
+
set -l quickchr_commands add start continue stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help
|
|
467
468
|
|
|
468
469
|
# Disable file completions globally for quickchr
|
|
469
470
|
complete -c quickchr -f
|
|
@@ -473,6 +474,8 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
473
474
|
-a "add" -d "Create a new CHR machine"
|
|
474
475
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
475
476
|
-a "start" -d "Start or restart a CHR instance"
|
|
477
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
478
|
+
-a "continue" -d "Resume a machine started with --frozen"
|
|
476
479
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
477
480
|
-a "stop" -d "Power cut instance(s)"
|
|
478
481
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
@@ -528,7 +531,7 @@ complete -c quickchr -n "__fish_seen_subcommand_from stop shutdown" \\
|
|
|
528
531
|
-d "machine"
|
|
529
532
|
|
|
530
533
|
# Machine name completions for commands that take any machine
|
|
531
|
-
complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status inspect env get set" \\
|
|
534
|
+
complete -c quickchr -n "__fish_seen_subcommand_from start continue remove rm clean console exec qga license disk snapshot snap status inspect env get set" \\
|
|
532
535
|
-a "(quickchr completions --machines 2>/dev/null)" \\
|
|
533
536
|
-d "machine"
|
|
534
537
|
|
package/src/lib/lock.ts
CHANGED
package/src/lib/platform.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
6
|
-
import type { AccelMode, EfiFirmwarePaths, HostInterface, PackageManager, PlatformInfo, SocketVmnetInfo } from "./types.ts";
|
|
6
|
+
import type { AccelMode, EfiFirmwarePaths, HostInterface, PackageManager, PlatformInfo, SocketVmnetInfo, DoctorCheck } from "./types.ts";
|
|
7
7
|
import { QuickCHRError as QError, parseAccelMode } from "./types.ts";
|
|
8
8
|
// settings-file.ts, not settings.ts: the latter's graph (cache → state → network
|
|
9
9
|
// → platform) is a cycle that would load eagerly on every CLI start.
|
|
@@ -590,3 +590,15 @@ export function resolveInterfaceAlias(
|
|
|
590
590
|
`No interface found for alias '${alias}' — run 'quickchr doctor' to see available interfaces`,
|
|
591
591
|
);
|
|
592
592
|
}
|
|
593
|
+
|
|
594
|
+
/** Compare the running Bun against the same pin CI installs. */
|
|
595
|
+
export async function bunRuntimeCheck(version = Bun.version): Promise<DoctorCheck> {
|
|
596
|
+
const pin = (await Bun.file(new URL("../../.bun-version", import.meta.url)).text()).trim();
|
|
597
|
+
// Runtimes predating the Semver API are also older than the tested pin.
|
|
598
|
+
const older = typeof Bun.semver?.order !== "function" || Bun.semver.order(version, pin) < 0;
|
|
599
|
+
return {
|
|
600
|
+
label: "Bun runtime",
|
|
601
|
+
status: older ? "warn" : "ok",
|
|
602
|
+
detail: `bun ${version}; tested with Bun ${pin}${older ? ` — upgrade to ${pin} or newer` : ""}`,
|
|
603
|
+
};
|
|
604
|
+
}
|