@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/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
 
@@ -152,26 +156,39 @@ directory, downloads the RouterOS image (cached), and writes
152
156
  | `--no-api-ssl` | false | Exclude API-SSL port |
153
157
  | `--port-base <n>` | auto from 9100 | First port of the 10-port block |
154
158
 
155
- `--vmnet-shared` and `--vmnet-bridge <iface>` are deprecated aliases for
156
- `--add-network shared` / `--add-network bridged:<iface>`.
159
+ `--vmnet-shared` and `--vmnet-bridge <iface>` have been removed. Use
160
+ `--add-network shared` and `--add-network bridged:<iface>`; either old spelling is
161
+ an error naming its replacement, not a silently ignored flag.
157
162
 
158
163
  #### `start [name]`
159
164
 
160
165
  Boot a stopped machine, or create-and-boot in one command. Blocks until
161
- REST is ready (background mode, default), or attaches QEMU stdio to your
162
- 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.
163
170
 
164
171
  Additional flags:
165
172
 
166
173
  | Flag | Notes |
167
174
  |---|---|
168
- | `--all` | Start every stopped machine in background |
169
- | `--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` |
170
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` |
171
179
  | `--install-deps` | Auto-install missing host deps (calls package manager) |
172
180
  | `--timeout-extra <s>` / `-T <s>` | Add extra seconds to computed boot timeout |
173
181
  | `--dry-run` | Print resolved options and QEMU command, exit |
174
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
+
175
192
  Foreground mode shortcuts (QEMU mux):
176
193
 
177
194
  | Key | Action |
@@ -187,9 +204,74 @@ machine state.
187
204
 
188
205
  #### `stop [name | --all]`
189
206
 
190
- Graceful shutdown via QEMU monitor. Updates `state.status = "stopped"`
191
- and unregisters socket-named networks. Listing-mode if no name and not
192
- `--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.
193
275
 
194
276
  #### `remove [name | --all]` (alias `rm`)
195
277
 
@@ -206,10 +288,17 @@ flag. The next `start` therefore logs in as factory `admin` with an empty
206
288
  password. EFI vars are deliberately **kept** (they store boot order, not
207
289
  guest state; wiping them costs an arm64 device rescan).
208
290
 
