@tikoci/quickchr 0.4.8 → 0.5.0

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,75 @@ async function cmdSetup() {
1261
1360
  }
1262
1361
  }
1263
1362
 
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
+
1264
1393
  async function cmdStart(argv: string[]) {
1265
1394
  const { flags, positional } = parseFlags(argv);
1266
1395
  applyTimeoutExtraShortFlag(argv, flags, positional);
1267
1396
  rejectUnknownFlags(flags, START_FLAGS, "start");
1268
1397
  // Before the --all branch: both start paths must see the same override.
1269
1398
  await applyAccelFlag(flags);
1399
+ warnDeprecatedBackgroundFlag(flags);
1400
+ const wait = flags.wait === false ? false : undefined;
1270
1401
 
1271
1402
  // --all: start every stopped machine in background
1272
1403
  if (flagBool(flags, "all")) {
1404
+ // Batch starts only accept options this branch actually forwards. In particular,
1405
+ // a provisioning flag must never disappear on a --no-wait start.
1406
+ const batchFlags = new Set(["all", "wait", "bg", "background", "accel", "timeout-extra", "secure-login"]);
1407
+ const unsupported = Object.keys(flags).filter((key) => !batchFlags.has(key));
1408
+ if (unsupported.length > 0 || flags.bg === false || flags.background === false) {
1409
+ 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.");
1410
+ }
1273
1411
  const { QuickCHR } = await import("../lib/quickchr.ts");
1274
1412
  const { statusIcon, link, bold, resolveDisplayCredentials, formatRestUrl, formatSshCommand } = await import("./format.ts");
1275
1413
  const timeoutExtra = await resolveTimeoutExtraMs(flags);
1276
- const secureLogin = await resolveSecureLoginFlag(flags);
1277
1414
  const stopped = QuickCHR.list().filter((m) => m.status !== "running");
1278
1415
  if (stopped.length === 0) {
1279
1416
  console.log("No stopped instances.");
1280
1417
  return;
1281
1418
  }
1419
+ const { isProvisioningWindowOpen } = await import("../lib/provisioning-window.ts");
1282
1420
  for (const m of stopped) {
1283
1421
  console.log(`Starting ${bold(m.name)}...`);
1284
- const instance = await QuickCHR.start({ name: m.name, background: true, timeoutExtra, secureLogin });
1422
+ // Per machine, not per invocation: a never-started machine in this batch is
1423
+ // still entitled to the secure-login setting on its first boot.
1424
+ const secureLogin = await resolveSecureLoginFlag(flags, secureLoginSettingApplies(flags, isProvisioningWindowOpen(m), m.version));
1425
+ const instance = await QuickCHR.start({ name: m.name, background: true, wait, timeoutExtra, secureLogin });
1285
1426
  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)}`);
1427
+ const endpoint = isRouterOs6(instance.state.version)
1428
+ ? `API: 127.0.0.1:${instance.ports.api}`
1429
+ : `REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`;
1430
+ console.log(`${statusIcon("running")} ${bold(instance.name)} ${endpoint} SSH: ${formatSshCommand(creds.user, instance.sshPort)}${wait === false ? " (not waited for)" : ""}`);
1431
+ if (wait === false) printReadinessTip(instance.name, instance.state.version);
1287
1432
  }
1288
1433
  return;
1289
1434
  }
@@ -1310,13 +1455,15 @@ async function cmdStart(argv: string[]) {
1310
1455
 
1311
1456
  const opts: StartOptions = {
1312
1457
  version: flag(flags, "version"),
1313
- channel: (flag(flags, "channel") as Channel | undefined) ?? (resolveSetting("default-channel").value as Channel | undefined),
1458
+ // The default channel only stands in for a missing --version; applied alongside
1459
+ // one, it reads to start()/add() as a caller passing both (#178).
1460
+ channel: (flag(flags, "channel") as Channel | undefined) ?? (flag(flags, "version") ? undefined : resolveSetting("default-channel").value as Channel | undefined),
1314
1461
  arch: (flag(flags, "arch") as Arch | "auto" | undefined) ?? (resolveSetting("default-arch").value as Arch | "auto" | undefined),
1315
1462
  name: flag(flags, "name") ?? positional[0],
1316
1463
  cpu: flag(flags, "cpu") ? Number(flag(flags, "cpu")) : undefined,
1317
1464
  mem: flag(flags, "mem") ? Number(flag(flags, "mem")) : undefined,
1318
- packages: flagList(flags, "add-package"),
1319
- installAllPackages: flagBool(flags, "install-all-packages"),
1465
+ packages: csvList(flagList(flags, "add-package")),
1466
+ installAllPackages: flagOptionalBool(flags, "install-all-packages"),
1320
1467
  portBase: flag(flags, "port-base") ? Number(flag(flags, "port-base")) : undefined,
1321
1468
  excludePorts: [
1322
1469
  ...(flags.winbox === false ? ["winbox" as ServiceName] : []),
@@ -1332,21 +1479,7 @@ async function cmdStart(argv: string[]) {
1332
1479
  timeoutExtra: await resolveTimeoutExtraMs(flags),
1333
1480
  };
1334
1481
 
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
- }
1482
+ opts.deviceMode = deviceModeFromFlags(flags);
1350
1483
 
1351
1484
  // --license-* flags (credentials from env if not supplied)
1352
1485
  const licenseLevel = flag(flags, "license-level");
@@ -1371,10 +1504,25 @@ async function cmdStart(argv: string[]) {
1371
1504
  opts.user = { name, password: password ?? "" };
1372
1505
  }
1373
1506
 
1374
- opts.disableAdmin = flagBool(flags, "disable-admin");
1375
- opts.secureLogin = await resolveSecureLoginFlag(flags);
1507
+ opts.disableAdmin = flagOptionalBool(flags, "disable-admin");
1508
+ {
1509
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1510
+ const { isProvisioningWindowOpen } = await import("../lib/provisioning-window.ts");
1511
+ const target = opts.name ? QuickCHR.get(opts.name) : null;
1512
+ // No machine yet, or one that was added and never booted: still a first boot,
1513
+ // so the setting applies. Only a closed window suppresses it — otherwise a
1514
+ // plain `quickchr start <booted-name>` would ask to provision a booted guest
1515
+ // and be refused.
1516
+ opts.secureLogin = await resolveSecureLoginFlag(flags, secureLoginSettingApplies(
1517
+ flags,
1518
+ !target || isProvisioningWindowOpen(target.state),
1519
+ target?.state.version ?? opts.version,
1520
+ ));
1521
+ }
1522
+ opts.wait = wait;
1376
1523
 
1377
1524
  // Background default: true. Explicitly foreground only with --fg / --foreground / --no-background / --no-bg.
1525
+ // `--bg` / `--background` are accepted and change nothing (see warnDeprecatedBackgroundFlag).
1378
1526
  const wantFg =
1379
1527
  flagBool(flags, "fg") ||
1380
1528
  flagBool(flags, "foreground") ||
@@ -1387,8 +1535,9 @@ async function cmdStart(argv: string[]) {
1387
1535
  return;
1388
1536
  }
1389
1537
 
1390
- // --fg on already-running machine: attach to its serial console
1391
- if (!opts.background && opts.name) {
1538
+ // --fg on already-running machine: attach to its serial console.
1539
+ // `--fg --no-wait` skips both foreground blocks so start() refuses the pair before any I/O.
1540
+ if (!opts.background && opts.wait !== false && opts.name) {
1392
1541
  const runningMachine = QuickCHR.list().find((m) => m.name === opts.name && m.status === "running");
1393
1542
  if (runningMachine) {
1394
1543
  await attachSerial(runningMachine.name, runningMachine.machineDir);
@@ -1396,7 +1545,7 @@ async function cmdStart(argv: string[]) {
1396
1545
  }
1397
1546
  }
1398
1547
 
1399
- if (!opts.background) {
1548
+ if (!opts.background && opts.wait !== false) {
1400
1549
  const wantsDeviceMode = !!opts.deviceMode && !["skip", "none", "off", "disabled"].includes((opts.deviceMode.mode ?? "auto").toLowerCase());
1401
1550
  const hasProv = !!(
1402
1551
  opts.installAllPackages ||
@@ -1423,13 +1572,19 @@ async function cmdStart(argv: string[]) {
1423
1572
  console.log(` ${dim("Tip: run background")} quickchr start ${instance.name}`);
1424
1573
  } else {
1425
1574
  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})`);
