@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/MANUAL.md CHANGED
@@ -41,6 +41,10 @@ by the user (or their AI agent) using `quickchr` as a building block.
41
41
  - ❌ Multi-router topology orchestration, test-matrix runners,
42
42
  configuration-as-code DSLs. Use `examples/` as references and compose
43
43
  yourself.
44
+ - ⚠️ **RouterOS 6** is a best-effort, boot-only target (x86, CLI only): quickchr
45
+ downloads, boots and tracks a 6.x CHR, and does nothing else to it. It exists so
46
+ v6→v7 migration tooling has a disposable 6.x router to test against — it is not
47
+ RouterOS 6 support. See [RouterOS 6](#routeros-6-boot-only) below.
44
48
 
45
49
  **Two ways to use it:**
46
50
 
@@ -159,20 +163,32 @@ an error naming its replacement, not a silently ignored flag.
159
163
  #### `start [name]`
160
164
 
161
165
  Boot a stopped machine, or create-and-boot in one command. Blocks until
162
- REST is ready (background mode, default), or attaches QEMU stdio to your
163
- terminal (`--fg`). Accepts the same provisioning flags as `add`.
166
+ RouterOS is ready (REST answers; on 6.x, a native-API login), or attaches QEMU
167
+ stdio to your terminal (`--fg`). Accepts the same provisioning flags as `add`.
168
+ QEMU runs detached in its own session either way, so it survives a signal to the
169
+ shell that started it.
164
170
 
165
171
  Additional flags:
166
172
 
167
173
  | Flag | Notes |
168
174
  |---|---|
169
- | `--all` | Start every stopped machine in background |
170
- | `--bg` / `--background` | (default) detach QEMU; return after boot |
175
+ | `--all` | Start every stopped machine in background; accepts readiness, acceleration, timeout and secure-login options only. Other options are refused rather than ignored |
176
+ | `--no-wait` | Return as soon as QEMU is running (past the 1.5 s early-exit check), with state and ports persisted; do not wait for RouterOS. Refused with any provisioning flag, including first-boot options `add` recorded, and with `--fg` |
171
177
  | `--fg` / `--foreground` (alias `--no-bg`, `--no-background`) | Serial console on stdio |
178
+ | `--bg` / `--background` | **Deprecated, does nothing.** Background is the default, and the flag never made `start` return early — it only ever chose where the serial console went. Prints a warning; use `--no-wait` |
172
179
  | `--install-deps` | Auto-install missing host deps (calls package manager) |
173
180
  | `--timeout-extra <s>` / `-T <s>` | Add extra seconds to computed boot timeout |
174
181
  | `--dry-run` | Print resolved options and QEMU command, exit |
175
182
 
183
+ After `--no-wait`, readiness is yours. centrs (0.1.8+) waits for it in one command:
184
+
185
+ ```sh
186
+ quickchr start lab --no-wait
187
+ centrs retrieve --quickchr lab /system/resource --wait 5m
188
+ # RouterOS 6: centrs validates against the 7.x command tree, so skip validation
189
+ centrs retrieve --quickchr lab6 /system/resource --via native-api --no-validate --wait 5m
190
+ ```
191
+
176
192
  Foreground mode shortcuts (QEMU mux):
177
193
 
178
194
  | Key | Action |
@@ -188,9 +204,74 @@ machine state.
188
204
 
189
205
  #### `stop [name | --all]`
190
206
 
191
- Graceful shutdown via QEMU monitor. Updates `state.status = "stopped"`
192
- and unregisters socket-named networks. Listing-mode if no name and not
193
- `--all`.
207
+ Power cut: sends SIGTERM to QEMU, waits up to 5 seconds, then SIGKILL if
208
+ needed. This does not request a guest shutdown; pending RouterOS changes
209
+ may be lost. Updates `state.status = "stopped"` and unregisters socket-named
210
+ networks. Listing-mode if no name and not `--all`.
211
+
212
+ Use `stop` for normal teardown and device-mode power-cycle confirmation.
213
+ Use `shutdown` when the caller wants to request a guest shutdown first.
214
+
215
+ #### `shutdown <name> [--timeout <seconds>]`
216
+
217
+ Sends `/system/shutdown` once through REST, independently waits for QEMU to
218
+ exit, then falls back to a power cut if it remains running. Default grace
219
+ budget: **10 seconds**. `--timeout` takes positive integer seconds and
220
+ overrides `QUICKCHR_SHUTDOWN_TIMEOUT`. Neither changes the boot timeout.
221
+ The maximum is 2,147,483 seconds (the HTTP timer's limit).
222
+
223
+ ```bash
224
+ quickchr shutdown lab
225
+ quickchr shutdown lab --timeout 20
226
+ QUICKCHR_SHUTDOWN_TIMEOUT=20 quickchr shutdown lab
227
+ ```
228
+
229
+ The CLI reports `guest exited`, `already stopped`, or **forced power cut —
230
+ guest shutdown not confirmed**. Forced fallback exits successfully if QEMU
231
+ is confirmed gone; invalid input, lock failure, or inability to stop QEMU
232
+ is an error. Disks, credentials and restart configuration survive.
233
+
234
+ The guest request and exit observation share one grace budget. Lifecycle
235
+ lock acquisition, the existing SIGTERM/SIGKILL escalation, and final exit
236
+ verification have separate bounded waits. A 10-second grace budget does
237
+ not bound the entire CLI invocation to 10 seconds.
238
+
239
+ An HTTP error does not establish failure: on CHR 7.24.5, both x86/HVF and
240
+ arm64/TCG returned HTTP 400 `interrupted`, then exited. Shutdown uses
241
+ process exit as its completion signal. Observed exit does not guarantee
242
+ application or storage durability. Unavailable REST (including RouterOS 6),
243
+ failed authentication and a guest that does not halt all use the same
244
+ explicit hard-stop fallback; no console/QGA/ACPI replay is attempted.
245
+
246
+ ```ts
247
+ const result = await chr.shutdown({ timeoutMs: 20_000 });
248
+ // outcome: "guest-exited" | "forced" | "already-stopped"
249
+ // requestError can accompany guest-exited (e.g. HTTP 400 interrupted).
250
+ console.log(result.outcome);
251
+ ```
252
+
253
+ Library `timeoutMs` values are positive integer milliseconds, maximum
254
+ 2,147,483,647, with a 10,000 ms default. Library callers supply options
255
+ explicitly; the CLI environment override is not read by `shutdown()`.
256
+
257
+ For consumers of an older quickchr version, the manual workaround needs
258
+ both request-error handling and independent exit observation:
259
+
260
+ ```ts
261
+ const pid = chr.state.pid;
262
+ if (pid) {
263
+ try { await chr.exec("/system/shutdown", { via: "rest", timeout: 10_000 }); }
264
+ catch { /* HTTP 400 interrupted may accompany successful guest shutdown. */ }
265
+ const exited = await chr.waitFor(async () => {
266
+ try { process.kill(pid, 0); return false; } catch { return true; }
267
+ }, 10_000);
268
+ if (!exited) console.warn("Guest shutdown not confirmed; forcing a power cut");
269
+ }
270
+ await chr.stop(); // reconcile state/network membership, or hard-stop if still alive
271
+ ```
272
+
273
+ This older-version workaround has separate request and wait budgets;
274
+ `shutdown()` provides one shared budget and structured fallback reporting.
194
275
 
195
276
  #### `remove [name | --all]` (alias `rm`)
196
277
 
@@ -207,10 +288,17 @@ flag. The next `start` therefore logs in as factory `admin` with an empty
207
288
  password. EFI vars are deliberately **kept** (they store boot order, not
208
289
  guest state; wiping them costs an arm64 device rescan).
209
290
 
210
- `clean` does not re-provision: the boot options in `machine.json`
211
- (`packages`, `deviceMode`, `secureLogin`) are re-applied only on a machine
212
- that has never started, so a cleaned machine comes back factory-fresh. Pass
213
- the provisioning flags again, or `remove` and re-create, to get them back.
291
+ `clean` itself never provisions — it only resets the disk. What it does do is
292
+ **reopen the provisioning window** (#176), so the *next* `start` re-applies the
293
+ provisioning intent `machine.json` still holds: `packages`, `deviceMode`,
294
+ `secureLogin`. Budget for that start accordingly; it costs what a first boot costs,
295
+ not what a restart costs.
296
+
297
+ What does **not** come back on its own is anything `clean` had to clear because it
298
+ described the erased guest rather than an intent — `user`, `disableAdmin`,
299
+ `licenseLevel` — and any option from a request that was *refused*, since a refused
300
+ request is never persisted. Pass those flags again, which is why the refusal says to
301
+ repeat the original command rather than to run a bare `start`.
214
302
 
215
303
  ### Inspection
216
304
 
@@ -238,6 +326,9 @@ machine as a directory to delete rather than state to load.
238
326
  Live REST query. Hits the running CHR and returns the requested group
239
327
  (or all if no group specified). Requires the machine to be running.
240
328
 
329
+ `device-mode` lists the mode plus every feature RouterOS reports, split into
330
+ enabled and disabled — the read-back for `quickchr set <name> --device-mode…`.
331
+
241
332
  #### `inspect <name> [--json]`
242
333
 
243
334
  Stable, versioned (`descriptorVersion: 1`) connection descriptor for a
@@ -328,11 +419,43 @@ Apply or renew a CHR trial license on a running instance. Resolves
328
419
  MikroTik.com credentials from `--license-account`/`--license-password`,
329
420
  the env vars, or the secret store. Requires RouterOS ≥ 7.20.8.
330
421
 
331
- #### `set <name> <subcommand>`
422
+ #### `set <name> [--license | --device-mode <m>] [--device-mode-enable <f>] [--device-mode-disable <f>]`
423
+
424
+ Change a property on a machine that has **already booted** — the post-boot half
425
+ of the provisioning flags. `add`/`start` apply them at first boot; `set` applies
426
+ them now. It is the route `PROVISIONING_WINDOW_CLOSED` names, printed with the
427
+ flags that were asked for so it can be run as given.
332
428
 
333
- Currently exposes a small surface for in-place changes (e.g.,
334
- `set <name> license …`). The `set/get` architecture is still being
335
- designed; expect additions.
429
+ | flag | what it does |
430
+ |---|---|
431
+ | `--license` | Apply or renew a CHR trial license (§ `license` above for credential resolution). |
432
+ | `--device-mode <m>` | `rose`, `advanced`, `basic`, `home`, `auto`. |
433
+ | `--device-mode-enable <f,…>` | Set one or more device-mode flags to yes. Repeatable, comma-separated. |
434
+ | `--device-mode-disable <f,…>` | Set one or more to no. |
435
+
436
+ **Device-mode restarts the machine.** RouterOS only applies a device-mode change
437
+ across a power cycle, and quickchr owns the power button — which is why this lives
438
+ here rather than in a tool that reaches a router over the network: the same request
439
+ issued from inside the guest never returns, because it is waiting for a power cycle
440
+ it cannot perform on itself. quickchr prints every setting that will move before it
441
+ restarts, and skips the restart entirely when the guest already matches.
442
+
443
+ Naming a feature without a mode resolves the mode to `rose` — so
444
+ `set lab --device-mode-enable container` moves `mode` as well. That move is printed
445
+ before it happens, not discovered afterwards.
446
+
447
+ Preconditions: the machine must be **running** and have a **user-mode NIC**
448
+ (device-mode is applied over localhost REST), on RouterOS ≥ 7.20.8. Each is a
449
+ named refusal rather than a timeout.
450
+
451
+ The change is folded into `machine.json` rather than overwriting it — device-mode
452
+ updates move only the settings they name, so the record stays cumulative — and
453
+ recorded in `provisioning.steps`, which makes a later `start` passing the same
454
+ device-mode a recognised no-op instead of a refusal.
455
+
456
+ **Not available here:** `--add-user`, `--disable-admin`, `--secure-login`. Those
457
+ rewrite working config rather than unlock a capability, and they are the steps that
458
+ drift; their route stays `quickchr clean <name>` followed by the original command.
336
459
 
337
460
  #### `snapshot <name> <list | save [snapname] | load <snapname> | delete <snapname>>` (alias `snap`)
338
461
 
@@ -372,7 +495,7 @@ another machine, view status, or quit.
372
495
  | 9 | Extra blank disks (repeatable) | none | `--add-disk` |
373
496
  | 10 | Additional networks (macOS/Linux) | user-mode only | `--add-network` |
374
497
  | 11 | Provisioning (packages, device-mode, user, license) | optional | various |
375
- | 12 | Run mode: background / foreground | background | `--bg` / `--fg` |
498
+ | 12 | Run mode: background / foreground | background | (default) / `--fg` |
376
499
  | 13 | Final confirmation | — | — |
377
500
 
378
501
  Steps 7–9 are skipped when `qemu-img` is absent (a warning is shown). Step 10
@@ -497,7 +620,7 @@ mutate any machine's `machine.json`.
497
620
  | `QUICKCHR_NO_PROMPT` | Force non-interactive mode (skips all wizards) |
498
621
  | `QUICKCHR_DEBUG` | Emit `[debug]` lines from the progress logger |
499
622
  | `QUICKCHR_INTEGRATION` | Required to run `test/integration/` |
500
- | `QUICKCHR_PRESERVE_ON_FAILURE` | `=1` keeps the machine directory after a boot timeout instead of removing it (QEMU is still stopped) |
623
+ | `QUICKCHR_PRESERVE_ON_FAILURE` | `=1` skips removal/reset after a first-boot timeout or failed first-boot provisioning run (QEMU is still stopped). Previously booted machines always survive a restart timeout |
501
624
  | `QUICKCHR_SERIAL_LOG` | `=1` tees the guest serial console to `<machineDir>/serial.log`. **Secret-bearing** — serial provisioning types the generated password in cleartext |
502
625
  | `QUICKCHR_DEEP_BOOT_DIAGNOSTICS` | `=1` lets a boot-timeout capture write to the guest (a mangle counting rule + host probes) to localize a silent packet drop. Skipped when `QUICKCHR_PRESERVE_ON_FAILURE=1` |
503
626
  | `MIKROTIK_WEB_ACCOUNT`, `MIKROTIK_WEB_PASSWORD` | License credentials fallback |
@@ -552,10 +675,29 @@ QuickCHR.resolveVersion(channel: Channel): Promise<string>
552
675
  ```
553
676
 
554
677
  `start()` creates the machine if needed, downloads the image, allocates
555
- a port block, spawns QEMU, waits for REST readiness, and runs all
556
- provisioning steps before returning. It auto-cleans the machine if boot
557
- times out (calls `stop()` then `remove()`). It acquires `start-lock` to
558
- serialize concurrent starts of the same machine.
678
+ a port block, spawns QEMU, waits for readiness (REST on 7.x, a native-API
679
+ `/login` on RouterOS 6), and runs all provisioning steps before returning.
680
+ With `wait: false` (`--no-wait`) it returns once QEMU is running and leaves
681
+ readiness to the caller; provisioning options are refused in that mode.
682
+ On a boot timeout it stops QEMU, then removes a machine this call created,
683
+ resets an existing never-booted machine to its factory disk, and preserves a
684
+ previously booted one — see [Auto-cleanup on boot timeout](#auto-cleanup-on-boot-timeout).
685
+
686
+ **Concurrency.** Machines may be created and started in parallel, from one
687
+ `Promise.all` or from separate processes. Each creation chooses its port
688
+ block and NIC MACs under a data-dir-wide allocation lock and reserves them
689
+ until `machine.json` is written, so concurrent auto-allocated machines never
690
+ share a block. An explicit `portBase` is never moved: one that overlaps
691
+ another machine's block fails with `PORT_CONFLICT`. Per machine, every
692
+ lifecycle operation takes the machine lock (`machines/<name>/.lock/`):
693
+
694
+ - `stop()`, `clean()` and `remove()` **wait** for an operation already in
695
+ progress (up to the machine's boot timeout), then act on the state it left.
696
+ - A second `start()` of the same machine, `setDeviceMode()` and removing a
697
+ leftover directory **fail at once** with `MACHINE_LOCKED`, naming the
698
+ operation, pid and age of the holder.
699
+ - A machine running in the **foreground** holds the lock until its VM exits.
700
+ Nothing waits for it. Quit it from its console.
559
701
 
560
702
  `add()` is the same minus the QEMU spawn — useful when you want to
561
703
  materialize a machine and start it later (or hand the config to another
@@ -598,7 +740,7 @@ throws `INVALID_NAME` for a name that is not a usable path segment.
598
740
  |---|---|---|
599
741
  | Identity | `name`, `state`, `ports`, `restUrl`, `sshPort`, `portBase` | Static metadata persisted in `machine.json` |
600
742
  | Capture | `captureInterface`, `hostGatewayIp` | Platform-correct values for TZSP capture (`tshark -i …`) and the guest→host address any UDP emitter targets |
601
- | Lifecycle | `waitForBoot()`, `waitFor(fn, ms?)`, `stop()`, `remove()`, `clean()`, `destroy()` | Boot readiness, custom polling, teardown |
743
+ | Lifecycle | `waitForBoot()`, `waitFor(fn, ms?)`, `stop()`, `shutdown(opts?)`, `remove()`, `clean()`, `destroy()` | Boot readiness, custom polling, power-cut or guest-shutdown teardown |
602
744
  | Comms | `rest()`, `exec()`, `monitor()`, `serial()`, `qga()` | REST, RouterOS CLI, QEMU monitor, serial console, QGA (x86 only) |
603
745
  | Provisioning | `license()`, `setDeviceMode()`, `availablePackages()`, `installPackage()` | Post-boot configuration |
604
746
  | Files | `upload()`, `download()` | SCP transfer to/from the CHR |
@@ -615,11 +757,11 @@ interface ChrInstance {
615
757
  portBase: number; // start of this instance's reserved port block
616
758
  captureInterface: string; // "lo0" on macOS, "any" on Linux — for TZSP capture
617
759
  hostGatewayIp: string; // "10.0.2.2" — the host, as seen from inside the guest
618
- tzspGatewayIp: string; // deprecated alias for hostGatewayIp (#26)
619
760
 
620
761
  waitForBoot(timeoutMs?: number): Promise<boolean>;
621
762
  waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
622
- stop(): Promise<void>;
763
+ stop(): Promise<void>; // power cut: SIGTERM, then SIGKILL
764
+ shutdown(opts?: ShutdownOptions): Promise<ShutdownResult>; // /system/shutdown, bounded fallback
623
765
  remove(): Promise<void>;
624
766
  clean(): Promise<void>;
625
767
  destroy(): Promise<void>; // stop + remove
@@ -687,7 +829,8 @@ redact before logging or attaching to bug reports.
687
829
  | `arch` | `"arm64" \| "x86"` | host | |
688
830
  | `cpu` | number | `1` | |
689
831
  | `mem` | number | `512` (1024 cross-arch TCG) | MiB |
690
- | `background` | boolean | `true` | |
832
+ | `background` | boolean | `true` | Where the serial console goes — not whether `start()` waits |
833
+ | `wait` | boolean | `true` | `false` resolves once QEMU is running and leaves readiness to the caller (`instance.waitForBoot()`); refused with provisioning or `background: false` (`INVALID_ARGUMENT`) |
691
834
  | `bootDiskFormat` | `"raw" \| "qcow2"` | `"raw"` (auto-`qcow2` if `bootSize`) | |
692
835
  | `bootSize` | string | — | e.g., `"512M"`, `"2G"`. Needs `qemu-img` |
693
836
  | `extraDisks` | string[] | `[]` | Each entry is a size string |
@@ -843,10 +986,43 @@ port mappings work on any 7.x.
843
986
  Disabling admin **does not** lock the REST API — RouterOS's `expired`
844
987
  flag only gates CLI/SSH/Winbox login, not REST.
845
988
 
846
- Each step is independent. A failure in step N stops further steps but
847
- does not roll back N–1. Re-running `start` re-applies pending
848
- provisioning from `machine.json` only if the machine had not been fully
849
- provisioned previously.
989
+ A failure in any step fails the whole first boot, and quickchr rolls it back
990
+ rather than leave a half-provisioned guest: a machine `start` created in that
991
+ call is removed (as a boot timeout removes it), and a machine created by `add`
992
+ has its disk reset to the factory image and `machine.json` restored to what
993
+ `add` recorded. Either way the retry is **the same command again**. With
994
+ `QUICKCHR_PRESERVE_ON_FAILURE=1` the half-provisioned machine is kept, stopped,
995
+ for inspection — `quickchr clean <name>` then resets it.
996
+
997
+ On the first `start` of a machine created by `add`, a provisioning option you
998
+ leave out keeps what `add` recorded, and an explicit negation
999
+ (`--no-install-all-packages`, `--no-disable-admin`, `--no-secure-login`,
1000
+ `--no-device-mode`) overrides it.
1001
+
1002
+ **The provisioning window closes at the machine's first boot** — not at
1003
+ `add`. A machine created with no provisioning options can still take them
1004
+ on its first `start`, which is where they are applied from `machine.json`.
1005
+ After that boot the guest may hold configuration quickchr did not put
1006
+ there, so provisioning cannot run against it: an option passed anyway is
1007
+ **refused**, never ignored, with `PROVISIONING_WINDOW_CLOSED` naming where
1008
+ the change can still be made. An option `machine.json` already records is
1009
+ reported as already applied rather than failing, so passing the same flags
1010
+ on every start is fine.
1011
+
1012
+ Routes that still work after the window has closed:
1013
+
1014
+ | change | route |
1015
+ |---|---|
1016
+ | license | `quickchr set <name> --license` |
1017
+ | device-mode | `quickchr set <name> --device-mode…` / `instance.setDeviceMode()` (power-cycles the VM) |
1018
+ | packages | `instance.installPackage()` (library; reboots the guest) |
1019
+ | anything else | `quickchr clean <name>` resets the disk to the factory image and reopens the window, at first-boot cost |
1020
+
1021
+ `clean()` retains provisioning *intent* — packages, device-mode, managed login — so the
1022
+ next `start` re-applies those on its own. It clears `user`, `disableAdmin` and
1023
+ `licenseLevel`, which are facts about the erased guest rather than intent, and a refused
1024
+ request is never persisted. **Repeat the original command after the reset** rather than
1025
+ a bare `quickchr start <name>`, or those options are silently left out.
850
1026
 
851
1027
  **Always read back what we wrote.** This catches version-specific drift
852
1028
  in REST responses and surfaces actionable errors instead of silent
@@ -1011,6 +1187,7 @@ restrictions, half-open SLiRP under TCG), see `docs/networking.md`.
1011
1187
 
1012
1188
  ```text
1013
1189
  $XDG_DATA_HOME/quickchr/ (defaults to ~/.local/share/quickchr/)
1190
+ ├── .allocate.lock/ held while a new machine picks its ports/MACs
1014
1191
  ├── cache/ Downloaded RouterOS images
1015
1192
  │ ├── chr-7.22.1.img.zip
1016
1193
  │ ├── chr-7.22.1.img
@@ -1026,7 +1203,7 @@ $XDG_DATA_HOME/quickchr/ (defaults to ~/.local/share/quickchr/)
1026
1203
  │ ├── qga.sock QGA (x86 only)
1027
1204
  │ ├── qemu.pid
1028
1205
  │ ├── qemu.log
1029
- │ ├── start-lock start serialization
1206
+ │ ├── .lock/ lifecycle lock + in-flight port/MAC reservation
1030
1207
  │ └── ssh/id_ed25519[.pub] SSH key (if managed user provisioned)
1031
1208
  └── networks/ Named L2 socket reservations
1032
1209
  └── <name>.json
@@ -1059,11 +1236,11 @@ is unset.
1059
1236
  ### Credentials (separate from data dir)
1060
1237
 
1061
1238
  - **Per-instance CHR credentials**: file in the user config dir
1062
- (`~/.config/quickchr/secrets.json` on POSIX; not the OS keychain — a
1239
+ (`~/.config/quickchr/com.quickchr.instance.json`; not the OS keychain — a
1063
1240
  Keychain dialog hangs in non-TTY contexts like tests and CI).
1064
1241
  - **MikroTik.com web account** (for license operations): tries
1065
1242
  `Bun.secrets` (OS keychain) when interactive, falls back to the same
1066
- config-file store otherwise.
1243
+ config-file store otherwise (`~/.config/quickchr/com.quickchr.mikrotik-web.json`).
1067
1244
 
1068
1245
  Both honor explicit CLI flags / env vars first.
1069
1246
 
@@ -1095,8 +1272,10 @@ If an extra mapping reuses a built-in service name such as `winbox`, it
1095
1272
  pins/replaces that service's host port rather than creating a second
1096
1273
  `ports.winbox` entry.
1097
1274
 
1098
- If port `9100` is in use, the next instance gets `9110`, etc. Manual
1099
- override via `--port-base`.
1275
+ If port `9100` is in use, the next instance gets `9110`, etc. Concurrent
1276
+ creations get distinct blocks (see **Concurrency** under [Static methods on `QuickCHR`](#static-methods-on-quickchr)).
1277
+ Manual override via `--port-base`, which is never remapped: a base that
1278
+ overlaps another machine's block is a `PORT_CONFLICT`.
1100
1279
 
1101
1280
  ---
1102
1281
 
@@ -1136,8 +1315,12 @@ Native (HVF/KVM): factor ~1 → 120s base.
1136
1315
  Cross-arch TCG: factor ~15 → up to 1800s.
1137
1316
  Package install adds a 2× multiplier (extra reboot cycle).
1138
1317
 
1139
- If boot times out, the machine is **automatically stopped + removed**.
1140
- Use `--timeout-extra` to add a buffer if you know your host is slow, or
1318
+ If boot times out, QEMU is stopped. A previously booted machine is **preserved**
1319
+ with its disks, credentials and configuration; retry with `quickchr start <name>`
1320
+ or explicitly remove it. A machine created by this `start()` is removed, and an
1321
+ existing never-booted machine is reset to its factory disk and pre-launch record.
1322
+ `QUICKCHR_PRESERVE_ON_FAILURE=1` skips removal/reset for those first-boot cases.
1323
+ See the [cleanup table](#auto-cleanup-on-boot-timeout) for all three cases. Use `--timeout-extra` to add a buffer if you know your host is slow, or
1141
1324
  persist a default via `quickchr settings set timeout-extra <seconds>` so
1142
1325
  every `start` picks it up without repeating the flag.
1143
1326
 
@@ -1154,7 +1337,8 @@ against:
1154
1337
  - Accidental credential leakage in commits, CI logs, CLI output.
1155
1338
 
1156
1339
  We **don't** protect against same-user code reading
1157
- `secrets.json`, `ps`-visible CLI args, or memory inspection.
1340
+ the `~/.config/quickchr/*.json` credential files, `ps`-visible CLI args, or memory
1341
+ inspection.
1158
1342
 
1159
1343
  ### Auth resolution (`resolveAuth`)
1160
1344
 
@@ -1196,13 +1380,14 @@ the 6 managed keys are all structural preferences, not secrets.
1196
1380
  | `QUICKCHR_NO_PROMPT` | CLI dispatcher — forces non-interactive mode |
1197
1381
  | `QUICKCHR_DEBUG` | progress logger — emits `[debug]` lines |
1198
1382
  | `QUICKCHR_INTEGRATION` | `bun:test` gate for `test/integration/` |
1199
- | `QUICKCHR_PRESERVE_ON_FAILURE` | `quickchr.ts` — skip `remove()` on `BOOT_TIMEOUT` so the machine dir survives for diagnosis |
1383
+ | `QUICKCHR_PRESERVE_ON_FAILURE` | `quickchr.ts` — skip `remove()` on `BOOT_TIMEOUT`, and the rollback of a failed provisioning run, so the machine dir survives for diagnosis |
1200
1384
  | `QUICKCHR_SERIAL_LOG` | `qemu.ts` — adds `logfile=` to the serial chardev (writes cleartext provisioning passwords) |
1201
1385
  | `QUICKCHR_DEEP_BOOT_DIAGNOSTICS` | `guest-snapshot.ts` — allows the guest-mutating counting-rule probe during boot-failure capture |
1202
1386
  | `QUICKCHR_DEFAULT_CHANNEL` | `settings.ts` — env tier for the `default-channel` setting |
1203
1387
  | `QUICKCHR_DEFAULT_ARCH` | `settings.ts` — env tier for the `default-arch` setting |
1204
1388
  | `QUICKCHR_ACCEL` | `settings.ts` / `platform.ts` — env tier for the `accel` setting; forces `-accel` and bypasses detection |
1205
1389
  | `QUICKCHR_CACHE_MAX_SIZE` | `settings.ts` — env tier for the `cache-max-size` setting |
1390
+ | `QUICKCHR_SHUTDOWN_TIMEOUT` | CLI `shutdown` grace budget in positive integer seconds; default 10, overridden by `--timeout` |
1206
1391
  | `QUICKCHR_TIMEOUT_EXTRA` | `settings.ts` — env tier for the `timeout-extra` setting |
1207
1392
  | `QUICKCHR_SECURE_LOGIN` | `settings.ts` — env tier for the `secure-login` setting |
1208
1393
  | `MIKROTIK_WEB_ACCOUNT` | license-renewal account (fallback) |
@@ -1366,7 +1551,13 @@ stored instance credentials, `state.user`, the managed SSH keypair under
1366
1551
  `<machineDir>/ssh/`, and `disableAdmin` — because the accounts they named
1367
1552
  went with the disk. The machine comes back as a factory CHR: `admin` with
1368
1553
  an empty password, which is what `rest()`, `exec()`, and `inspect` resolve
1369
- to afterwards. Nothing is re-provisioned automatically.
1554
+ to afterwards.
1555
+
1556
+ `clean()` performs no provisioning of its own, but it does reopen the provisioning
1557
+ window: the next `start` re-applies the retained intent (`packages`, `deviceMode`,
1558
+ `secureLogin`) as a first boot would. The credential facts above are cleared rather
1559
+ than retained, so an explicit `--add-user` / `--disable-admin` — and anything from a
1560
+ request that was refused, which is never persisted — has to be passed again.
1370
1561
 
1371
1562
  ### Snapshots
1372
1563
 
@@ -1410,13 +1601,13 @@ present, then exits `1`.
1410
1601
  | `MISSING_FIRMWARE` | edk2 UEFI firmware not installed (arm64) | install `qemu-efi-aarch64` / `edk2-aarch64` |
1411
1602
  | `MISSING_UNZIP` | image extraction needs `unzip` | install OS package |
1412
1603
  | `PORT_CONFLICT` | no free 10-port block | stop a machine or set `--port-base` |
1413
- | `BOOT_TIMEOUT` | CHR didn't respond within timeout | check `qemu.log`; under TCG, raise `--timeout-extra`; machine is **auto-removed** |
1604
+ | `BOOT_TIMEOUT` | CHR didn't respond within timeout | check `qemu.log`; under TCG, raise `--timeout-extra`; previously booted machines are **preserved**; new machines are removed, existing first boots reset, unless `QUICKCHR_PRESERVE_ON_FAILURE=1` |
1414
1605
  | `DOWNLOAD_FAILED` | image fetch failed | retry; check connectivity to MikroTik download server |
1415
1606
  | `INVALID_VERSION` / `INVALID_ARCH` / `INVALID_NAME` / `INVALID_DISK_SIZE` | argument parse | fix the value |
1416
1607
  | `MACHINE_EXISTS` | name already in `machines/` | choose another name or `QuickCHR.get(name)` |
1417
1608
  | `MACHINE_NOT_FOUND` | name not in `machines/` | `quickchr list` |
1418
1609
  | `MACHINE_RUNNING` / `MACHINE_STOPPED` | wrong lifecycle state | `start` or `stop` first |
1419
- | `MACHINE_LOCKED` | another `start` in progress | wait or pick another name |
1610
+ | `MACHINE_LOCKED` | another operation holds the machine (the message names it, its pid and age) | wait for it; a `foreground` holder must be quit from its console |
1420
1611
  | `EXEC_FAILED` | RouterOS rejected the command (auth, syntax) | check creds, command syntax |
1421
1612
  | `PROCESS_FAILED` | QEMU spawn / reboot / verify failed | check `qemu.log`, host resources |
1422
1613
  | `SPAWN_FAILED` | exec/spawn of host binary failed | check binary exists + executable |
@@ -1424,7 +1615,9 @@ present, then exits `1`.
1424
1615
  | `QGA_TIMEOUT` | QGA daemon unreachable | KVM-only; not on macOS HVF or TCG |
1425
1616
  | `NETWORK_UNAVAILABLE` | provisioning needs `user` NIC, none present | add `--add-network user` |
1426
1617
  | `INVALID_NETWORK` | unknown alias / unresolvable iface / dead socket daemon | `quickchr networks` |
1427
- | `PROVISIONING_VERSION_UNSUPPORTED` | provisioning option on RouterOS < 7.20.8 | use `--channel long-term` or pin a `7.20.8+` version |
1618
+ | `PROVISIONING_VERSION_UNSUPPORTED` | provisioning option on RouterOS < 7.20.8 (including any 6.x) | use `--channel long-term` or pin a `7.20.8+` version |
1619
+ | `REST_UNAVAILABLE` | REST operation (`exec`, `get`, `rest()`) on a RouterOS 6 machine | use the native API — `centrs … --via native-api --no-validate` |
1620
+ | `INVALID_ARGUMENT` | a flag combination quickchr will not honour — e.g. `--no-wait` with provisioning | drop one of the two |
1428
1621
  | `INSUFFICIENT_DISK_SPACE` | host data dir full | free space or move via `QUICKCHR_DATA_DIR` |
1429
1622
  | `STATE_ERROR` | snapshot on raw boot disk | recreate with `--boot-disk-format=qcow2` |
1430
1623
  | `INVALID_SIZE_STRING` | invalid cache/settings size string | use size syntax like `512M`, `2G`, or `1.5G` |
@@ -1433,10 +1626,44 @@ present, then exits `1`.
1433
1626
 
1434
1627
  ### Auto-cleanup on boot timeout
1435
1628
 
1436
- If `start()` hits `BOOT_TIMEOUT`, quickchr automatically calls
1437
- `stop()` then `remove()` on the failing instance. You'll see a
1438
- diagnostic and the machine will be gone from `quickchr list`. This
1439
- prevents orphan disks accumulating across attempts.
1629
+ If `start()` hits `BOOT_TIMEOUT`, quickchr stops the failing instance and then:
1630
+
1631
+ | Machine at launch entry | Timeout cleanup | Recovery |
1632
+ | --- | --- | --- |
1633
+ | Created by this `start()` call | Remove automatically | Repeat the original command |
1634
+ | Existing, never booted, provisioning window open | Reset factory disk and restore the pre-launch record, window open | `quickchr start <name>` |
1635
+ | Existing, provisioning window already closed | Preserve disks, credentials and persistent configuration; clear PID and release socket memberships | `quickchr start <name>`, or explicit `quickchr remove <name>` |
1636
+
1637
+ `QUICKCHR_PRESERVE_ON_FAILURE=1` skips removal/reset for the first two cases and
1638
+ keeps the machine as it failed. Previously booted machines survive without the
1639
+ flag, including older records whose only boot-history marker is `lastStartedAt`.
1640
+ The diagnostic report is written outside the machine directory before cleanup;
1641
+ QEMU logs in a preserved machine also remain available. A configured guest does
1642
+ not need `clean` to retry: that command deliberately erases its configuration.
1643
+
1644
+ ### RouterOS 6 (boot-only)
1645
+
1646
+ A best-effort target for v6→v7 migration testing (tikoci/centrs#386), CLI only —
1647
+ the setup wizard does not offer it.
1648
+
1649
+ ```sh
1650
+ quickchr add --name lab6 --version 6.49.23 --arch x86
1651
+ quickchr start lab6
1652
+ centrs retrieve --quickchr lab6 /system/resource --via native-api --no-validate
1653
+ ```
1654
+
1655
+ - **x86 only.** MikroTik publishes no arm64 CHR for 6.x; `--arch arm64` is refused
1656
+ before download (`INVALID_ARCH`).
1657
+ - **Readiness is a native-API `/login`** on guest port 8728 (two consecutive
1658
+ answers), since 6.x has no REST. A 6.49.23 guest is ready in ~25 s under HVF.
1659
+ - **No provisioning.** Every provisioning flag, and the post-boot `set` routes, are
1660
+ refused before download where possible (`PROVISIONING_VERSION_UNSUPPORTED`). The
1661
+ `secure-login` setting is a default, not a request, so it does not apply to 6.x.
1662
+ - **No REST.** `exec` (REST/auto transport), `get`'s live groups and
1663
+ `ChrInstance.rest()` fail with `REST_UNAVAILABLE` rather than time out.
1664
+ `inspect` reports `rest-api` unavailable and `native-api` available.
1665
+ - `list`, `inspect`, `stop`, `remove` and `start --no-wait` work as for 7.x
1666
+ (checked live on 6.49.23).
1440
1667
 
1441
1668
  ### Where to look
1442
1669
 
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  > **Experimental** — quickchr is under active development. Most testing has been on
4
4
  > macOS (Apple Silicon + Intel), with CI on Linux (x86_64 + aarch64). Windows support
5
- > is early-stage: unit tests pass on `windows-latest` CI, but integration tests
6
- > (running a real CHR via QEMU for Windows) are not yet automated. Expect rough edges
7
- > on Windows — bug reports welcome.
5
+ > is early-stage: unit tests pass on `windows-latest` CI, and a weekly sweep boots a real
6
+ > CHR on Windows (TCG, anchor smoke subset only). Expect rough edges on Windows — bug
7
+ > reports welcome.
8
8
 
9
9
  CLI and library to download, launch, and manage MikroTik CHR virtual machines via QEMU.
10
10
 
@@ -158,6 +158,11 @@ quickchr remove my-chr
158
158
  quickchr doctor
159
159
  ```
160
160
 
161
+ `quickchr stop <name>` terminates QEMU (a power cut). For an explicit guest
162
+ shutdown request, use `quickchr shutdown <name> [--timeout <seconds>]` (default
163
+ 10 seconds, or `QUICKCHR_SHUTDOWN_TIMEOUT`). It waits for QEMU to exit and clearly
164
+ reports a forced power cut if the guest does not stop within the grace budget.
165
+
161
166
  ### Create / Start Options
162
167
 
163
168
  | Flag | Description | Default |
@@ -172,8 +177,9 @@ quickchr doctor
172
177
  | `--boot-size <size>` | Resize boot disk (e.g., 512M, 2G). Requires `qemu-img`. | |
173
178
  | `--add-disk <size>` | Attach an extra blank qcow2 disk. Repeatable. Requires `qemu-img`. | |
174
179
  | `--forward <spec>` | Add or pin a QEMU SLiRP hostfwd mapping. Repeatable. | |
175
- | `--bg` / `--background` | Run in background (default) | true |
180
+ | `--no-wait` | Return once QEMU is running; readiness is up to the caller. Not with provisioning flags or `--fg` | |
176
181
  | `--fg` / `--foreground` | Run in foreground — serial console on stdio | |
182
+ | `--bg` / `--background` | **Deprecated, does nothing** — background is already the default. Warns; use `--no-wait` to return early | |
177
183
  | `--add-package <pkg>` | Extra package to install (repeatable) | |
178
184
  | `--install-all-packages` | Install all packages from `all_packages.zip` | |
179
185
  | `--add-user <user:pass>` | Create a user after boot | |
@@ -204,7 +210,7 @@ quickchr separates **boot-only** machine management from **post-boot provisionin
204
210
 
205
211
  | Feature | Minimum RouterOS | Notes |
206
212
  |---------|------------------|-------|
207
- | Boot / start / stop | Any RouterOS 7.x | Boot-only path; no post-boot RouterOS mutations |
213
+ | Boot / start / stop | Any RouterOS 7.x; RouterOS 6 on x86 (best-effort) | Boot-only path; no post-boot RouterOS mutations. 6.x has no REST: readiness is a native-API login, and provisioning is refused — see MANUAL "RouterOS 6 (boot-only)" |
208
214
  | Disk resize / extra disks / disk inspection | Any RouterOS 7.x | Requires host `qemu-img`; QEMU-local only |
209
215
  | Network attachment / port mappings | Any RouterOS 7.x | QEMU networking setup; not provisioning |
210
216
  | Managed login / custom user / disable-admin | 7.20.8+ | Post-boot provisioning |
@@ -359,7 +365,8 @@ const result = await chr.exec("/system/resource/print");
359
365
  console.log(result);
360
366
 
361
367
  // Stop (or remove)
362
- await chr.stop();
368
+ await chr.stop(); // power cut for normal teardown
369
+ // Alternatively, replace stop() above with: await chr.shutdown({ timeoutMs: 10_000 });
363
370
  await chr.remove();
364
371
 
365
372
  console.log(chr.state.bootDiskFormat); // "qcow2"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/quickchr",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
4
4
  "description": "CLI and library to download, launch, and manage MikroTik CHR virtual machines via QEMU",
5
5
  "module": "src/index.ts",
6
6
  "main": "src/index.ts",
package/src/cli/flags.ts CHANGED
@@ -24,13 +24,27 @@ const CREATE_FLAGS = [
24
24
 
25
25
  /** `start` also boots, so it owns the run-time flags on top of the create set. */
26
26
  const START_ONLY_FLAGS = [
27
- "all", "dry-run", "background", "bg", "fg", "foreground",
27
+ "all", "dry-run", "wait", "background", "bg", "fg", "foreground",
28
28
  "install-deps", "timeout-extra",
29
29
  "license-level", "license-account", "license-password",
30
30
  ] as const;
31
31
 
32
+ /** `set` applies a change to a machine that has already booted, so it takes the
33
+ * post-boot subset of the provisioning flags plus `--license`'s credential flags.
34
+ *
35
+ * It is listed here for the **valueless** check, not for strictness: `set` still
36
+ * tolerates an unknown flag like every other non-creating command. The check that
37
+ * matters is arity — `quickchr set lab --device-mode` with no value parses as a
38
+ * boolean and would otherwise reach "Nothing to set", which blames the user for the
39
+ * wrong thing. */
40
+ const SET_ONLY_FLAGS = [
41
+ "name", "license", "level", "account", "password",
42
+ "device-mode", "device-mode-enable", "device-mode-disable",
43
+ ] as const;
44
+
32
45
  export const ADD_FLAGS: readonly string[] = CREATE_FLAGS;
33
46
  export const START_FLAGS: readonly string[] = [...CREATE_FLAGS, ...START_ONLY_FLAGS];
47
+ export const SET_FLAGS: readonly string[] = SET_ONLY_FLAGS;
34
48
 
35
49
  /** Every flag that takes a value, across every command — `parseFlags`'s arity table.
36
50
  *