209
- `clean` does not re-provision: the boot options in `machine.json`
210
- (`packages`, `deviceMode`, `secureLogin`) are re-applied only on a machine
211
- that has never started, so a cleaned machine comes back factory-fresh. Pass
212
- 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`.
213
302
 
214
303
  ### Inspection
215
304
 
@@ -219,11 +308,27 @@ Without a name: tabular summary of every machine. With a name: detailed
219
308
  view including credentials and connection tips. `--json` for
220
309
  machine-readable output.
221
310
 
311
+ A machine whose `machine.json` cannot be read — unreadable on disk, not valid JSON, or
312
+ valid JSON that is not usable machine state — still appears, as a row marked
313
+ `unreadable` followed by `quickchr remove <name>`. A `saveMachine()` interrupted by a
314
+ disk-full or a power loss is the usual cause. The listing exits 0: it succeeded, and the
315
+ broken entry is named rather than silently dropped. In `--json` that entry is
316
+ `{"name": …, "status": "unreadable", "error": …}` with no machine fields.
317
+
318
+ Naming that machine in a command that has to *use* its state — `list <name>`, `stop`,
319
+ `clean`, `env` — fails with `STATE_ERROR` naming the file, because a lookup you asked
320
+ for by name is not an enumeration. `quickchr remove <name>` is the exception, and has to
321
+ be: it is the command whose job is to clear that state, so it treats an unreadable
322
+ machine as a directory to delete rather than state to load.
323
+
222
324
  #### `get <name> [license | device-mode | admin] [--json]`
223
325
 
224
326
  Live REST query. Hits the running CHR and returns the requested group
225
327
  (or all if no group specified). Requires the machine to be running.
226
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
+
227
332
  #### `inspect <name> [--json]`
228
333
 
229
334
  Stable, versioned (`descriptorVersion: 1`) connection descriptor for a
@@ -265,8 +370,9 @@ Tail the per-machine `qemu.log`. `--follow` streams new output;
265
370
 
266
371
  `networks` enumerates host interfaces and their alias resolution
267
372
  (`wifi`, `ethernet`, `auto`). `networks sockets` lists registered
268
- named L2 sockets; `networks sockets create <name>` reserves a port for a
269
- new tunnel.
373
+ named L2 sockets and the transport each one uses; `networks sockets create <name>`
374
+ registers a new link, taking `--mode` to pick that transport (the default needs no
375
+ port at all).
270
376
 
271
377
  #### `version [--json]`
272
378
 
@@ -313,11 +419,43 @@ Apply or renew a CHR trial license on a running instance. Resolves
313
419
  MikroTik.com credentials from `--license-account`/`--license-password`,
314
420
  the env vars, or the secret store. Requires RouterOS ≥ 7.20.8.
315
421
 
316
- #### `set <name> <subcommand>`
422
+ #### `set <name> [--license | --device-mode <m>] [--device-mode-enable <f>] [--device-mode-disable <f>]`
317
423
 
318
- Currently exposes a small surface for in-place changes (e.g.,
319
- `set <name> license …`). The `set/get` architecture is still being
320
- designed; expect additions.
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.
428
+
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.
321
459
 
322
460
  #### `snapshot <name> <list | save [snapname] | load <snapname> | delete <snapname>>` (alias `snap`)
323
461
 
@@ -357,7 +495,7 @@ another machine, view status, or quit.
357
495
  | 9 | Extra blank disks (repeatable) | none | `--add-disk` |
358
496
  | 10 | Additional networks (macOS/Linux) | user-mode only | `--add-network` |
359
497
  | 11 | Provisioning (packages, device-mode, user, license) | optional | various |
360
- | 12 | Run mode: background / foreground | background | `--bg` / `--fg` |
498
+ | 12 | Run mode: background / foreground | background | (default) / `--fg` |
361
499
  | 13 | Final confirmation | — | — |
362
500
 
363
501
  Steps 7–9 are skipped when `qemu-img` is absent (a warning is shown). Step 10
@@ -392,6 +530,43 @@ shell completions if not yet configured.
392
530
 
393
531
  Print or install shell completions. With no arg, detects current shell.
394
532
 
533
+ #### `cache [add|key|list|prune|clear]`
534
+
535
+ Manage downloaded CHR images. `cache add` resolves and downloads an image only;
536
+ it does not check QEMU, create a machine, or boot RouterOS. A cache hit is an
537
+ idempotent success.
538
+
539
+ ```bash
540
+ quickchr cache add --channel stable --arch x86
541
+ quickchr cache add --version 7.24.4 --arch arm64 --json
542
+ quickchr cache key --channel stable --arch x86
543
+ # dir=/Users/me/.local/share/quickchr/cache
544
+ # version=7.24.4
545
+ # arch=x86
546
+ quickchr cache key --version 7.24.4 --arch x86 --json
547
+ # {"dir":".../quickchr/cache","version":"7.24.4","arch":"x86"}
548
+ ```
549
+
550
+ `--channel` and `--version` are mutually exclusive. When neither is supplied,
551
+ `default-channel` applies; both subcommands use `default-arch`. A pinned
552
+ version makes no network request in `cache key`. If channel resolution is
553
+ offline, `cache key` prints the stable `unresolved` sentinel and exits 0 so a CI
554
+ cache lookup cannot red the job; `cache add` fails because it cannot download an
555
+ unknown image. Without `--json`, `cache key` emits `key=value` lines that can be
556
+ appended directly to `$GITHUB_OUTPUT`.
557
+
558
+ ```yaml
559
+ - id: chr-cache
560
+ run: quickchr cache key --channel stable --arch x86 >> "$GITHUB_OUTPUT"
561
+ - uses: actions/cache/restore@v5
562
+ with:
563
+ path: ${{ steps.chr-cache.outputs.dir }}
564
+ key: chr-${{ runner.os }}-${{ steps.chr-cache.outputs.version }}
565
+ ```
566
+
567
+ `--json` intentionally emits one JSON object for programmatic parsing; use the
568
+ default output form when writing step outputs directly.
569
+
395
570
  #### `settings [print|get|set|reset] [key] [value] [--json]`
396
571
 
397
572
  Manage quickchr's own global preferences — repeated flags/defaults that
@@ -445,10 +620,11 @@ mutate any machine's `machine.json`.
445
620
  | `QUICKCHR_NO_PROMPT` | Force non-interactive mode (skips all wizards) |
446
621
  | `QUICKCHR_DEBUG` | Emit `[debug]` lines from the progress logger |
447
622
  | `QUICKCHR_INTEGRATION` | Required to run `test/integration/` |
448
- | `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 |
449
624
  | `QUICKCHR_SERIAL_LOG` | `=1` tees the guest serial console to `<machineDir>/serial.log`. **Secret-bearing** — serial provisioning types the generated password in cleartext |