1575
+ const v6 = isRouterOs6(instance.state.version);
1576
+ console.log(`${statusIcon("running")} ${bold(instance.name)} ${opts.wait === false ? "launched — not waiting for RouterOS (--no-wait)" : "started"}`);
1577
+ console.log(` Version: ${instance.state.version} (${instance.state.arch})${v6 ? ` ${dim("boot-only: no REST, no provisioning")}` : ""}`);
1428
1578
  console.log(` Network: ${formatNetworks(instance.state.networks ?? [])}`);
1429
1579
  console.log(` Ports: ${formatPorts(instance.state.ports)}`);
1430
- console.log(` REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`);
1580
+ if (v6) {
1581
+ console.log(` API: 127.0.0.1:${instance.ports.api} ${dim(`(native API, user ${creds.user})`)}`);
1582
+ } else {
1583
+ console.log(` REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`);
1584
+ }
1431
1585
  console.log(` SSH: ${formatSshCommand(creds.user, instance.sshPort)}`);
1432
1586
  console.log(` WinBox: 127.0.0.1:${instance.ports.winbox}`);
1587
+ if (opts.wait === false) printReadinessTip(instance.name, instance.state.version);
1433
1588
  if (creds.user !== "admin") {
1434
1589
  console.log(` Login: ${creds.user} / ${creds.password || "(no password)"}`);
1435
1590
  console.log(` Run: quickchr exec ${instance.name} /system/resource/print`);
@@ -1437,6 +1592,32 @@ async function cmdStart(argv: string[]) {
1437
1592
  }
1438
1593
  }
