@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/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
- export async function resolveSecureLoginFlag(flags: Record<string, string | boolean | string[]>): Promise<boolean | undefined> {
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
- if (err.code === "PROVISIONING_VERSION_UNSUPPORTED") {
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
- channel: (flag(flags, "channel") as Channel | undefined) ?? (resolveSetting("default-channel").value as Channel | undefined),
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: flagBool(flags, "install-all-packages"),
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
- const deviceModeValue = flag(flags, "device-mode");
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 = flagBool(flags, "disable-admin");
665
- opts.secureLogin = await resolveSecureLoginFlag(flags);
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
- console.log(` REST: http://127.0.0.1:${ports.http} ${dim("(after start)")}`);
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.packages.length > 0) console.log(` Packages: ${state.packages.join(", ")} ${dim("(applied on first start)")}`);
679
- if (state.deviceMode) console.log(` Device-mode: ${state.deviceMode.mode ?? "auto"} ${dim("(applied on first start)")}`);
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
- async function cmdStart(argv: string[]) {
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
- const instance = await QuickCHR.start({ name: m.name, background: true, timeoutExtra, secureLogin });
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
- console.log(`${statusIcon("running")} ${bold(instance.name)} REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))} SSH: ${formatSshCommand(creds.user, instance.sshPort)}`);
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
- channel: (flag(flags, "channel") as Channel | undefined) ?? (resolveSetting("default-channel").value as Channel | undefined),
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: flagBool(flags, "install-all-packages"),
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
- const deviceModeValue = flag(flags, "device-mode");
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 = flagBool(flags, "disable-admin");
1375
- opts.secureLogin = await resolveSecureLoginFlag(flags);
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
- if (!opts.background && opts.name) {
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
- console.log(`${statusIcon("running")} ${bold(instance.name)} started`);
1427
- console.log(` Version: ${instance.state.version} (${instance.state.arch})`);
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
- console.log(` REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`);
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
- const features = Object.entries(dm)
1755
- .filter(([k, v]) => k !== "mode" && v === true)
1756
- .map(([k]) => k);
1757
- if (features.length > 0) console.log(` Enabled: ${features.join(", ")}`);
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
- const name = positional[0];
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("Usage: quickchr set <name> --license [--level=p1|p10|unlimited]");
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 bootPath = state.bootDiskFormat === "qcow2"
1995
- ? join(state.machineDir, "boot.qcow2")
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 { listCacheEntries, pruneCache, parseSizeString, formatSizeBytes } = await import("../lib/cache.ts");
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 = listCacheEntries();
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 cached images.");
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} image(s), ${formatSize(total)}`);
2942
+ console.log(`\nTotal: ${countEntries(entries.length)}, ${formatSize(total)}`);
2943
+ if (unmanagedBytes > 0) console.log(`Other files: ${formatSize(unmanagedBytes)} (not written by quickchr; never pruned)`);
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} image(s):`);
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(`Freed: ${formatSize(result.freedBytes)}`);
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 cached images)");
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} image(s), freed ${formatSize(result.freedBytes)}`);
2978
+ console.log(`${verb} ${countEntries(result.evicted.length)}, ${flags["dry-run"] === true ? "would free" : "freed"} ${formatSize(result.freedBytes)}`);
2979
+ printFailedDeletes(result.failed);
2980
+ if (result.failed.length > 0) process.exitCode = 1;
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 image(s):`);
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} cached image(s) older than current long-term (${longTerm}):`);
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] Stop instance(s) — print list if no name
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] Show machine config (license, device-mode, admin)
2911
- set <name> --license Apply/renew CHR trial license
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
- --boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
2960
- --boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
2961
- --add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
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
- --bg / --background Run in background (default)
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
- --boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
3045
- --boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
3046
- --add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
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
- Set properties on a CHR instance.
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/credentials.json (fallback)`);
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/credentials.json (fallback)`);
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 cached images (basename, version, arch, size, age, in-use).
3289
- prune [options] Evict cached images per policy. Never evicts in-use images.
3290
- --older-than <ver> Evict images with version < <ver>.
3291
- --max-age <dur> Evict images older than duration (e.g. 7d, 24h).
3292
- --max-size <size> Evict oldest-version images until total size <= <size>.
3641
+ list [--json] List cache entries (basename, version, arch, size, age, in-use).
3642
+ prune [options] Evict cache entries per policy. Never evicts in-use entries.
3643
+ --older-than <ver> Evict entries with version < <ver>.
3644
+ --max-age <dur> Evict entries not used for longer than duration (e.g. 7d, 24h).
3645
+ --max-size <size> Evict entries until total size <= <size>: partial downloads
3646
+ first, then oldest RouterOS version.
3293
3647
  --dry-run Show what would be evicted without deleting.
3294
- clear --all Delete all non-in-use cached images.
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