450
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` |
451
626
  | `MIKROTIK_WEB_ACCOUNT`, `MIKROTIK_WEB_PASSWORD` | License credentials fallback |
627
+ | `QUICKCHR_NO_TIPS` | `=1` suppresses the dim `tip:` lines quickchr prints on stderr |
452
628
  | `NO_COLOR` | Disable ANSI styling |
453
629
 
454
630
  `--json` is honored on `list`, `status`, `get`, `env`, `snapshot`, and a
@@ -456,6 +632,26 @@ few others; `inspect` is always JSON and accepts `--json` for parity. Check
456
632
  `quickchr help <command>`. Errors are caught at the top level and exit `1`
457
633
  with a `[code] message` line plus the install hint when present.
458
634
 
635
+ **Help.** `--help` / `-h` on any subcommand prints that subcommand's help, exits `0`,
636
+ and touches no state — including `quickchr add --help`, which is a machine-creating
637
+ command. Anything after a bare `--` is your payload and is never scanned for flags:
638
+
639
+ ```bash
640
+ quickchr exec my-chr -- ":put [/system/resource/get version]"
641
+ ```
642
+
643
+ **Unknown flags.** `add` and `start` reject a flag they do not know rather than
644
+ ignoring it, and suggest the closest match. The read-only commands still tolerate
645
+ stray arguments.
646
+
647
+ **Names.** Machine and named-socket names may contain letters, digits, `.`, `_`
648
+ and `-`, must start with a letter or digit, and are capped at 64 characters. Both
649
+ become a directory or file name under the data dir (§8).
650
+
651
+ **Tips.** quickchr prints an occasional one-line `tip:` on **stderr** pointing at a
652
+ better-suited command or tool. They never go to stdout, so piping `--json` output is
653
+ unaffected. `QUICKCHR_NO_TIPS=1` turns them off.
654
+
459
655
  ---
460
656
 
461
657
  ## 4. Library API reference
@@ -471,15 +667,37 @@ QuickCHR.start(opts?: StartOptions): Promise<ChrInstance>
471
667
  QuickCHR.add(opts?: StartOptions): Promise<MachineState>
472
668
  QuickCHR.list(): MachineState[]
473
669
  QuickCHR.get(name: string): ChrInstance | null
670
+ QuickCHR.listUnreadable(): Array<{ name: string; error: string }>
671
+ QuickCHR.listOrphans(): string[]
672
+ QuickCHR.removeOrphan(name: string): boolean
474
673
  QuickCHR.doctor(): Promise<DoctorResult>
475
674
  QuickCHR.resolveVersion(channel: Channel): Promise<string>
476
675
  ```
477
676
 
478
677
  `start()` creates the machine if needed, downloads the image, allocates
479
- a port block, spawns QEMU, waits for REST readiness, and runs all
480
- provisioning steps before returning. It auto-cleans the machine if boot
481
- times out (calls `stop()` then `remove()`). It acquires `start-lock` to
482
- 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.
483
701
 
484
702
  `add()` is the same minus the QEMU spawn — useful when you want to
485
703
  materialize a machine and start it later (or hand the config to another
@@ -489,6 +707,31 @@ process).
489
707
  loads `machine.json`, refreshes PID liveness, and returns a runtime
490
708
  handle. `list()` is `get()` for everything in `machines/`.
491
709
 
710
+ `listOrphans()` / `removeOrphan()` cover the gap between the two: a directory under
711
+ `machines/` with no readable `machine.json`, left by a create that a crash or a
712
+ SIGKILL interrupted. It still occupies the name, so `add()` refuses it, but no
713
+ machine answers to it.
714
+
715
+ How `get()` and `list()` behave depends on *why* the state is unreadable, and the
716
+ two differ — a targeted lookup fails, an enumeration does not:
717
+
718
+ | State | `get(name)` | `list()` |
719
+ |---|---|---|
720
+ | `machine.json` missing | `null` | omits it |
721
+ | `machine.json` present but unreadable (read error, invalid JSON, or valid JSON that is not machine state) | **throws** `STATE_ERROR`, naming the file and `quickchr remove <name>` | skips it; `listUnreadable()` names it, and `quickchr list` shows it as a row |
722
+
723
+ `listUnreadable()` returns `{ name, error }` for every machine in the second row — the
724
+ file is present but cannot be turned into state, whether because the read failed, the
725
+ JSON is invalid, or the parsed value is not a machine — so nothing is dropped without
726
+ being reported somewhere. A
727
+ directory with *no* `machine.json` is not in it; that is a create that never finished,
728
+ and `listOrphans()` covers it.
729
+
730
+ `listOrphans()` and `removeOrphan()` treat both the same — unreadable is unreadable —
731
+ so `quickchr remove <name>` clears either. `removeOrphan()` returns `false` for a real
732
+ machine (use `get(name)?.remove()` for those) and for a name that does not exist, and
733
+ throws `INVALID_NAME` for a name that is not a usable path segment.
734
+
492
735
  ### `ChrInstance`