1439
1594
 
1595
+ async function cmdShutdown(argv: string[]) {
1596
+ const { flags, positional } = parseFlags(argv);
1597
+ const invalid = unknownFlags(flags, ["timeout"]);
1598
+ if (invalid.length) throw new QuickCHRError("INVALID_ARGUMENT", "shutdown accepts only <name> and --timeout <seconds>");
1599
+ if (positional.length !== 1 || positional[0] === undefined) throw new QuickCHRError("INVALID_ARGUMENT", "Usage: quickchr shutdown <name> [--timeout <seconds>]");
1600
+ const raw = flags.timeout === undefined ? (process.env.QUICKCHR_SHUTDOWN_TIMEOUT ?? "10") : flag(flags, "timeout");
1601
+ if (raw === undefined || !/^[0-9]+$/.test(raw) || Number(raw) <= 0) {
1602
+ throw new QuickCHRError("INVALID_ARGUMENT", "shutdown timeout must be a positive integer number of seconds");
1603
+ }
1604
+ const { validateShutdownTimeout } = await import("../lib/shutdown.ts");
1605
+ const timeoutMs = validateShutdownTimeout(Number(raw) * 1000);
1606
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1607
+ const name = positional[0];
1608
+ const instance = QuickCHR.get(name);
1609
+ if (!instance) throw new QuickCHRError("MACHINE_NOT_FOUND", `Machine "${name}" not found`);
1610
+ const result = await instance.shutdown({ timeoutMs });
1611
+ if (result.outcome === "forced") {
1612
+ console.log(`${name}: forced power cut — guest shutdown not confirmed`);
1613
+ if (result.requestError) console.error(`Shutdown request: ${result.requestError}`);
1614
+ } else if (result.outcome === "already-stopped") {
1615
+ console.log(`${name}: already stopped`);
1616
+ } else {
1617
+ console.log(`${name}: guest exited after shutdown request`);
1618
+ }
1619
+ }
1620
+
1440
1621
  async function cmdStop(argv: string[]) {
1441
1622
  const { flags, positional } = parseFlags(argv);
1442
1623
  const { QuickCHR } = await import("../lib/quickchr.ts");
@@ -1700,6 +1881,11 @@ async function cmdGet(argv: string[]) {
1700
1881
  return;
1701
1882
  }
1702
1883
 
1884
+ // Every live group is a REST read. Refused here rather than left to fetchGroup(),
1885
+ // which swallows errors and would print an empty report for a v6 machine (#182).
1886
+ const { assertRestAvailable } = await import("../lib/versions.ts");
1887
+ assertRestAvailable(s, "'quickchr get' (live properties)");
1888
+
1703
1889
  const { resolveAuth } = await import("../lib/auth.ts");
1704
1890
  const auth = resolveAuth(s);
1705
1891
  const base = `http://127.0.0.1:${instance.ports.http}/rest`;
@@ -1747,14 +1933,20 @@ async function cmdGet(argv: string[]) {
1747
1933
  }
1748
1934
 
1749
1935
  if (results["device-mode"]) {
1936
+ const { DEVICE_MODE_NON_FEATURE_KEYS, isDeviceModeFeatureEnabled } = await import("../lib/device-mode.ts");
1750
1937
  const dm = results["device-mode"] as Record<string, unknown>;
1751
1938
  const mode = dm.mode ?? "(not set)";
1752
1939
  console.log(`\n ${bold("Device Mode")}`);
1753
1940
  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(", ")}`);
1941
+ // RouterOS reports these as strings ("true"/"false"), so the old `v === true`
1942
+ // test matched nothing and the feature lines never printed — `get` could not
1943
+ // read back what `set` had just written. Both lines are shown: after
1944
+ // `--device-mode-disable smb` the interesting answer is the one that is off.
1945
+ const features = Object.entries(dm).filter(([k]) => !DEVICE_MODE_NON_FEATURE_KEYS.has(k));
1946
+ const enabled = features.filter(([, v]) => isDeviceModeFeatureEnabled(v)).map(([k]) => k);
1947
+ const disabled = features.filter(([, v]) => !isDeviceModeFeatureEnabled(v)).map(([k]) => k);
1948
+ if (enabled.length > 0) console.log(` Enabled: ${enabled.join(", ")}`);
1949
+ if (disabled.length > 0) console.log(` Disabled: ${disabled.join(", ")}`);
1758
1950
  }
1759
1951
 
1760
1952
  if (results.admin) {
@@ -1883,6 +2075,10 @@ async function applyLicense(argv: string[]) {
1883
2075
  console.error(`Machine "${name}" is not running.`);
1884
2076
  process.exit(1);
1885
2077
  }
2078
+ // Before any credential prompt: instance.license() refuses RouterOS 6 too, but only
2079
+ // after the user has typed a MikroTik password for nothing.
2080
+ const { assertProvisioningSupportedVersion } = await import("../lib/versions.ts");
2081
+ assertProvisioningSupportedVersion(instance.state.version, "apply a license");
1886
2082
 
1887
2083
  let account = flag(flags, "account") ?? process.env.MIKROTIK_WEB_ACCOUNT;
1888
2084
  let password = flag(flags, "password") ?? process.env.MIKROTIK_WEB_PASSWORD;
@@ -1928,28 +2124,100 @@ async function applyLicense(argv: string[]) {
1928
2124
  console.log(`License applied: ${before} → ${level}`);
1929
2125
  }
1930
2126
 
2127
+ /** `set` applies a change to a machine that has already booted.
2128
+ *
2129
+ * It is the post-boot half of the provisioning flags, and deliberately narrow: only
2130
+ * the steps that are safe to run against a guest whose configuration quickchr no
2131
+ * longer knows get a home here (#176). `--license` is account-level. `--device-mode`
2132
+ * is a capability flag — it gates whether a feature *can* run and describes nothing
2133
+ * about how the guest is configured, so applying it later cannot clobber working
2134
+ * config; the config it was blocking could not have been applied while it was off.
2135
+ *
2136
+ * `--add-user`, `--disable-admin` and `--secure-login` are excluded on purpose. They
2137
+ * are working config rather than a capability, they collide with whatever has been
2138
+ * created since, and they are the steps that drift. Their route stays
2139
+ * `quickchr clean <name>` and a fresh provisioning run. */
1931
2140
  async function cmdSet(argv: string[]) {
1932
2141
  const { flags, positional } = parseFlags(argv);
1933
- const name = positional[0];
2142
+ // Positional first, `--name` as the fallback — the same order `applyLicense` uses,
2143
+ // which `set --license` delegates to. Without this `quickchr set --name lab
2144
+ // --license` printed a usage error while `quickchr license --name lab` worked.
2145
+ const name = positional[0] ?? flag(flags, "name");
1934
2146
 
2147
+ const usage = "Usage: quickchr set <name> [--license [--level=p1|p10|unlimited]] [--device-mode <mode>] [--device-mode-enable <features>] [--device-mode-disable <features>]";
1935
2148
  if (!name) {
1936
- console.error("Usage: quickchr set <name> --license [--level=p1|p10|unlimited]");
2149
+ console.error(usage);
2150
+ process.exit(1);
2151
+ }
2152
+
2153
+ const valueless = valuelessFlags(flags, SET_FLAGS);
2154
+ if (valueless.length > 0) {
2155
+ console.error(valuelessFlagMessage(valueless, "set"));
2156
+ process.exit(1);
2157
+ }
2158
+
2159
+ if (flags["device-mode"] === false) {
2160
+ // `--no-device-mode` means "skip device-mode provisioning" on add/start. There is
2161
+ // nothing to skip in an imperative set, so honouring it would be accepting a flag
2162
+ // and doing nothing — the failure this whole area exists to stop.
2163
+ 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
2164
  process.exit(1);
1938
2165
  }
1939
2166
 
1940
2167
  const wantsLicense = flagBool(flags, "license");
2168
+ const deviceMode = deviceModeFromFlags(flags);
1941
2169
 
1942
- if (!wantsLicense) {
1943
- console.error("Nothing to set. Available flags: --license");
2170
+ if (!wantsLicense && !deviceMode) {
2171
+ console.error("Nothing to set. Available flags: --license, --device-mode, --device-mode-enable, --device-mode-disable");
1944
2172
  process.exit(1);
1945
2173
  }
1946
2174
 
2175
+ // Device-mode first: it power-cycles the guest, and a license applied beforehand
2176
+ // would be read back across a reboot it did not expect.
2177
+ if (deviceMode) await applyDeviceModeChange(name, deviceMode);
1947
2178
  if (wantsLicense) {
1948
2179
  const subcmdArgs = [name, ...Object.entries(flags).flatMap(([k, v]) => v === true ? [`--${k}`] : [`--${k}=${v}`]), ...positional.slice(1)];
1949
2180
  await applyLicense(subcmdArgs);
1950
2181
  }
1951
2182
  }
1952
2183
 
2184
+ /** `set <name> --device-mode…` — the post-boot route the refusal names.
2185
+ *
2186
+ * Device-mode needs a **power cycle**: RouterOS will not apply it otherwise, and
2187
+ * `/system/device-mode/update` issued from inside the guest simply never returns,
2188
+ * because it is waiting for a power cycle it cannot perform on itself. quickchr owns
2189
+ * the power button, which is why this lives here and not in a tool that talks to a
2190
+ * router over the network. */
2191
+ async function applyDeviceModeChange(name: string, deviceMode: DeviceModeOptions) {
2192
+ const { QuickCHR } = await import("../lib/quickchr.ts");
2193
+ const { bold, dim, machineNotFoundMessage } = await import("./format.ts");
2194
+ const { formatDeviceModeSelection, resolveDeviceModeOptions } = await import("../lib/device-mode.ts");
2195
+ const { shellQuote } = await import("../lib/names.ts");
2196
+
2197
+ const instance = QuickCHR.get(name);
2198
+ if (!instance) {
2199
+ console.error(machineNotFoundMessage(name));
2200
+ process.exit(1);
2201
+ }
2202
+
2203
+ // Before announcing a change that cannot happen (setDeviceMode() refuses it as well).
2204
+ const { assertProvisioningSupportedVersion } = await import("../lib/versions.ts");
2205
+ assertProvisioningSupportedVersion(instance.state.version, "set device-mode");
2206
+
2207
+ const selection = formatDeviceModeSelection(resolveDeviceModeOptions(deviceMode));
2208
+ console.log(`Setting device-mode on ${bold(name)}: ${selection}`);
2209
+ // Hedged on purpose: a request the guest already satisfies costs nothing, and
2210
+ // setDeviceMode() skips the power cycle in that case. Promising a restart before
2211
+ // reading the guest would make the quiet no-op look like a failure to restart.
2212
+ console.log(dim(" RouterOS only applies device-mode across a power cycle, so any setting that moves restarts the machine."));
2213
+ // setDeviceMode() checks the preconditions (running, user-mode NIC), prints the
2214
+ // settings that will actually move, and skips the power cycle when none would.
2215
+ await instance.setDeviceMode(deviceMode);
2216
+ // Quoted for the same reason the refusal routes are: a legacy machine name can
2217
+ // contain a space, and an unquoted read-back hint would name a different machine.
2218
+ console.log(`Device-mode set. Read it back with: quickchr get ${shellQuote(name)} device-mode`);
2219
+ }
2220
+
1953
2221
  async function cmdLicense(argv: string[]) {
1954
2222
  await applyLicense(argv);
1955
2223
  }
@@ -2897,7 +3165,8 @@ Commands:
2897
3165
  setup Interactive setup wizard (TTY only)
2898
3166
  add [options] Create a new CHR machine (without starting)
2899
3167
  start [<name>|options] Start or restart a CHR instance
2900
- stop [<name>|--all] Stop instance(s) — print list if no name
3168
+ stop [<name>|--all] Power cut — print list if no name
3169
+ shutdown <name> Request guest shutdown, then bounded hard-stop fallback
2901
3170
  list [<name>] [--json] List all instances, or detail for one
2902
3171
  status Alias for list
2903
3172
  inspect <name> [--json] Print running machine descriptor as JSON
@@ -2907,8 +3176,8 @@ Commands:
2907
3176
  (raw /rest/execute — see 'quickchr exec --help' for centrs)
2908
3177
  remove [<name>|--all] Remove instance(s) and disk
2909
3178
  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
3179
+ get <name> [group] Show machine config (license, device-mode, admin)
3180
+ set <name> [flags] Change a booted machine (license, device-mode)
2912
3181
  license <name> Deprecated — use 'set <name> --license'
2913
3182
  disk <name> Show disk details for an instance
2914
3183
  snapshot <name> [cmd] Manage snapshots (list/save/load/delete)
@@ -2946,6 +3215,8 @@ Post-boot provisioning flags in this command (--add-package, --install-all-packa
2946
3215
  RouterOS ${MIN_PROVISION_VERSION}+ only. Older 7.x remains boot-only. Disk and network
2947
3216
  options still work on older versions when the required host tools are installed.
2948
3217
 
3218
+ ${ROUTEROS6_HELP}
3219
+
2949
3220
  Options:
2950
3221
  --name <name> Instance name (required)
2951
3222
  --version <ver> RouterOS version (e.g., 7.22.1)
@@ -2956,9 +3227,9 @@ Options:
2956
3227
  --accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
2957
3228
  Anything but 'auto' bypasses detection, including the
2958
3229
  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)
3230
+ --boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
3231
+ --boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
3232
+ --add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
2962
3233
  --add-package <pkg> Extra package to install on first boot (repeatable)
2963
3234
  --forward <spec> Add an extra hostfwd port (repeatable). Spec:
2964
3235
  name[:host[:guest]][/proto], or a port range
@@ -2976,9 +3247,18 @@ Options:
2976
3247
  --no-winbox Exclude WinBox port mapping
2977
3248
  --no-api-ssl Exclude API-SSL port mapping
2978
3249
  --device-mode <m> Set device-mode on first boot: rose|advanced|basic|home|auto|skip
3250
+ --device-mode-enable <f> Set one or more device-mode flags to yes (e.g. container)
3251
+ --device-mode-disable <f> Set one or more device-mode flags to no
3252
+ --no-device-mode Skip device-mode provisioning entirely
2979
3253
  --add-network <spec> Add a network NIC (repeatable). Specs: user, shared, bridged:<if>,
2980
3254
  socket::<name>, tap:<if>. Default: single user NIC.
2981
- --no-network Start with no NICs (headless)`);
3255
+ --no-network Start with no NICs (headless)
3256
+
3257
+ Provisioning flags apply on the machine's first boot — whether they are given
3258
+ here or on that first 'start'. After the guest has booted, a flag the machine already
3259
+ has is accepted as a no-op; any other is refused rather than ignored, naming where the
3260
+ change can still be made (often 'quickchr set <name>'). 'quickchr clean <name>' also
3261
+ reopens the window, but resets the disk to factory and erases the guest's configuration.`);
2982
3262
  break;
