@tikoci/quickchr 0.4.2 → 0.4.3

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 ADDED
@@ -0,0 +1,1259 @@
1
+ # quickchr Manual
2
+
3
+ > Audit-style reference for what quickchr actually does today. README is the
4
+ > elevator pitch and quickstart; DESIGN.md is the architecture rationale;
5
+ > this MANUAL is the complete, source-checked map of every CLI command,
6
+ > library API, and provisioning step.
7
+
8
+ ## Table of Contents
9
+
10
+ 1. [What quickchr is](#1-what-quickchr-is)
11
+ 2. [Install & doctor](#2-install--doctor)
12
+ 3. [CLI command reference](#3-cli-command-reference)
13
+ 4. [Library API reference](#4-library-api-reference)
14
+ 5. [Provisioning pipeline](#5-provisioning-pipeline)
15
+ 6. [Channels — REST, monitor, serial, QGA, console](#6-channels--rest-monitor-serial-qga-console)
16
+ 7. [Networking](#7-networking)
17
+ 8. [Storage layout](#8-storage-layout)
18
+ 9. [Port layout](#9-port-layout)
19
+ 10. [Acceleration & timeouts](#10-acceleration--timeouts)
20
+ 11. [Auth, secrets, environment variables](#11-auth-secrets-environment-variables)
21
+ 12. [Exec — running RouterOS commands](#12-exec--running-routeros-commands)
22
+ 13. [Disks & snapshots](#13-disks--snapshots)
23
+ 14. [Errors & recovery](#14-errors--recovery)
24
+ 15. [Cross-references](#15-cross-references)
25
+
26
+ ---
27
+
28
+ ## 1. What quickchr is
29
+
30
+ A **CLI plus importable library** for managing MikroTik CHR (Cloud Hosted
31
+ Router) virtual machines on macOS, Linux, and (experimentally) Windows.
32
+ quickchr wraps QEMU directly — it is **not** an orchestrator. Each
33
+ invocation manages one CHR instance; multi-router topologies are composed
34
+ by the user (or their AI agent) using `quickchr` as a building block.
35
+
36
+ **Scope boundary** (see DESIGN.md §Scope Boundary):
37
+
38
+ - ✅ Download CHR images, manage QEMU lifecycle, expose REST/SSH/WinBox
39
+ ports, persist machine state, run RouterOS CLI commands, install
40
+ packages, apply trial licenses, switch device-mode.
41
+ - ❌ Multi-router topology orchestration, test-matrix runners,
42
+ configuration-as-code DSLs. Use `examples/` as references and compose
43
+ yourself.
44
+
45
+ **Two ways to use it:**
46
+
47
+ - `quickchr <command>` — the CLI. Git-style subcommands plus an interactive
48
+ setup wizard (`quickchr setup`).
49
+ - `import { QuickCHR } from "@tikoci/quickchr"` — the library. Returns
50
+ `ChrInstance` handles for programmatic control.
51
+
52
+ ---
53
+
54
+ ## 2. Install & doctor
55
+
56
+ ### Prerequisites
57
+
58
+ | Component | Required for | Install |
59
+ |---|---|---|
60
+ | Bun ≥ 1.1 | runtime | macOS/Linux: `curl -fsSL https://bun.sh/install \| bash` · Windows: `powershell -c "irm bun.sh/install.ps1 \| iex"` (or `winget install Oven-sh.Bun`) |
61
+ | `qemu-system-x86_64` | x86 CHR | brew/apt/dnf/pacman · Windows: `winget install SoftwareFreedomConservancy.QEMU` |
62
+ | `qemu-system-aarch64` | arm64 CHR | brew/apt/dnf/pacman · Windows: bundled in the SFC QEMU package above |
63
+ | `qemu-img` | `--boot-size`, `--add-disk`, snapshots | included in all standard QEMU packages (brew, apt, dnf, SFC installer) |
64
+ | UEFI firmware (edk2) | arm64 CHR | `qemu-efi-aarch64` (apt), `edk2-aarch64` (dnf), bundled with `qemu` (brew) |
65
+ | `socket_vmnet` (optional) | rootless `shared`/`bridged` networking on macOS | `brew install socket_vmnet` |
66
+ | `socat` (optional) | piping serial console externally | brew/apt |
67
+ | `sshpass` (optional) | future SSH exec transport | brew/apt |
68
+
69
+ ### Install quickchr
70
+
71
+ The first GitHub release is **0.1.1** — pre-release per the changelog's
72
+ odd/even policy. Not yet on npm.
73
+
74
+ ```bash
75
+ # Library
76
+ bun add github:tikoci/quickchr
77
+
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
83
+ ```
84
+
85
+ Once `0.2.0` is published to npm:
86
+
87
+ ```bash
88
+ bun install -g @tikoci/quickchr
89
+ ```
90
+
91
+ ### `quickchr doctor`
92
+
93
+ Reports prerequisite status, accelerator availability, image cache
94
+ size, machine count, free disk space, and shell-completion install
95
+ state. Exit code is `0` when no checks return `error` (warnings are OK).
96
+
97
+ ```bash
98
+ quickchr doctor # human-readable
99
+ quickchr doctor --json # { ok, checks, staleImages } for CI
100
+ ```
101
+
102
+ The `--json` form emits `{ ok, checks, staleImages }`, where `checks` is an
103
+ array of `{ label, status, detail }` and `staleImages` lists cached images older
104
+ than the current long-term release. The exit code still reflects `ok`.
105
+
106
+ ![quickchr doctor output](./images/doctor-demo.gif)
107
+
108
+ Use this whenever a `start` fails — the diagnostic almost always points
109
+ at a missing dependency, missing firmware, or insufficient disk.
110
+
111
+ ---
112
+
113
+ ## 3. CLI command reference
114
+
115
+ The dispatcher lives in `src/cli/index.ts` (lines 121–194). Help text is
116
+ available for every command via `quickchr help <command>`.
117
+
118
+ ### Lifecycle
119
+
120
+ #### `add [name]`
121
+
122
+ Create a machine without starting it. Materializes the machine
123
+ directory, downloads the RouterOS image (cached), and writes
124
+ `machine.json` with all provisioning options stored for later.
125
+
126
+ | Flag | Default | Notes |
127
+ |---|---|---|
128
+ | `--name <s>` | auto | `host-arch-N` if omitted |
129
+ | `--version <ver>` | latest in channel | e.g., `7.22.1` |
130
+ | `--channel <ch>` | `stable` | `stable`, `long-term`, `testing`, `development` |
131
+ | `--arch <a>` | host native | `arm64`, `x86`, or `auto` |
132
+ | `--cpu <n>` | `1` | vCPU count |
133
+ | `--mem <mb>` | `512` | RAM (cross-arch TCG bumps to `1024`) |
134
+ | `--boot-disk-format <f>` | `qcow2` | `qcow2` or `raw` |
135
+ | `--boot-size <size>` | — | Resize boot disk; needs `qemu-img`; auto-converts to qcow2 |
136
+ | `--add-disk <size>` | — | Extra blank qcow2 disk; repeatable |
137
+ | `--forward <spec>` | — | Extra hostfwd port or built-in service host-port pin; repeatable. See §Custom port forwards. |
138
+ | `--add-package <pkg>` | — | Provisioning, repeatable, ≥ 7.20.8 |
139
+ | `--install-all-packages` | false | Provisioning, ≥ 7.20.8 |
140
+ | `--add-user <user:pass>` | — | Provisioning, ≥ 7.20.8 |
141
+ | `--disable-admin` | false | Provisioning, ≥ 7.20.8 |
142
+ | `--secure-login` / `--no-secure-login` | unset | Provisioning, ≥ 7.20.8 |
143
+ | `--license-level <l>` | — | `p1`, `p10`, `unlimited`, ≥ 7.20.8 |
144
+ | `--license-account <a>` | env `MIKROTIK_WEB_ACCOUNT` | MikroTik.com email |
145
+ | `--license-password <p>` | env `MIKROTIK_WEB_PASSWORD` | |
146
+ | `--device-mode <m>` | — | `rose`, `advanced`, `basic`, `home`, `auto`, `skip`, ≥ 7.20.8 |
147
+ | `--device-mode-enable <f,...>` | — | Repeatable, comma-separated |
148
+ | `--device-mode-disable <f,...>` | — | Repeatable, comma-separated |
149
+ | `--add-network <spec>` | `user` | See §7. Repeatable. |
150
+ | `--no-network` | false | Headless, no NICs |
151
+ | `--no-winbox` | false | Exclude WinBox port |
152
+ | `--no-api-ssl` | false | Exclude API-SSL port |
153
+ | `--port-base <n>` | auto from 9100 | First port of the 10-port block |
154
+
155
+ `--vmnet-shared` and `--vmnet-bridge <iface>` are deprecated aliases for
156
+ `--add-network shared` / `--add-network bridged:<iface>`.
157
+
158
+ #### `start [name]`
159
+
160
+ 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`.
163
+
164
+ Additional flags:
165
+
166
+ | Flag | Notes |
167
+ |---|---|
168
+ | `--all` | Start every stopped machine in background |
169
+ | `--bg` / `--background` | (default) detach QEMU; return after boot |
170
+ | `--fg` / `--foreground` (alias `--no-bg`, `--no-background`) | Serial console on stdio |
171
+ | `--install-deps` | Auto-install missing host deps (calls package manager) |
172
+ | `--timeout-extra <s>` / `-T <s>` | Add extra seconds to computed boot timeout |
173
+ | `--dry-run` | Print resolved options and QEMU command, exit |
174
+
175
+ Foreground mode shortcuts (QEMU mux):
176
+
177
+ | Key | Action |
178
+ |---|---|
179
+ | `Ctrl-A X` | Quit QEMU |
180
+ | `Ctrl-A C` | Toggle monitor (`quit` to force-stop from monitor) |
181
+ | `Ctrl-A H` | Help |
182
+
183
+ When `--fg` is combined with provisioning options, quickchr boots
184
+ **background** first, runs provisioning, then **stops** and re-launches
185
+ foreground with stdio attached. This is observable as a brief stop in
186
+ machine state.
187
+
188
+ #### `stop [name | --all]`
189
+
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`.
193
+
194
+ #### `remove [name | --all]` (alias `rm`)
195
+
196
+ Stops if running, deletes the machine directory, and clears per-instance
197
+ credentials from the secret store.
198
+
199
+ #### `clean <name>`
200
+
201
+ Resets the machine to a fresh image: stops QEMU, re-copies the boot
202
+ image from cache, recreates extra disks per the saved config, deletes
203
+ EFI vars (UEFI re-initializes), and clears per-instance credentials.
204
+ Provisioning options remain in `machine.json` and re-run on next `start`.
205
+
206
+ ### Inspection
207
+
208
+ #### `list [name]` (alias `ls`, `status`)
209
+
210
+ Without a name: tabular summary of every machine. With a name: detailed
211
+ view including credentials and connection tips. `--json` for
212
+ machine-readable output.
213
+
214
+ #### `get <name> [license | device-mode | admin] [--json]`
215
+
216
+ Live REST query. Hits the running CHR and returns the requested group
217
+ (or all if no group specified). Requires the machine to be running.
218
+
219
+ #### `inspect <name> [--json]`
220
+
221
+ Stable machine-readable connection descriptor for a **running** CHR. The
222
+ descriptor includes status, ports, original port mappings, URLs, auth, env
223
+ vars, and the machine directory. `--json` is accepted for symmetry with other
224
+ read commands; output is always JSON.
225
+
226
+ Stopped machines fail with `MACHINE_STOPPED` instead of returning stale
227
+ connection details. Use `quickchr start <name>` first.
228
+
229
+ #### `env <name> [--json]`
230
+
231
+ Print the subprocess environment for a **running** CHR. By default it emits
232
+ shell-ready `KEY=value` lines; with `--json` it emits just the env map as
233
+ JSON. This is CLI parity with `ChrInstance.subprocessEnv()`.
234
+
235
+ The env map includes credentials (`QUICKCHR_AUTH`, `BASICAUTH`) so subprocesses
236
+ can connect without reading quickchr's secret store. Treat `quickchr env` and
237
+ `quickchr inspect` output like password material: avoid commits, public issue
238
+ comments, and unredacted CI logs.
239
+
240
+ #### `disk [name]`
241
+
242
+ Shows stored disk layout. With `qemu-img` installed, also reports
243
+ virtual + actual sizes for each disk.
244
+
245
+ #### `logs <name> [--follow] [-n N]`
246
+
247
+ Tail the per-machine `qemu.log`. `--follow` streams new output;
248
+ `-n` limits to last N lines.
249
+
250
+ #### `networks` (alias `net`)
251
+
252
+ `networks` enumerates host interfaces and their alias resolution
253
+ (`wifi`, `ethernet`, `auto`). `networks sockets` lists registered
254
+ named L2 sockets; `networks sockets create <name>` reserves a port for a
255
+ new tunnel.
256
+
257
+ #### `version [--json]`
258
+
259
+ Print quickchr version (matches `package.json`) plus the latest RouterOS
260
+ version per channel. `--json` emits just the channel map for CI consumers:
261
+
262
+ ```bash
263
+ quickchr version --json
264
+ # {"stable":"7.23.1","long-term":"7.21.4","testing":"7.23rc4","development":"7.24beta2"}
265
+ ```
266
+
267
+ Offline, `--json` emits `{}`. For the "which channels are worth booting right
268
+ now" smarts (recency-aware active channels), import `resolveActiveChannels` /
269
+ `resolveChannelStatuses` from the library — see *Version & channel helpers* under
270
+ the Library API reference.
271
+
272
+ ### Interaction
273
+
274
+ #### `exec <name> <command> [--via=auto|rest|qga|console|ssh] [--user u] [--password p] [--timeout=ms]`
275
+
276
+ Run a RouterOS CLI command. See §12 for transport details and JSON
277
+ output trick.
278
+
279
+ #### `console <name>`
280
+
281
+ Attach to the QEMU serial socket. Exit with `Ctrl-A X` (or `Ctrl-]` if
282
+ the underlying transport is socat). Requires the machine to be running.
283
+
284
+ #### `qga <name> <subcommand> [args...]`
285
+
286
+ Direct QEMU Guest Agent commands (`ping`, `info`, `osinfo`, `hostname`,
287
+ `time`, `timezone`, `network-interfaces`, `fsfreeze-status`, `exec`,
288
+ `file-read`, `file-write`, `shutdown`).
289
+
290
+ QGA is **x86 only and KVM only**. ARM64 CHR doesn't ship the agent;
291
+ macOS HVF and any TCG fallback don't initialize the virtio channel
292
+ (see `docs/qga-x86-macos-qemu10-investigation.md` for the QEMU bug).
293
+
294
+ ### Configuration
295
+
296
+ #### `license <name> [--level p1|p10|unlimited]`
297
+
298
+ Apply or renew a CHR trial license on a running instance. Resolves
299
+ MikroTik.com credentials from `--license-account`/`--license-password`,
300
+ the env vars, or the secret store. Requires RouterOS ≥ 7.20.8.
301
+
302
+ #### `set <name> <subcommand>`
303
+
304
+ Currently exposes a small surface for in-place changes (e.g.,
305
+ `set <name> license …`). The `set/get` architecture is still being
306
+ designed; expect additions.
307
+
308
+ #### `snapshot <name> <list | save [snapname] | load <snapname> | delete <snapname>>` (alias `snap`)
309
+
310
+ Internal qcow2 snapshots. `save` requires the machine running (`savevm`
311
+ goes through QEMU monitor). `list` and `delete` work in either state
312
+ (uses `qemu-img info` when stopped). Requires `bootDiskFormat: qcow2`.
313
+
314
+ ### Meta
315
+
316
+ #### `setup`
317
+
318
+ Interactive wizard (`@clack/prompts`) that walks through all machine creation
319
+ options and starts the CHR. Triggered by `quickchr setup` or automatically
320
+ when `quickchr` is run with no subcommand on a TTY.
321
+
322
+ ![quickchr setup wizard walkthrough](./images/wizard-demo.gif)
323
+
324
+ **First-run detection**: if no machines exist yet, the wizard opens with a
325
+ welcome message before the first prompt.
326
+
327
+ **Setup menu loop**: when running via `quickchr setup` (not the bare `quickchr`
328
+ shortcut), a main menu appears after each machine starts, offering to create
329
+ another machine, view status, or quit.
330
+
331
+ **Wizard steps** (all traced from `src/cli/wizard.ts`):
332
+
333
+ | Step | Prompt | Default | Corresponding flag |
334
+ |---|---|---|---|
335
+ | 1 | Version source: channel or specific | channel | `--channel` / `--version` |
336
+ | 2 | Channel (if channel chosen) | `stable` (or the `default-channel` setting) | `--channel` |
337
+ | 3 | Architecture | host native (or the `default-arch` setting) | `--arch` |
338
+ | 4 | Instance name | auto-generated | `--name` |
339
+ | 5 | CPU cores | 1 | `--cpu` |
340
+ | 6 | Memory | 256 MB | `--mem` |
341
+ | 7 | Boot disk format (qcow2 / raw) | qcow2 | `--boot-disk-format` |
342
+ | 8 | Boot disk resize (optional, qcow2 only) | no resize | `--boot-size` |
343
+ | 9 | Extra blank disks (repeatable) | none | `--add-disk` |
344
+ | 10 | Additional networks (macOS/Linux) | user-mode only | `--add-network` |
345
+ | 11 | Provisioning (packages, device-mode, user, license) | optional | various |
346
+ | 12 | Run mode: background / foreground | background | `--bg` / `--fg` |
347
+ | 13 | Final confirmation | — | — |
348
+
349
+ Steps 7–9 are skipped when `qemu-img` is absent (a warning is shown). Step 10
350
+ only appears when the platform supports networks beyond user-mode (macOS or
351
+ Linux). The provisioning gate (step 11) only appears for RouterOS ≥ 7.20.8;
352
+ older versions show an upgrade prompt.
353
+
354
+ **Provisioning sub-steps** (within step 11):
355
+
356
+ | Sub-step | Options |
357
+ |---|---|
358
+ | Packages | No extra / All packages / Choose (multiselect per arch) |
359
+ | Device-mode | no / yes → profile (rose/advanced/basic/home) + extra feature flags |
360
+ | Login | quickchr managed (recommended) / custom user+pass / keep admin no-password |
361
+ | License | no / yes → level (p1/p10/unlimited) + credentials (stored or entered) |
362
+
363
+ **Post-start**: after a successful background boot, the wizard shows port
364
+ details (REST URL, SSH, WinBox), login credentials, and optionally installs
365
+ shell completions if not yet configured.
366
+
367
+ **Notes**:
368
+
369
+ - "Managed login" sets both `disableAdmin: true` and `secureLogin: true` —
370
+ the provisioning step only auto-creates the `quickchr` managed account when
371
+ `secureLogin` is explicitly `true`, so both fields must be set together or
372
+ admin would be disabled with no replacement login provisioned.
373
+ - Set `QUICKCHR_NO_PROMPT=1` to suppress all wizard flows (useful in scripts
374
+ and CI pipelines).
375
+ - `Ctrl-C` at any prompt cleanly cancels with no partial machine left behind.
376
+
377
+ #### `completions [bash|zsh|fish]`
378
+
379
+ Print or install shell completions. With no arg, detects current shell.
380
+
381
+ #### `settings [print|get|set|reset] [key] [value] [--json]`
382
+
383
+ Manage quickchr's own global preferences — repeated flags/defaults that
384
+ previously had no persisted home. Stored in `~/.config/quickchr/quickchr.env`
385
+ (dotenv-style: `QUICKCHR_KEY=value` lines). See §8 (Storage layout) and §11
386
+ (Environment variables) for the file format and full env var list.
387
+
388
+ Precedence per key (highest wins): CLI flag > `QUICKCHR_<KEY>` env var >
389
+ `quickchr.env` > built-in default.
390
+
391
+ | Key | Consumed by | Type | Built-in default |
392
+ |---|---|---|---|
393
+ | `default-channel` | `add`/`start`'s `--channel`, wizard's channel prompt | `stable`\|`long-term`\|`testing`\|`development` | `stable` |
394
+ | `default-arch` | `add`/`start`'s `--arch`, wizard's arch prompt | `arm64`\|`x86`\|`auto` | `auto` (host native) |
395
+ | `cache-max-size` | Auto-prune cap applied after each successful `start` | size string (e.g. `2G`, `512M`) | `2G` |
396
+ | `timeout-extra` | `start`'s `--timeout-extra`/`-T` when omitted | non-negative integer seconds | `0` |
397
+ | `secure-login` | `add`/`start`'s `--secure-login` when neither `--secure-login` nor `--no-secure-login` is passed | boolean | `unset` (reported as `(unset)` — see note) |
398
+
399
+ `secure-login` is the one key `settings print`/`get` report as `(unset)` rather than a
400
+ concrete `false`, unlike the other four: the setup wizard's login prompt needs to tell
401
+ "not configured" apart from "explicitly set to false" (it only pre-highlights "Keep
402
+ admin with no password" when the setting is concretely `false`; leaving it unset keeps
403
+ the wizard's own recommended "managed login" highlighted). Giving it a real
404
+ `builtinDefault` would collapse that distinction. Every *consumer* still treats
405
+ "unset" and "false" identically — `add`/`start` never force `secureLogin: true` unless
406
+ the setting resolves to exactly `true`.
407
+
408
+ ```bash
409
+ quickchr settings print # all 5 keys, resolved value + source
410
+ quickchr settings set default-channel testing # write one key to quickchr.env
411
+ quickchr settings get default-arch # print one key's resolved value
412
+ quickchr settings reset default-channel # delete one managed line
413
+ quickchr settings reset # clear all managed lines
414
+ ```
415
+
416
+ Credentials (`--add-user`, `MIKROTIK_WEB_ACCOUNT`/`MIKROTIK_WEB_PASSWORD`) are
417
+ never stored here — `settings` only manages the 5 keys above, and does not
418
+ mutate any machine's `machine.json`.
419
+
420
+ ### Global behavior
421
+
422
+ | Env var | Effect |
423
+ |---|---|
424
+ | `QUICKCHR_DATA_DIR` | Override the data root (defaults to `$XDG_DATA_HOME/quickchr` or platform equivalent — see §8) |
425
+ | `QUICKCHR_NO_PROMPT` | Force non-interactive mode (skips all wizards) |
426
+ | `QUICKCHR_DEBUG` | Emit `[debug]` lines from the progress logger |
427
+ | `QUICKCHR_INTEGRATION` | Required to run `test/integration/` |
428
+ | `MIKROTIK_WEB_ACCOUNT`, `MIKROTIK_WEB_PASSWORD` | License credentials fallback |
429
+ | `NO_COLOR` | Disable ANSI styling |
430
+
431
+ `--json` is honored on `list`, `status`, `get`, `env`, `snapshot`, and a
432
+ few others; `inspect` is always JSON and accepts `--json` for parity. Check
433
+ `quickchr help <command>`. Errors are caught at the top level and exit `1`
434
+ with a `[code] message` line plus the install hint when present.
435
+
436
+ ---
437
+
438
+ ## 4. Library API reference
439
+
440
+ Public exports are in `src/index.ts`. The two surfaces you use are the
441
+ `QuickCHR` class (machine lifecycle) and the `ChrInstance` interface
442
+ (per-instance operations).
443
+
444
+ ### Static methods on `QuickCHR`
445
+
446
+ ```ts
447
+ QuickCHR.start(opts?: StartOptions): Promise<ChrInstance>
448
+ QuickCHR.add(opts?: StartOptions): Promise<MachineState>
449
+ QuickCHR.list(): MachineState[]
450
+ QuickCHR.get(name: string): ChrInstance | null
451
+ QuickCHR.doctor(): Promise<DoctorResult>
452
+ QuickCHR.resolveVersion(channel: Channel): Promise<string>
453
+ ```
454
+
455
+ `start()` creates the machine if needed, downloads the image, allocates
456
+ a port block, spawns QEMU, waits for REST readiness, and runs all
457
+ provisioning steps before returning. It auto-cleans the machine if boot
458
+ times out (calls `stop()` then `remove()`). It acquires `start-lock` to
459
+ serialize concurrent starts of the same machine.
460
+
461
+ `add()` is the same minus the QEMU spawn — useful when you want to
462
+ materialize a machine and start it later (or hand the config to another
463
+ process).
464
+
465
+ `get()` returns `null` if the machine doesn't exist; otherwise it
466
+ loads `machine.json`, refreshes PID liveness, and returns a runtime
467
+ handle. `list()` is `get()` for everything in `machines/`.
468
+
469
+ ### `ChrInstance`
470
+
471
+ **At a glance** — every method on a running instance:
472
+
473
+ | Group | Member | Purpose |
474
+ |---|---|---|
475
+ | Identity | `name`, `state`, `ports`, `restUrl`, `sshPort`, `portBase` | Static metadata persisted in `machine.json` |
476
+ | Capture | `captureInterface`, `tzspGatewayIp` | Platform-correct values for TZSP capture (`tshark -i …`) and RouterOS streaming target |
477
+ | Lifecycle | `waitForBoot()`, `waitFor(fn, ms?)`, `stop()`, `remove()`, `clean()`, `destroy()` | Boot readiness, custom polling, teardown |
478
+ | Comms | `rest()`, `exec()`, `monitor()`, `serial()`, `qga()` | REST, RouterOS CLI, QEMU monitor, serial console, QGA (x86 only) |
479
+ | Provisioning | `license()`, `setDeviceMode()`, `availablePackages()`, `installPackage()` | Post-boot configuration |
480
+ | Files | `upload()`, `download()` | SCP transfer to/from the CHR |
481
+ | Snapshots | `snapshot.{list,save,load,delete}()` | qcow2 savevm/loadvm |
482
+ | Diagnostics | `queryLoad()`, `subprocessEnv()`, `descriptor()` | Live CPU/mem sample, env vars/descriptor for child processes |
483
+
484
+ ```ts
485
+ interface ChrInstance {
486
+ name: string;
487
+ state: MachineState; // full persisted config
488
+ ports: ChrPorts; // {http, https, ssh, api, apiSsl, winbox, ...}
489
+ restUrl: string; // http://127.0.0.1:{ports.http}
490
+ sshPort: number;
491
+ portBase: number; // start of this instance's reserved port block
492
+ captureInterface: string; // "lo0" on macOS, "any" on Linux — for TZSP capture
493
+ tzspGatewayIp: string; // "10.0.2.2" — QEMU slirp host gateway
494
+
495
+ waitForBoot(timeoutMs?: number): Promise<boolean>;
496
+ waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
497
+ stop(): Promise<void>;
498
+ remove(): Promise<void>;
499
+ clean(): Promise<void>;
500
+ destroy(): Promise<void>; // stop + remove
501
+
502
+ rest(path: string, opts?: RequestInit): Promise<unknown>;
503
+ exec(cmd: string, opts?: ExecOptions): Promise<ExecResult>;
504
+
505
+ monitor(cmd: string): Promise<string>;
506
+ serial(): { readable: ReadableStream; writable: WritableStream };
507
+ qga(cmd: QgaCommand, args?: object): Promise<unknown>;
508
+
509
+ license(opts: LicenseOptions): Promise<void>;
510
+ setDeviceMode(opts: DeviceModeOptions, log?: ProgressLogger): Promise<void>;
511
+ availablePackages(): Promise<string[]>;
512
+ installPackage(pkgs: string | string[]): Promise<string[]>;
513
+
514
+ upload(localPath: string, remotePath?: string): Promise<void>;
515
+ download(remotePath: string, localPath: string): Promise<void>;
516
+
517
+ snapshot: {
518
+ list(): Promise<SnapshotInfo[]>;
519
+ save(name?: string): Promise<SnapshotInfo>;
520
+ load(name: string): Promise<void>;
521
+ delete(name: string): Promise<void>;
522
+ };
523
+
524
+ queryLoad(): Promise<ChrLoadSample | null>;
525
+ subprocessEnv(): Promise<Record<string, string>>;
526
+ descriptor(): Promise<MachineDescriptor>;
527
+ }
528
+ ```
529
+
530
+ `rest()` retries up to 3 times with 2s backoff on `ECONNRESET` (RouterOS
531
+ transiently resets connections post-boot/reboot). HTTP errors and
532
+ `QuickCHRError` from the REST layer are NOT retried.
533
+
534
+ `subprocessEnv()` returns env-var keys (`QUICKCHR_NAME`,
535
+ `QUICKCHR_REST_URL`, `QUICKCHR_REST_BASE`, `QUICKCHR_SSH_PORT`,
536
+ `QUICKCHR_AUTH`, plus legacy `URLBASE`/`BASICAUTH`) so child processes
537
+ can hit the CHR without re-resolving auth.
538
+
539
+ `descriptor()` returns the stable JSON shape used by `quickchr inspect`:
540
+ machine identity, status, ports, port mappings, URLs, auth, env, and
541
+ timestamps. It is intentionally **running-only** and throws
542
+ `MACHINE_STOPPED` for stopped machines.
543
+
544
+ Both `subprocessEnv()` and `descriptor()` expose auth material by design
545
+ (`QUICKCHR_AUTH`, `BASICAUTH`, `auth.password`, `auth.basic`,
546
+ `auth.header`). Treat their output as credentials and redact before
547
+ logging or attaching to bug reports.
548
+
549
+ ### `StartOptions` (selected)
550
+
551
+ | Field | Type | Default | Notes |
552
+ |---|---|---|---|
553
+ | `name` | string | auto | Must not start with `-` |
554
+ | `version` | string | — | Mutually exclusive with `channel` |
555
+ | `channel` | Channel | `"stable"` | |
556
+ | `arch` | `"arm64" \| "x86"` | host | |
557
+ | `cpu` | number | `1` | |
558
+ | `mem` | number | `512` (1024 cross-arch TCG) | MiB |
559
+ | `background` | boolean | `true` | |
560
+ | `bootDiskFormat` | `"raw" \| "qcow2"` | `"raw"` (auto-`qcow2` if `bootSize`) | |
561
+ | `bootSize` | string | — | e.g., `"512M"`, `"2G"`. Needs `qemu-img` |
562
+ | `extraDisks` | string[] | `[]` | Each entry is a size string |
563
+ | `networks` | NetworkSpecifier[] | `["user"]` | See §7 |
564
+ | `portBase` | number | auto | First of 10 ports |
565
+ | `excludePorts` | ServiceName[] | `[]` | e.g., `["winbox"]` |
566
+ | `extraPorts` | PortMapping[] | `[]` | Custom forwards |
567
+ | `packages` | string[] | `[]` | Provisioning |
568
+ | `installAllPackages` | boolean | `false` | Provisioning |
569
+ | `user` | `{ name, password }` | — | Provisioning |
570
+ | `disableAdmin` | boolean | `false` | Provisioning |
571
+ | `secureLogin` | boolean | `false` | **`true` is the explicit trigger** for managed-account creation |
572
+ | `license` | `LicenseInput` | — | `"p1"` / `"p10"` / `"unlimited"` or `LicenseOptions` |
573
+ | `deviceMode` | `DeviceModeOptions` | — | `{mode?, enable?[], disable?[]}` |
574
+ | `installDeps` | boolean | — | Auto-install missing OS deps |
575
+ | `dryRun` | boolean | `false` | Print QEMU command and exit |
576
+ | `timeoutExtra` | number | `0` | Extra **milliseconds** added to boot timeout |
577
+ | `onProgress` | `(msg) => void` | `console.log` | `[debug]` lines only when `QUICKCHR_DEBUG=1` |
578
+
579
+ Provisioning fields trigger a version check at the top of `start()` /
580
+ `add()`: any provisioning option on RouterOS < 7.20.8 throws
581
+ `PROVISIONING_VERSION_UNSUPPORTED`.
582
+
583
+ See `src/lib/types.ts` for the full type surface (ChrPorts, MachineState,
584
+ NetworkConfig, NetworkSpecifier, PortMapping, ExecOptions, ExecResult,
585
+ DeviceModeOptions, LicenseInput/Level/Options, DoctorResult,
586
+ SnapshotInfo, ChrLoadSample, ErrorCode, plus QGA result types).
587
+
588
+ The barrel also re-exports utility functions from `auth.ts`, `disk.ts`,
589
+ `exec.ts`, `qga.ts`, `console.ts`, `license.ts`, `credentials.ts`,
590
+ `packages.ts`, `network.ts`, `log.ts`, `versions.ts`. Use them when the
591
+ high-level class doesn't fit (typically not needed).
592
+
593
+ ### Programmatic networking
594
+
595
+ The CLI's `--add-network` and `--forward` flags are just `StartOptions.networks`
596
+ and `StartOptions.extraPorts`. Full decision guide:
597
+ [`docs/networking-recipes.md`](docs/networking-recipes.md).
598
+
599
+ ```ts
600
+ import { QuickCHR, expandForwardSpec } from "@tikoci/quickchr";
601
+
602
+ const chr = await QuickCHR.start({
603
+ // ether1 = user (REST/mgmt + hostfwd); ether2 = L2 frames streamed to a host
604
+ // TCP server on `port` (e.g. to receive MNDP — see examples/mndp/).
605
+ networks: ["user", { type: "socket-connect", port }],
606
+
607
+ // host→guest forwards; UDP supported. Build PortMappings by hand…
608
+ extraPorts: [
609
+ { name: "snmp", host: 9161, guest: 161, proto: "udp" },
610
+ // …or parse CLI-style spec strings, including a UDP range:
611
+ ...expandForwardSpec("btest:9200-9210:2000-2010/udp"),
612
+ ],
613
+ });
614
+ ```
615
+
616
+ To **receive** UDP a guest sends, no forward is needed — bind an *unconnected*
617
+ host socket and have the guest send to `chr.tzspGatewayIp` (`10.0.2.2`); see
618
+ [`docs/networking-recipes.md`](docs/networking-recipes.md) §2 and
619
+ [`examples/udp-gateway/`](examples/udp-gateway/).
620
+
621
+ ### Version & channel helpers
622
+
623
+ For tools that pick which CHRs to boot (e.g. CI matrices), the barrel exports
624
+ the RouterOS version facts and a recency classifier:
625
+
626
+ ```ts
627
+ import {
628
+ resolveAllVersions, compareRouterOsVersion,
629
+ resolveChannelStatuses, resolveActiveChannels,
630
+ classifyChannels, selectActiveChannels, channelMaturity,
631
+ CHANNELS, type Channel, type ChannelStatus,
632
+ } from "@tikoci/quickchr";
633
+
634
+ await resolveAllVersions();
635
+ // { stable: "7.23.1", "long-term": "7.21.4", testing: "7.23rc4", development: "7.24beta2" }
636
+
637
+ compareRouterOsVersion("7.24beta2", "7.24rc1"); // < 0 (beta < rc < release < patch)
638
+
639
+ await resolveActiveChannels();
640
+ // ["stable", "long-term", "development"] — released channels always, plus any
641
+ // pre-release at or ahead of stable (here testing 7.23rc4 is behind 7.23.1, excluded)
642
+ ```
643
+
644
+ `resolveChannelStatuses()` returns `ChannelStatus[]`
645
+ (`{ channel, version, maturity, aheadOfStable }`); `resolveActiveChannels({ aheadOf })`
646
+ lets you measure pre-release recency against a channel other than `stable`. The pure
647
+ `classifyChannels(versions)` / `selectActiveChannels(versions, opts)` take a
648
+ `Record<Channel, string>` so you can classify without a network round-trip.
649
+
650
+ These answer **"what's worth booting,"** never "what must pass" — keep merge-gating
651
+ policy in the consumer.
652
+
653
+ The entry also re-exports the lower-level version helpers for pre-flight checks:
654
+ `resolveVersion`, `parseVersionParts`, `isValidVersion`, and
655
+ `isProvisioningSupportedVersion` (validate a version against the provisioning floor
656
+ before calling `start()`).
657
+
658
+ ---
659
+
660
+ ## 5. Provisioning pipeline
661
+
662
+ Everything below the boot wait runs in **background** mode regardless of
663
+ the user's `--fg` choice; if foreground was requested, quickchr stops
664
+ QEMU after provisioning and re-launches with stdio attached.
665
+
666
+ **Version gate:** RouterOS ≥ 7.20.8 (first long-term baseline). Any
667
+ provisioning option on an older image throws
668
+ `PROVISIONING_VERSION_UNSUPPORTED` upfront. Boot, disk, networking, and
669
+ port mappings work on any 7.x.
670
+
671
+ **Order of operations** (`_provisionInstance` in `src/lib/quickchr.ts`):
672
+
673
+ 1. **Packages** — `installAllPackages` or `packages[]` triggers SCP
674
+ upload of `.npk` files to the SSH port, then `/system/reboot`.
675
+ quickchr waits 2× the normal boot timeout (package init takes
676
+ longer). Persists the installed list to `state.packages`.
677
+ 2. **Device-mode** — applies `deviceMode.mode` / `enable[]` / `disable[]`
678
+ via REST. RouterOS requires a hard power-cycle to confirm; quickchr
679
+ kills QEMU and restarts it. The blocking REST call returns
680
+ ECONNRESET when QEMU is killed — this is suppressed and treated as
681
+ success after the verification read-back. Persisted to
682
+ `state.deviceMode`.
683
+ 3. **License** — `/system/license/renew` with `account`, `password`,
684
+ `level`, `duration=10s` so the server-side wait fits in a single
685
+ call. Read-back confirms the actual applied level and persists it
686
+ to `state.licenseLevel`. Errors come back inside HTTP 200 bodies as
687
+ `status: "ERROR: …"` strings — quickchr classifies these as
688
+ immediate failures (no retry).
689
+ 4. **User & admin** — only runs when `user` is set OR `disableAdmin` is
690
+ true OR `secureLogin === true`. Creates the custom user and/or the
691
+ auto-generated `quickchr` managed account, optionally disables admin,
692
+ stores the password in the per-instance secret file, and writes the
693
+ user's name to `machine.json` (real password is NEVER written there).
694
+ Disabling admin **does not** lock the REST API — RouterOS's `expired`
695
+ flag only gates CLI/SSH/Winbox login, not REST.
696
+
697
+ Each step is independent. A failure in step N stops further steps but
698
+ does not roll back N–1. Re-running `start` re-applies pending
699
+ provisioning from `machine.json` only if the machine had not been fully
700
+ provisioned previously.
701
+
702
+ **Always read back what we wrote.** This catches version-specific drift
703
+ in REST responses and surfaces actionable errors instead of silent
704
+ state corruption.
705
+
706
+ The post-boot REST race (early endpoints can return wrong/empty bodies
707
+ even after `waitForBoot` returns) is handled per-endpoint: license,
708
+ device-mode, identity, and user reads each poll for field presence
709
+ with 15–30s deadlines. `waitForBoot` itself uses a two-consecutive-OK
710
+ guard on `/system/resource`.
711
+
712
+ ---
713
+
714
+ ## 6. Channels — REST, monitor, serial, QGA, console
715
+
716
+ Every running instance exposes five host-facing channels. Names map to
717
+ absolute file paths under `<machineDir>/`; on Windows they are named
718
+ pipes (`\\.\pipe\…`).
719
+
720
+ | Channel | Purpose | Transport (POSIX) | Transport (Windows) |
721
+ |---|---|---|---|
722
+ | REST | RouterOS HTTP API | TCP `127.0.0.1:{ports.http}` | same |
723
+ | QEMU monitor | `info cpus`, `quit`, `savevm`, etc. | Unix socket `monitor.sock` | named pipe |
724
+ | Serial | RouterOS serial console | Unix socket `serial.sock` | named pipe |
725
+ | QGA (x86 only) | guest agent commands | Unix socket `qga.sock` | named pipe |
726
+ | QEMU log | stdout/stderr of QEMU itself | file `qemu.log` | same |
727
+
728
+ **REST** is what `chr.rest()` and `chr.exec({via: "rest"})` use. It
729
+ **must** go through `src/lib/rest.ts` (which uses `node:http` with
730
+ `agent: false`) — Bun's `fetch()` connection pool reuses sockets after
731
+ machine restarts and returns stale responses. See
732
+ `.github/instructions/bun-http.instructions.md`.
733
+
734
+ **Monitor** is for QEMU itself, not RouterOS. `instance.monitor("info
735
+ cpus")` returns the raw text. quickchr uses it internally for
736
+ `stop()`, `snapshot.save/load/delete`, and `queryLoad()`.
737
+
738
+ **Serial** is a bidirectional byte stream to the RouterOS console. Both
739
+ `chr.serial()` (returns streams) and `quickchr console <name>` (attaches
740
+ stdio) use it. Serial-based `exec` (`--via=console`) does prompt
741
+ detection plus buffer-offset tracking; works in restricted environments
742
+ where REST is unavailable, slower than REST.
743
+
744
+ **QGA** uses a JSON-line protocol (`guest-sync-delimited` first, then
745
+ `guest-exec`, etc.). Available **only** on x86 CHR running under KVM.
746
+ ARM64 CHR doesn't ship the agent; macOS HVF (and any TCG run) don't
747
+ initialize the virtio-serial channel correctly — that's a QEMU 10.x
748
+ behavior documented in `docs/qga-x86-macos-qemu10-investigation.md`.
749
+
750
+ **QEMU log** captures everything QEMU itself writes, including boot
751
+ panics, EFI errors, and signal traces. `quickchr logs <name>` tails it.
752
+
753
+ ---
754
+
755
+ ## 7. Networking
756
+
757
+ Each `--add-network <spec>` adds one NIC. Without any flag, you get a
758
+ single user-mode NIC (SLiRP) on `ether1`. Order matters: `user` should
759
+ always be `ether1` because RouterOS auto-creates a DHCP client only on
760
+ the first interface, and SLiRP's built-in DHCP needs the
761
+ `10.0.2.15` address to make `hostfwd` work (see DESIGN.md
762
+ §SLiRP-must-be-ether1 and `test/lab/slirp-hostfwd/`).
763
+
764
+ ### Which mechanism? (by traffic shape)
765
+
766
+ Picking among the specifiers/forwards by *goal* — full guide with recipes in
767
+ [`docs/networking-recipes.md`](docs/networking-recipes.md):
768
+
769
+ | You want… | Reach for |
770
+ |---|---|
771
+ | Reach a guest TCP/UDP service (REST, SSH, SNMP, container port) | `user` NIC + `--forward`/`extraPorts` (`hostfwd`) |
772
+ | Reach a guest on many/dynamic ports | `--forward name:9200-9210:2000-2010/udp` (range) |
773
+ | Receive UDP the **guest sends** (syslog, NetFlow, TZSP, a reply) | gateway path — guest → `10.0.2.2:<port>`, host binds an **unconnected** socket; **no forward** |
774
+ | Receive guest L2 frames/broadcasts (MNDP, MAC-Telnet) | `socket-connect` NIC ([`docs/mndp.md`](docs/mndp.md)) |
775
+ | L2 link between two VMs | `socket::<name>` |
776
+ | Real LAN presence / DHCP from host | `shared` / `bridged:<iface>` |
777
+
778
+ ### Specifiers
779
+
780
+ | Spec | What it does | Root needed? | Cross-VM? | Host-visible? |
781
+ |---|---|---|---|---|
782
+ | `user` | SLiRP user-mode NAT, hostfwd port mapping | no | no | via hostfwd ports only |
783
+ | `socket::<name>` | L2 socket pair between two VMs sharing the name | no | yes | no |
784
+ | `socket-listen:<port>` / `socket-connect:<port>` / `socket-mcast:<group>:<port>` | low-level QEMU socket netdevs | no | yes | no |
785
+ | `shared` | NAT'd L3 with DHCP from the host | yes (or `socket_vmnet` rootless) | yes | yes |
786
+ | `bridged:<iface>` | L2 bridged onto host iface | yes (or `socket_vmnet` rootless) | yes | yes |
787
+ | `tap:<iface>` | pre-created TAP, no quickchr setup | depends | yes | yes |
788
+ | `wifi`, `ethernet`, `auto` (alias) | resolves to detected host iface | as above | | |
789
+
790
+ Resolution chains:
791
+
792
+ - **`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.
793
+ - **`bridged:<iface>`** on macOS: socket_vmnet bridged → vmnet-bridged (root). On Linux: TAP attached to a bridge containing the named iface.
794
+
795
+ ### Named L2 sockets
796
+
797
+ `quickchr networks sockets create lab-sw1` reserves a port and stores
798
+ metadata in `<dataDir>/networks/lab-sw1.json`. Two machines using
799
+ `--add-network socket::lab-sw1` form an L2 tunnel: the first to start
800
+ listens on the port; the second connects.
801
+
802
+ quickchr unregisters socket members on `stop`/`remove`/`clean` so port
803
+ allocation can recycle.
804
+
805
+ For platform internals (vmnet quirks, TAP creation, bridged-mode
806
+ restrictions, half-open SLiRP under TCG), see `docs/networking.md`.
807
+
808
+ ---
809
+
810
+ ## 8. Storage layout
811
+
812
+ ### macOS / Linux (XDG)
813
+
814
+ ```text
815
+ $XDG_DATA_HOME/quickchr/ (defaults to ~/.local/share/quickchr/)
816
+ ├── cache/ Downloaded RouterOS images
817
+ │ ├── chr-7.22.1.img.zip
818
+ │ ├── chr-7.22.1.img
819
+ │ └── chr-7.22.1-arm64.img
820
+ ├── machines/
821
+ │ └── <name>/
822
+ │ ├── machine.json Persisted MachineState
823
+ │ ├── disk.img | disk.qcow2
824
+ │ ├── extra-disk-*.qcow2
825
+ │ ├── efi-vars.fd arm64 only (UEFI vars)
826
+ │ ├── monitor.sock QEMU monitor
827
+ │ ├── serial.sock serial console
828
+ │ ├── qga.sock QGA (x86 only)
829
+ │ ├── qemu.pid
830
+ │ ├── qemu.log
831
+ │ ├── start-lock start serialization
832
+ │ └── ssh/id_ed25519[.pub] SSH key (if managed user provisioned)
833
+ └── networks/ Named L2 socket reservations
834
+ └── <name>.json
835
+ ```
836
+
837
+ `$XDG_DATA_HOME` defaults to `~/.local/share/quickchr/`. Override the
838
+ whole tree with `QUICKCHR_DATA_DIR=/some/path`.
839
+
840
+ **Global settings** live separately, under the XDG **config** tier, not this
841
+ data tree: `~/.config/quickchr/quickchr.env` (dotenv-style). See §3's
842
+ `settings` command (under Meta) for the command surface and §11 for the env
843
+ var list.
844
+
845
+ ### Windows
846
+
847
+ ```text
848
+ %LOCALAPPDATA%\quickchr\
849
+ ├── cache\
850
+ ├── machines\<name>\
851
+ │ ├── machine.json
852
+ │ ├── disk.img
853
+ │ ├── monitor (named pipe handle, no on-disk file)
854
+ │ └── …
855
+ └── …
856
+ ```
857
+
858
+ Falls back to `%USERPROFILE%\AppData\Local\quickchr\` if `LOCALAPPDATA`
859
+ is unset.
860
+
861
+ ### Credentials (separate from data dir)
862
+
863
+ - **Per-instance CHR credentials**: file in the user config dir
864
+ (`~/.config/quickchr/secrets.json` on POSIX; not the OS keychain — a
865
+ Keychain dialog hangs in non-TTY contexts like tests and CI).
866
+ - **MikroTik.com web account** (for license operations): tries
867
+ `Bun.secrets` (OS keychain) when interactive, falls back to the same
868
+ config-file store otherwise.
869
+
870
+ Both honor explicit CLI flags / env vars first.
871
+
872
+ ---
873
+
874
+ ## 9. Port layout
875
+
876
+ Each instance reserves a contiguous **block of 10 ports** starting at
877
+ `portBase`. Default base is `9100`; auto-allocator scans existing
878
+ machines and TCP-probes for a free block.
879
+
880
+ | Offset | Service | Guest port |
881
+ |---|---|---|
882
+ | +0 | HTTP / REST / Webfig | 80 |
883
+ | +1 | HTTPS | 443 |
884
+ | +2 | SSH | 22 |
885
+ | +3 | API | 8728 |
886
+ | +4 | API-SSL | 8729 |
887
+ | +5 | WinBox | 8291 |
888
+ | +6 | (reserved — QEMU monitor TCP, when used) | — |
889
+ | +7 | (reserved — serial TCP, when used) | — |
890
+ | +8 | (reserved — QGA TCP, when used) | — |
891
+ | +9 | (spare — `extraPorts`) | — |
892
+
893
+ Defaults map the first six offsets. `--no-winbox`, `--no-api-ssl`, and
894
+ `excludePorts: ["…"]` skip individual mappings. `extraPorts:
895
+ [{name,host,guest,proto}]` adds custom forwards in offsets 6–9.
896
+ If an extra mapping reuses a built-in service name such as `winbox`, it
897
+ pins/replaces that service's host port rather than creating a second
898
+ `ports.winbox` entry.
899
+
900
+ If port `9100` is in use, the next instance gets `9110`, etc. Manual
901
+ override via `--port-base`.
902
+
903
+ ---
904
+
905
+ ## 10. Acceleration & timeouts
906
+
907
+ `detectAccel()` selects the fastest available accelerator for each
908
+ arch:
909
+
910
+ | Host | Guest | Accel |
911
+ |---|---|---|
912
+ | macOS x86_64 | x86 | HVF |
913
+ | macOS x86_64 | arm64 | TCG |
914
+ | macOS arm64 (native bun) | arm64 | HVF |
915
+ | macOS arm64 (native bun) | x86 | TCG |
916
+ | macOS arm64 (Rosetta bun) | arm64 | TCG |
917
+ | Linux x86_64 + `/dev/kvm` writable | x86 | KVM |
918
+ | Linux aarch64 + `/dev/kvm` writable | arm64 | KVM |
919
+ | any other combo | any | TCG |
920
+
921
+ `Bun` running under Rosetta on Apple Silicon reports `process.arch ===
922
+ "x64"`, so HVF for arm64 is skipped (architecture mismatch). Run with
923
+ native arm64 Bun to get arm64 HVF.
924
+
925
+ ### Boot timeout
926
+
927
+ ```text
928
+ base = ceil(120s × accelTimeoutFactor(accel, isCrossArch))
929
+ withPackages ? base × 2 : base
930
+ final = base + (timeoutExtra ms)
931
+ ```
932
+
933
+ Native (HVF/KVM): factor ~1 → 120s base.
934
+ Cross-arch TCG: factor ~15 → up to 1800s.
935
+ Package install adds a 2× multiplier (extra reboot cycle).
936
+
937
+ If boot times out, the machine is **automatically stopped + removed**.
938
+ Use `--timeout-extra` to add a buffer if you know your host is slow, or
939
+ persist a default via `quickchr settings set timeout-extra <seconds>` so
940
+ every `start` picks it up without repeating the flag.
941
+
942
+ ---
943
+
944
+ ## 11. Auth, secrets, environment variables
945
+
946
+ **Threat model:** quickchr is a test/dev harness. The privilege boundary
947
+ is "any process running as the user that owns the data dir." We protect
948
+ against:
949
+
950
+ - Off-box access via bridged/NAT'd interfaces (RouterOS ships with empty
951
+ admin password by default; managed login mitigates this).
952
+ - Accidental credential leakage in commits, CI logs, CLI output.
953
+
954
+ We **don't** protect against same-user code reading
955
+ `secrets.json`, `ps`-visible CLI args, or memory inspection.
956
+
957
+ ### Auth resolution (`resolveAuth`)
958
+
959
+ Priority order, used by `chr.rest()`, `chr.exec()`, and
960
+ `subprocessEnv()`:
961
+
962
+ 1. Explicit `--user` / `--password` (or `opts.user` / `opts.password`).
963
+ 2. `state.user.name` from `machine.json` + password from secret store.
964
+ 3. CHR default `admin` with empty password.
965
+
966
+ `disableAdmin: true` does NOT block REST API access — the `expired`
967
+ flag only gates CLI/SSH/Winbox login (and even there it's bypassable
968
+ with Ctrl-C). Don't add workarounds for the "expired admin" myth.
969
+
970
+ ### Secret stores
971
+
972
+ - **Per-instance CHR creds**: config-file only (~/.config/quickchr/`).
973
+ Bun.secrets triggers a Keychain dialog in headless contexts that
974
+ never resolves.
975
+ - **MikroTik.com creds**: Bun.secrets when TTY is available; same
976
+ config file otherwise.
977
+
978
+ ### Settings file (`quickchr.env`)
979
+
980
+ `~/.config/quickchr/quickchr.env` — user-scoped global preferences, separate
981
+ from the secret stores above. Dotenv-style (`QUICKCHR_KEY=value` lines),
982
+ managed via `quickchr settings get|set|print|reset` (§3, Meta). Atomic writes
983
+ (temp file + `fsync` + rename); `set`/`reset` touch only the matching line and
984
+ preserve every other line (comments, blank lines, foreign vars) byte-for-byte.
985
+ `reset <key>` deletes the line entirely rather than blanking it, since a blank
986
+ `KEY=` line is still "set" to a bash-sourced file. Never stores credentials —
987
+ the 5 managed keys are all structural preferences, not secrets.
988
+
989
+ ### Environment variables consumed
990
+
991
+ | Var | Where |
992
+ |---|---|
993
+ | `QUICKCHR_DATA_DIR` | `state.ts` — overrides data root |
994
+ | `QUICKCHR_NO_PROMPT` | CLI dispatcher — forces non-interactive mode |
995
+ | `QUICKCHR_DEBUG` | progress logger — emits `[debug]` lines |
996
+ | `QUICKCHR_INTEGRATION` | `bun:test` gate for `test/integration/` |
997
+ | `QUICKCHR_DEFAULT_CHANNEL` | `settings.ts` — env tier for the `default-channel` setting |
998
+ | `QUICKCHR_DEFAULT_ARCH` | `settings.ts` — env tier for the `default-arch` setting |
999
+ | `QUICKCHR_CACHE_MAX_SIZE` | `settings.ts` — env tier for the `cache-max-size` setting |
1000
+ | `QUICKCHR_TIMEOUT_EXTRA` | `settings.ts` — env tier for the `timeout-extra` setting |
1001
+ | `QUICKCHR_SECURE_LOGIN` | `settings.ts` — env tier for the `secure-login` setting |
1002
+ | `MIKROTIK_WEB_ACCOUNT` | license-renewal account (fallback) |
1003
+ | `MIKROTIK_WEB_PASSWORD` | license-renewal password (fallback) |
1004
+ | `NO_COLOR` | disable ANSI styling |
1005
+ | `LOCALAPPDATA`, `USERPROFILE`, `HOME` | data-dir base resolution |
1006
+
1007
+ ### Environment variables `subprocessEnv()` sets
1008
+
1009
+ For child processes you want to point at the CHR:
1010
+
1011
+ ```text
1012
+ QUICKCHR_NAME machine name
1013
+ QUICKCHR_REST_URL http://127.0.0.1:{ports.http}
1014
+ QUICKCHR_REST_BASE same plus /rest
1015
+ QUICKCHR_SSH_PORT ports.ssh
1016
+ QUICKCHR_AUTH user:password
1017
+ URLBASE legacy alias for QUICKCHR_REST_BASE
1018
+ BASICAUTH legacy user:password value
1019
+ ```
1020
+
1021
+ These values are connection secrets. `QUICKCHR_AUTH` and `BASICAUTH` contain
1022
+ `user:password` credentials; `quickchr inspect` additionally exposes the
1023
+ derived Basic auth header. Do not commit or publicly log these values.
1024
+
1025
+ ---
1026
+
1027
+ ## 12. Exec — running RouterOS commands
1028
+
1029
+ `quickchr exec <name> <command>` and `chr.exec(command, opts)` both
1030
+ funnel through the same transport selector.
1031
+
1032
+ ### Transports
1033
+
1034
+ | `--via` | Source | Notes |
1035
+ |---|---|---|
1036
+ | `auto` (default) | tries REST, falls back to **console** on network errors (not auth errors) | Best general choice |
1037
+ | `rest` | `POST /rest/execute` with `{"script": "…", "as-string": ""}` | RouterOS 7.1+. Synchronous (any value for `as-string` enables sync mode; presence is what counts). 60s server-side timeout. |
1038
+ | `console` | serial socket with prompt detection + buffer offset tracking | Fallback for restricted environments. Uses `\r` (not `\r\n`) on PTY. |
1039
+ | `qga` | QGA `guest-exec` | x86 + KVM only. Throws `QGA_UNSUPPORTED` on ARM64. |
1040
+ | `ssh` | reserved | Currently throws `EXEC_FAILED`; planned. |
1041
+
1042
+ ### Output formatting
1043
+
1044
+ `exec` does NOT have a `--json` flag. Use the RouterOS-native trick:
1045
+
1046
+ ```bash
1047
+ quickchr exec my-chr ":put [:serialize to=json [/ip/address/print detail as-value]]"
1048
+ ```
1049
+
1050
+ `:serialize` accepts `json` and `dsv` to output structure data from an array. The exec result body is the
1051
+ literal stdout — no quickchr processing.
1052
+
1053
+ | Command | Syntax | Description | Example |
1054
+ |:--|:--|:--|:--|
1055
+ | **serialize** | `:serialize [<value>] to=[arg]` | Serialize specified value/array to JSON or dsv (delimiter separated values) format. **`value`** specifies which values to process. **`to`** specifies the format - *json, dsv* **`delimiter`** sets the "separator". **`order`** specifies the order for variables. **`options`** specifies additional options*:* json.pretty  - makes the JSON output more visually appealing;json.no-string-conversion - prevents implicit conversions from console string type to json number type;dsv.wrap-strings - wraps string values inside quotation marks;dsv.ignore-size - if array values have different sizes, e.g. `a=(1,2);b=(3,4);c=(5,6,7)`, this option will work around `array size mismatch` error and set "empty" values in those slots.dsv.remap - merges array of dictionaries into a single dictionary (useful when working with "`print as-value`") **file-name** enables the option to generate command's output into a file (available for download in the "/files" section). | `:put [:serialize value=a,b,c to=json]``["a","b","c"]``:local test {a=(1,2,3);b=(4,5,6);c=(7,"text",9)}; :put [ :serialize to=dsv delimiter=";" value=$test order=("c","a","b") ]``c;a;b``7;1;4``text;2;5``9;3;6``:global var ({ "string"="1234"; "number"=1234 });:put [ :serialize to=json value=$var ]``{"number":1234,"string":1234.000000}``:put [ :serialize to=json value=$var options=json.no-string-conversion ]``{"number":1234,"string":"1234"}``:put [:serialize to=dsv options=dsv.remap delimiter="#" [/ip/address/print as-value]]``.id#address#comment#interface#network``*1#192.168.88.1/24#defconf#bridge#192.168.88.0``*2#192.168.69.190/24##ether1#192.168.69.0`|
1056
+
1057
+ _Source: <https://manual.mikrotik.com/docs/developer-guides/scripting/index.md>
1058
+ > Note: `:serialize` expects an array, so a `/print` *without* `as-value` arg will return nothing when serialized using `to=json` or `to=dsv`.
1059
+
1060
+ ### Authentication
1061
+
1062
+ Same `resolveAuth()` chain as REST. Override on the CLI with
1063
+ `--user u --password p`, or in the library with `{user, password}` in
1064
+ `ExecOptions`.
1065
+
1066
+ ### Timeouts
1067
+
1068
+ `opts.timeout` defaults to 30s for QGA/console and 10s for REST when
1069
+ running under `auto` (so the fallback to console kicks in quickly).
1070
+ On explicit `--via=rest`, the 60s server-side `as-string` cap is the
1071
+ ceiling.
1072
+
1073
+ ---
1074
+
1075
+ ## 13. Disks & snapshots
1076
+
1077
+ ### Boot disk
1078
+
1079
+ Default format is **raw** (the image MikroTik ships). `--boot-size`
1080
+ auto-converts to **qcow2** before the first boot and resizes to the
1081
+ requested size. Requires `qemu-img` on the host.
1082
+
1083
+ `--boot-disk-format=qcow2` (without `--boot-size`) just keeps the
1084
+ default size but enables snapshots.
1085
+
1086
+ ### Extra disks
1087
+
1088
+ `--add-disk <size>` (repeatable) creates blank qcow2 images and attaches
1089
+ them as additional virtio-blk-pci devices. RouterOS sees them as
1090
+ `ether2`/`disk2`/etc. depending on whether you've also added NICs.
1091
+ `quickchr disk <name>` shows the layout; with `qemu-img` it also reports
1092
+ virtual + actual sizes.
1093
+
1094
+ ### Custom port forwards and service port pinning
1095
+
1096
+ `--forward <spec>` (repeatable on `add` and `start`) adds a QEMU SLiRP
1097
+ `hostfwd` mapping. Spec grammar is `name[:host[:guest]][/proto]`.
1098
+ When `name` matches a key or alias in the built-in
1099
+ `WELL_KNOWN_GUEST_PORTS` registry (e.g. `smb`/`cifs`, `winbox`, `mqtt`,
1100
+ `http-alt`, `dns`, `snmp`, …), the `guest` port and `proto` are filled
1101
+ in for you; otherwise both must be supplied. Omitting `host` (or passing
1102
+ `0`) auto-allocates a free host port, mirroring the default service-port
1103
+ behaviour.
1104
+
1105
+ Reusing a built-in service name pins that service's host port. This is
1106
+ useful for legacy clients that assume a fixed local port. For example,
1107
+ the Windows Dude client's reliable Wine command-line login path connects
1108
+ to `127.0.0.1:8291`, so a fresh lab machine can be created with WinBox
1109
+ pinned directly to that host port:
1110
+
1111
+ ```bash
1112
+ quickchr add my-chr --forward smb # host auto, guest 445/tcp
1113
+ quickchr add dude-lab --forward winbox:8291 # host 8291, guest 8291/tcp
1114
+ quickchr add my-chr --forward winbox:9300 # host pinned, guest 8291/tcp
1115
+ quickchr add my-chr --forward myapp:9200:7777/udp # fully explicit
1116
+ ```
1117
+
1118
+ **Port ranges.** For L3 peer protocols with dynamic data ports (e.g. RouterOS
1119
+ bandwidth-test allocates UDP ports at runtime), a range spec
1120
+ `name:hostStart-hostEnd[:guestStart-guestEnd][/proto]` expands to one `hostfwd`
1121
+ per port (QEMU has no native range):
1122
+
1123
+ ```bash
1124
+ quickchr add btest-lab --forward btest:9200-9210:2000-2010/udp
1125
+ ```
1126
+
1127
+ Rules: the host range is **required** (ranges are not auto-allocated), the guest
1128
+ range defaults to the host numbers when omitted and must be the same length, and a
1129
+ range may span at most 64 ports. Programmatically, `expandForwardSpec(spec)`
1130
+ returns the `PortMapping[]` for an `extraPorts` array; `parseForwardSpec(spec)`
1131
+ remains the single-port form. To *receive* guest-originated UDP with no forward at
1132
+ all, see §7 (the gateway path) and `docs/networking-recipes.md`.
1133
+
1134
+ Port maps are part of `machine.json`. Once a machine exists, `quickchr
1135
+ start <name>` uses the stored mapping; create a new machine (or edit the
1136
+ state deliberately) if a built-in service needs a different host port.
1137
+
1138
+ ### `clean`
1139
+
1140
+ `quickchr clean <name>` re-copies the boot image from cache and
1141
+ recreates extra disks per `machine.json`. Snapshots are gone (they live
1142
+ inside the qcow2 file). Per-instance credentials are wiped so the next
1143
+ boot re-provisions them.
1144
+
1145
+ ### Snapshots
1146
+
1147
+ Use the QEMU monitor via `instance.snapshot`:
1148
+
1149
+ ```ts
1150
+ await chr.snapshot.save("before-upgrade");
1151
+ await chr.snapshot.load("before-upgrade");
1152
+ await chr.snapshot.list();
1153
+ await chr.snapshot.delete("before-upgrade");
1154
+ ```
1155
+
1156
+ CLI:
1157
+
1158
+ ```bash
1159
+ quickchr snapshot my-chr save before-upgrade
1160
+ quickchr snapshot my-chr list
1161
+ ```
1162
+
1163
+ Constraints:
1164
+
1165
+ - Boot disk must be qcow2.
1166
+ - `save` and `load` need the machine running (uses `savevm`/`loadvm`).
1167
+ - `list` and `delete` work either way (`qemu-img info` when stopped).
1168
+ - Snapshots live inside the qcow2 file, so `remove` deletes them all.
1169
+ - QEMU's practical cap is 16 snapshots per disk.
1170
+
1171
+ ---
1172
+
1173
+ ## 14. Errors & recovery
1174
+
1175
+ Every library throw is `QuickCHRError(code, message, installHint?)`.
1176
+ The CLI catches it and prints `[code] message` plus the install hint if
1177
+ present, then exits `1`.
1178
+
1179
+ ### Codes
1180
+
1181
+ | Code | Likely cause | First check |
1182
+ |---|---|---|
1183
+ | `MISSING_QEMU` | host arch QEMU binary not on PATH | `quickchr doctor` |
1184
+ | `MISSING_FIRMWARE` | edk2 UEFI firmware not installed (arm64) | install `qemu-efi-aarch64` / `edk2-aarch64` |
1185
+ | `MISSING_UNZIP` | image extraction needs `unzip` | install OS package |
1186
+ | `PORT_CONFLICT` | no free 10-port block | stop a machine or set `--port-base` |
1187
+ | `BOOT_TIMEOUT` | CHR didn't respond within timeout | check `qemu.log`; under TCG, raise `--timeout-extra`; machine is **auto-removed** |
1188
+ | `DOWNLOAD_FAILED` | image fetch failed | retry; check connectivity to MikroTik download server |
1189
+ | `INVALID_VERSION` / `INVALID_ARCH` / `INVALID_NAME` / `INVALID_DISK_SIZE` | argument parse | fix the value |
1190
+ | `MACHINE_EXISTS` | name already in `machines/` | choose another name or `QuickCHR.get(name)` |
1191
+ | `MACHINE_NOT_FOUND` | name not in `machines/` | `quickchr list` |
1192
+ | `MACHINE_RUNNING` / `MACHINE_STOPPED` | wrong lifecycle state | `start` or `stop` first |
1193
+ | `MACHINE_LOCKED` | another `start` in progress | wait or pick another name |
1194
+ | `EXEC_FAILED` | RouterOS rejected the command (auth, syntax) | check creds, command syntax |
1195
+ | `PROCESS_FAILED` | QEMU spawn / reboot / verify failed | check `qemu.log`, host resources |
1196
+ | `SPAWN_FAILED` | exec/spawn of host binary failed | check binary exists + executable |
1197
+ | `QGA_UNSUPPORTED` | QGA on ARM64 | use `--via=rest` or `--via=console` |
1198
+ | `QGA_TIMEOUT` | QGA daemon unreachable | KVM-only; not on macOS HVF or TCG |
1199
+ | `NETWORK_UNAVAILABLE` | provisioning needs `user` NIC, none present | add `--add-network user` |
1200
+ | `INVALID_NETWORK` | unknown alias / unresolvable iface / dead socket daemon | `quickchr networks` |
1201
+ | `PROVISIONING_VERSION_UNSUPPORTED` | provisioning option on RouterOS < 7.20.8 | use `--channel long-term` or pin a `7.20.8+` version |
1202
+ | `INSUFFICIENT_DISK_SPACE` | host data dir full | free space or move via `QUICKCHR_DATA_DIR` |
1203
+ | `STATE_ERROR` | snapshot on raw boot disk | recreate with `--boot-disk-format=qcow2` |
1204
+ | `INVALID_SIZE_STRING` | invalid cache/settings size string | use size syntax like `512M`, `2G`, or `1.5G` |
1205
+ | `INVALID_SETTING_KEY` | `settings get`/`set`/`reset` received an unrecognized key | `quickchr settings print` to see the managed keys |
1206
+ | `INVALID_SETTING_VALUE` | `settings set` (or a `QUICKCHR_*` env var / `quickchr.env` line) failed validation for a recognized key | `quickchr help settings` for the expected type/enum per key |
1207
+
1208
+ ### Auto-cleanup on boot timeout
1209
+
1210
+ If `start()` hits `BOOT_TIMEOUT`, quickchr automatically calls
1211
+ `stop()` then `remove()` on the failing instance. You'll see a
1212
+ diagnostic and the machine will be gone from `quickchr list`. This
1213
+ prevents orphan disks accumulating across attempts.
1214
+
1215
+ ### Where to look
1216
+
1217
+ 1. `quickchr doctor` — prerequisites, accelerator, disk space.
1218
+ 2. `quickchr logs <name>` — QEMU's own stderr/stdout, including boot
1219
+ panics, EFI errors, signal traces.
1220
+ 3. `<machineDir>/machine.json` — last-known state, ports, options.
1221
+ 4. `bun run check` — type/lint regressions if you've been editing.
1222
+ 5. `.github/instructions/` — agent-facing rules with deeper
1223
+ troubleshooting matrices for QEMU, RouterOS REST, provisioning,
1224
+ testing, CI, and Bun HTTP quirks.
1225
+
1226
+ ---
1227
+
1228
+ ## 15. Cross-references
1229
+
1230
+ - **README.md** — quickstart, install, simple examples.
1231
+ - **DESIGN.md** — architecture, layers, port/storage tables, design
1232
+ principles (scope boundary, networking philosophy, SLiRP-must-be-ether1,
1233
+ exec transport design, examples philosophy).
1234
+ - **CHANGELOG.md** — release history; `[Unreleased]` is the working
1235
+ set, `[0.1.1]` is the first GitHub release.
1236
+ - **CONTRIBUTING.md** — dev setup and `bun run check`.
1237
+ - **docs/networking-recipes.md** — "which mechanism for which traffic
1238
+ shape" decision guide (hostfwd, the guest→host gateway path, L2 capture,
1239
+ VM↔VM links, LAN). Start here when picking a networking approach.
1240
+ - **docs/networking.md** — platform-specific QEMU networking
1241
+ internals (vmnet, TAP, socket netdevs).
1242
+ - **docs/mndp.md** — worked recipe for receiving guest L2 broadcasts
1243
+ (MNDP) on the host via a `socket-connect` NIC.
1244
+ - **docs/qga-x86-macos-qemu10-investigation.md** — root cause for the
1245
+ QGA-on-macOS-arm64 limitation.
1246
+ - **`.github/instructions/`** — agent-facing rules (testing, qemu,
1247
+ routeros-rest, provisioning, ci, bun-http, general).
1248
+ - **`examples/`** — runnable scripts (`bun run`): `quickstart` (single-CHR smoke),
1249
+ `grounding` (apply config → read back — the one `bun:test`), `harness` (drive an
1250
+ external tool via `subprocessEnv()`/`descriptor()`), `dude` (install a package),
1251
+ `rollback`/`service-forward`/`file-transfer`/`device-mode`/`trial-license`,
1252
+ `version-matrix` (parallel matrix), `mndp`/`udp-gateway` (networking). Coverage
1253
+ map: `examples/COVERAGE.md`; convention: `.github/instructions/examples.instructions.md`.
1254
+ - **`routeros-quickchr` skill** (in
1255
+ [tikoci/routeros-skills](https://github.com/tikoci/routeros-skills)) —
1256
+ agent-facing guide to grounding RouterOS against a real router with quickchr
1257
+ (the apply→read-back loop, networking recipes, harness patterns).
1258
+ - **BACKLOG.md** — a map of how work is tracked. Open work lives in GitHub
1259
+ Issues; see CONTRIBUTING.md "Tracking work".