@tikoci/quickchr 0.4.2 → 0.4.4

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