2983
3263
  case "setup":
2984
3264
  console.log(`quickchr setup
@@ -3028,10 +3308,23 @@ validated on RouterOS ${MIN_PROVISION_VERSION}+ only. Older 7.x remains boot-onl
3028
3308
  Disk and network options still work on older versions when the required host tools
3029
3309
  are installed.
3030
3310
 
3311
+ ${ROUTEROS6_HELP}
3312
+
3313
+ By default 'start' returns once RouterOS is ready (REST answers; on 6.x, a native-API
3314
+ login). --no-wait returns as soon as QEMU is running and leaves readiness to you:
3315
+ centrs retrieve --quickchr <name> /system/resource --wait 5m
3316
+ QEMU runs detached either way. --no-wait is refused with --fg, and with provisioning
3317
+ flags, including ones 'add' recorded for the first boot — provisioning needs a booted
3318
+ guest.
3319
+
3031
3320
  Options:
3032
- --all Start all stopped machines
3033
- --bg / --background Run in background (default)
3321
+ --all Start all stopped machines, in background. Accepts only
3322
+ --no-wait, --accel, --timeout-extra and --[no-]secure-login
3323
+ (plus the deprecated no-op --bg / --background); any other
3324
+ option is refused rather than ignored
3325
+ --no-wait Return once QEMU is running; do not wait for RouterOS (not with --fg)
3034
3326
  --fg / --foreground Run in foreground — serial console on stdio
3327
+ --bg / --background Deprecated, does nothing: background is already the default
3035
3328
  --version <ver> RouterOS version (e.g., 7.22.1)
3036
3329
  --channel <ch> Channel: stable, long-term, testing, development
3037
3330
  --arch <arch> Architecture: arm64, x86, auto (default: host native)
@@ -3041,9 +3334,9 @@ Options:
3041
3334
  --accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
3042
3335
  Anything but 'auto' bypasses detection, including the
3043
3336
  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)
3337
+ --boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
3338
+ --boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
3339
+ --add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
3047
3340
  --add-package <pkg> Extra package to install (repeatable)
3048
3341
  --forward <spec> Add an extra hostfwd port (repeatable). Spec:
3049
3342
  name[:host[:guest]][/proto], or a range
@@ -3068,15 +3361,35 @@ Options:
3068
3361
  --device-mode-disable <f> Set one or more device-mode flags to no
3069
3362
  --no-device-mode Skip device-mode provisioning entirely
3070
3363
  --timeout-extra <s>, -T <s> Add extra seconds to the auto-computed boot timeout
3071
- --dry-run Print what would run without starting`);
3364
+ --dry-run Print what would run without starting
3365
+
3366
+ Provisioning flags (packages, login, device-mode, license) apply only on a machine's
3367
+ first boot. On a machine that has booted, a flag the machine already has (same
3368
+ packages, user, license, device-mode) is accepted as a no-op. Any other is refused,
3369
+ naming where the change can still be made — often 'quickchr set <name>'. Only reach for
3370
+ 'quickchr clean <name>' if you mean it: it resets the disk to factory, erasing the
3371
+ guest's configuration, and reopens the first-boot window.`);
3072
3372
  break;