493
736
 
494
737
  **At a glance** — every method on a running instance:
@@ -496,8 +739,8 @@ handle. `list()` is `get()` for everything in `machines/`.
496
739
  | Group | Member | Purpose |
497
740
  |---|---|---|
498
741
  | Identity | `name`, `state`, `ports`, `restUrl`, `sshPort`, `portBase` | Static metadata persisted in `machine.json` |
499
- | Capture | `captureInterface`, `tzspGatewayIp` | Platform-correct values for TZSP capture (`tshark -i …`) and RouterOS streaming target |
500
- | Lifecycle | `waitForBoot()`, `waitFor(fn, ms?)`, `stop()`, `remove()`, `clean()`, `destroy()` | Boot readiness, custom polling, teardown |
742
+ | Capture | `captureInterface`, `hostGatewayIp` | Platform-correct values for TZSP capture (`tshark -i …`) and the guest→host address any UDP emitter targets |
743
+ | Lifecycle | `waitForBoot()`, `waitFor(fn, ms?)`, `stop()`, `shutdown(opts?)`, `remove()`, `clean()`, `destroy()` | Boot readiness, custom polling, power-cut or guest-shutdown teardown |
501
744
  | Comms | `rest()`, `exec()`, `monitor()`, `serial()`, `qga()` | REST, RouterOS CLI, QEMU monitor, serial console, QGA (x86 only) |
502
745
  | Provisioning | `license()`, `setDeviceMode()`, `availablePackages()`, `installPackage()` | Post-boot configuration |
503
746
  | Files | `upload()`, `download()` | SCP transfer to/from the CHR |
@@ -513,11 +756,12 @@ interface ChrInstance {
513
756
  sshPort: number;
514
757
  portBase: number; // start of this instance's reserved port block
515
758
  captureInterface: string; // "lo0" on macOS, "any" on Linux — for TZSP capture
516
- tzspGatewayIp: string; // "10.0.2.2" — QEMU slirp host gateway
759
+ hostGatewayIp: string; // "10.0.2.2" — the host, as seen from inside the guest
517
760
 
518
761
  waitForBoot(timeoutMs?: number): Promise<boolean>;
519
762
  waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
520
- stop(): Promise<void>;
763
+ stop(): Promise<void>; // power cut: SIGTERM, then SIGKILL
764
+ shutdown(opts?: ShutdownOptions): Promise<ShutdownResult>; // /system/shutdown, bounded fallback
521
765
  remove(): Promise<void>;
522
766
  clean(): Promise<void>;
523
767
  destroy(): Promise<void>; // stop + remove
@@ -585,7 +829,8 @@ redact before logging or attaching to bug reports.
585
829
  | `arch` | `"arm64" \| "x86"` | host | |
586
830
  | `cpu` | number | `1` | |
587
831
  | `mem` | number | `512` (1024 cross-arch TCG) | MiB |
588
- | `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`) |
589
834
  | `bootDiskFormat` | `"raw" \| "qcow2"` | `"raw"` (auto-`qcow2` if `bootSize`) | |
590
835
  | `bootSize` | string | — | e.g., `"512M"`, `"2G"`. Needs `qemu-img` |
591
836
  | `extraDisks` | string[] | `[]` | Each entry is a size string |
