@tikoci/quickchr 0.4.7 → 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,22 +3,43 @@
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, SET_FLAGS, START_FLAGS, VALUE_FLAGS, unknownFlags, unknownFlagMessage, valuelessFlags, valuelessFlagMessage } from "./flags.ts";
10
+ import { QuickCHRError } from "../lib/types.ts";
11
+ import { CENTRS_EXEC_TIP, CENTRS_SEE_ALSO, tip, tipsForError } from "./tips.ts";
9
12
  import {
13
+ isRouterOs6,
10
14
  MIN_PROVISION_VERSION,
11
15
  PROVISIONING_BOOT_ONLY_SUMMARY,
12
16
  PROVISIONING_FEATURE_SUMMARY,
13
17
  provisioningSupportHint,
14
18
  provisioningSupportSummary,
19
+ ROUTEROS6_BOOT_ONLY,
15
20
  } from "../lib/versions.ts";
16
21
 
17
22
  const args = process.argv.slice(2);
18
23
  const command = args[0];
19
24
 
20
- /** Parse --flag=value and --flag value pairs from args. */
21
- export function parseFlags(argv: string[]): { flags: Record<string, string | boolean | string[]>; positional: string[] } {
25
+ /** Stop a removed flag at the parser, naming what replaced it. Thrown rather than
26
+ * printed so it reaches `main()`'s handler like any other error, and so no command can
27
+ * reach a state where the flag was accepted and ignored (#164). */
28
+ function rejectRemovedFlag(key: string): void {
29
+ const replacement = REMOVED_FLAGS.get(key);
30
+ if (replacement === undefined) return;
31
+ throw new QuickCHRError("INVALID_ARGUMENT", `--${key} was removed. Use: ${replacement}`);
32
+ }
33
+
34
+ /** Parse --flag=value and --flag value pairs from args.
35
+ *
36
+ * Arity comes from `VALUE_FLAGS`, never from the shape of the next argument: a flag
37
+ * that is not in that table is boolean and leaves the following argument alone, so a
38
+ * positional cannot be eaten by a flag that has no use for it (#164). */
39
+ export function parseFlags(
40
+ argv: string[],
41
+ valueFlags: ReadonlySet<string> = VALUE_FLAGS,
42
+ ): { flags: Record<string, string | boolean | string[]>; positional: string[] } {
22
43
  const flags: Record<string, string | boolean | string[]> = {};
23
44
  const positional: string[] = [];
24
45
 
@@ -33,13 +54,16 @@ export function parseFlags(argv: string[]): { flags: Record<string, string | boo
33
54
  const eqIdx = arg.indexOf("=");
34
55
  if (eqIdx !== -1) {
35
56
  const key = arg.slice(2, eqIdx);
57
+ rejectRemovedFlag(key);
36
58
  flags[key] = arg.slice(eqIdx + 1);
37
59
  } else if (arg.startsWith("--no-")) {
60
+ rejectRemovedFlag(arg.slice(5));
38
61
  flags[arg.slice(5)] = false;
39
62
  } else {
40
63
  const key = arg.slice(2);
64
+ rejectRemovedFlag(key);
41
65
  const next = argv[i + 1];
42
- if (next && !next.startsWith("--")) {
66
+ if (valueFlags.has(key) && next !== undefined && !next.startsWith("--")) {
43
67
  // Check if this is a repeatable flag
44
68
  const existing = flags[key];
45
69
  if (Array.isArray(existing)) {
@@ -68,10 +92,33 @@ function flag(flags: Record<string, string | boolean | string[]>, key: string):
68
92
  return undefined;
69
93
  }
70
94
 
95
+ function cacheTargetFlag(flags: Record<string, string | boolean | string[]>, key: string): string | undefined {
96
+ if (!(key in flags)) return undefined;
97
+ const value = flags[key];
98
+ if (typeof value !== "string" || value.length === 0) {
99
+ throw new Error(`--${key} requires a value`);
100
+ }
101
+ return value;
102
+ }
103
+
71
104
  function flagBool(flags: Record<string, string | boolean | string[]>, key: string): boolean {
72
105
  return flags[key] === true || flags[key] === "true";
73
106
  }
74
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
+
75
122
  function flagList(flags: Record<string, string | boolean | string[]>, key: string): string[] {
76
123
  const val = flags[key];
77
124
  if (Array.isArray(val)) return val;
@@ -83,20 +130,53 @@ function csvList(values: string[]): string[] {
83
130
  return [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean))];
84
131
  }
85
132
 
86
- /** Build networks array from --add-network, --no-network, and legacy vmnet flags. */
87
- function buildNetworks(flags: Record<string, string | boolean | string[]>): NetworkSpecifier[] | undefined {
88
- const specs: NetworkSpecifier[] = [];
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"));
89
160
 
90
- // Legacy flags first
91
- if (flag(flags, "vmnet-shared") !== undefined) {
92
- specs.push(parseNetworkSpecifier("shared"));
93
- }
94
- const bridgeIface = flag(flags, "vmnet-bridge");
95
- if (bridgeIface) {
96
- specs.push(parseNetworkSpecifier(`bridged:${bridgeIface}`));
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" };
97
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
+
176
+ /** Build networks array from --add-network and --no-network. */
177
+ function buildNetworks(flags: Record<string, string | boolean | string[]>): NetworkSpecifier[] | undefined {
178
+ const specs: NetworkSpecifier[] = [];
98
179
 
99
- // New --add-network flags
100
180
  for (const raw of flagList(flags, "add-network")) {
101
181
  specs.push(parseNetworkSpecifier(raw));
102
182
  }
@@ -111,6 +191,28 @@ function buildNetworks(flags: Record<string, string | boolean | string[]>): Netw
111
191
  return specs;
112
192
  }
113
193
 
194
+ /** Exit with a flag-list error when a machine-creating command is given a flag it does
195
+ * not know. An unrecognised flag is a typo, and a typo that downloads 43 MB and creates
196
+ * a machine is not a good default (#156). */
197
+ function rejectUnknownFlags(
198
+ flags: Record<string, string | boolean | string[]>,
199
+ known: readonly string[],
200
+ command: string,
201
+ ): void {
202
+ const unknown = unknownFlags(flags, known);
203
+ if (unknown.length > 0) {
204
+ console.error(unknownFlagMessage(unknown, command));
205
+ process.exit(1);
206
+ }
207
+ // A known flag given without its value is the same hazard wearing a valid name:
208
+ // `add --name --version 7.24.3` would otherwise create an auto-named machine.
209
+ const valueless = valuelessFlags(flags, known);
210
+ if (valueless.length > 0) {
211
+ console.error(valuelessFlagMessage(valueless, command));
212
+ process.exit(1);
213
+ }
214
+ }
215
+
114
216
  /** True when interactive prompts must be suppressed (e.g. LLM / CI / pipe). */
115
217
  function isNoPrompt(): boolean {
116
218
  return !!process.env.QUICKCHR_NO_PROMPT || !process.stdout.isTTY || !process.stdin.isTTY;
@@ -179,16 +281,55 @@ export async function applyAccelFlag(flags: Record<string, string | boolean | st
179
281
 
180
282
  /** Resolve --secure-login/--no-secure-login, falling back to the secure-login setting
181
283
  * only when neither flag was passed. Shared by cmdAdd, cmdStart, and cmdStart's
182
- * --all bulk-restart path. */
183
- 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> {
184
302
  if (flags["secure-login"] === false) return false;
185
303
  if (flagBool(flags, "secure-login")) return true;
304
+ if (!applySetting) return undefined;
186
305
  const { resolveSetting } = await import("../lib/settings.ts");
187
306
  return resolveSetting("secure-login").value === true ? true : undefined;
188
307
  }
189
308
 
309
+ /** True when the user asked for help on this subcommand.
310
+ *
311
+ * `--help` used to reach subcommands as an ordinary argument: `add --help` generated a
312
+ * name and downloaded 43 MB, and `networks sockets create --help` persisted a socket
313
+ * called `--help` (#156). Handling it here — before the dispatch — means a new
314
+ * subcommand cannot miss the guard.
315
+ *
316
+ * Everything after a bare `--` is the user's payload (a RouterOS command for `exec`),
317
+ * so the scan stops there. */
318
+ function wantsCommandHelp(argv: string[]): boolean {
319
+ for (const arg of argv) {
320
+ if (arg === "--") return false;
321
+ if (arg === "--help" || arg === "-h") return true;
322
+ }
323
+ return false;
324
+ }
325
+
190
326
  async function main() {
191
327
  try {
328
+ // Help before side effects — for every subcommand, present and future (#156).
329
+ if (command !== undefined && !command.startsWith("-") && wantsCommandHelp(args.slice(1))) {
330
+ printHelp(command);
331
+ return;
332
+ }
192
333
  switch (command) {
193
334
  case "add":
194
335
  await cmdAdd(args.slice(1));
@@ -199,6 +340,9 @@ async function main() {
199
340
  case "stop":
200
341
  await cmdStop(args.slice(1));
201
342
  break;
343
+ case "shutdown":
344
+ await cmdShutdown(args.slice(1));
345
+ break;
202
346
  case "list":
203
347
  case "ls":
204
348
  case "status":
@@ -294,7 +438,10 @@ async function main() {
294
438
  if (err.installHint) {
295
439
  console.error(` Hint: ${err.installHint}`);
296
440
  }
297
- if (err.code === "PROVISIONING_VERSION_UNSUPPORTED") {
441
+ for (const line of tipsForError(err.code, command)) tip(line);
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)) {
298
445
  console.error(` Why this happened: ${provisioningSupportSummary(MIN_PROVISION_VERSION)}`);
299
446
  console.error(` ${PROVISIONING_BOOT_ONLY_SUMMARY}`);
300
447
  console.error(" Try this:");
@@ -309,6 +456,12 @@ async function main() {
309
456
 
310
457
  // --- Commands ---
311
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
+
312
465
  /** Print QEMU keyboard shortcuts before foreground launch. Screen clears after QEMU starts. */
313
466
  function printForegroundTips(hasProvisioning = false) {
314
467
  const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
@@ -552,6 +705,7 @@ function printMachineListWithTip(
552
705
 
553
706
  async function cmdAdd(argv: string[]) {
554
707
  const { flags, positional } = parseFlags(argv);
708
+ rejectUnknownFlags(flags, ADD_FLAGS, "add");
555
709
  const { QuickCHR } = await import("../lib/quickchr.ts");
556
710
  const { bold, formatPorts, formatNetworks, dim } = await import("./format.ts");
557
711
  const { resolveSetting } = await import("../lib/settings.ts");
@@ -559,13 +713,15 @@ async function cmdAdd(argv: string[]) {
559
713
 
560
714
  const opts: StartOptions = {
561
715
  version: flag(flags, "version"),
562
- 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),
563
719
  arch: (flag(flags, "arch") as Arch | "auto" | undefined) ?? (resolveSetting("default-arch").value as Arch | "auto" | undefined),
564
720
  name: flag(flags, "name") ?? positional[0],
565
721
  cpu: flag(flags, "cpu") ? Number(flag(flags, "cpu")) : undefined,
566
722
  mem: flag(flags, "mem") ? Number(flag(flags, "mem")) : undefined,
567
723
  packages: csvList(flagList(flags, "add-package")),
568
- installAllPackages: flagBool(flags, "install-all-packages"),
724
+ installAllPackages: flagOptionalBool(flags, "install-all-packages"),
569
725
  portBase: flag(flags, "port-base") ? Number(flag(flags, "port-base")) : undefined,
570
726
  excludePorts: [
571
727
  ...(flags.winbox === false ? ["winbox" as ServiceName] : []),
@@ -578,24 +734,18 @@ async function cmdAdd(argv: string[]) {
578
734
  extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
579
735
  };
580
736
 
581
- const deviceModeValue = flag(flags, "device-mode");
582
- const deviceModeEnable = csvList(flagList(flags, "device-mode-enable"));
583
- const deviceModeDisable = csvList(flagList(flags, "device-mode-disable"));
584
- if (deviceModeValue !== undefined || deviceModeEnable.length > 0 || deviceModeDisable.length > 0) {
585
- opts.deviceMode = {
586
- mode: deviceModeValue ?? "auto",
587
- enable: deviceModeEnable.length > 0 ? deviceModeEnable : undefined,
588
- disable: deviceModeDisable.length > 0 ? deviceModeDisable : undefined,
589
- };
590
- }
737
+ opts.deviceMode = deviceModeFromFlags(flags);
591
738
 
592
739
  const userStr = flag(flags, "add-user");
593
740
  if (userStr) {
594
741
  const [uname = "", password] = userStr.split(":");
595
742
  opts.user = { name: uname, password: password ?? "" };
596
743
  }
597
- opts.disableAdmin = flagBool(flags, "disable-admin");
598
- 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 ?? ""));
599
749
 
600
750
  const state = await QuickCHR.add(opts);
601
751
 
@@ -606,10 +756,26 @@ async function cmdAdd(argv: string[]) {
606
756
  console.log(` CPU/Mem: ${state.cpu} vCPU${state.cpu > 1 ? "s" : ""}, ${state.mem} MB`);
607
757
  console.log(` Network: ${formatNetworks(state.networks ?? [])}`);
608
758
  console.log(` Ports: ${formatPorts(state.ports)}`);
609
- 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
+ }
610
764
  console.log(` Dir: ${dim(state.machineDir)}`);
611
- if (state.packages.length > 0) console.log(` Packages: ${state.packages.join(", ")} ${dim("(applied on first start)")}`);
612
- 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
+ }
613
779
  console.log();
614
780
  console.log(`${dim("tip:")} quickchr start ${state.name}`);
615
781
  }
@@ -656,6 +822,7 @@ async function cmdExec(argv: string[]) {
656
822
  } else {
657
823
  console.error("Usage: quickchr exec <name> <command...>\n Run a RouterOS CLI command on a running instance.");
658
824
  }
825
+ tip(CENTRS_EXEC_TIP);
659
826
  process.exit(1);
660
827
  }
661
828
 
@@ -1193,28 +1360,75 @@ async function cmdSetup() {
1193
1360
  }
1194
1361
  }
1195
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
+
1196
1393
  async function cmdStart(argv: string[]) {
1197
1394
  const { flags, positional } = parseFlags(argv);
1198
1395
  applyTimeoutExtraShortFlag(argv, flags, positional);
1396
+ rejectUnknownFlags(flags, START_FLAGS, "start");
1199
1397
  // Before the --all branch: both start paths must see the same override.
1200
1398
  await applyAccelFlag(flags);
1399
+ warnDeprecatedBackgroundFlag(flags);
1400
+ const wait = flags.wait === false ? false : undefined;
1201
1401
 
1202
1402
  // --all: start every stopped machine in background
1203
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
+ }
1204
1411
  const { QuickCHR } = await import("../lib/quickchr.ts");
1205
1412
  const { statusIcon, link, bold, resolveDisplayCredentials, formatRestUrl, formatSshCommand } = await import("./format.ts");
1206
1413
  const timeoutExtra = await resolveTimeoutExtraMs(flags);
1207
- const secureLogin = await resolveSecureLoginFlag(flags);
1208
1414
  const stopped = QuickCHR.list().filter((m) => m.status !== "running");
1209
1415
  if (stopped.length === 0) {
1210
1416
  console.log("No stopped instances.");
1211
1417
  return;
1212
1418
  }
1419
+ const { isProvisioningWindowOpen } = await import("../lib/provisioning-window.ts");
1213
1420
  for (const m of stopped) {
1214
1421
  console.log(`Starting ${bold(m.name)}...`);
1215
- 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 });
1216
1426
  const creds = await resolveDisplayCredentials(instance.state);
1217
- 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);
1218
1432
  }
1219
1433
  return;
1220
1434
  }
@@ -1241,13 +1455,15 @@ async function cmdStart(argv: string[]) {
1241
1455
 
1242
1456
  const opts: StartOptions = {
1243
1457
  version: flag(flags, "version"),
1244
- 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),
1245
1461
  arch: (flag(flags, "arch") as Arch | "auto" | undefined) ?? (resolveSetting("default-arch").value as Arch | "auto" | undefined),
1246
1462
  name: flag(flags, "name") ?? positional[0],
1247
1463
  cpu: flag(flags, "cpu") ? Number(flag(flags, "cpu")) : undefined,
1248
1464
  mem: flag(flags, "mem") ? Number(flag(flags, "mem")) : undefined,
1249
- packages: flagList(flags, "add-package"),
1250
- installAllPackages: flagBool(flags, "install-all-packages"),
1465
+ packages: csvList(flagList(flags, "add-package")),
1466
+ installAllPackages: flagOptionalBool(flags, "install-all-packages"),
1251
1467
  portBase: flag(flags, "port-base") ? Number(flag(flags, "port-base")) : undefined,
1252
1468
  excludePorts: [
1253
1469
  ...(flags.winbox === false ? ["winbox" as ServiceName] : []),
@@ -1263,21 +1479,7 @@ async function cmdStart(argv: string[]) {
1263
1479
  timeoutExtra: await resolveTimeoutExtraMs(flags),
1264
1480
  };
1265
1481
 
1266
- const deviceModeValue = flag(flags, "device-mode");
1267
- const deviceModeEnable = csvList(flagList(flags, "device-mode-enable"));
1268
- const deviceModeDisable = csvList(flagList(flags, "device-mode-disable"));
1269
- const noDeviceMode = flags["device-mode"] === false;
1270
- if (noDeviceMode) {
1271
- if (deviceModeEnable.length > 0 || deviceModeDisable.length > 0) {
1272
- console.warn("Warning: --device-mode-enable/--device-mode-disable ignored because --no-device-mode was set.");
1273
- }
1274
- } else if (deviceModeValue !== undefined || deviceModeEnable.length > 0 || deviceModeDisable.length > 0) {
1275
- opts.deviceMode = {
1276
- mode: deviceModeValue ?? "auto",
1277
- enable: deviceModeEnable.length > 0 ? deviceModeEnable : undefined,
1278
- disable: deviceModeDisable.length > 0 ? deviceModeDisable : undefined,
1279
- };
1280
- }
1482
+ opts.deviceMode = deviceModeFromFlags(flags);
1281
1483
 
1282
1484
  // --license-* flags (credentials from env if not supplied)
1283
1485
  const licenseLevel = flag(flags, "license-level");
@@ -1302,10 +1504,25 @@ async function cmdStart(argv: string[]) {
1302
1504
  opts.user = { name, password: password ?? "" };
1303
1505
  }
1304
1506
 
1305
- opts.disableAdmin = flagBool(flags, "disable-admin");
1306
- 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;
1307
1523
 
1308
1524
  // Background default: true. Explicitly foreground only with --fg / --foreground / --no-background / --no-bg.
1525
+ // `--bg` / `--background` are accepted and change nothing (see warnDeprecatedBackgroundFlag).
1309
1526
  const wantFg =
1310
1527
  flagBool(flags, "fg") ||
1311
1528
  flagBool(flags, "foreground") ||
@@ -1318,8 +1535,9 @@ async function cmdStart(argv: string[]) {
1318
1535
  return;
1319
1536
  }
1320
1537
 
1321
- // --fg on already-running machine: attach to its serial console
1322
- 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) {
1323
1541
  const runningMachine = QuickCHR.list().find((m) => m.name === opts.name && m.status === "running");
1324
1542
  if (runningMachine) {
1325
1543
  await attachSerial(runningMachine.name, runningMachine.machineDir);
@@ -1327,7 +1545,7 @@ async function cmdStart(argv: string[]) {
1327
1545
  }
1328
1546
  }
1329
1547
 
1330
- if (!opts.background) {
1548
+ if (!opts.background && opts.wait !== false) {
1331
1549
  const wantsDeviceMode = !!opts.deviceMode && !["skip", "none", "off", "disabled"].includes((opts.deviceMode.mode ?? "auto").toLowerCase());
1332
1550
  const hasProv = !!(
1333
1551
  opts.installAllPackages ||
@@ -1354,13 +1572,19 @@ async function cmdStart(argv: string[]) {
1354
1572
  console.log(` ${dim("Tip: run background")} quickchr start ${instance.name}`);
1355
1573
  } else {
1356
1574
  const creds = await resolveDisplayCredentials(instance.state);
1357
- console.log(`${statusIcon("running")} ${bold(instance.name)} started`);
1358
- 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")}` : ""}`);
1359
1578
  console.log(` Network: ${formatNetworks(instance.state.networks ?? [])}`);
1360
1579
  console.log(` Ports: ${formatPorts(instance.state.ports)}`);
1361
- 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
+ }
1362
1585
  console.log(` SSH: ${formatSshCommand(creds.user, instance.sshPort)}`);
1363
1586
  console.log(` WinBox: 127.0.0.1:${instance.ports.winbox}`);
1587
+ if (opts.wait === false) printReadinessTip(instance.name, instance.state.version);
1364
1588
  if (creds.user !== "admin") {
1365
1589
  console.log(` Login: ${creds.user} / ${creds.password || "(no password)"}`);
1366
1590
  console.log(` Run: quickchr exec ${instance.name} /system/resource/print`);
@@ -1368,6 +1592,32 @@ async function cmdStart(argv: string[]) {
1368
1592
  }
1369
1593
  }
1370
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
+
1371
1621
  async function cmdStop(argv: string[]) {
1372
1622
  const { flags, positional } = parseFlags(argv);
1373
1623
  const { QuickCHR } = await import("../lib/quickchr.ts");
@@ -1479,8 +1729,13 @@ async function cmdList(argv: string[] = []) {
1479
1729
 
1480
1730
  const { table, statusIcon, formatPorts, formatNetworks, dim } = await import("./format.ts");
1481
1731
  const machines = QuickCHR.list();
1732
+ // A machine whose state went bad keeps its place in the listing. It still holds a
1733
+ // disk image, and `list` is where anyone looks first — deferring it to `doctor`
1734
+ // assumes you already suspect trouble, which a quietly absent machine does not
1735
+ // suggest. The listing itself succeeded, so the exit code stays 0 (#165).
1736
+ const unreadable = QuickCHR.listUnreadable();
1482
1737
 
1483
- if (machines.length === 0) {
1738
+ if (machines.length === 0 && unreadable.length === 0) {
1484
1739
  if (asJson) {
1485
1740
  console.log("[]");
1486
1741
  } else {
@@ -1490,7 +1745,10 @@ async function cmdList(argv: string[] = []) {
1490
1745
  }
1491
1746
 
1492
1747
  if (asJson) {
1493
- console.log(JSON.stringify(machines, null, 2));
1748
+ console.log(JSON.stringify([
1749
+ ...machines,
1750
+ ...unreadable.map((u) => ({ name: u.name, status: "unreadable", error: u.error })),
1751
+ ], null, 2));
1494
1752
  return;
1495
1753
  }
1496
1754
 
@@ -1504,8 +1762,17 @@ async function cmdList(argv: string[] = []) {
1504
1762
  formatPorts(m.ports),
1505
1763
  m.pid ? String(m.pid) : dim("—"),
1506
1764
  ]);
1765
+ for (const u of unreadable) {
1766
+ rows.push([statusIcon("error"), u.name, dim("unreadable"), dim("—"), dim("—"), dim("—"), dim("—")]);
1767
+ }
1507
1768
 
1508
1769
  console.log(table(headers, rows));
1770
+
1771
+ // The row says which machine; this says what to do about it, so neither takes a
1772
+ // second command to learn.
1773
+ for (const u of unreadable) {
1774
+ console.log(`\n${statusIcon("error")} ${u.name}: machine.json unreadable — 'quickchr remove ${u.name}' to clear it`);
1775
+ }
1509
1776
  }
1510
1777
 
1511
1778
  function shellQuoteEnv(value: string): string {
@@ -1614,6 +1881,11 @@ async function cmdGet(argv: string[]) {
1614
1881
  return;
1615
1882
  }
1616
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
+
1617
1889
  const { resolveAuth } = await import("../lib/auth.ts");
1618
1890
  const auth = resolveAuth(s);
1619
1891
  const base = `http://127.0.0.1:${instance.ports.http}/rest`;
@@ -1661,14 +1933,20 @@ async function cmdGet(argv: string[]) {
1661
1933
  }
1662
1934
 
1663
1935
  if (results["device-mode"]) {
1936
+ const { DEVICE_MODE_NON_FEATURE_KEYS, isDeviceModeFeatureEnabled } = await import("../lib/device-mode.ts");
1664
1937
  const dm = results["device-mode"] as Record<string, unknown>;
1665
1938
  const mode = dm.mode ?? "(not set)";
1666
1939
  console.log(`\n ${bold("Device Mode")}`);
1667
1940
  console.log(` Mode: ${mode}`);
1668
- const features = Object.entries(dm)
1669
- .filter(([k, v]) => k !== "mode" && v === true)
1670
- .map(([k]) => k);
1671
- 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(", ")}`);
1672
1950
  }
1673
1951
 
1674
1952
  if (results.admin) {
@@ -1713,8 +1991,21 @@ async function cmdRemove(argv: string[]) {
1713
1991
  return;
1714
1992
  }
1715
1993
 
1716
- const instance = QuickCHR.get(name);
1994
+ // A machine.json that does not parse throws out of get(), because a lookup by name
1995
+ // must not answer "no such machine" about a directory that exists (#165). `remove`
1996
+ // is the command whose job is to clear that state, so here it routes into the
1997
+ // orphan path below instead of surfacing the error (#155).
1998
+ let instance: ReturnType<typeof QuickCHR.get> = null;
1999
+ try {
2000
+ instance = QuickCHR.get(name);
2001
+ } catch { /* unreadable state → orphan */ }
1717
2002
  if (!instance) {
2003
+ // A half-created directory is unremovable any other way, and `remove` naming it
2004
+ // in the "not found" list while refusing to delete it was the worst of both (#155).
2005
+ if (QuickCHR.removeOrphan(name)) {
2006
+ console.log(`${bold(name)} removed (leftover directory — no readable machine.json).`);
2007
+ return;
2008
+ }
1718
2009
  console.error(machineNotFoundMessage(name));
1719
2010
  process.exit(1);
1720
2011
  }
@@ -1784,6 +2075,10 @@ async function applyLicense(argv: string[]) {
1784
2075
  console.error(`Machine "${name}" is not running.`);
1785
2076
  process.exit(1);
1786
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");
1787
2082
 
1788
2083
  let account = flag(flags, "account") ?? process.env.MIKROTIK_WEB_ACCOUNT;
1789
2084
  let password = flag(flags, "password") ?? process.env.MIKROTIK_WEB_PASSWORD;
@@ -1829,28 +2124,100 @@ async function applyLicense(argv: string[]) {
1829
2124
  console.log(`License applied: ${before} → ${level}`);
1830
2125
  }
1831
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. */
1832
2140
  async function cmdSet(argv: string[]) {
1833
2141
  const { flags, positional } = parseFlags(argv);
1834
- 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");
1835
2146
 
2147
+ const usage = "Usage: quickchr set <name> [--license [--level=p1|p10|unlimited]] [--device-mode <mode>] [--device-mode-enable <features>] [--device-mode-disable <features>]";
1836
2148
  if (!name) {
1837
- 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.");
1838
2164
  process.exit(1);
1839
2165
  }
1840
2166
 
1841
2167
  const wantsLicense = flagBool(flags, "license");
2168
+ const deviceMode = deviceModeFromFlags(flags);
1842
2169
 
1843
- if (!wantsLicense) {
1844
- 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");
1845
2172
  process.exit(1);
1846
2173
  }
1847
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);
1848
2178
  if (wantsLicense) {
1849
2179
  const subcmdArgs = [name, ...Object.entries(flags).flatMap(([k, v]) => v === true ? [`--${k}`] : [`--${k}=${v}`]), ...positional.slice(1)];
1850
2180
  await applyLicense(subcmdArgs);
1851
2181
  }
1852
2182
  }
1853
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
+
1854
2221
  async function cmdLicense(argv: string[]) {
1855
2222
  await applyLicense(argv);
1856
2223
  }
@@ -2053,6 +2420,7 @@ async function cmdNetworks(argv: string[]) {
2053
2420
  async function showNetworkOverview() {
2054
2421
  const { detectPlatform, detectPhysicalInterfaces } = await import("../lib/platform.ts");
2055
2422
  const { listNamedSockets } = await import("../lib/socket-registry.ts");
2423
+ const { describeSocketTransport } = await import("../lib/network.ts");
2056
2424
  const { statusIcon, bold, dim } = await import("./format.ts");
2057
2425
 
2058
2426
  const platform = await detectPlatform();
@@ -2126,7 +2494,8 @@ async function showNetworkOverview() {
2126
2494
  console.log(" Named Sockets:");
2127
2495
  for (const s of sockets) {
2128
2496
  const members = s.members.length > 0 ? dim(` members: ${s.members.join(", ")}`) : "";
2129
- console.log(` ${s.name.padEnd(16)}${s.mode} port:${s.port}${members}`);
2497
+ // Not `port:${s.port}` — a dgram link has no port and printed `port:undefined`.
2498
+ console.log(` ${s.name.padEnd(16)}${s.mode} ${describeSocketTransport(s)}${members}`);
2130
2499
  }
2131
2500
  } else {
2132
2501
  console.log(" Named Sockets:");
@@ -2153,8 +2522,11 @@ async function showInterfaces() {
2153
2522
  console.log(table(["Device", "Name", "Alias", "MAC"], rows));
2154
2523
  }
2155
2524
 
2525
+ const SOCKET_MODES = ["dgram", "listen-connect", "mcast"] as const;
2526
+
2156
2527
  async function handleSockets(argv: string[]) {
2157
- const { listNamedSockets, createNamedSocket, removeNamedSocket } = await import("../lib/socket-registry.ts");
2528
+ const { listNamedSockets, createNamedSocket, removeNamedSocket, defaultSocketMode } = await import("../lib/socket-registry.ts");
2529
+ const { describeSocketTransport } = await import("../lib/network.ts");
2158
2530
  const { bold, dim, table: fmtTable } = await import("./format.ts");
2159
2531
 
2160
2532
  const action = argv[0];
@@ -2168,22 +2540,73 @@ async function handleSockets(argv: string[]) {
2168
2540
  const rows = sockets.map((s) => [
2169
2541
  s.name,
2170
2542
  s.mode,
2171
- String(s.port),
2172
- s.mcastGroup ?? "",
2543
+ describeSocketTransport(s),
2173
2544
  s.members.join(", ") || dim("none"),
2174
2545
  ]);
2175
- console.log(fmtTable(["Name", "Mode", "Port", "Group", "Members"], rows));
2546
+ console.log(fmtTable(["Name", "Mode", "Transport", "Members"], rows));
2176
2547
  return;
2177
2548
  }
2178
2549
 
2179
2550
  if (action === "create") {
2180
- const name = argv[1];
2551
+ const { flags, positional } = parseFlags(argv.slice(1));
2552
+ const name = positional[0];
2181
2553
  if (!name) {
2182
- console.error("Usage: quickchr networks sockets create <name>");
2554
+ console.error("Usage: quickchr networks sockets create <name> [--mode dgram|listen-connect|mcast] [--port <n>] [--group <addr>]");
2555
+ process.exit(1);
2556
+ }
2557
+ // `parseFlags` stores `true` for a flag with no value and `flag()` turns that back
2558
+ // into `undefined`, so a bare `--mode` would silently fall through to the default
2559
+ // — the same shape as #156's valueless-flag bug.
2560
+ for (const key of ["mode", "port", "group"]) {
2561
+ if (typeof flags[key] === "boolean") {
2562
+ console.error(`Error: --${key} requires a value.`);
2563
+ process.exit(1);
2564
+ }
2565
+ }
2566
+ const mode = flag(flags, "mode");
2567
+ if (mode !== undefined && !(SOCKET_MODES as readonly string[]).includes(mode)) {
2568
+ console.error(`Error: unknown --mode "${mode}" — expected one of ${SOCKET_MODES.join(", ")}.`);
2569
+ process.exit(1);
2570
+ }
2571
+ const portRaw = flag(flags, "port");
2572
+ // Number(), not parseInt(): parseInt stops at the first non-digit, so "4000abc"
2573
+ // came through as 4000.
2574
+ const port = portRaw === undefined ? undefined : Number(portRaw);
2575
+ if (portRaw !== undefined && (!/^\d+$/.test(portRaw) || !Number.isInteger(port) || (port as number) < 1 || (port as number) > 65535)) {
2576
+ console.error(`Error: --port must be a number between 1 and 65535, got "${portRaw}".`);
2577
+ process.exit(1);
2578
+ }
2579
+ const group = flag(flags, "group");
2580
+ const effectiveMode = mode ?? defaultSocketMode();
2581
+ if (group !== undefined && effectiveMode !== "mcast") {
2582
+ console.error(`Error: --group only applies to --mode mcast (this socket is ${effectiveMode}).`);
2183
2583
  process.exit(1);
2184
2584
  }
2185
- const entry = createNamedSocket(name);
2186
- console.log(`Created named socket: ${bold(entry.name)} (${entry.mode} port:${entry.port})`);
2585
+ if (portRaw !== undefined && effectiveMode === "dgram") {
2586
+ console.error("Error: --port does not apply to --mode dgram — a unix datagram pair uses no port.");
2587
+ process.exit(1);
2588
+ }
2589
+ const entry = createNamedSocket(name, {
2590
+ mode: mode as (typeof SOCKET_MODES)[number] | undefined,
2591
+ port,
2592
+ mcastGroup: group,
2593
+ });
2594
+ console.log(`Created named socket: ${bold(entry.name)} (${entry.mode})`);
2595
+ console.log(` Transport: ${describeSocketTransport(entry)}`);
2596
+ if (entry.mode === "mcast") {
2597
+ // The caveat belongs at create time, not in a doc: mcast fails with no error
2598
+ // anywhere — interfaces up, addresses assigned, 100% loss. Three distinct
2599
+ // hazards, not one (#167): a macOS delivery bug, a sandbox permission block,
2600
+ // and — because quickchr passes no localaddr= — a group that rides the host's
2601
+ // default interface rather than loopback.
2602
+ console.log(dim(" Note: UDP multicast is the only N-way segment, and the only one that"));
2603
+ console.log(dim(" fails silently. It does not deliver on macOS, and it is refused"));
2604
+ console.log(dim(" in sandboxes that block unconnected UDP sends (some Linux CI)."));
2605
+ console.log(dim(` This group is not confined to loopback: ${entry.mcastGroup} rides the host's`));
2606
+ console.log(dim(" default multicast interface, so another machine on your LAN using"));
2607
+ console.log(dim(" the same group joins this segment. Use a unique group, or prefer"));
2608
+ console.log(dim(" 'dgram' for two machines."));
2609
+ }
2187
2610
  return;
2188
2611
  }
2189
2612
 
@@ -2217,6 +2640,21 @@ Usage:
2217
2640
  quickchr networks sockets create <n> Create a named socket
2218
2641
  quickchr networks sockets remove <n> Remove a named socket
2219
2642
 
2643
+ Socket create options:
2644
+ --mode <mode> dgram | listen-connect | mcast
2645
+ Default: dgram (POSIX), listen-connect (Windows)
2646
+ --port <n> TCP/UDP port for listen-connect and mcast
2647
+ --group <addr> Multicast group for --mode mcast (default 230.0.0.1)
2648
+
2649
+ Transports:
2650
+ dgram A pair of unix datagram sockets. No ports, no UDP syscalls,
2651
+ and either machine may start first. Two machines. POSIX only.
2652
+ listen-connect A TCP pair on loopback. Two machines; the first to join takes
2653
+ the listening end and keeps it, and must be started first
2654
+ thereafter. The Windows default.
2655
+ mcast UDP multicast — the only N-way segment, but broken on macOS
2656
+ and in UDP-blocked sandboxes, and silent when it fails.
2657
+
2220
2658
  Aliases: quickchr net`);
2221
2659
  }
2222
2660
 
@@ -2418,6 +2856,45 @@ async function cmdCache(argv: string[]) {
2418
2856
  };
2419
2857
 
2420
2858
  switch (sub) {
2859
+ case "add": {
2860
+ const { cacheAdd } = await import("../lib/cache-api.ts");
2861
+ const { resolveSetting } = await import("../lib/settings.ts");
2862
+ const version = cacheTargetFlag(flags, "version");
2863
+ const explicitChannel = cacheTargetFlag(flags, "channel") as Channel | undefined;
2864
+ const channel = version !== undefined || explicitChannel !== undefined
2865
+ ? explicitChannel
2866
+ : resolveSetting("default-channel").value as Channel;
2867
+ const arch = (cacheTargetFlag(flags, "arch") ?? resolveSetting("default-arch").value) as Arch | "auto";
2868
+ const asJson = flagBool(flags, "json");
2869
+ const quiet = { status() {}, debug() {}, warn() {} };
2870
+ const result = await cacheAdd({ version, channel, arch, logger: asJson ? quiet : undefined });
2871
+ if (asJson) {
2872
+ console.log(JSON.stringify(result));
2873
+ } else {
2874
+ console.log(`${result.cacheHit ? "Already cached" : "Cached"}: RouterOS ${result.version} (${result.arch})`);
2875
+ console.log(`Path: ${result.path}`);
2876
+ }
2877
+ break;
2878
+ }
2879
+ case "key": {
2880
+ const { cacheKey } = await import("../lib/cache-api.ts");
2881
+ const version = cacheTargetFlag(flags, "version");
2882
+ const explicitChannel = cacheTargetFlag(flags, "channel") as Channel | undefined;
2883
+ const { resolveSetting } = await import("../lib/settings.ts");
2884
+ const channel = version !== undefined || explicitChannel !== undefined
2885
+ ? explicitChannel
2886
+ : resolveSetting("default-channel").value as Channel;
2887
+ const arch = (cacheTargetFlag(flags, "arch") ?? resolveSetting("default-arch").value) as Arch | "auto";
2888
+ const result = await cacheKey({ version, channel, arch });
2889
+ if (flagBool(flags, "json")) {
2890
+ console.log(JSON.stringify(result));
2891
+ } else {
2892
+ console.log(`dir=${result.dir}`);
2893
+ console.log(`version=${result.version}`);
2894
+ console.log(`arch=${result.arch}`);
2895
+ }
2896
+ break;
2897
+ }
2421
2898
  case "list":
2422
2899
  case "ls": {
2423
2900
  const entries = listCacheEntries();
@@ -2688,22 +3165,24 @@ Commands:
2688
3165
  setup Interactive setup wizard (TTY only)
2689
3166
  add [options] Create a new CHR machine (without starting)
2690
3167
  start [<name>|options] Start or restart a CHR instance
2691
- 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
2692
3170
  list [<name>] [--json] List all instances, or detail for one
2693
3171
  status Alias for list
2694
3172
  inspect <name> [--json] Print running machine descriptor as JSON
2695
3173
  env <name> [--json] Print subprocess environment for a running machine
2696
3174
  console <name> Attach to serial console of a running instance
2697
3175
  exec <name> <command> Run a RouterOS CLI command on a running instance
3176
+ (raw /rest/execute — see 'quickchr exec --help' for centrs)
2698
3177
  remove [<name>|--all] Remove instance(s) and disk
2699
3178
  clean [<name>|--all] Reset instance disk to fresh image
2700
- get <name> [group] Show machine config (license, device-mode, admin)
2701
- 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)
2702
3181
  license <name> Deprecated — use 'set <name> --license'
2703
3182
  disk <name> Show disk details for an instance
2704
3183
  snapshot <name> [cmd] Manage snapshots (list/save/load/delete)
2705
3184
  networks Network discovery & socket management
2706
- cache [list|prune|clear] Manage cached CHR images
3185
+ cache [add|key|list|prune|clear] Manage cached CHR images
2707
3186
  settings [print|get|set|reset] Manage quickchr's own global settings (quickchr.env)
2708
3187
  completions Manage shell completions (Tab completion)
2709
3188
  logs <name> Tail the QEMU log for an instance
@@ -2736,6 +3215,8 @@ Post-boot provisioning flags in this command (--add-package, --install-all-packa
2736
3215
  RouterOS ${MIN_PROVISION_VERSION}+ only. Older 7.x remains boot-only. Disk and network
2737
3216
  options still work on older versions when the required host tools are installed.
2738
3217
 
3218
+ ${ROUTEROS6_HELP}
3219
+
2739
3220
  Options:
2740
3221
  --name <name> Instance name (required)
2741
3222
  --version <ver> RouterOS version (e.g., 7.22.1)
@@ -2746,9 +3227,9 @@ Options:
2746
3227
  --accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
2747
3228
  Anything but 'auto' bypasses detection, including the
2748
3229
  arm64-on-Apple-Silicon TCG fallback (tikoci/quickchr#97).
2749
- --boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
2750
- --boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
2751
- --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)
2752
3233
  --add-package <pkg> Extra package to install on first boot (repeatable)
2753
3234
  --forward <spec> Add an extra hostfwd port (repeatable). Spec:
2754
3235
  name[:host[:guest]][/proto], or a port range
@@ -2766,11 +3247,18 @@ Options:
2766
3247
  --no-winbox Exclude WinBox port mapping
2767
3248
  --no-api-ssl Exclude API-SSL port mapping
2768
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
2769
3253
  --add-network <spec> Add a network NIC (repeatable). Specs: user, shared, bridged:<if>,
2770
3254
  socket::<name>, tap:<if>. Default: single user NIC.
2771
3255
  --no-network Start with no NICs (headless)
2772
- --vmnet-shared [deprecated] Use --add-network shared
2773
- --vmnet-bridge <if> [deprecated] Use --add-network bridged:<if>`);
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.`);
2774
3262
  break;
2775
3263
  case "setup":
2776
3264
  console.log(`quickchr setup
@@ -2802,7 +3290,9 @@ Options:
2802
3290
  Examples:
2803
3291
  quickchr exec my-chr /system/resource/print
2804
3292
  quickchr exec my-chr ":put [:serialize to=json [/ip/address/print]]"
2805
- quickchr exec my-chr "/log/info message=hello"`);
3293
+ quickchr exec my-chr "/log/info message=hello"
3294
+
3295
+ ${CENTRS_SEE_ALSO}`);
2806
3296
  break;
2807
3297
  case "start":
2808
3298
  console.log(`quickchr start [<name>] [options]
@@ -2818,10 +3308,23 @@ validated on RouterOS ${MIN_PROVISION_VERSION}+ only. Older 7.x remains boot-onl
2818
3308
  Disk and network options still work on older versions when the required host tools
2819
3309
  are installed.
2820
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
+
2821
3320
  Options:
2822
- --all Start all stopped machines
2823
- --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)
2824
3326
  --fg / --foreground Run in foreground — serial console on stdio
3327
+ --bg / --background Deprecated, does nothing: background is already the default
2825
3328
  --version <ver> RouterOS version (e.g., 7.22.1)
2826
3329
  --channel <ch> Channel: stable, long-term, testing, development
2827
3330
  --arch <arch> Architecture: arm64, x86, auto (default: host native)
@@ -2831,9 +3334,9 @@ Options:
2831
3334
  --accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
2832
3335
  Anything but 'auto' bypasses detection, including the
2833
3336
  arm64-on-Apple-Silicon TCG fallback (tikoci/quickchr#97).
2834
- --boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
2835
- --boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
2836
- --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)
2837
3340
  --add-package <pkg> Extra package to install (repeatable)
2838
3341
  --forward <spec> Add an extra hostfwd port (repeatable). Spec:
2839
3342
  name[:host[:guest]][/proto], or a range
@@ -2849,8 +3352,6 @@ Options:
2849
3352
  --add-network <spec> Add a network NIC (repeatable). Specs: user, shared, bridged:<if>,
2850
3353
  socket::<name>, tap:<if>. Default: single user NIC.
2851
3354
  --no-network Start with no NICs (headless)
2852
- --vmnet-shared [deprecated] Use --add-network shared
2853
- --vmnet-bridge <if> [deprecated] Use --add-network bridged:<if>
2854
3355
  --install-all-packages Install all packages from all_packages.zip
2855
3356
  --license-level <l> Apply trial license: p1 (1 Gbps), p10 (10 Gbps), unlimited
2856
3357
  --license-account <a> MikroTik account email (or use MIKROTIK_WEB_ACCOUNT env var)
@@ -2860,21 +3361,42 @@ Options:
2860
3361
  --device-mode-disable <f> Set one or more device-mode flags to no
2861
3362
  --no-device-mode Skip device-mode provisioning entirely
2862
3363
  --timeout-extra <s>, -T <s> Add extra seconds to the auto-computed boot timeout
2863
- --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.`);
2864
3372
  break;
2865
3373
  case "stop":
2866
3374
  console.log(`quickchr stop [<name>] [--all]
2867
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
+
2868
3379
  <name> Stop a specific running instance.
2869
3380
  Omit to see a list of running instances.
2870
3381
  --all Stop all running instances.`);
2871
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;
2872
3393
  case "status":
2873
3394
  console.log(`quickchr status [<name>] [--json]
2874
3395
 
2875
3396
  Alias for 'quickchr list'. See 'quickchr help list'.`);
2876
3397
  break;
2877
3398
  case "list":
3399
+ case "ls":
2878
3400
  console.log(`quickchr list [<name>] [--json]
2879
3401
 
2880
3402
  List all CHR instances or show detailed info for one.
@@ -2986,14 +3508,30 @@ When no group is given, all properties are shown.
2986
3508
  The machine must be running to query live properties.`);
2987
3509
  break;
2988
3510
  case "set":
2989
- console.log(`quickchr set <name> --license [options]
3511
+ console.log(`quickchr set <name> [--license | --device-mode...] [options]
2990
3512
 
2991
- 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.
2992
3516
 
2993
3517
  <name> Name of a running CHR instance (required).
2994
3518
 
2995
3519
  Flags:
2996
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.
2997
3535
 
2998
3536
  License options (used with --license):
2999
3537
  --level <level> License level: p1 (1 Gbps), p10 (10 Gbps), unlimited (default: p1)
@@ -3004,7 +3542,7 @@ Credential resolution order:
3004
3542
  1. --account / --password flags
3005
3543
  2. MIKROTIK_WEB_ACCOUNT / MIKROTIK_WEB_PASSWORD environment variables
3006
3544
  3. OS native secret store (macOS Keychain / Linux Keyring)
3007
- 4. ~/.config/quickchr/credentials.json (fallback)`);
3545
+ 4. ~/.config/quickchr/com.quickchr.mikrotik-web.json (fallback)`);
3008
3546
  break;
3009
3547
  case "license":
3010
3548
  console.log(`quickchr license <name> [options]
@@ -3026,7 +3564,15 @@ Credential resolution order (highest priority first):
3026
3564
  1. --account / --password flags
3027
3565
  2. MIKROTIK_WEB_ACCOUNT / MIKROTIK_WEB_PASSWORD environment variables
3028
3566
  3. OS native secret store (macOS Keychain, Linux GNOME Keyring, Windows Credential Manager)
3029
- 4. ~/.config/quickchr/credentials.json (fallback)`);
3567
+ 4. ~/.config/quickchr/com.quickchr.mikrotik-web.json (fallback)`);
3568
+ break;
3569
+ case "qga":
3570
+ console.log(`quickchr qga <name> <operation> [options]
3571
+
3572
+ Query the QEMU Guest Agent on a running CHR instance.
3573
+ QGA is x86-only — the arm64 CHR image does not start the guest agent.
3574
+
3575
+ Run 'quickchr qga <name>' with no operation to list every operation.`);
3030
3576
  break;
3031
3577
  case "clean":
3032
3578
  console.log(`quickchr clean [<name>] [--all]
@@ -3036,6 +3582,7 @@ Credential resolution order (highest priority first):
3036
3582
  --all Clean all instances.`);
3037
3583
  break;
3038
3584
  case "remove":
3585
+ case "rm":
3039
3586
  console.log(`quickchr remove [<name>] [--all]
3040
3587
 
3041
3588
  <name> Remove an instance (stops if running, deletes disk and state).
@@ -3057,11 +3604,16 @@ Options:
3057
3604
  showNetworksHelp();
3058
3605
  break;
3059
3606
  case "cache":
3060
- console.log(`quickchr cache <list|prune|clear> [options]
3607
+ console.log(`quickchr cache <add|key|list|prune|clear> [options]
3061
3608
 
3062
3609
  Manage cached CHR images in the data dir cache directory.
3063
3610
 
3064
3611
  Subcommands:
3612
+ add [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
3613
+ Resolve, download, and cache one CHR image without booting.
3614
+ key [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
3615
+ Print cache directory, resolved version, and architecture for CI.
3616
+ Without --json, output is GITHUB_OUTPUT-compatible key=value lines.
3065
3617
  list [--json] List cached images (basename, version, arch, size, age, in-use).
3066
3618
  prune [options] Evict cached images per policy. Never evicts in-use images.
3067
3619
  --older-than <ver> Evict images with version < <ver>.
@@ -3110,6 +3662,7 @@ Credentials (--add-user, MIKROTIK_WEB_ACCOUNT/PASSWORD) are never stored here.`)
3110
3662
  break;
3111
3663
  default:
3112
3664
  console.log(`No detailed help for '${command}'.`);
3665
+ console.log("Run 'quickchr help' for the command list.");
3113
3666
  }
3114
3667
  }
3115
3668