3073
3373
  case "stop":
3074
3374
  console.log(`quickchr stop [<name>] [--all]
3075
3375
 
3376
+ Power cut: QEMU SIGTERM, then SIGKILL if needed. Pending guest changes may be lost.
3377
+ Use shutdown to request /system/shutdown first.
3378
+
3076
3379
  <name> Stop a specific running instance.
3077
3380
  Omit to see a list of running instances.
3078
3381
  --all Stop all running instances.`);
3079
3382
  break;
3383
+ case "shutdown":
3384
+ console.log(`quickchr shutdown <name> [--timeout <seconds>]
3385
+
3386
+ Request /system/shutdown over REST, then wait for QEMU to exit.
3387
+ If it is still running at the deadline, force a power cut and report it.
3388
+ --timeout <seconds> Positive integer seconds (default 10).
3389
+ Overrides QUICKCHR_SHUTDOWN_TIMEOUT.
3390
+ Lock wait and hard-stop escalation are separate bounded waits.
3391
+ Preserves disks and credentials. Use stop for immediate teardown.`);
3392
+ break;
3080
3393
  case "status":
3081
3394
  console.log(`quickchr status [<name>] [--json]
3082
3395
 
@@ -3195,14 +3508,30 @@ When no group is given, all properties are shown.
3195
3508
  The machine must be running to query live properties.`);
