@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/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), 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. */
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` skips both foreground blocks so start() refuses the pair before any I/O.
1545
- if (!opts.background && opts.wait !== false && opts.name) {
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
- if (!opts.background) {
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 (required)
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 an existing stopped machine by name.
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 — requires qemu-img; skip gracefully if absent
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 qemuImg = findQemuImg();
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 (!qemuImg) {
161
- clack.log.warn(`Disk features (boot resize, extra disks) require qemu-img. Run 'quickchr doctor' to confirm setup. ${getQemuInstallHint()}`);
162
- } else {
163
- const diskFormatChoice = await clack.select({
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(diskFormatChoice)) { clack.cancel("Cancelled."); process.exit(0); }
172
- bootDiskFormat = diskFormatChoice as "qcow2" | "raw";
173
-
174
- if (bootDiskFormat === "qcow2") {
175
- const wantBootResize = await clack.confirm({
176
- message: "Resize the boot disk? (default is ~128 MB)",
177
- initialValue: false,
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(wantBootResize)) { clack.cancel("Cancelled."); process.exit(0); }
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
- const wantExtraDisks = await clack.confirm({
196
- message: "Add extra blank disks?",
197
- initialValue: false,
198
- });
199
- if (clack.isCancel(wantExtraDisks)) { clack.cancel("Cancelled."); process.exit(0); }
200
-
201
- if (wantExtraDisks) {
202
- let addMore = true;
203
- while (addMore) {
204
- const diskSize = await clack.text({
205
- message: `Extra disk ${extraDisks.length + 1} size (e.g., 64M, 512M, 1G):`,
206
- validate: (v) => {
207
- if (!isValidDiskSize(v)) return "Size like 64M, 512M, or 1G";
208
- },
209
- });
210
- if (clack.isCancel(diskSize)) { clack.cancel("Cancelled."); process.exit(0); }
211
- extraDisks.push(diskSize.trim());
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
- const another = await clack.confirm({
214
- message: "Add another disk?",
215
- initialValue: false,
216
- });
217
- if (clack.isCancel(another)) { clack.cancel("Cancelled."); process.exit(0); }
218
- addMore = another;
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
@@ -32,6 +32,7 @@ export type {
32
32
  Channel,
33
33
  ChrInstance,
34
34
  ChrPorts,
35
+ ContinueOptions,
35
36
  CustomForward,
36
37
  Descriptor,
37
38
  DeviceModeOptions,
@@ -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
@@ -61,6 +61,7 @@ export type LockOperation =
61
61
  | "foreground"
62
62
  | "stop"
63
63
  | "shutdown"
64
+ | "continue"
64
65
  | "clean"
65
66
  | "remove"
66
67
  | "device-mode"
@@ -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
+ }