@tikoci/quickchr 0.4.8 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -68,31 +72,40 @@ by the user (or their AI agent) using `quickchr` as a building block.
68
72
 
69
73
  ### Install quickchr
70
74
 
71
- The first GitHub release is **0.1.1** — pre-release per the changelog's
72
- odd/even policy. Not yet on npm.
75
+ Install the stable npm channel:
73
76
 
74
77
  ```bash
75
- # Library
76
- bun add github:tikoci/quickchr
78
+ bun install -g @tikoci/quickchr
79
+ bun add @tikoci/quickchr
80
+ ```
77
81
 
78
- # CLI from source (until npm publish)
79
- git clone https://github.com/tikoci/quickchr
80
- cd quickchr
81
- bun install
82
- bun run dev -- doctor
82
+ For prerelease QA, use `@next` for the current candidate or, after publication,
83
+ `@0.5.1` to pin this candidate. A release is published to `next` first and promoted
84
+ to `latest` once QA passes; `next` is never older than `latest`. The quickchr package
85
+ channel is independent of `--channel`, which selects a RouterOS firmware channel.
86
+
87
+ ```bash
88
+ bun install -g @tikoci/quickchr@0.5.1
89
+ bun add @tikoci/quickchr@0.5.1
90
+ quickchr --version
83
91
  ```
84
92
 
85
- Once `0.2.0` is published to npm:
93
+ To run an unpublished checkout:
86
94
 
87
95
  ```bash
88
- bun install -g @tikoci/quickchr
96
+ git clone https://github.com/tikoci/quickchr
97
+ cd quickchr
98
+ bun install
99
+ bun run dev -- doctor
89
100
  ```
90
101
 
91
102
  ### `quickchr doctor`
92
103
 
93
104
  Reports prerequisite status, accelerator availability, image cache
94
105
  size, machine count, free disk space, and shell-completion install
95
- state. Exit code is `0` when no checks return `error` (warnings are OK).
106
+ state. The disk check measures the data dir's filesystem and warns when free
107
+ space is under `storage-reserve` + 2 GiB. Exit code is `0` when no checks return
108
+ `error` (warnings are OK).
96
109
 
97
110
  ```bash
98
111
  quickchr doctor # human-readable
@@ -146,6 +159,7 @@ directory, downloads the RouterOS image (cached), and writes
146
159
  | `--device-mode <m>` | — | `rose`, `advanced`, `basic`, `home`, `auto`, `skip`, ≥ 7.20.8 |
147
160
  | `--device-mode-enable <f,...>` | — | Repeatable, comma-separated |
148
161
  | `--device-mode-disable <f,...>` | — | Repeatable, comma-separated |
162
+ | `--no-device-mode` | — | Skip device-mode provisioning entirely |
149
163
  | `--add-network <spec>` | `user` | See §7. Repeatable. |
150
164
  | `--no-network` | false | Headless, no NICs |
151
165
  | `--no-winbox` | false | Exclude WinBox port |
@@ -159,20 +173,36 @@ an error naming its replacement, not a silently ignored flag.
159
173
  #### `start [name]`
160
174
 
161
175
  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`.
176
+ RouterOS is ready (REST answers; on 6.x, a native-API login), or attaches QEMU
177
+ stdio to your terminal (`--fg`). Accepts the same provisioning flags as `add`.
178
+ QEMU runs detached in its own session either way, so it survives a signal to the
179
+ shell that started it.
180
+
181
+ When `<name>` matches no machine (a typo, or one removed earlier), `start` creates a
182
+ factory CHR under that name and says so: `<name> created and started`, or
183
+ `created — not waiting for RouterOS` with `--no-wait`. A machine that already
184
+ existed, including one made by `add`, prints `started`.
164
185
 
165
186
  Additional flags:
166
187
 
167
188
  | Flag | Notes |
168
189
  |---|---|