3196
3509
  break;
3197
3510
  case "set":
3198
- console.log(`quickchr set <name> --license [options]
3511
+ console.log(`quickchr set <name> [--license | --device-mode...] [options]
3199
3512
 
3200
- Set properties on a CHR instance.
3513
+ Change a property on a machine that has already booted. This is the post-boot
3514
+ half of the provisioning flags: 'add'/'start' apply them at first boot, 'set'
3515
+ applies them now.
3201
3516
 
3202
3517
  <name> Name of a running CHR instance (required).
3203
3518
 
3204
3519
  Flags:
3205
3520
  --license Apply or renew a CHR trial license.
3521
+ --device-mode <m> Set device-mode: rose|advanced|basic|home|auto
3522
+ --device-mode-enable <f> Set one or more device-mode flags to yes (e.g. container)
3523
+ --device-mode-disable <f> Set one or more device-mode flags to no
3524
+
3525
+ Device-mode notes:
3526
+ RouterOS only applies device-mode across a power cycle, so 'set' restarts the
3527
+ machine (roughly double a plain start). Enabling a feature without naming a
3528
+ mode resolves the mode to 'rose'; quickchr prints every setting that moves
3529
+ before it restarts. The machine must be running and have a user-mode NIC.
3530
+ Read it back with 'quickchr get <name> device-mode'.
3531
+
3532
+ Not available here: --add-user, --disable-admin and --secure-login. Those rewrite
3533
+ working config rather than unlock a capability, so their route stays
3534
+ 'quickchr clean <name>' followed by the original add/start command.
3206
3535
 
3207
3536
  License options (used with --license):
3208
3537
  --level <level> License level: p1 (1 Gbps), p10 (10 Gbps), unlimited (default: p1)
@@ -3213,7 +3542,7 @@ Credential resolution order:
3213
3542
  1. --account / --password flags
3214
3543
  2. MIKROTIK_WEB_ACCOUNT / MIKROTIK_WEB_PASSWORD environment variables
3215
3544
  3. OS native secret store (macOS Keychain / Linux Keyring)
3216
- 4. ~/.config/quickchr/credentials.json (fallback)`);
3545
+ 4. ~/.config/quickchr/com.quickchr.mikrotik-web.json (fallback)`);
3217
3546
  break;
3218
3547
  case "license":
3219
3548
  console.log(`quickchr license <name> [options]
@@ -3235,7 +3564,7 @@ Credential resolution order (highest priority first):
3235
3564
  1. --account / --password flags
3236
3565
  2. MIKROTIK_WEB_ACCOUNT / MIKROTIK_WEB_PASSWORD environment variables
3237
3566
  3. OS native secret store (macOS Keychain, Linux GNOME Keyring, Windows Credential Manager)
3238
- 4. ~/.config/quickchr/credentials.json (fallback)`);
3567
+ 4. ~/.config/quickchr/com.quickchr.mikrotik-web.json (fallback)`);
3239
3568
  break;
3240
3569
  case "qga":
3241
3570
  console.log(`quickchr qga <name> <operation> [options]