@@ -643,7 +888,7 @@ const chr = await QuickCHR.start({
643
888
  ```
644
889
 
645
890
  To **receive** UDP a guest sends, no forward is needed — bind an *unconnected*
646
- host socket and have the guest send to `chr.tzspGatewayIp` (`10.0.2.2`); see
891
+ host socket and have the guest send to `chr.hostGatewayIp` (`10.0.2.2`); see
647
892
  [`docs/networking-recipes.md`](docs/networking-recipes.md) §2 and
648
893
  [`examples/udp-gateway/`](examples/udp-gateway/).
649
894
 
@@ -684,6 +929,24 @@ The entry also re-exports the lower-level version helpers for pre-flight checks:
684
929
  `isProvisioningSupportedVersion` (validate a version against the provisioning floor
685
930
  before calling `start()`).
686
931
 
932
+ ### Cache helpers
933
+
934
+ `cacheKey()` and `cacheAdd()` are the library equivalents of the CLI cache
935
+ subcommands:
936
+
937
+ ```ts
938
+ import { cacheAdd, cacheKey, CACHE_VERSION_UNRESOLVED } from "@tikoci/quickchr";
939
+
940
+ const { dir, version, arch } = await cacheKey({ channel: "stable", arch: "x86" });
941
+ if (version !== CACHE_VERSION_UNRESOLVED) {
942
+ await cacheAdd({ version, arch });
943
+ }
944
+ ```
945
+
946
+ `cacheKey({ version })` validates the pin without resolving a channel.
947
+ `cacheAdd()` returns `{ dir, version, arch, path, cacheHit }`; it only downloads
948
+ and extracts the image and never creates or boots a machine.
949
+
687
950
  ---
688
951
 
689
952
  ## 5. Provisioning pipeline
@@ -723,10 +986,43 @@ port mappings work on any 7.x.
723
986
  Disabling admin **does not** lock the REST API — RouterOS's `expired`
724
987
  flag only gates CLI/SSH/Winbox login, not REST.
725
988
 
726
- Each step is independent. A failure in step N stops further steps but
727
- does not roll back N–1. Re-running `start` re-applies pending
728
- provisioning from `machine.json` only if the machine had not been fully
729
- 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.
730
1026
 
731
1027
  **Always read back what we wrote.** This catches version-specific drift
732
1028
  in REST responses and surfaces actionable errors instead of silent
@@ -810,7 +1106,7 @@ Picking among the specifiers/forwards by *goal* — full guide with recipes in
810
1106
  |---|---|---|---|---|
811
1107
  | `user` | SLiRP user-mode NAT, hostfwd port mapping | no | no | via hostfwd ports only |
812
1108
  | `socket::<name>` | L2 socket pair between two VMs sharing the name | no | yes | no |
813
- | `socket-listen:<port>` / `socket-connect:<port>` / `socket-mcast:<group>:<port>` | low-level QEMU socket netdevs | no | yes | no |
1109
+ | `socket:listen:<port>` / `socket:connect:<port>` / `socket:mcast:<group>:<port>` | low-level QEMU socket netdevs | no | yes | no |
814
1110
  | `shared` | NAT'd L3 with DHCP from the host | yes (or `socket_vmnet` rootless) | yes | yes |
815
1111
  | `bridged:<iface>` | L2 bridged onto host iface | yes (or `socket_vmnet` rootless) | yes | yes |
816
1112
  | `tap:<iface>` | pre-created TAP, no quickchr setup | depends | yes | yes |
@@ -821,15 +1117,64 @@ Resolution chains:
821
1117
  - **`shared`** on macOS: socket_vmnet daemon → vmnet-shared (root-only QEMU). On Linux: pre-created TAP owned by the user, else root-created TAP+bridge.
822
1118
  - **`bridged:<iface>`** on macOS: socket_vmnet bridged → vmnet-bridged (root). On Linux: TAP attached to a bridge containing the named iface.
823
1119
 
1120
+ > The table lists the strings the **CLI** takes. The library's equivalents are the
1121
+ > `NetworkSpecifier` type names, which are spelled with a dash rather than a colon:
1122
+ > `socket:listen:5000` on the command line is `{ type: "socket-listen", port: 5000 }`
1123
+ > in TypeScript.
1124
+
824
1125
  ### Named L2 sockets
825
1126
 
826
- `quickchr networks sockets create lab-sw1` reserves a port and stores
827
- metadata in `<dataDir>/networks/lab-sw1.json`. Two machines using
828
- `--add-network socket::lab-sw1` form an L2 tunnel: the first to start
829
- listens on the port; the second connects.
1127
+ `quickchr networks sockets create lab-sw1` registers a link in
1128
+ `<dataDir>/networks/lab-sw1.json`, and two machines using
1129
+ `--add-network socket::lab-sw1` join it. A link carries a **transport**, fixed when
1130
+ the name is created and printed by `start`, `networks sockets` and `create` itself —
1131
+ you never have to open a file under the data dir to find out what your link is.
1132
+
1133
+ | `--mode` | Transport | Machines | Start order | Notes |
1134
+ |---|---|---|---|---|
1135
+ | `dgram` (default on macOS/Linux) | a pair of unix datagram sockets | 2 | either first | no host port, no UDP syscalls; needs QEMU 7.2+ |
1136
+ | `listen-connect` (default on Windows) | TCP pair on loopback | 2 | the listening end first | the first machine to *join* takes the listening end and keeps it; a connector never retries, so start that machine first |
1137
+ | `mcast` | UDP multicast on `230.0.0.1` | any number | any | the only N-way segment — **and the only one that fails silently** |
1138
+
1139
+ `mcast` is worth the warning it prints at create time. Three separate hazards, all with
1140
+ the same signature — interfaces up, addresses assigned, 100% packet loss, nothing logged:
1141
+
1142
+ - **It does not deliver on macOS.** QEMU's multicast socket omits `SO_REUSEPORT`, which
1143
+ macOS/BSD require before two sockets on one group both receive.
1144
+ - **It is refused where unconnected UDP sends are blocked** — seccomp-filtered sandboxes
1145
+ and some CI containers, including on Linux. The group still joins cleanly; only the
1146
+ send is denied.
1147
+ - **It is not confined to your machine.** quickchr does not pass `localaddr=`, so the
1148
+ group rides the host's default multicast interface rather than loopback. Another
1149
+ machine on your LAN using the same group joins your segment.
1150
+
1151
+ Use it when you genuinely need more than two machines on one segment, give it a group of
1152
+ its own, and check the link before you trust it. For two machines, `dgram` is both
1153
+ confined to the filesystem and able to refuse a bad configuration up front.
1154
+
1155
+ The two-machine modes hold their ends in named slots, so a machine that stops and
1156
+ starts again comes back on the same end. That is what makes a restart safe, and it is
1157
+ also why `listen-connect` has a start order at all: the roles are fixed when the two
1158
+ machines first join, and from then on the listening end has to be running before the
1159
+ connecting one starts, because QEMU's `connect=` fails silently and never retries.
1160
+ `dgram` has no such constraint — either end may start first, every time. A third machine is refused, with a pointer
1161
+ to `--mode mcast`:
1162
+
1163
+ ```console
1164
+ $ quickchr start --name chr3 --add-network socket::lab-sw1
1165
+ Error [NETWORK_UNAVAILABLE]: Named socket "lab-sw1" is a dgram link and carries 2
1166
+ machines; chr1 and chr2 already hold both ends. Stop one of them, or create an N-way
1167
+ segment with 'quickchr networks sockets create <name> --mode mcast'.
1168
+ ```
1169
+
1170
+ quickchr unregisters socket members on `stop`/`remove`/`clean`, which frees the end
1171
+ for another machine. `networks sockets remove` refuses a link that still has members —
1172
+ removing it unlinks the endpoint sockets, which would kill a running link without
1173
+ saying so.
830
1174
 
831
- quickchr unregisters socket members on `stop`/`remove`/`clean` so port
832
- allocation can recycle.
1175
+ Joining a link takes a registry lock, so `quickchr start a & quickchr start b &` is
1176
+ safe: without it both machines claim the same endpoint and are handed the same socket
1177
+ path.
833
1178
 
834
1179
  For platform internals (vmnet quirks, TAP creation, bridged-mode
835
1180
  restrictions, half-open SLiRP under TCG), see `docs/networking.md`.
@@ -842,6 +1187,7 @@ restrictions, half-open SLiRP under TCG), see `docs/networking.md`.
842
1187
 
843
1188
  ```text
844
1189
  $XDG_DATA_HOME/quickchr/ (defaults to ~/.local/share/quickchr/)
1190
+ ├── .allocate.lock/ held while a new machine picks its ports/MACs
845
1191
  ├── cache/ Downloaded RouterOS images
846
1192
  │ ├── chr-7.22.1.img.zip
847
1193
  │ ├── chr-7.22.1.img
@@ -857,7 +1203,7 @@ $XDG_DATA_HOME/quickchr/ (defaults to ~/.local/share/quickchr/)
857
1203
  │ ├── qga.sock QGA (x86 only)
858
1204
  │ ├── qemu.pid
859
1205
  │ ├── qemu.log
860
- │ ├── start-lock start serialization
1206
+ │ ├── .lock/ lifecycle lock + in-flight port/MAC reservation
861
1207
  │ └── ssh/id_ed25519[.pub] SSH key (if managed user provisioned)
862
1208
  └── networks/ Named L2 socket reservations
863
1209
  └── <name>.json
@@ -890,11 +1236,11 @@ is unset.
890
1236
  ### Credentials (separate from data dir)
891
1237
 
892
1238
  - **Per-instance CHR credentials**: file in the user config dir
893
- (`~/.config/quickchr/secrets.json` on POSIX; not the OS keychain — a
1239
+ (`~/.config/quickchr/com.quickchr.instance.json`; not the OS keychain — a
894
1240
  Keychain dialog hangs in non-TTY contexts like tests and CI).
895
1241
  - **MikroTik.com web account** (for license operations): tries
896
1242
  `Bun.secrets` (OS keychain) when interactive, falls back to the same
897
- config-file store otherwise.
1243
+ config-file store otherwise (`~/.config/quickchr/com.quickchr.mikrotik-web.json`).
898
1244
 
899
1245
  Both honor explicit CLI flags / env vars first.
900
1246
 
@@ -926,8 +1272,10 @@ If an extra mapping reuses a built-in service name such as `winbox`, it
926
1272
  pins/replaces that service's host port rather than creating a second
927
1273
  `ports.winbox` entry.
928
1274
 
929
- If port `9100` is in use, the next instance gets `9110`, etc. Manual
930
- 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`.
931
1279
 
932
1280
  ---
933
1281
 
@@ -967,8 +1315,12 @@ Native (HVF/KVM): factor ~1 → 120s base.
967
1315
  Cross-arch TCG: factor ~15 → up to 1800s.
968
1316
  Package install adds a 2× multiplier (extra reboot cycle).
969
1317
 
970
- If boot times out, the machine is **automatically stopped + removed**.
971
- 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
972
1324
  persist a default via `quickchr settings set timeout-extra <seconds>` so
973
1325
  every `start` picks it up without repeating the flag.
974
1326
 
@@ -985,7 +1337,8 @@ against:
985
1337
  - Accidental credential leakage in commits, CI logs, CLI output.
986
1338
 
987
1339
  We **don't** protect against same-user code reading
988
- `secrets.json`, `ps`-visible CLI args, or memory inspection.
1340
+ the `~/.config/quickchr/*.json` credential files, `ps`-visible CLI args, or memory
1341
+ inspection.
989
1342
 
990
1343
  ### Auth resolution (`resolveAuth`)
991
1344
 
@@ -1027,13 +1380,14 @@ the 6 managed keys are all structural preferences, not secrets.
1027
1380
  | `QUICKCHR_NO_PROMPT` | CLI dispatcher — forces non-interactive mode |
1028
1381
  | `QUICKCHR_DEBUG` | progress logger — emits `[debug]` lines |
1029
1382
  | `QUICKCHR_INTEGRATION` | `bun:test` gate for `test/integration/` |
1030
- | `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 |
1031
1384
  | `QUICKCHR_SERIAL_LOG` | `qemu.ts` — adds `logfile=` to the serial chardev (writes cleartext provisioning passwords) |
1032
1385
  | `QUICKCHR_DEEP_BOOT_DIAGNOSTICS` | `guest-snapshot.ts` — allows the guest-mutating counting-rule probe during boot-failure capture |
1033
1386
  | `QUICKCHR_DEFAULT_CHANNEL` | `settings.ts` — env tier for the `default-channel` setting |
1034
1387
  | `QUICKCHR_DEFAULT_ARCH` | `settings.ts` — env tier for the `default-arch` setting |
1035
1388
  | `QUICKCHR_ACCEL` | `settings.ts` / `platform.ts` — env tier for the `accel` setting; forces `-accel` and bypasses detection |
1036
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` |
1037
1391
  | `QUICKCHR_TIMEOUT_EXTRA` | `settings.ts` — env tier for the `timeout-extra` setting |
1038
1392
  | `QUICKCHR_SECURE_LOGIN` | `settings.ts` — env tier for the `secure-login` setting |
1039
1393
  | `MIKROTIK_WEB_ACCOUNT` | license-renewal account (fallback) |
@@ -1066,6 +1420,22 @@ derived Basic auth header. Do not commit or publicly log these values.
1066
1420
  `quickchr exec <name> <command>` and `chr.exec(command, opts)` both
1067
1421
  funnel through the same transport selector.
1068
1422
 
1423
+ > **`exec` does not validate your command.** It is a pipe: whatever you pass is what
1424
+ > RouterOS is asked to run, and a malformed command comes back as RouterOS's own
1425
+ > rejection. For anything beyond a one-liner, use
1426
+ > [centrs](https://github.com/tikoci/centrs), which checks a command against the
1427
+ > device's own command tree *before* running it and has per-path help:
1428
+ >
1429
+ > ```bash
1430
+ > centrs retrieve --quickchr my-chr /system/resource # read state
1431
+ > centrs execute --quickchr my-chr <command> # read/write command
1432
+ > centrs explain <command> # analyze without running
1433
+ > ```
1434
+ >
1435
+ > centrs resolves the machine's live connection facts through `quickchr inspect`
1436
+ > (see [`docs/centrs-interface.md`](./docs/centrs-interface.md)) — it never reads
1437
+ > `machine.json`, and neither should you.
1438
+
1069
1439
  ### Transports
1070
1440
 
1071
1441
  | `--via` | Source | Notes |
@@ -1181,7 +1551,13 @@ stored instance credentials, `state.user`, the managed SSH keypair under
1181
1551
  `<machineDir>/ssh/`, and `disableAdmin` — because the accounts they named
1182
1552
  went with the disk. The machine comes back as a factory CHR: `admin` with
1183
1553
  an empty password, which is what `rest()`, `exec()`, and `inspect` resolve
1184
- 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.
1185
1561
 
1186
1562
  ### Snapshots
1187
1563
 
@@ -1225,13 +1601,13 @@ present, then exits `1`.
1225
1601
  | `MISSING_FIRMWARE` | edk2 UEFI firmware not installed (arm64) | install `qemu-efi-aarch64` / `edk2-aarch64` |
1226
1602
  | `MISSING_UNZIP` | image extraction needs `unzip` | install OS package |
1227
1603
  | `PORT_CONFLICT` | no free 10-port block | stop a machine or set `--port-base` |
1228
- | `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` |
1229
1605
  | `DOWNLOAD_FAILED` | image fetch failed | retry; check connectivity to MikroTik download server |
1230
1606
  | `INVALID_VERSION` / `INVALID_ARCH` / `INVALID_NAME` / `INVALID_DISK_SIZE` | argument parse | fix the value |
1231
1607
  | `MACHINE_EXISTS` | name already in `machines/` | choose another name or `QuickCHR.get(name)` |
1232
1608
  | `MACHINE_NOT_FOUND` | name not in `machines/` | `quickchr list` |
1233
1609
  | `MACHINE_RUNNING` / `MACHINE_STOPPED` | wrong lifecycle state | `start` or `stop` first |
1234
- | `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 |
1235
1611
  | `EXEC_FAILED` | RouterOS rejected the command (auth, syntax) | check creds, command syntax |
1236
1612
  | `PROCESS_FAILED` | QEMU spawn / reboot / verify failed | check `qemu.log`, host resources |
1237
1613
  | `SPAWN_FAILED` | exec/spawn of host binary failed | check binary exists + executable |
@@ -1239,7 +1615,9 @@ present, then exits `1`.
1239
1615
  | `QGA_TIMEOUT` | QGA daemon unreachable | KVM-only; not on macOS HVF or TCG |
1240
1616
  | `NETWORK_UNAVAILABLE` | provisioning needs `user` NIC, none present | add `--add-network user` |
1241
1617
  | `INVALID_NETWORK` | unknown alias / unresolvable iface / dead socket daemon | `quickchr networks` |
1242
- | `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 |
1243
1621
  | `INSUFFICIENT_DISK_SPACE` | host data dir full | free space or move via `QUICKCHR_DATA_DIR` |
1244
1622
  | `STATE_ERROR` | snapshot on raw boot disk | recreate with `--boot-disk-format=qcow2` |
1245
1623
  | `INVALID_SIZE_STRING` | invalid cache/settings size string | use size syntax like `512M`, `2G`, or `1.5G` |
@@ -1248,10 +1626,44 @@ present, then exits `1`.
1248
1626
 
1249
1627
  ### Auto-cleanup on boot timeout
1250
1628
 
1251
- If `start()` hits `BOOT_TIMEOUT`, quickchr automatically calls
1252
- `stop()` then `remove()` on the failing instance. You'll see a
1253
- diagnostic and the machine will be gone from `quickchr list`. This
1254
- 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).
1255
1667
 
1256
1668
  ### Where to look
1257
1669