169
- | `--all` | Start every stopped machine in background |
170
- | `--bg` / `--background` | (default) detach QEMU; return after boot |
190
+ | `--all` | Start every stopped machine in background; accepts readiness, acceleration, timeout and secure-login options only. Other options are refused rather than ignored. `--secure-login` is resolved per machine: one that has never booted creates the managed login; one that already booted accepts it as a no-op only if it already has one, and is refused otherwise. With `--no-wait`, a never-booted machine refuses it (provisioning needs the wait). Machines start one at a time, so a refusal stops the batch after the earlier machines have started |
191
+ | `--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
192
  | `--fg` / `--foreground` (alias `--no-bg`, `--no-background`) | Serial console on stdio |
172
- | `--install-deps` | Auto-install missing host deps (calls package manager) |
193
+ | `--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` |
173
194
  | `--timeout-extra <s>` / `-T <s>` | Add extra seconds to computed boot timeout |
174
195
  | `--dry-run` | Print resolved options and QEMU command, exit |
175
196
 
197
+ After `--no-wait`, readiness is yours. centrs (0.1.8+) waits for it in one command:
198
+
199
+ ```sh
200
+ quickchr start lab --no-wait
201
+ centrs retrieve --quickchr lab /system/resource --wait 5m
202
+ # RouterOS 6: centrs validates against the 7.x command tree, so skip validation
203
+ centrs retrieve --quickchr lab6 /system/resource --via native-api --no-validate --wait 5m
204
+ ```
205
+
176
206
  Foreground mode shortcuts (QEMU mux):
177
207
 
178
208
  | Key | Action |
@@ -188,9 +218,74 @@ machine state.
188
218
 
189
219
  #### `stop [name | --all]`
190
220
 
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`.
221
+ Power cut: sends SIGTERM to QEMU, waits up to 5 seconds, then SIGKILL if
222
+ needed. This does not request a guest shutdown; pending RouterOS changes
223
+ may be lost. Updates `state.status = "stopped"` and unregisters socket-named
224
+ networks. Listing-mode if no name and not `--all`.
225
+
226
+ Use `stop` for normal teardown and device-mode power-cycle confirmation.
227
+ Use `shutdown` when the caller wants to request a guest shutdown first.
228
+
229
+ #### `shutdown <name> [--timeout <seconds>]`
230
+
231
+ Sends `/system/shutdown` once through REST, independently waits for QEMU to
232
+ exit, then falls back to a power cut if it remains running. Default grace
233
+ budget: **10 seconds**. `--timeout` takes positive integer seconds and
234
+ overrides `QUICKCHR_SHUTDOWN_TIMEOUT`. Neither changes the boot timeout.
235
+ The maximum is 2,147,483 seconds (the HTTP timer's limit).
236
+
237
+ ```bash
238
+ quickchr shutdown lab
239
+ quickchr shutdown lab --timeout 20
240
+ QUICKCHR_SHUTDOWN_TIMEOUT=20 quickchr shutdown lab
241
+ ```
242
+
243
+ The CLI reports `guest exited`, `already stopped`, or **forced power cut —
244
+ guest shutdown not confirmed**. Forced fallback exits successfully if QEMU
245
+ is confirmed gone; invalid input, lock failure, or inability to stop QEMU
246
+ is an error. Disks, credentials and restart configuration survive.
247
+
248
+ The guest request and exit observation share one grace budget. Lifecycle
249
+ lock acquisition, the existing SIGTERM/SIGKILL escalation, and final exit
250
+ verification have separate bounded waits. A 10-second grace budget does
251
+ not bound the entire CLI invocation to 10 seconds.
252
+
253
+ An HTTP error does not establish failure: on CHR 7.24.5, both x86/HVF and
254
+ arm64/TCG returned HTTP 400 `interrupted`, then exited. Shutdown uses
255
+ process exit as its completion signal. Observed exit does not guarantee
256
+ application or storage durability. Unavailable REST (including RouterOS 6),
257
+ failed authentication and a guest that does not halt all use the same
258
+ explicit hard-stop fallback; no console/QGA/ACPI replay is attempted.
259
+
260
+ ```ts
261
+ const result = await chr.shutdown({ timeoutMs: 20_000 });
262
+ // outcome: "guest-exited" | "forced" | "already-stopped"
263
+ // requestError can accompany guest-exited (e.g. HTTP 400 interrupted).
264
+ console.log(result.outcome);
265
+ ```
266
+
267
+ Library `timeoutMs` values are positive integer milliseconds, maximum
268
+ 2,147,483,647, with a 10,000 ms default. Library callers supply options
269
+ explicitly; the CLI environment override is not read by `shutdown()`.
270
+
271
+ For consumers of an older quickchr version, the manual workaround needs
272
+ both request-error handling and independent exit observation:
273
+
274
+ ```ts
275
+ const pid = chr.state.pid;
276
+ if (pid) {
277
+ try { await chr.exec("/system/shutdown", { via: "rest", timeout: 10_000 }); }
278
+ catch { /* HTTP 400 interrupted may accompany successful guest shutdown. */ }
279
+ const exited = await chr.waitFor(async () => {
280
+ try { process.kill(pid, 0); return false; } catch { return true; }
281
+ }, 10_000);
282
+ if (!exited) console.warn("Guest shutdown not confirmed; forcing a power cut");
283
+ }
284
+ await chr.stop(); // reconcile state/network membership, or hard-stop if still alive
285
+ ```
286
+
287
+ This older-version workaround has separate request and wait budgets;
288
+ `shutdown()` provides one shared budget and structured fallback reporting.
194
289
 
195
290
  #### `remove [name | --all]` (alias `rm`)
196
291
 
@@ -207,10 +302,17 @@ flag. The next `start` therefore logs in as factory `admin` with an empty
207
302
  password. EFI vars are deliberately **kept** (they store boot order, not
208
303
  guest state; wiping them costs an arm64 device rescan).
209
304
 
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.
305
+ `clean` itself never provisions — it only resets the disk. What it does do is
306
+ **reopen the provisioning window** (#176), so the *next* `start` re-applies the
307
+ provisioning intent `machine.json` still holds: `packages`, `deviceMode`,
308
+ `secureLogin`. Budget for that start accordingly; it costs what a first boot costs,
309
+ not what a restart costs.
310
+
311
+ What does **not** come back on its own is anything `clean` had to clear because it
312
+ described the erased guest rather than an intent — `user`, `disableAdmin`,
313
+ `licenseLevel` — and any option from a request that was *refused*, since a refused
314
+ request is never persisted. Pass those flags again, which is why the refusal says to
315
+ repeat the original command rather than to run a bare `start`.
214
316
 
215
317
  ### Inspection
216
318
 
@@ -238,6 +340,9 @@ machine as a directory to delete rather than state to load.
238
340
  Live REST query. Hits the running CHR and returns the requested group
239
341
  (or all if no group specified). Requires the machine to be running.
240
342
 
343
+ `device-mode` lists the mode plus every feature RouterOS reports, split into
344
+ enabled and disabled — the read-back for `quickchr set <name> --device-mode…`.
345
+
241
346
  #### `inspect <name> [--json]`
242
347
 
243
348
  Stable, versioned (`descriptorVersion: 1`) connection descriptor for a
@@ -303,7 +408,8 @@ the Library API reference.
303
408
  #### `exec <name> <command> [--via=auto|rest|qga|console|ssh] [--user u] [--password p] [--timeout=ms]`
304
409
 
305
410
  Run a RouterOS CLI command. See §12 for transport details and JSON
306
- output trick.
411
+ output trick. Exit 0 means the command was delivered, not that RouterOS
412
+ accepted it — see §12 "Exit status" for the pass/fail route.
307
413
 
308
414
  #### `console <name>`
309
415
 
@@ -328,11 +434,43 @@ Apply or renew a CHR trial license on a running instance. Resolves
328
434
  MikroTik.com credentials from `--license-account`/`--license-password`,
329
435
  the env vars, or the secret store. Requires RouterOS ≥ 7.20.8.
330
436
 
331
- #### `set <name> <subcommand>`
437
+ #### `set <name> [--license | --device-mode <m>] [--device-mode-enable <f>] [--device-mode-disable <f>]`
332
438
 
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.
439
+ Change a property on a machine that has **already booted** — the post-boot half
440
+ of the provisioning flags. `add`/`start` apply them at first boot; `set` applies
441
+ them now. It is the route `PROVISIONING_WINDOW_CLOSED` names, printed with the
442
+ flags that were asked for so it can be run as given.
443
+
444
+ | flag | what it does |
445
+ |---|---|
446
+ | `--license` | Apply or renew a CHR trial license (§ `license` above for credential resolution). |
447
+ | `--device-mode <m>` | `rose`, `advanced`, `basic`, `home`, `auto`. |
448
+ | `--device-mode-enable <f,…>` | Set one or more device-mode flags to yes. Repeatable, comma-separated. |
449
+ | `--device-mode-disable <f,…>` | Set one or more to no. |
450
+
451
+ **Device-mode restarts the machine.** RouterOS only applies a device-mode change
452
+ across a power cycle, and quickchr owns the power button — which is why this lives
453
+ here rather than in a tool that reaches a router over the network: the same request
454
+ issued from inside the guest never returns, because it is waiting for a power cycle
455
+ it cannot perform on itself. quickchr prints every setting that will move before it
456
+ restarts, and skips the restart entirely when the guest already matches.
457
+
458
+ Naming a feature without a mode resolves the mode to `rose` — so
459
+ `set lab --device-mode-enable container` moves `mode` as well. That move is printed
460
+ before it happens, not discovered afterwards.
461
+
462
+ Preconditions: the machine must be **running** and have a **user-mode NIC**
463
+ (device-mode is applied over localhost REST), on RouterOS ≥ 7.20.8. Each is a
464
+ named refusal rather than a timeout.
465
+
466
+ The change is folded into `machine.json` rather than overwriting it — device-mode
467
+ updates move only the settings they name, so the record stays cumulative — and
468
+ recorded in `provisioning.steps`, which makes a later `start` passing the same
469
+ device-mode a recognised no-op instead of a refusal.
470
+
471
+ **Not available here:** `--add-user`, `--disable-admin`, `--secure-login`. Those
472
+ rewrite working config rather than unlock a capability, and they are the steps that
473
+ drift; their route stays `quickchr clean <name>` followed by the original command.
336
474
 
337
475
  #### `snapshot <name> <list | save [snapname] | load <snapname> | delete <snapname>>` (alias `snap`)
338
476
 
@@ -372,7 +510,7 @@ another machine, view status, or quit.
372
510
  | 9 | Extra blank disks (repeatable) | none | `--add-disk` |
373
511
  | 10 | Additional networks (macOS/Linux) | user-mode only | `--add-network` |
374
512
  | 11 | Provisioning (packages, device-mode, user, license) | optional | various |
375
- | 12 | Run mode: background / foreground | background | `--bg` / `--fg` |
513
+ | 12 | Run mode: background / foreground | background | (default) / `--fg` |
376
514
  | 13 | Final confirmation | — | — |
377
515
 
378
516
  Steps 7–9 are skipped when `qemu-img` is absent (a warning is shown). Step 10
@@ -444,6 +582,64 @@ appended directly to `$GITHUB_OUTPUT`.
444
582
  `--json` intentionally emits one JSON object for programmatic parsing; use the
445
583
  default output form when writing step outputs directly.
446
584
 
585
+ `cache list [--json]` shows each cache entry with its version, arch, size, age
586
+ (time since it was cached or last used) and whether it is in use. Entries are:
587
+
588
+ - **images** (`chr-<ver>[-arm64].img`, and a `.img.zip` not yet extracted). An
589
+ image is **in use** while any machine, running or stopped, was created from
590
+ that version and arch: `clean` and a failed first boot rebuild from it.
591
+ - **package archives** (`packages-<arch>-<ver>`): the `all_packages` ZIP and its
592
+ extraction, sized and deleted together, since the extraction is only a cache
593
+ hit while its ZIP is there. In use while a machine that asks for packages has
594
+ not finished its first boot.
595
+ - **partial** entries: an interrupted download (`*.<pid>-<id>.part`) or
596
+ extraction (`.quickchr-image-*`, `.quickchr-packages-*`). In use while the
597
+ process that wrote it is alive (an extraction: for 30 minutes after its last
598
+ write). `prune` removes stale ones before anything else.
599
+
600
+ `cache list --json` prints one object per entry, including `kind` (`image`, `image-zip`,
601
+ `packages` or `partial`) and `paths` (every file deleting the entry removes).
602
+ Files in the cache that quickchr did not write are reported as "Other files"
603
+ and never deleted. `prune` and `clear` never delete an in-use entry.
604
+
605
+ ```bash
606
+ quickchr cache list
607
+ quickchr cache prune --older-than 7.22 --dry-run # versions below 7.22
608
+ quickchr cache prune --max-age 30d # not used for more than 30 days
609
+ quickchr cache prune --max-size 2G # oldest versions first, until <= 2 GiB
610
+ quickchr cache clear --all # every entry not in use
611
+ ```
612
+
613
+ `prune` needs at least one of `--older-than <ver>`, `--max-age <dur>` or
614
+ `--max-size <size>`, and applies them in that order when combined. `--max-size`
615
+ evicts the oldest RouterOS version first (oldest cache time breaks ties) until
616
+ the total, in-use entries included, fits. Durations are `<N>(s|m|h|d|w)`; sizes
617
+ are `<N>(K|M|G|T)` with an optional `i`/`B`, read as IEC binary. `--dry-run`
618
+ lists what `prune` or `clear` would delete without deleting it. `clear` refuses
619
+ without `--all`. A delete that fails is listed on stderr, is not counted as
620
+ freed, and makes the command exit 1. A cache hit counts as a use, so `--max-age`
621
+ measures time since an entry was last used.
622
+
623
+ Separately, a successful `start` prunes to the `cache-max-size` setting (default
624
+ `2G`; see `settings` below). That automatic prune, and the one below, skip any
625
+ entry used in the last 30 minutes, which another create may be reading.
626
+
627
+ **Disk space.** Creating a machine (`add`, or `start` with a new name) and
628
+ filling the cache (an image or package download) are admitted when each
629
+ filesystem the operation writes to has room for what it writes there plus the
630
+ `storage-reserve` setting (default `2G`), less what other quickchr operations
631
+ in progress have already claimed. The estimate is the boot disk
632
+ (192 MiB), plus an image download (256 MiB) and a package download (192 MiB)
633
+ when those are not cached yet, so a cold create needs about 2.6 GiB free and a
634
+ warm one about 2.2 GiB. When there is not enough, quickchr first evicts what the
635
+ cache can spare on that filesystem (oldest version first; never entries a
636
+ machine or an operation in progress needs, nor recently used ones), measures
637
+ again until there is room or nothing more can go, and only then refuses with `INSUFFICIENT_DISK_SPACE`,
638
+ naming the free space, what it needed, what it evicted and what it kept.
639
+ Restarting an existing machine, `stop`, `remove` and read-only commands are
640
+ never gated. The estimate covers creation, not a guest that later fills its
641
+ disk: extra disks and `--boot-size` are sparse and grow as the guest writes.
642
+
447
643
  #### `settings [print|get|set|reset] [key] [value] [--json]`
448
644
 
449
645
  Manage quickchr's own global preferences — repeated flags/defaults that
@@ -460,6 +656,7 @@ Precedence per key (highest wins): CLI flag > `QUICKCHR_<KEY>` env var >
460
656
  | `default-arch` | `add`/`start`'s `--arch`, wizard's arch prompt | `arm64`\|`x86`\|`auto` | `auto` (host native) |
461
657
  | `accel` | `add`/`start`'s `--accel`; every `detectAccel()` call in the run | `auto`\|`tcg`\|`hvf`\|`kvm` | `auto` (detect) |
462
658
  | `cache-max-size` | Auto-prune cap applied after each successful `start` | size string (e.g. `2G`, `512M`) | `2G` |
659
+ | `storage-reserve` | Free space creating a machine or filling the cache must leave, beyond what it writes | size string | `2G` |
463
660
  | `timeout-extra` | `start`'s `--timeout-extra`/`-T` when omitted | non-negative integer seconds | `0` |
464
661
  | `secure-login` | `add`/`start`'s `--secure-login` when neither `--secure-login` nor `--no-secure-login` is passed | boolean | `unset` (reported as `(unset)` — see note) |
465
662
 
@@ -478,7 +675,7 @@ the wizard's own recommended "managed login" highlighted). Giving it a real
478
675
  the setting resolves to exactly `true`.
479
676
 
480
677
  ```bash
481
- quickchr settings print # all 6 keys, resolved value + source
678
+ quickchr settings print # all 7 keys, resolved value + source
482
679
  quickchr settings set default-channel testing # write one key to quickchr.env
483
680
  quickchr settings get default-arch # print one key's resolved value
484
681
  quickchr settings reset default-channel # delete one managed line
@@ -486,7 +683,7 @@ quickchr settings reset # clear all managed lines
486
683
  ```
487
684
 
488
685
  Credentials (`--add-user`, `MIKROTIK_WEB_ACCOUNT`/`MIKROTIK_WEB_PASSWORD`) are
489
- never stored here — `settings` only manages the 6 keys above, and does not
686
+ never stored here — `settings` only manages the 7 keys above, and does not
490
687
  mutate any machine's `machine.json`.
491
688
 
492
689
  ### Global behavior
@@ -497,7 +694,7 @@ mutate any machine's `machine.json`.
497
694
  | `QUICKCHR_NO_PROMPT` | Force non-interactive mode (skips all wizards) |
498
695
  | `QUICKCHR_DEBUG` | Emit `[debug]` lines from the progress logger |
499
696
  | `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) |
697
+ | `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
698
  | `QUICKCHR_SERIAL_LOG` | `=1` tees the guest serial console to `<machineDir>/serial.log`. **Secret-bearing** — serial provisioning types the generated password in cleartext |
502
699
  | `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
700
  | `MIKROTIK_WEB_ACCOUNT`, `MIKROTIK_WEB_PASSWORD` | License credentials fallback |
@@ -552,10 +749,29 @@ QuickCHR.resolveVersion(channel: Channel): Promise<string>
552
749
  ```
553
750
 
554
751
  `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.
752
+ a port block, spawns QEMU, waits for readiness (REST on 7.x, a native-API
753
+ `/login` on RouterOS 6), and runs all provisioning steps before returning.
754
+ With `wait: false` (`--no-wait`) it returns once QEMU is running and leaves
755
+ readiness to the caller; provisioning options are refused in that mode.
756
+ On a boot timeout it stops QEMU, then removes a machine this call created,
757
+ resets an existing never-booted machine to its factory disk, and preserves a
758
+ previously booted one — see [Auto-cleanup on boot timeout](#auto-cleanup-on-boot-timeout).
759
+
760
+ **Concurrency.** Machines may be created and started in parallel, from one
761
+ `Promise.all` or from separate processes. Each creation chooses its port
762
+ block and NIC MACs under a data-dir-wide allocation lock and reserves them
763
+ until `machine.json` is written, so concurrent auto-allocated machines never
764
+ share a block. An explicit `portBase` is never moved: one that overlaps
765
+ another machine's block fails with `PORT_CONFLICT`. Per machine, every
766
+ lifecycle operation takes the machine lock (`machines/<name>/.lock/`):
767
+
768
+ - `stop()`, `clean()` and `remove()` **wait** for an operation already in
769
+ progress (up to the machine's boot timeout), then act on the state it left.
770
+ - A second `start()` of the same machine, `setDeviceMode()` and removing a
771
+ leftover directory **fail at once** with `MACHINE_LOCKED`, naming the
772
+ operation, pid and age of the holder.
773
+ - A machine running in the **foreground** holds the lock until its VM exits.
774
+ Nothing waits for it. Quit it from its console.
559
775
 
560
776
  `add()` is the same minus the QEMU spawn — useful when you want to
561
777
  materialize a machine and start it later (or hand the config to another
@@ -598,7 +814,7 @@ throws `INVALID_NAME` for a name that is not a usable path segment.
598
814
  |---|---|---|
599
815
  | Identity | `name`, `state`, `ports`, `restUrl`, `sshPort`, `portBase` | Static metadata persisted in `machine.json` |
600
816
  | 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 |
817
+ | Lifecycle | `waitForBoot()`, `waitFor(fn, ms?)`, `stop()`, `shutdown(opts?)`, `remove()`, `clean()`, `destroy()` | Boot readiness, custom polling, power-cut or guest-shutdown teardown |
602
818
  | Comms | `rest()`, `exec()`, `monitor()`, `serial()`, `qga()` | REST, RouterOS CLI, QEMU monitor, serial console, QGA (x86 only) |
603
819
  | Provisioning | `license()`, `setDeviceMode()`, `availablePackages()`, `installPackage()` | Post-boot configuration |
604
820
  | Files | `upload()`, `download()` | SCP transfer to/from the CHR |
@@ -615,11 +831,11 @@ interface ChrInstance {
615
831
  portBase: number; // start of this instance's reserved port block
616
832
  captureInterface: string; // "lo0" on macOS, "any" on Linux — for TZSP capture
617
833
  hostGatewayIp: string; // "10.0.2.2" — the host, as seen from inside the guest
618
- tzspGatewayIp: string; // deprecated alias for hostGatewayIp (#26)
619
834
 
620
835
  waitForBoot(timeoutMs?: number): Promise<boolean>;
621
836
  waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
622
- stop(): Promise<void>;
837
+ stop(): Promise<void>; // power cut: SIGTERM, then SIGKILL
838
+ shutdown(opts?: ShutdownOptions): Promise<ShutdownResult>; // /system/shutdown, bounded fallback
623
839
  remove(): Promise<void>;
624
840
  clean(): Promise<void>;
625
841
  destroy(): Promise<void>; // stop + remove
@@ -682,13 +898,14 @@ redact before logging or attaching to bug reports.
682
898
  | Field | Type | Default | Notes |
683
899
  |---|---|---|---|
684
900
  | `name` | string | auto | Must not start with `-` |
685
- | `version` | string | — | Mutually exclusive with `channel` |
901
+ | `version` | string | — | Wins over `channel`: passing both warns and uses `version` |
686
902
  | `channel` | Channel | `"stable"` | |
687
903
  | `arch` | `"arm64" \| "x86"` | host | |
688
904
  | `cpu` | number | `1` | |
689
905
  | `mem` | number | `512` (1024 cross-arch TCG) | MiB |
690
- | `background` | boolean | `true` | |
691
- | `bootDiskFormat` | `"raw" \| "qcow2"` | `"raw"` (auto-`qcow2` if `bootSize`) | |
906
+ | `background` | boolean | `true` | Where the serial console goes — not whether `start()` waits |
907
+ | `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`) |
908
+ | `bootDiskFormat` | `"raw" \| "qcow2"` | `"raw"` (auto-`qcow2` if `bootSize`) | The CLI always passes a format, defaulting to `qcow2`; the `"raw"` default applies to library callers only |
692
909
  | `bootSize` | string | — | e.g., `"512M"`, `"2G"`. Needs `qemu-img` |
693
910
  | `extraDisks` | string[] | `[]` | Each entry is a size string |
694
911
  | `networks` | NetworkSpecifier[] | `["user"]` | See §7 |
@@ -702,7 +919,7 @@ redact before logging or attaching to bug reports.
702
919
  | `secureLogin` | boolean | `false` | **`true` is the explicit trigger** for managed-account creation |
703
920
  | `license` | `LicenseInput` | — | `"p1"` / `"p10"` / `"unlimited"` or `LicenseOptions` |
704
921
  | `deviceMode` | `DeviceModeOptions` | — | `{mode?, enable?[], disable?[]}` |
705
- | `installDeps` | boolean | — | Auto-install missing OS deps |
922
+ | `installDeps` | boolean | — | **Deprecated, ignored.** Never installed anything; run `quickchr doctor` for install hints |
706
923
  | `dryRun` | boolean | `false` | Print QEMU command and exit |
707
924
  | `timeoutExtra` | number | `0` | Extra **milliseconds** added to boot timeout |
708
925
  | `onProgress` | `(msg) => void` | `console.log` | `[debug]` lines only when `QUICKCHR_DEBUG=1` |
@@ -843,10 +1060,43 @@ port mappings work on any 7.x.
843
1060
  Disabling admin **does not** lock the REST API — RouterOS's `expired`
844
1061
  flag only gates CLI/SSH/Winbox login, not REST.
845
1062
 
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.
1063
+ A failure in any step fails the whole first boot, and quickchr rolls it back
1064
+ rather than leave a half-provisioned guest: a machine `start` created in that
1065
+ call is removed (as a boot timeout removes it), and a machine created by `add`
1066
+ has its disk reset to the factory image and `machine.json` restored to what
1067
+ `add` recorded. Either way the retry is **the same command again**. With
1068
+ `QUICKCHR_PRESERVE_ON_FAILURE=1` the half-provisioned machine is kept, stopped,
1069
+ for inspection — `quickchr clean <name>` then resets it.
1070
+
1071
+ On the first `start` of a machine created by `add`, a provisioning option you
1072
+ leave out keeps what `add` recorded, and an explicit negation
1073
+ (`--no-install-all-packages`, `--no-disable-admin`, `--no-secure-login`,
1074
+ `--no-device-mode`) overrides it.
1075
+
1076
+ **The provisioning window closes at the machine's first boot** — not at
1077
+ `add`. A machine created with no provisioning options can still take them
1078
+ on its first `start`, which is where they are applied from `machine.json`.
1079
+ After that boot the guest may hold configuration quickchr did not put
1080
+ there, so provisioning cannot run against it: an option passed anyway is
1081
+ **refused**, never ignored, with `PROVISIONING_WINDOW_CLOSED` naming where
1082
+ the change can still be made. An option `machine.json` already records is
1083
+ reported as already applied rather than failing, so passing the same flags
1084
+ on every start is fine.
1085
+
1086
+ Routes that still work after the window has closed:
1087
+
1088
+ | change | route |
1089
+ |---|---|
1090
+ | license | `quickchr set <name> --license` |
1091
+ | device-mode | `quickchr set <name> --device-mode…` / `instance.setDeviceMode()` (power-cycles the VM) |
1092
+ | packages | `instance.installPackage()` (library; reboots the guest) |
1093
+ | anything else | `quickchr clean <name>` resets the disk to the factory image and reopens the window, at first-boot cost |
1094
+
1095
+ `clean()` retains provisioning *intent* — packages, device-mode, managed login — so the
1096
+ next `start` re-applies those on its own. It clears `user`, `disableAdmin` and
1097
+ `licenseLevel`, which are facts about the erased guest rather than intent, and a refused
1098
+ request is never persisted. **Repeat the original command after the reset** rather than
1099
+ a bare `quickchr start <name>`, or those options are silently left out.
850
1100
 
851
1101
  **Always read back what we wrote.** This catches version-specific drift
852
1102
  in REST responses and surfaces actionable errors instead of silent
@@ -1011,6 +1261,7 @@ restrictions, half-open SLiRP under TCG), see `docs/networking.md`.
1011
1261
 
1012
1262
  ```text
1013
1263
  $XDG_DATA_HOME/quickchr/ (defaults to ~/.local/share/quickchr/)
1264
+ ├── .allocate.lock/ held while a new machine picks its ports/MACs
1014
1265
  ├── cache/ Downloaded RouterOS images
1015
1266
  │ ├── chr-7.22.1.img.zip
1016
1267
  │ ├── chr-7.22.1.img
@@ -1018,15 +1269,15 @@ $XDG_DATA_HOME/quickchr/ (defaults to ~/.local/share/quickchr/)
1018
1269
  ├── machines/
1019
1270
  │ └── <name>/
1020
1271
  │ ├── machine.json Persisted MachineState
1021
- │ ├── disk.img | disk.qcow2
1022
- │ ├── extra-disk-*.qcow2
1272
+ │ ├── boot.qcow2 | disk.img boot disk: qcow2 (CLI default) or raw — one, not both
1273
+ │ ├── disk<N>.qcow2 extra disks
1023
1274
  │ ├── efi-vars.fd arm64 only (UEFI vars)
1024
1275
  │ ├── monitor.sock QEMU monitor
1025
1276
  │ ├── serial.sock serial console
1026
1277
  │ ├── qga.sock QGA (x86 only)
1027
1278
  │ ├── qemu.pid
1028
1279
  │ ├── qemu.log
1029
- │ ├── start-lock start serialization
1280
+ │ ├── .lock/ lifecycle lock + in-flight port/MAC reservation
1030
1281
  │ └── ssh/id_ed25519[.pub] SSH key (if managed user provisioned)
1031
1282
  └── networks/ Named L2 socket reservations
1032
1283
  └── <name>.json
@@ -1047,7 +1298,7 @@ var list.
1047
1298
  ├── cache\
1048
1299
  ├── machines\<name>\
1049
1300
  │ ├── machine.json
1050
- │ ├── disk.img
1301
+ │ ├── boot.qcow2 | disk.img
1051
1302
  │ ├── monitor (named pipe handle, no on-disk file)
1052
1303
  │ └── …
1053
1304
  └── …
@@ -1059,11 +1310,11 @@ is unset.
1059
1310
  ### Credentials (separate from data dir)
1060
1311
 
1061
1312
  - **Per-instance CHR credentials**: file in the user config dir
1062
- (`~/.config/quickchr/secrets.json` on POSIX; not the OS keychain — a
1313
+ (`~/.config/quickchr/com.quickchr.instance.json`; not the OS keychain — a
1063
1314
  Keychain dialog hangs in non-TTY contexts like tests and CI).
1064
1315
  - **MikroTik.com web account** (for license operations): tries
1065
1316
  `Bun.secrets` (OS keychain) when interactive, falls back to the same
1066
- config-file store otherwise.
1317
+ config-file store otherwise (`~/.config/quickchr/com.quickchr.mikrotik-web.json`).
1067
1318
 
1068
1319
  Both honor explicit CLI flags / env vars first.
1069
1320
 
@@ -1095,8 +1346,10 @@ If an extra mapping reuses a built-in service name such as `winbox`, it
1095
1346
  pins/replaces that service's host port rather than creating a second
1096
1347
  `ports.winbox` entry.
1097
1348
 
1098
- If port `9100` is in use, the next instance gets `9110`, etc. Manual
1099
- override via `--port-base`.
1349
+ If port `9100` is in use, the next instance gets `9110`, etc. Concurrent
1350
+ creations get distinct blocks (see **Concurrency** under [Static methods on `QuickCHR`](#static-methods-on-quickchr)).
1351
+ Manual override via `--port-base`, which is never remapped: a base that
1352
+ overlaps another machine's block is a `PORT_CONFLICT`.
1100
1353
 
1101
1354
  ---
1102
1355
 
@@ -1136,8 +1389,12 @@ Native (HVF/KVM): factor ~1 → 120s base.
1136
1389
  Cross-arch TCG: factor ~15 → up to 1800s.
1137
1390
  Package install adds a 2× multiplier (extra reboot cycle).
1138
1391
 
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
1392
+ If boot times out, QEMU is stopped. A previously booted machine is **preserved**
1393
+ with its disks, credentials and configuration; retry with `quickchr start <name>`
1394
+ or explicitly remove it. A machine created by this `start()` is removed, and an
1395
+ existing never-booted machine is reset to its factory disk and pre-launch record.
1396
+ `QUICKCHR_PRESERVE_ON_FAILURE=1` skips removal/reset for those first-boot cases.
1397
+ 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
1398
  persist a default via `quickchr settings set timeout-extra <seconds>` so
1142
1399
  every `start` picks it up without repeating the flag.
1143
1400
 
@@ -1154,7 +1411,8 @@ against:
1154
1411
  - Accidental credential leakage in commits, CI logs, CLI output.
1155
1412
 
1156
1413
  We **don't** protect against same-user code reading
1157
- `secrets.json`, `ps`-visible CLI args, or memory inspection.
1414
+ the `~/.config/quickchr/*.json` credential files, `ps`-visible CLI args, or memory
1415
+ inspection.
1158
1416
 
1159
1417
  ### Auth resolution (`resolveAuth`)
1160
1418
 
@@ -1186,7 +1444,7 @@ managed via `quickchr settings get|set|print|reset` (§3, Meta). Atomic writes
1186
1444
  preserve every other line (comments, blank lines, foreign vars) byte-for-byte.
1187
1445
  `reset <key>` deletes the line entirely rather than blanking it, since a blank
1188
1446
  `KEY=` line is still "set" to a bash-sourced file. Never stores credentials —
1189
- the 6 managed keys are all structural preferences, not secrets.
1447
+ the 7 managed keys are all structural preferences, not secrets.
1190
1448
 
1191
1449
  ### Environment variables consumed
1192
1450
 
@@ -1196,13 +1454,15 @@ the 6 managed keys are all structural preferences, not secrets.
1196
1454
  | `QUICKCHR_NO_PROMPT` | CLI dispatcher — forces non-interactive mode |
1197
1455
  | `QUICKCHR_DEBUG` | progress logger — emits `[debug]` lines |
1198
1456
  | `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 |
1457
+ | `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
1458
  | `QUICKCHR_SERIAL_LOG` | `qemu.ts` — adds `logfile=` to the serial chardev (writes cleartext provisioning passwords) |
1201
1459
  | `QUICKCHR_DEEP_BOOT_DIAGNOSTICS` | `guest-snapshot.ts` — allows the guest-mutating counting-rule probe during boot-failure capture |
1202
1460
  | `QUICKCHR_DEFAULT_CHANNEL` | `settings.ts` — env tier for the `default-channel` setting |
1203
1461
  | `QUICKCHR_DEFAULT_ARCH` | `settings.ts` — env tier for the `default-arch` setting |
1204
1462
  | `QUICKCHR_ACCEL` | `settings.ts` / `platform.ts` — env tier for the `accel` setting; forces `-accel` and bypasses detection |
1205
1463
  | `QUICKCHR_CACHE_MAX_SIZE` | `settings.ts` — env tier for the `cache-max-size` setting |
1464
+ | `QUICKCHR_STORAGE_RESERVE` | `settings.ts` — env tier for the `storage-reserve` setting |
1465
+ | `QUICKCHR_SHUTDOWN_TIMEOUT` | CLI `shutdown` grace budget in positive integer seconds; default 10, overridden by `--timeout` |
1206
1466
  | `QUICKCHR_TIMEOUT_EXTRA` | `settings.ts` — env tier for the `timeout-extra` setting |
1207
1467
  | `QUICKCHR_SECURE_LOGIN` | `settings.ts` — env tier for the `secure-login` setting |
1208
1468
  | `MIKROTIK_WEB_ACCOUNT` | license-renewal account (fallback) |
@@ -1251,6 +1511,29 @@ funnel through the same transport selector.
1251
1511
  > (see [`docs/centrs-interface.md`](./docs/centrs-interface.md)) — it never reads
1252
1512
  > `machine.json`, and neither should you.
1253
1513
 
1514
+ ### Exit status
1515
+
1516
+ `quickchr exec` exits 0 when the command was **delivered**, not when RouterOS
1517
+ accepted it. `/rest/execute` returns HTTP 200 with the rejection text in the same
1518
+ envelope as a success, so a refused command looks like this:
1519
+
1520
+ ```console
1521
+ $ quickchr exec my-chr "/user/add name=-2 group=read password=x"; echo "rc=$?"
1522
+ failure: invalid user name (/user/add; line 1)
1523
+ rc=0
1524
+ ```
1525
+
1526
+ The library is the same: `chr.exec()` resolves, and `ExecResult` has no failure
1527
+ field. quickchr does not pattern-match RouterOS error text, whose wording changes
1528
+ between versions (#142). When a script needs a pass/fail exit code, run the
1529
+ command through centrs, which validates it first and exits non-zero on a rejection.
1530
+ A write command needs `--yes` outside a TTY; without it centrs refuses with
1531
+ `usage/confirmation-required` (also exit 1) and sends nothing:
1532
+
1533
+ ```bash
1534
+ centrs execute --quickchr my-chr --yes "/user/add name=-2 group=read password=x"
1535
+ ```
1536
+
1254
1537
  ### Transports
1255
1538
 
1256
1539
  | `--via` | Source | Notes |
@@ -1366,7 +1649,13 @@ stored instance credentials, `state.user`, the managed SSH keypair under
1366
1649
  `<machineDir>/ssh/`, and `disableAdmin` — because the accounts they named
1367
1650
  went with the disk. The machine comes back as a factory CHR: `admin` with
1368
1651
  an empty password, which is what `rest()`, `exec()`, and `inspect` resolve
1369
- to afterwards. Nothing is re-provisioned automatically.
1652
+ to afterwards.
1653
+
1654
+ `clean()` performs no provisioning of its own, but it does reopen the provisioning
1655
+ window: the next `start` re-applies the retained intent (`packages`, `deviceMode`,
1656
+ `secureLogin`) as a first boot would. The credential facts above are cleared rather
1657
+ than retained, so an explicit `--add-user` / `--disable-admin` — and anything from a
1658
+ request that was refused, which is never persisted — has to be passed again.
1370
1659
 
1371
1660
  ### Snapshots
1372
1661
 
@@ -1410,13 +1699,13 @@ present, then exits `1`.
1410
1699
  | `MISSING_FIRMWARE` | edk2 UEFI firmware not installed (arm64) | install `qemu-efi-aarch64` / `edk2-aarch64` |
1411
1700
  | `MISSING_UNZIP` | image extraction needs `unzip` | install OS package |
1412
1701
  | `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** |
1702
+ | `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
1703
  | `DOWNLOAD_FAILED` | image fetch failed | retry; check connectivity to MikroTik download server |
1415
1704
  | `INVALID_VERSION` / `INVALID_ARCH` / `INVALID_NAME` / `INVALID_DISK_SIZE` | argument parse | fix the value |
1416
1705
  | `MACHINE_EXISTS` | name already in `machines/` | choose another name or `QuickCHR.get(name)` |
1417
- | `MACHINE_NOT_FOUND` | name not in `machines/` | `quickchr list` |
1706
+ | `MACHINE_NOT_FOUND` | name not in `machines/`, or a booted machine's disk file is missing | `quickchr list`; for a missing disk, restore it or `quickchr clean <name>` |
1418
1707
  | `MACHINE_RUNNING` / `MACHINE_STOPPED` | wrong lifecycle state | `start` or `stop` first |
1419
- | `MACHINE_LOCKED` | another `start` in progress | wait or pick another name |
1708
+ | `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
1709
  | `EXEC_FAILED` | RouterOS rejected the command (auth, syntax) | check creds, command syntax |
1421
1710
  | `PROCESS_FAILED` | QEMU spawn / reboot / verify failed | check `qemu.log`, host resources |
1422
1711
  | `SPAWN_FAILED` | exec/spawn of host binary failed | check binary exists + executable |
@@ -1424,8 +1713,10 @@ present, then exits `1`.
1424
1713
  | `QGA_TIMEOUT` | QGA daemon unreachable | KVM-only; not on macOS HVF or TCG |
1425
1714
  | `NETWORK_UNAVAILABLE` | provisioning needs `user` NIC, none present | add `--add-network user` |
1426
1715
  | `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 |
1428
- | `INSUFFICIENT_DISK_SPACE` | host data dir full | free space or move via `QUICKCHR_DATA_DIR` |
1716
+ | `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 |
1717
+ | `REST_UNAVAILABLE` | REST operation (`exec`, `get`, `rest()`) on a RouterOS 6 machine | use the native API — `centrs … --via native-api --no-validate` |
1718
+ | `INVALID_ARGUMENT` | a flag combination quickchr will not honour — e.g. `--no-wait` with provisioning | drop one of the two |
1719
+ | `INSUFFICIENT_DISK_SPACE` | data dir's filesystem has less than the operation's estimate plus `storage-reserve`, even after evicting what the cache could spare | free space, remove machines, lower `storage-reserve`, or move via `QUICKCHR_DATA_DIR` |
1429
1720
  | `STATE_ERROR` | snapshot on raw boot disk | recreate with `--boot-disk-format=qcow2` |
1430
1721
  | `INVALID_SIZE_STRING` | invalid cache/settings size string | use size syntax like `512M`, `2G`, or `1.5G` |
1431
1722
  | `INVALID_SETTING_KEY` | `settings get`/`set`/`reset` received an unrecognized key | `quickchr settings print` to see the managed keys |
@@ -1433,10 +1724,44 @@ present, then exits `1`.
1433
1724
 
1434
1725
  ### Auto-cleanup on boot timeout
1435
1726
 
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.
1727
+ If `start()` hits `BOOT_TIMEOUT`, quickchr stops the failing instance and then:
1728
+
1729
+ | Machine at launch entry | Timeout cleanup | Recovery |
1730
+ | --- | --- | --- |
1731
+ | Created by this `start()` call | Remove automatically | Repeat the original command |
1732
+ | Existing, never booted, provisioning window open | Reset factory disk and restore the pre-launch record, window open | `quickchr start <name>` |
1733
+ | 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>` |
1734
+
1735
+ `QUICKCHR_PRESERVE_ON_FAILURE=1` skips removal/reset for the first two cases and
1736
+ keeps the machine as it failed. Previously booted machines survive without the
1737
+ flag, including older records whose only boot-history marker is `lastStartedAt`.
1738
+ The diagnostic report is written outside the machine directory before cleanup;
1739
+ QEMU logs in a preserved machine also remain available. A configured guest does
1740
+ not need `clean` to retry: that command deliberately erases its configuration.
1741
+
1742
+ ### RouterOS 6 (boot-only)
1743
+
1744
+ A best-effort target for v6→v7 migration testing (tikoci/centrs#386), CLI only —
1745
+ the setup wizard does not offer it.
1746
+
1747
+ ```sh
1748
+ quickchr add --name lab6 --version 6.49.23 --arch x86
1749
+ quickchr start lab6
1750
+ centrs retrieve --quickchr lab6 /system/resource --via native-api --no-validate
1751
+ ```
1752
+
1753
+ - **x86 only.** MikroTik publishes no arm64 CHR for 6.x; `--arch arm64` is refused
1754
+ before download (`INVALID_ARCH`).
1755
+ - **Readiness is a native-API `/login`** on guest port 8728 (two consecutive
1756
+ answers), since 6.x has no REST. A 6.49.23 guest is ready in ~25 s under HVF.
1757
+ - **No provisioning.** Every provisioning flag, and the post-boot `set` routes, are
1758
+ refused before download where possible (`PROVISIONING_VERSION_UNSUPPORTED`). The
1759
+ `secure-login` setting is a default, not a request, so it does not apply to 6.x.
1760
+ - **No REST.** `exec` (REST/auto transport), `get`'s live groups and
1761
+ `ChrInstance.rest()` fail with `REST_UNAVAILABLE` rather than time out.
1762
+ `inspect` reports `rest-api` unavailable and `native-api` available.
1763
+ - `list`, `inspect`, `stop`, `remove` and `start --no-wait` work as for 7.x
1764
+ (checked live on 6.49.23).
1440
1765
 
1441
1766
  ### Where to look
1442
1767