# quickchr Manual

> Audit-style reference for what quickchr actually does today. README is the
> elevator pitch and quickstart; DESIGN.md is the architecture rationale;
> this MANUAL is the complete, source-checked map of every CLI command,
> library API, and provisioning step.

## Table of Contents

1. [What quickchr is](#1-what-quickchr-is)
2. [Install & doctor](#2-install--doctor)
3. [CLI command reference](#3-cli-command-reference)
4. [Library API reference](#4-library-api-reference)
5. [Provisioning pipeline](#5-provisioning-pipeline)
6. [Channels — REST, monitor, serial, QGA, console](#6-channels--rest-monitor-serial-qga-console)
7. [Networking](#7-networking)
8. [Storage layout](#8-storage-layout)
9. [Port layout](#9-port-layout)
10. [Acceleration & timeouts](#10-acceleration--timeouts)
11. [Auth, secrets, environment variables](#11-auth-secrets-environment-variables)
12. [Exec — running RouterOS commands](#12-exec--running-routeros-commands)
13. [Disks & snapshots](#13-disks--snapshots)
14. [Errors & recovery](#14-errors--recovery)
15. [Cross-references](#15-cross-references)

---

## 1. What quickchr is

A **CLI plus importable library** for managing MikroTik CHR (Cloud Hosted
Router) virtual machines on macOS, Linux, and (experimentally) Windows.
quickchr wraps QEMU directly — it is **not** an orchestrator. Each
invocation manages one CHR instance; multi-router topologies are composed
by the user (or their AI agent) using `quickchr` as a building block.

**Scope boundary** (see DESIGN.md §Scope Boundary):

- ✅ Download CHR images, manage QEMU lifecycle, expose REST/SSH/WinBox
  ports, persist machine state, run RouterOS CLI commands, install
  packages, apply trial licenses, switch device-mode.
- ❌ Multi-router topology orchestration, test-matrix runners,
  configuration-as-code DSLs. Use `examples/` as references and compose
  yourself.

**Two ways to use it:**

- `quickchr <command>` — the CLI. Git-style subcommands plus an interactive
  setup wizard (`quickchr setup`).
- `import { QuickCHR } from "@tikoci/quickchr"` — the library. Returns
  `ChrInstance` handles for programmatic control.

---

## 2. Install & doctor

### Prerequisites

| Component | Required for | Install |
|---|---|---|
| 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`) |
| `qemu-system-x86_64` | x86 CHR | brew/apt/dnf/pacman · Windows: `winget install SoftwareFreedomConservancy.QEMU` |
| `qemu-system-aarch64` | arm64 CHR | brew/apt/dnf/pacman · Windows: bundled in the SFC QEMU package above |
| `qemu-img` | `--boot-size`, `--add-disk`, snapshots | included in all standard QEMU packages (brew, apt, dnf, SFC installer) |
| UEFI firmware (edk2) | arm64 CHR | `qemu-efi-aarch64` (apt), `edk2-aarch64` (dnf), bundled with `qemu` (brew) |
| `socket_vmnet` (optional) | rootless `shared`/`bridged` networking on macOS | `brew install socket_vmnet` |
| `socat` (optional) | piping serial console externally | brew/apt |
| `sshpass` (optional) | future SSH exec transport | brew/apt |

### Install quickchr

The first GitHub release is **0.1.1** — pre-release per the changelog's
odd/even policy. Not yet on npm.

```bash
# Library
bun add github:tikoci/quickchr

# CLI from source (until npm publish)
git clone https://github.com/tikoci/quickchr
cd quickchr
bun install
bun run dev -- doctor
```

Once `0.2.0` is published to npm:

```bash
bun install -g @tikoci/quickchr
```

### `quickchr doctor`

Reports prerequisite status, accelerator availability, image cache
size, machine count, free disk space, and shell-completion install
state. Exit code is `0` when no checks return `error` (warnings are OK).

```bash
quickchr doctor              # human-readable
quickchr doctor --json       # { ok, checks, staleImages } for CI
```

The `--json` form emits `{ ok, checks, staleImages }`, where `checks` is an
array of `{ label, status, detail }` and `staleImages` lists cached images older
than the current long-term release. The exit code still reflects `ok`.

![quickchr doctor output](./images/doctor-demo.gif)

Use this whenever a `start` fails — the diagnostic almost always points
at a missing dependency, missing firmware, or insufficient disk.

---

## 3. CLI command reference

The dispatcher lives in `src/cli/index.ts` (lines 121–194). Help text is
available for every command via `quickchr help <command>`.

### Lifecycle

#### `add [name]`

Create a machine without starting it. Materializes the machine
directory, downloads the RouterOS image (cached), and writes
`machine.json` with all provisioning options stored for later.

| Flag | Default | Notes |
|---|---|---|
| `--name <s>` | auto | `host-arch-N` if omitted |
| `--version <ver>` | latest in channel | e.g., `7.22.1` |
| `--channel <ch>` | `stable` | `stable`, `long-term`, `testing`, `development` |
| `--arch <a>` | host native | `arm64`, `x86`, or `auto` |
| `--cpu <n>` | `1` | vCPU count |
| `--mem <mb>` | `512` | RAM (cross-arch TCG bumps to `1024`) |
| `--boot-disk-format <f>` | `qcow2` | `qcow2` or `raw` |
| `--boot-size <size>` | — | Resize boot disk; needs `qemu-img`; auto-converts to qcow2 |
| `--add-disk <size>` | — | Extra blank qcow2 disk; repeatable |
| `--forward <spec>` | — | Extra hostfwd port or built-in service host-port pin; repeatable. See §Custom port forwards. |
| `--add-package <pkg>` | — | Provisioning, repeatable, ≥ 7.20.8 |
| `--install-all-packages` | false | Provisioning, ≥ 7.20.8 |
| `--add-user <user:pass>` | — | Provisioning, ≥ 7.20.8 |
| `--disable-admin` | false | Provisioning, ≥ 7.20.8 |
| `--secure-login` / `--no-secure-login` | unset | Provisioning, ≥ 7.20.8 |
| `--license-level <l>` | — | `p1`, `p10`, `unlimited`, ≥ 7.20.8 |
| `--license-account <a>` | env `MIKROTIK_WEB_ACCOUNT` | MikroTik.com email |
| `--license-password <p>` | env `MIKROTIK_WEB_PASSWORD` | |
| `--device-mode <m>` | — | `rose`, `advanced`, `basic`, `home`, `auto`, `skip`, ≥ 7.20.8 |
| `--device-mode-enable <f,...>` | — | Repeatable, comma-separated |
| `--device-mode-disable <f,...>` | — | Repeatable, comma-separated |
| `--add-network <spec>` | `user` | See §7. Repeatable. |
| `--no-network` | false | Headless, no NICs |
| `--no-winbox` | false | Exclude WinBox port |
| `--no-api-ssl` | false | Exclude API-SSL port |
| `--port-base <n>` | auto from 9100 | First port of the 10-port block |

`--vmnet-shared` and `--vmnet-bridge <iface>` are deprecated aliases for
`--add-network shared` / `--add-network bridged:<iface>`.

#### `start [name]`

Boot a stopped machine, or create-and-boot in one command. Blocks until
REST is ready (background mode, default), or attaches QEMU stdio to your
terminal (`--fg`). Accepts the same provisioning flags as `add`.

Additional flags:

| Flag | Notes |
|---|---|
| `--all` | Start every stopped machine in background |
| `--bg` / `--background` | (default) detach QEMU; return after boot |
| `--fg` / `--foreground` (alias `--no-bg`, `--no-background`) | Serial console on stdio |
| `--install-deps` | Auto-install missing host deps (calls package manager) |
| `--timeout-extra <s>` / `-T <s>` | Add extra seconds to computed boot timeout |
| `--dry-run` | Print resolved options and QEMU command, exit |

Foreground mode shortcuts (QEMU mux):

| Key | Action |
|---|---|
| `Ctrl-A X` | Quit QEMU |
| `Ctrl-A C` | Toggle monitor (`quit` to force-stop from monitor) |
| `Ctrl-A H` | Help |

When `--fg` is combined with provisioning options, quickchr boots
**background** first, runs provisioning, then **stops** and re-launches
foreground with stdio attached. This is observable as a brief stop in
machine state.

#### `stop [name | --all]`

Graceful shutdown via QEMU monitor. Updates `state.status = "stopped"`
and unregisters socket-named networks. Listing-mode if no name and not
`--all`.

#### `remove [name | --all]` (alias `rm`)

Stops if running, deletes the machine directory, and clears per-instance
credentials from the secret store.

#### `clean <name>`

Resets the machine to a fresh image: stops QEMU, re-copies the boot
image from cache, recreates extra disks per the saved config, deletes
EFI vars (UEFI re-initializes), and clears per-instance credentials.
Provisioning options remain in `machine.json` and re-run on next `start`.

### Inspection

#### `list [name]` (alias `ls`, `status`)

Without a name: tabular summary of every machine. With a name: detailed
view including credentials and connection tips. `--json` for
machine-readable output.

#### `get <name> [license | device-mode | admin] [--json]`

Live REST query. Hits the running CHR and returns the requested group
(or all if no group specified). Requires the machine to be running.

#### `inspect <name> [--json]`

Stable, versioned (`descriptorVersion: 1`) connection descriptor for a
**running** CHR — the quickchr ↔ centrs interface contract (issue #71,
[`docs/centrs-interface.md`](docs/centrs-interface.md)). Machine identity
(name, version, arch, cpu, mem, pid, machineDir, timestamps) plus a
per-service `services` map (`rest-api`, `native-api`, `ssh`), each entry
giving its host, port, transport, `tls` flag, availability, and auth; plus
optional `customForwards` (e.g. `winbox`) and topology-only `networks`.
`--json` is accepted for symmetry with other read commands; output is
always JSON. Does not include subprocess env vars — use `quickchr env` for
those.

Stopped machines fail with `MACHINE_STOPPED` instead of returning stale
connection details. Use `quickchr start <name>` first.

#### `env <name> [--json]`

Print the subprocess environment for a **running** CHR. By default it emits
shell-ready `KEY=value` lines; with `--json` it emits just the env map as
JSON. This is CLI parity with `ChrInstance.subprocessEnv()`.

The env map includes credentials (`QUICKCHR_AUTH`, `BASICAUTH`) so subprocesses
can connect without reading quickchr's secret store. Treat `quickchr env` and
`quickchr inspect` output like password material: avoid commits, public issue
comments, and unredacted CI logs.

#### `disk [name]`

Shows stored disk layout. With `qemu-img` installed, also reports
virtual + actual sizes for each disk.

#### `logs <name> [--follow] [-n N]`

Tail the per-machine `qemu.log`. `--follow` streams new output;
`-n` limits to last N lines.

#### `networks` (alias `net`)

`networks` enumerates host interfaces and their alias resolution
(`wifi`, `ethernet`, `auto`). `networks sockets` lists registered
named L2 sockets; `networks sockets create <name>` reserves a port for a
new tunnel.

#### `version [--json]`

Print quickchr version (matches `package.json`) plus the latest RouterOS
version per channel. `--json` emits just the channel map for CI consumers:

```bash
quickchr version --json
# {"stable":"7.23.1","long-term":"7.21.4","testing":"7.23rc4","development":"7.24beta2"}
```

Offline, `--json` emits `{}`. For the "which channels are worth booting right
now" smarts (recency-aware active channels), import `resolveActiveChannels` /
`resolveChannelStatuses` from the library — see *Version & channel helpers* under
the Library API reference.

### Interaction

#### `exec <name> <command> [--via=auto|rest|qga|console|ssh] [--user u] [--password p] [--timeout=ms]`

Run a RouterOS CLI command. See §12 for transport details and JSON
output trick.

#### `console <name>`

Attach to the QEMU serial socket. Exit with `Ctrl-A X` (or `Ctrl-]` if
the underlying transport is socat). Requires the machine to be running.

#### `qga <name> <subcommand> [args...]`

Direct QEMU Guest Agent commands (`ping`, `info`, `osinfo`, `hostname`,
`time`, `timezone`, `network-interfaces`, `fsfreeze-status`, `exec`,
`file-read`, `file-write`, `shutdown`).

QGA is **x86 only and KVM only**. ARM64 CHR doesn't ship the agent;
macOS HVF and any TCG fallback don't initialize the virtio channel
(see `docs/qga-x86-macos-qemu10-investigation.md` for the QEMU bug).

### Configuration

#### `license <name> [--level p1|p10|unlimited]`

Apply or renew a CHR trial license on a running instance. Resolves
MikroTik.com credentials from `--license-account`/`--license-password`,
the env vars, or the secret store. Requires RouterOS ≥ 7.20.8.

#### `set <name> <subcommand>`

Currently exposes a small surface for in-place changes (e.g.,
`set <name> license …`). The `set/get` architecture is still being
designed; expect additions.

#### `snapshot <name> <list | save [snapname] | load <snapname> | delete <snapname>>` (alias `snap`)

Internal qcow2 snapshots. `save` requires the machine running (`savevm`
goes through QEMU monitor). `list` and `delete` work in either state
(uses `qemu-img info` when stopped). Requires `bootDiskFormat: qcow2`.

### Meta

#### `setup`

Interactive wizard (`@clack/prompts`) that walks through all machine creation
options and starts the CHR. Triggered by `quickchr setup` or automatically
when `quickchr` is run with no subcommand on a TTY.

![quickchr setup wizard walkthrough](./images/wizard-demo.gif)

**First-run detection**: if no machines exist yet, the wizard opens with a
welcome message before the first prompt.

**Setup menu loop**: when running via `quickchr setup` (not the bare `quickchr`
shortcut), a main menu appears after each machine starts, offering to create
another machine, view status, or quit.

**Wizard steps** (all traced from `src/cli/wizard.ts`):

| Step | Prompt | Default | Corresponding flag |
|---|---|---|---|
| 1 | Version source: channel or specific | channel | `--channel` / `--version` |
| 2 | Channel (if channel chosen) | `stable` (or the `default-channel` setting) | `--channel` |
| 3 | Architecture | host native (or the `default-arch` setting) | `--arch` |
| 4 | Instance name | auto-generated | `--name` |
| 5 | CPU cores | 1 | `--cpu` |
| 6 | Memory | 256 MB | `--mem` |
| 7 | Boot disk format (qcow2 / raw) | qcow2 | `--boot-disk-format` |
| 8 | Boot disk resize (optional, qcow2 only) | no resize | `--boot-size` |
| 9 | Extra blank disks (repeatable) | none | `--add-disk` |
| 10 | Additional networks (macOS/Linux) | user-mode only | `--add-network` |
| 11 | Provisioning (packages, device-mode, user, license) | optional | various |
| 12 | Run mode: background / foreground | background | `--bg` / `--fg` |
| 13 | Final confirmation | — | — |

Steps 7–9 are skipped when `qemu-img` is absent (a warning is shown). Step 10
only appears when the platform supports networks beyond user-mode (macOS or
Linux). The provisioning gate (step 11) only appears for RouterOS ≥ 7.20.8;
older versions show an upgrade prompt.

**Provisioning sub-steps** (within step 11):

| Sub-step | Options |
|---|---|
| Packages | No extra / All packages / Choose (multiselect per arch) |
| Device-mode | no / yes → profile (rose/advanced/basic/home) + extra feature flags |
| Login | quickchr managed (recommended) / custom user+pass / keep admin no-password |
| License | no / yes → level (p1/p10/unlimited) + credentials (stored or entered) |

**Post-start**: after a successful background boot, the wizard shows port
details (REST URL, SSH, WinBox), login credentials, and optionally installs
shell completions if not yet configured.

**Notes**:

- "Managed login" sets both `disableAdmin: true` and `secureLogin: true` —
  the provisioning step only auto-creates the `quickchr` managed account when
  `secureLogin` is explicitly `true`, so both fields must be set together or
  admin would be disabled with no replacement login provisioned.
- Set `QUICKCHR_NO_PROMPT=1` to suppress all wizard flows (useful in scripts
  and CI pipelines).
- `Ctrl-C` at any prompt cleanly cancels with no partial machine left behind.

#### `completions [bash|zsh|fish]`

Print or install shell completions. With no arg, detects current shell.

#### `settings [print|get|set|reset] [key] [value] [--json]`

Manage quickchr's own global preferences — repeated flags/defaults that
previously had no persisted home. Stored in `~/.config/quickchr/quickchr.env`
(dotenv-style: `QUICKCHR_KEY=value` lines). See §8 (Storage layout) and §11
(Environment variables) for the file format and full env var list.

Precedence per key (highest wins): CLI flag > `QUICKCHR_<KEY>` env var >
`quickchr.env` > built-in default.

| Key | Consumed by | Type | Built-in default |
|---|---|---|---|
| `default-channel` | `add`/`start`'s `--channel`, wizard's channel prompt | `stable`\|`long-term`\|`testing`\|`development` | `stable` |
| `default-arch` | `add`/`start`'s `--arch`, wizard's arch prompt | `arm64`\|`x86`\|`auto` | `auto` (host native) |
| `accel` | `add`/`start`'s `--accel`; every `detectAccel()` call in the run | `auto`\|`tcg`\|`hvf`\|`kvm` | `auto` (detect) |
| `cache-max-size` | Auto-prune cap applied after each successful `start` | size string (e.g. `2G`, `512M`) | `2G` |
| `timeout-extra` | `start`'s `--timeout-extra`/`-T` when omitted | non-negative integer seconds | `0` |
| `secure-login` | `add`/`start`'s `--secure-login` when neither `--secure-login` nor `--no-secure-login` is passed | boolean | `unset` (reported as `(unset)` — see note) |

Anything other than `auto` for `accel` is handed to QEMU verbatim and **skips detection
entirely**, including the arm64-on-Apple-Silicon TCG fallback (§Platform notes) and the
availability checks — force `kvm` on macOS and QEMU itself reports the error. That is
deliberate: the override exists to test an accelerator the detector currently refuses.

`secure-login` is the one key `settings print`/`get` report as `(unset)` rather than a
concrete `false`, unlike the others: the setup wizard's login prompt needs to tell
"not configured" apart from "explicitly set to false" (it only pre-highlights "Keep
admin with no password" when the setting is concretely `false`; leaving it unset keeps
the wizard's own recommended "managed login" highlighted). Giving it a real
`builtinDefault` would collapse that distinction. Every *consumer* still treats
"unset" and "false" identically — `add`/`start` never force `secureLogin: true` unless
the setting resolves to exactly `true`.

```bash
quickchr settings print                       # all 6 keys, resolved value + source
quickchr settings set default-channel testing # write one key to quickchr.env
quickchr settings get default-arch            # print one key's resolved value
quickchr settings reset default-channel       # delete one managed line
quickchr settings reset                       # clear all managed lines
```

Credentials (`--add-user`, `MIKROTIK_WEB_ACCOUNT`/`MIKROTIK_WEB_PASSWORD`) are
never stored here — `settings` only manages the 6 keys above, and does not
mutate any machine's `machine.json`.

### Global behavior

| Env var | Effect |
|---|---|
| `QUICKCHR_DATA_DIR` | Override the data root (defaults to `$XDG_DATA_HOME/quickchr` or platform equivalent — see §8) |
| `QUICKCHR_NO_PROMPT` | Force non-interactive mode (skips all wizards) |
| `QUICKCHR_DEBUG` | Emit `[debug]` lines from the progress logger |
| `QUICKCHR_INTEGRATION` | Required to run `test/integration/` |
| `MIKROTIK_WEB_ACCOUNT`, `MIKROTIK_WEB_PASSWORD` | License credentials fallback |
| `NO_COLOR` | Disable ANSI styling |

`--json` is honored on `list`, `status`, `get`, `env`, `snapshot`, and a
few others; `inspect` is always JSON and accepts `--json` for parity. Check
`quickchr help <command>`. Errors are caught at the top level and exit `1`
with a `[code] message` line plus the install hint when present.

---

## 4. Library API reference

Public exports are in `src/index.ts`. The two surfaces you use are the
`QuickCHR` class (machine lifecycle) and the `ChrInstance` interface
(per-instance operations).

### Static methods on `QuickCHR`

```ts
QuickCHR.start(opts?: StartOptions): Promise<ChrInstance>
QuickCHR.add(opts?: StartOptions): Promise<MachineState>
QuickCHR.list(): MachineState[]
QuickCHR.get(name: string): ChrInstance | null
QuickCHR.doctor(): Promise<DoctorResult>
QuickCHR.resolveVersion(channel: Channel): Promise<string>
```

`start()` creates the machine if needed, downloads the image, allocates
a port block, spawns QEMU, waits for REST readiness, and runs all
provisioning steps before returning. It auto-cleans the machine if boot
times out (calls `stop()` then `remove()`). It acquires `start-lock` to
serialize concurrent starts of the same machine.

`add()` is the same minus the QEMU spawn — useful when you want to
materialize a machine and start it later (or hand the config to another
process).

`get()` returns `null` if the machine doesn't exist; otherwise it
loads `machine.json`, refreshes PID liveness, and returns a runtime
handle. `list()` is `get()` for everything in `machines/`.

### `ChrInstance`

**At a glance** — every method on a running instance:

| Group | Member | Purpose |
|---|---|---|
| Identity | `name`, `state`, `ports`, `restUrl`, `sshPort`, `portBase` | Static metadata persisted in `machine.json` |
| Capture | `captureInterface`, `tzspGatewayIp` | Platform-correct values for TZSP capture (`tshark -i …`) and RouterOS streaming target |
| Lifecycle | `waitForBoot()`, `waitFor(fn, ms?)`, `stop()`, `remove()`, `clean()`, `destroy()` | Boot readiness, custom polling, teardown |
| Comms | `rest()`, `exec()`, `monitor()`, `serial()`, `qga()` | REST, RouterOS CLI, QEMU monitor, serial console, QGA (x86 only) |
| Provisioning | `license()`, `setDeviceMode()`, `availablePackages()`, `installPackage()` | Post-boot configuration |
| Files | `upload()`, `download()` | SCP transfer to/from the CHR |
| Snapshots | `snapshot.{list,save,load,delete}()` | qcow2 savevm/loadvm |
| Diagnostics | `queryLoad()`, `subprocessEnv()`, `descriptor()` | Live CPU/mem sample, env vars/descriptor for child processes |

```ts
interface ChrInstance {
  name: string;
  state: MachineState;        // full persisted config
  ports: ChrPorts;            // {http, https, ssh, api, apiSsl, winbox, ...}
  restUrl: string;            // http://127.0.0.1:{ports.http}
  sshPort: number;
  portBase: number;           // start of this instance's reserved port block
  captureInterface: string;   // "lo0" on macOS, "any" on Linux — for TZSP capture
  tzspGatewayIp: string;      // "10.0.2.2" — QEMU slirp host gateway

  waitForBoot(timeoutMs?: number): Promise<boolean>;
  waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
  stop(): Promise<void>;
  remove(): Promise<void>;
  clean(): Promise<void>;
  destroy(): Promise<void>;   // stop + remove

  rest(path: string, opts?: RequestInit): Promise<unknown>;
  exec(cmd: string, opts?: ExecOptions): Promise<ExecResult>;

  monitor(cmd: string): Promise<string>;
  serial(): { readable: ReadableStream; writable: WritableStream };
  qga(cmd: QgaCommand, args?: object): Promise<unknown>;

  license(opts: LicenseOptions): Promise<void>;
  setDeviceMode(opts: DeviceModeOptions, log?: ProgressLogger): Promise<void>;
  availablePackages(): Promise<string[]>;
  installPackage(pkgs: string | string[]): Promise<string[]>;

  upload(localPath: string, remotePath?: string): Promise<void>;
  download(remotePath: string, localPath: string): Promise<void>;

  snapshot: {
    list(): Promise<SnapshotInfo[]>;
    save(name?: string): Promise<SnapshotInfo>;
    load(name: string): Promise<void>;
    delete(name: string): Promise<void>;
  };

  queryLoad(): Promise<ChrLoadSample | null>;
  subprocessEnv(): Promise<Record<string, string>>;
  descriptor(): Promise<Descriptor>;
}
```

`rest()` retries up to 3 times with 2s backoff on `ECONNRESET` (RouterOS
transiently resets connections post-boot/reboot). HTTP errors and
`QuickCHRError` from the REST layer are NOT retried.

`subprocessEnv()` returns env-var keys (`QUICKCHR_NAME`,
`QUICKCHR_REST_URL`, `QUICKCHR_REST_BASE`, `QUICKCHR_SSH_PORT`,
`QUICKCHR_AUTH`, plus legacy `URLBASE`/`BASICAUTH`) so child processes
can hit the CHR without re-resolving auth.

`descriptor()` returns the stable, versioned JSON shape used by `quickchr
inspect` — the quickchr ↔ centrs interface contract (issue #71,
[`docs/centrs-interface.md`](docs/centrs-interface.md)): machine identity,
status, a per-service `services` map (`rest-api`, `native-api`, `ssh` — each
with host/port/transport/`tls`/availability/auth), optional
`customForwards`/`networks`, and timestamps. It is intentionally
**running-only** and throws `MACHINE_STOPPED` for stopped machines. It has
no `env` field — use `subprocessEnv()` for env-var-shaped connection facts.

Both `subprocessEnv()` and `descriptor()` expose auth material by design —
`subprocessEnv()`'s `QUICKCHR_AUTH`/`BASICAUTH` env vars, and `descriptor()`'s
own per-service auth fields (`services["rest-api"].auth.password`/`.basic`/
`.header`; `services["native-api"].auth.password`, no basic/header;
`services.ssh.auth.privateKeyPath`). Treat their output as credentials and
redact before logging or attaching to bug reports.

### `StartOptions` (selected)

| Field | Type | Default | Notes |
|---|---|---|---|
| `name` | string | auto | Must not start with `-` |
| `version` | string | — | Mutually exclusive with `channel` |
| `channel` | Channel | `"stable"` | |
| `arch` | `"arm64" \| "x86"` | host | |
| `cpu` | number | `1` | |
| `mem` | number | `512` (1024 cross-arch TCG) | MiB |
| `background` | boolean | `true` | |
| `bootDiskFormat` | `"raw" \| "qcow2"` | `"raw"` (auto-`qcow2` if `bootSize`) | |
| `bootSize` | string | — | e.g., `"512M"`, `"2G"`. Needs `qemu-img` |
| `extraDisks` | string[] | `[]` | Each entry is a size string |
| `networks` | NetworkSpecifier[] | `["user"]` | See §7 |
| `portBase` | number | auto | First of 10 ports |
| `excludePorts` | ServiceName[] | `[]` | e.g., `["winbox"]` |
| `extraPorts` | PortMapping[] | `[]` | Custom forwards |
| `packages` | string[] | `[]` | Provisioning |
| `installAllPackages` | boolean | `false` | Provisioning |
| `user` | `{ name, password }` | — | Provisioning |
| `disableAdmin` | boolean | `false` | Provisioning |
| `secureLogin` | boolean | `false` | **`true` is the explicit trigger** for managed-account creation |
| `license` | `LicenseInput` | — | `"p1"` / `"p10"` / `"unlimited"` or `LicenseOptions` |
| `deviceMode` | `DeviceModeOptions` | — | `{mode?, enable?[], disable?[]}` |
| `installDeps` | boolean | — | Auto-install missing OS deps |
| `dryRun` | boolean | `false` | Print QEMU command and exit |
| `timeoutExtra` | number | `0` | Extra **milliseconds** added to boot timeout |
| `onProgress` | `(msg) => void` | `console.log` | `[debug]` lines only when `QUICKCHR_DEBUG=1` |

Provisioning fields trigger a version check at the top of `start()` /
`add()`: any provisioning option on RouterOS < 7.20.8 throws
`PROVISIONING_VERSION_UNSUPPORTED`.

See `src/lib/types.ts` for the full type surface (ChrPorts, MachineState,
NetworkConfig, NetworkSpecifier, PortMapping, ExecOptions, ExecResult,
DeviceModeOptions, LicenseInput/Level/Options, DoctorResult,
SnapshotInfo, ChrLoadSample, ErrorCode, plus QGA result types).

The barrel also re-exports utility functions from `auth.ts`, `disk.ts`,
`exec.ts`, `qga.ts`, `console.ts`, `license.ts`, `credentials.ts`,
`packages.ts`, `network.ts`, `log.ts`, `versions.ts`. Use them when the
high-level class doesn't fit (typically not needed).

### Programmatic networking

The CLI's `--add-network` and `--forward` flags are just `StartOptions.networks`
and `StartOptions.extraPorts`. Full decision guide:
[`docs/networking-recipes.md`](docs/networking-recipes.md).

```ts
import { QuickCHR, expandForwardSpec } from "@tikoci/quickchr";

const chr = await QuickCHR.start({
  // ether1 = user (REST/mgmt + hostfwd); ether2 = L2 frames streamed to a host
  // TCP server on `port` (e.g. to receive MNDP — see examples/mndp/).
  networks: ["user", { type: "socket-connect", port }],

  // host→guest forwards; UDP supported. Build PortMappings by hand…
  extraPorts: [
    { name: "snmp", host: 9161, guest: 161, proto: "udp" },
    // …or parse CLI-style spec strings, including a UDP range:
    ...expandForwardSpec("btest:9200-9210:2000-2010/udp"),
  ],
});
```

To **receive** UDP a guest sends, no forward is needed — bind an *unconnected*
host socket and have the guest send to `chr.tzspGatewayIp` (`10.0.2.2`); see
[`docs/networking-recipes.md`](docs/networking-recipes.md) §2 and
[`examples/udp-gateway/`](examples/udp-gateway/).

### Version & channel helpers

For tools that pick which CHRs to boot (e.g. CI matrices), the barrel exports
the RouterOS version facts and a recency classifier:

```ts
import {
  resolveAllVersions, compareRouterOsVersion,
  resolveChannelStatuses, resolveActiveChannels,
  classifyChannels, selectActiveChannels, channelMaturity,
  CHANNELS, type Channel, type ChannelStatus,
} from "@tikoci/quickchr";

await resolveAllVersions();
// { stable: "7.23.1", "long-term": "7.21.4", testing: "7.23rc4", development: "7.24beta2" }

compareRouterOsVersion("7.24beta2", "7.24rc1"); // < 0  (beta < rc < release < patch)

await resolveActiveChannels();
// ["stable", "long-term", "development"]  — released channels always, plus any
// pre-release at or ahead of stable (here testing 7.23rc4 is behind 7.23.1, excluded)
```

`resolveChannelStatuses()` returns `ChannelStatus[]`
(`{ channel, version, maturity, aheadOfStable }`); `resolveActiveChannels({ aheadOf })`
lets you measure pre-release recency against a channel other than `stable`. The pure
`classifyChannels(versions)` / `selectActiveChannels(versions, opts)` take a
`Record<Channel, string>` so you can classify without a network round-trip.

These answer **"what's worth booting,"** never "what must pass" — keep merge-gating
policy in the consumer.

The entry also re-exports the lower-level version helpers for pre-flight checks:
`resolveVersion`, `parseVersionParts`, `isValidVersion`, and
`isProvisioningSupportedVersion` (validate a version against the provisioning floor
before calling `start()`).

---

## 5. Provisioning pipeline

Everything below the boot wait runs in **background** mode regardless of
the user's `--fg` choice; if foreground was requested, quickchr stops
QEMU after provisioning and re-launches with stdio attached.

**Version gate:** RouterOS ≥ 7.20.8 (first long-term baseline). Any
provisioning option on an older image throws
`PROVISIONING_VERSION_UNSUPPORTED` upfront. Boot, disk, networking, and
port mappings work on any 7.x.

**Order of operations** (`_provisionInstance` in `src/lib/quickchr.ts`):

1. **Packages** — `installAllPackages` or `packages[]` triggers SCP
   upload of `.npk` files to the SSH port, then `/system/reboot`.
   quickchr waits 2× the normal boot timeout (package init takes
   longer). Persists the installed list to `state.packages`.
2. **Device-mode** — applies `deviceMode.mode` / `enable[]` / `disable[]`
   via REST. RouterOS requires a hard power-cycle to confirm; quickchr
   kills QEMU and restarts it. The blocking REST call returns
   ECONNRESET when QEMU is killed — this is suppressed and treated as
   success after the verification read-back. Persisted to
   `state.deviceMode`.
3. **License** — `/system/license/renew` with `account`, `password`,
   `level`, `duration=10s` so the server-side wait fits in a single
   call. Read-back confirms the actual applied level and persists it
   to `state.licenseLevel`. Errors come back inside HTTP 200 bodies as
   `status: "ERROR: …"` strings — quickchr classifies these as
   immediate failures (no retry).
4. **User & admin** — only runs when `user` is set OR `disableAdmin` is
   true OR `secureLogin === true`. Creates the custom user and/or the
   auto-generated `quickchr` managed account, optionally disables admin,
   stores the password in the per-instance secret file, and writes the
   user's name to `machine.json` (real password is NEVER written there).
   Disabling admin **does not** lock the REST API — RouterOS's `expired`
   flag only gates CLI/SSH/Winbox login, not REST.

Each step is independent. A failure in step N stops further steps but
does not roll back N–1. Re-running `start` re-applies pending
provisioning from `machine.json` only if the machine had not been fully
provisioned previously.

**Always read back what we wrote.** This catches version-specific drift
in REST responses and surfaces actionable errors instead of silent
state corruption.

The post-boot REST race (early endpoints can return wrong/empty bodies
even after `waitForBoot` returns) is handled per-endpoint: license,
device-mode, identity, and user reads each poll for field presence
with 15–30s deadlines. `waitForBoot` itself uses a two-consecutive-OK
guard on `/system/resource`.

---

## 6. Channels — REST, monitor, serial, QGA, console

Every running instance exposes five host-facing channels. Names map to
absolute file paths under `<machineDir>/`; on Windows they are named
pipes (`\\.\pipe\…`).

| Channel | Purpose | Transport (POSIX) | Transport (Windows) |
|---|---|---|---|
| REST | RouterOS HTTP API | TCP `127.0.0.1:{ports.http}` | same |
| QEMU monitor | `info cpus`, `quit`, `savevm`, etc. | Unix socket `monitor.sock` | named pipe |
| Serial | RouterOS serial console | Unix socket `serial.sock` | named pipe |
| QGA (x86 only) | guest agent commands | Unix socket `qga.sock` | named pipe |
| QEMU log | stdout/stderr of QEMU itself | file `qemu.log` | same |

**REST** is what `chr.rest()` and `chr.exec({via: "rest"})` use. It
**must** go through `src/lib/rest.ts` (which uses `node:http` with
`agent: false`) — Bun's `fetch()` connection pool reuses sockets after
machine restarts and returns stale responses. See
`.github/instructions/bun-http.instructions.md`.

**Monitor** is for QEMU itself, not RouterOS. `instance.monitor("info
cpus")` returns the raw text. quickchr uses it internally for
`stop()`, `snapshot.save/load/delete`, and `queryLoad()`.

**Serial** is a bidirectional byte stream to the RouterOS console. Both
`chr.serial()` (returns streams) and `quickchr console <name>` (attaches
stdio) use it. Serial-based `exec` (`--via=console`) does prompt
detection plus buffer-offset tracking; works in restricted environments
where REST is unavailable, slower than REST.

**QGA** uses a JSON-line protocol (`guest-sync-delimited` first, then
`guest-exec`, etc.). Available **only** on x86 CHR running under KVM.
ARM64 CHR doesn't ship the agent; macOS HVF (and any TCG run) don't
initialize the virtio-serial channel correctly — that's a QEMU 10.x
behavior documented in `docs/qga-x86-macos-qemu10-investigation.md`.

**QEMU log** captures everything QEMU itself writes, including boot
panics, EFI errors, and signal traces. `quickchr logs <name>` tails it.

---

## 7. Networking

Each `--add-network <spec>` adds one NIC. Without any flag, you get a
single user-mode NIC (SLiRP) on `ether1`. Order matters: `user` should
always be `ether1` because RouterOS auto-creates a DHCP client only on
the first interface, and SLiRP's built-in DHCP needs the
`10.0.2.15` address to make `hostfwd` work (see DESIGN.md
§SLiRP-must-be-ether1 and `test/lab/slirp-hostfwd/`).

### Which mechanism? (by traffic shape)

Picking among the specifiers/forwards by *goal* — full guide with recipes in
[`docs/networking-recipes.md`](docs/networking-recipes.md):

| You want… | Reach for |
|---|---|
| Reach a guest TCP/UDP service (REST, SSH, SNMP, container port) | `user` NIC + `--forward`/`extraPorts` (`hostfwd`) |
| Reach a guest on many/dynamic ports | `--forward name:9200-9210:2000-2010/udp` (range) |
| 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** |
| Receive guest L2 frames/broadcasts (MNDP, MAC-Telnet) | `socket-connect` NIC ([`docs/mndp.md`](docs/mndp.md)) |
| L2 link between two VMs | `socket::<name>` |
| Real LAN presence / DHCP from host | `shared` / `bridged:<iface>` |

### Specifiers

| Spec | What it does | Root needed? | Cross-VM? | Host-visible? |
|---|---|---|---|---|
| `user` | SLiRP user-mode NAT, hostfwd port mapping | no | no | via hostfwd ports only |
| `socket::<name>` | L2 socket pair between two VMs sharing the name | no | yes | no |
| `socket-listen:<port>` / `socket-connect:<port>` / `socket-mcast:<group>:<port>` | low-level QEMU socket netdevs | no | yes | no |
| `shared` | NAT'd L3 with DHCP from the host | yes (or `socket_vmnet` rootless) | yes | yes |
| `bridged:<iface>` | L2 bridged onto host iface | yes (or `socket_vmnet` rootless) | yes | yes |
| `tap:<iface>` | pre-created TAP, no quickchr setup | depends | yes | yes |
| `wifi`, `ethernet`, `auto` (alias) | resolves to detected host iface | as above | | |

Resolution chains:

- **`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.
- **`bridged:<iface>`** on macOS: socket_vmnet bridged → vmnet-bridged (root). On Linux: TAP attached to a bridge containing the named iface.

### Named L2 sockets

`quickchr networks sockets create lab-sw1` reserves a port and stores
metadata in `<dataDir>/networks/lab-sw1.json`. Two machines using
`--add-network socket::lab-sw1` form an L2 tunnel: the first to start
listens on the port; the second connects.

quickchr unregisters socket members on `stop`/`remove`/`clean` so port
allocation can recycle.

For platform internals (vmnet quirks, TAP creation, bridged-mode
restrictions, half-open SLiRP under TCG), see `docs/networking.md`.

---

## 8. Storage layout

### macOS / Linux (XDG)

```text
$XDG_DATA_HOME/quickchr/  (defaults to ~/.local/share/quickchr/)
├── cache/                       Downloaded RouterOS images
│   ├── chr-7.22.1.img.zip
│   ├── chr-7.22.1.img
│   └── chr-7.22.1-arm64.img
├── machines/
│   └── <name>/
│       ├── machine.json         Persisted MachineState
│       ├── disk.img | disk.qcow2
│       ├── extra-disk-*.qcow2
│       ├── efi-vars.fd          arm64 only (UEFI vars)
│       ├── monitor.sock         QEMU monitor
│       ├── serial.sock          serial console
│       ├── qga.sock             QGA (x86 only)
│       ├── qemu.pid
│       ├── qemu.log
│       ├── start-lock           start serialization
│       └── ssh/id_ed25519[.pub] SSH key (if managed user provisioned)
└── networks/                     Named L2 socket reservations
    └── <name>.json
```

`$XDG_DATA_HOME` defaults to `~/.local/share/quickchr/`. Override the
whole tree with `QUICKCHR_DATA_DIR=/some/path`.

**Global settings** live separately, under the XDG **config** tier, not this
data tree: `~/.config/quickchr/quickchr.env` (dotenv-style). See §3's
`settings` command (under Meta) for the command surface and §11 for the env
var list.

### Windows

```text
%LOCALAPPDATA%\quickchr\
├── cache\
├── machines\<name>\
│   ├── machine.json
│   ├── disk.img
│   ├── monitor (named pipe handle, no on-disk file)
│   └── …
└── …
```

Falls back to `%USERPROFILE%\AppData\Local\quickchr\` if `LOCALAPPDATA`
is unset.

### Credentials (separate from data dir)

- **Per-instance CHR credentials**: file in the user config dir
  (`~/.config/quickchr/secrets.json` on POSIX; not the OS keychain — a
  Keychain dialog hangs in non-TTY contexts like tests and CI).
- **MikroTik.com web account** (for license operations): tries
  `Bun.secrets` (OS keychain) when interactive, falls back to the same
  config-file store otherwise.

Both honor explicit CLI flags / env vars first.

---

## 9. Port layout

Each instance reserves a contiguous **block of 10 ports** starting at
`portBase`. Default base is `9100`; auto-allocator scans existing
machines and TCP-probes for a free block.

| Offset | Service | Guest port |
|---|---|---|
| +0 | HTTP / REST / Webfig | 80 |
| +1 | HTTPS | 443 |
| +2 | SSH | 22 |
| +3 | API | 8728 |
| +4 | API-SSL | 8729 |
| +5 | WinBox | 8291 |
| +6 | (reserved — QEMU monitor TCP, when used) | — |
| +7 | (reserved — serial TCP, when used) | — |
| +8 | (reserved — QGA TCP, when used) | — |
| +9 | (spare — `extraPorts`) | — |

Defaults map the first six offsets. `--no-winbox`, `--no-api-ssl`, and
`excludePorts: ["…"]` skip individual mappings. `extraPorts:
[{name,host,guest,proto}]` adds custom forwards in offsets 6–9.
If an extra mapping reuses a built-in service name such as `winbox`, it
pins/replaces that service's host port rather than creating a second
`ports.winbox` entry.

If port `9100` is in use, the next instance gets `9110`, etc. Manual
override via `--port-base`.

---

## 10. Acceleration & timeouts

`detectAccel()` selects the fastest available accelerator for each
arch:

| Host | Guest | Accel |
|---|---|---|
| macOS x86_64 | x86 | HVF |
| macOS x86_64 | arm64 | TCG |
| macOS arm64 (native bun) | arm64 | TCG |
| macOS arm64 (native bun) | x86 | TCG |
| macOS arm64 (Rosetta bun) | arm64 | TCG |
| macOS arm64 (Rosetta bun) | x86 | TCG |
| Linux x86_64 + `/dev/kvm` writable | x86 | KVM |
| Linux aarch64 + `/dev/kvm` writable | arm64 | KVM |
| any other combo | any | TCG |

`Bun` running under Rosetta on Apple Silicon reports `process.arch ===
"x64"`, so quickchr checks `sysctl.proc_translated` to identify the physical
arm64 host. Auto-selection uses TCG for both guest architectures there: HVF
cannot virtualize x86 across architectures, and current arm64 CHR images need
AArch32 userspace support that Apple Silicon does not provide. Use `--accel`
(or `QUICKCHR_ACCEL` / the `accel` setting) only to override this deliberately.

### Boot timeout

```text
base = ceil(120s × accelTimeoutFactor(accel, isCrossArch))
withPackages ? base × 2 : base
final = base + (timeoutExtra ms)
```

Native (HVF/KVM): factor ~1 → 120s base.
Cross-arch TCG: factor ~15 → up to 1800s.
Package install adds a 2× multiplier (extra reboot cycle).

If boot times out, the machine is **automatically stopped + removed**.
Use `--timeout-extra` to add a buffer if you know your host is slow, or
persist a default via `quickchr settings set timeout-extra <seconds>` so
every `start` picks it up without repeating the flag.

---

## 11. Auth, secrets, environment variables

**Threat model:** quickchr is a test/dev harness. The privilege boundary
is "any process running as the user that owns the data dir." We protect
against:

- Off-box access via bridged/NAT'd interfaces (RouterOS ships with empty
  admin password by default; managed login mitigates this).
- Accidental credential leakage in commits, CI logs, CLI output.

We **don't** protect against same-user code reading
`secrets.json`, `ps`-visible CLI args, or memory inspection.

### Auth resolution (`resolveAuth`)

Priority order, used by `chr.rest()`, `chr.exec()`, and
`subprocessEnv()`:

1. Explicit `--user` / `--password` (or `opts.user` / `opts.password`).
2. `state.user.name` from `machine.json` + password from secret store.
3. CHR default `admin` with empty password.

`disableAdmin: true` does NOT block REST API access — the `expired`
flag only gates CLI/SSH/Winbox login (and even there it's bypassable
with Ctrl-C). Don't add workarounds for the "expired admin" myth.

### Secret stores

- **Per-instance CHR creds**: config-file only (~/.config/quickchr/`).
  Bun.secrets triggers a Keychain dialog in headless contexts that
  never resolves.
- **MikroTik.com creds**: Bun.secrets when TTY is available; same
  config file otherwise.

### Settings file (`quickchr.env`)

`~/.config/quickchr/quickchr.env` — user-scoped global preferences, separate
from the secret stores above. Dotenv-style (`QUICKCHR_KEY=value` lines),
managed via `quickchr settings get|set|print|reset` (§3, Meta). Atomic writes
(temp file + `fsync` + rename); `set`/`reset` touch only the matching line and
preserve every other line (comments, blank lines, foreign vars) byte-for-byte.
`reset <key>` deletes the line entirely rather than blanking it, since a blank
`KEY=` line is still "set" to a bash-sourced file. Never stores credentials —
the 6 managed keys are all structural preferences, not secrets.

### Environment variables consumed

| Var | Where |
|---|---|
| `QUICKCHR_DATA_DIR` | `state.ts` — overrides data root |
| `QUICKCHR_NO_PROMPT` | CLI dispatcher — forces non-interactive mode |
| `QUICKCHR_DEBUG` | progress logger — emits `[debug]` lines |
| `QUICKCHR_INTEGRATION` | `bun:test` gate for `test/integration/` |
| `QUICKCHR_DEFAULT_CHANNEL` | `settings.ts` — env tier for the `default-channel` setting |
| `QUICKCHR_DEFAULT_ARCH` | `settings.ts` — env tier for the `default-arch` setting |
| `QUICKCHR_ACCEL` | `settings.ts` / `platform.ts` — env tier for the `accel` setting; forces `-accel` and bypasses detection |
| `QUICKCHR_CACHE_MAX_SIZE` | `settings.ts` — env tier for the `cache-max-size` setting |
| `QUICKCHR_TIMEOUT_EXTRA` | `settings.ts` — env tier for the `timeout-extra` setting |
| `QUICKCHR_SECURE_LOGIN` | `settings.ts` — env tier for the `secure-login` setting |
| `MIKROTIK_WEB_ACCOUNT` | license-renewal account (fallback) |
| `MIKROTIK_WEB_PASSWORD` | license-renewal password (fallback) |
| `NO_COLOR` | disable ANSI styling |
| `LOCALAPPDATA`, `USERPROFILE`, `HOME` | data-dir base resolution |

### Environment variables `subprocessEnv()` sets

For child processes you want to point at the CHR:

```text
QUICKCHR_NAME       machine name
QUICKCHR_REST_URL   http://127.0.0.1:{ports.http}
QUICKCHR_REST_BASE  same plus /rest
QUICKCHR_SSH_PORT   ports.ssh
QUICKCHR_AUTH       user:password
URLBASE             legacy alias for QUICKCHR_REST_BASE
BASICAUTH           legacy user:password value
```

These values are connection secrets. `QUICKCHR_AUTH` and `BASICAUTH` contain
`user:password` credentials; `quickchr inspect` additionally exposes the
derived Basic auth header. Do not commit or publicly log these values.

---

## 12. Exec — running RouterOS commands

`quickchr exec <name> <command>` and `chr.exec(command, opts)` both
funnel through the same transport selector.

### Transports

| `--via` | Source | Notes |
|---|---|---|
| `auto` (default) | tries REST, falls back to **console** on network errors (not auth errors) | Best general choice |
| `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. |
| `console` | serial socket with prompt detection + buffer offset tracking | Fallback for restricted environments. Uses `\r` (not `\r\n`) on PTY. |
| `qga` | QGA `guest-exec` | x86 + KVM only. Throws `QGA_UNSUPPORTED` on ARM64. |
| `ssh` | reserved | Currently throws `EXEC_FAILED`; planned. |

### Output formatting

`exec` does NOT have a `--json` flag. Use the RouterOS-native trick:

```bash
quickchr exec my-chr ":put [:serialize to=json [/ip/address/print detail as-value]]"
```

`:serialize` accepts `json` and `dsv` to output structure data from an array. The exec result body is the
literal stdout — no quickchr processing.  

| Command         | Syntax     | Description          | Example                |
|:--|:--|:--|:--|
| **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`|

_Source: <https://manual.mikrotik.com/docs/developer-guides/scripting/index.md>
> Note: `:serialize` expects an array, so a `/print` *without* `as-value` arg will return nothing when serialized using `to=json` or `to=dsv`.

### Authentication

Same `resolveAuth()` chain as REST. Override on the CLI with
`--user u --password p`, or in the library with `{user, password}` in
`ExecOptions`.

### Timeouts

`opts.timeout` defaults to 30s for QGA/console and 10s for REST when
running under `auto` (so the fallback to console kicks in quickly).
On explicit `--via=rest`, the 60s server-side `as-string` cap is the
ceiling.

---

## 13. Disks & snapshots

### Boot disk

Default format is **raw** (the image MikroTik ships). `--boot-size`
auto-converts to **qcow2** before the first boot and resizes to the
requested size. Requires `qemu-img` on the host.

`--boot-disk-format=qcow2` (without `--boot-size`) just keeps the
default size but enables snapshots.

### Extra disks

`--add-disk <size>` (repeatable) creates blank qcow2 images and attaches
them as additional virtio-blk-pci devices. RouterOS sees them as
`ether2`/`disk2`/etc. depending on whether you've also added NICs.
`quickchr disk <name>` shows the layout; with `qemu-img` it also reports
virtual + actual sizes.

### Custom port forwards and service port pinning

`--forward <spec>` (repeatable on `add` and `start`) adds a QEMU SLiRP
`hostfwd` mapping. Spec grammar is `name[:host[:guest]][/proto]`.
When `name` matches a key or alias in the built-in
`WELL_KNOWN_GUEST_PORTS` registry (e.g. `smb`/`cifs`, `winbox`, `mqtt`,
`http-alt`, `dns`, `snmp`, …), the `guest` port and `proto` are filled
in for you; otherwise both must be supplied. Omitting `host` (or passing
`0`) auto-allocates a free host port, mirroring the default service-port
behaviour.

Reusing a built-in service name pins that service's host port. This is
useful for legacy clients that assume a fixed local port. For example,
the Windows Dude client's reliable Wine command-line login path connects
to `127.0.0.1:8291`, so a fresh lab machine can be created with WinBox
pinned directly to that host port:

```bash
quickchr add my-chr --forward smb                  # host auto, guest 445/tcp
quickchr add dude-lab --forward winbox:8291        # host 8291, guest 8291/tcp
quickchr add my-chr --forward winbox:9300          # host pinned, guest 8291/tcp
quickchr add my-chr --forward myapp:9200:7777/udp  # fully explicit
```

**Port ranges.** For L3 peer protocols with dynamic data ports (e.g. RouterOS
bandwidth-test allocates UDP ports at runtime), a range spec
`name:hostStart-hostEnd[:guestStart-guestEnd][/proto]` expands to one `hostfwd`
per port (QEMU has no native range):

```bash
quickchr add btest-lab --forward btest:9200-9210:2000-2010/udp
```

Rules: the host range is **required** (ranges are not auto-allocated), the guest
range defaults to the host numbers when omitted and must be the same length, and a
range may span at most 64 ports. Programmatically, `expandForwardSpec(spec)`
returns the `PortMapping[]` for an `extraPorts` array; `parseForwardSpec(spec)`
remains the single-port form. To *receive* guest-originated UDP with no forward at
all, see §7 (the gateway path) and `docs/networking-recipes.md`.

Port maps are part of `machine.json`. Once a machine exists, `quickchr
start <name>` uses the stored mapping; create a new machine (or edit the
state deliberately) if a built-in service needs a different host port.

### `clean`

`quickchr clean <name>` re-copies the boot image from cache and
recreates extra disks per `machine.json`. Snapshots are gone (they live
inside the qcow2 file). Per-instance credentials are wiped so the next
boot re-provisions them.

### Snapshots

Use the QEMU monitor via `instance.snapshot`:

```ts
await chr.snapshot.save("before-upgrade");
await chr.snapshot.load("before-upgrade");
await chr.snapshot.list();
await chr.snapshot.delete("before-upgrade");
```

CLI:

```bash
quickchr snapshot my-chr save before-upgrade
quickchr snapshot my-chr list
```

Constraints:

- Boot disk must be qcow2.
- `save` and `load` need the machine running (uses `savevm`/`loadvm`).
- `list` and `delete` work either way (`qemu-img info` when stopped).
- Snapshots live inside the qcow2 file, so `remove` deletes them all.
- QEMU's practical cap is 16 snapshots per disk.

---

## 14. Errors & recovery

Every library throw is `QuickCHRError(code, message, installHint?)`.
The CLI catches it and prints `[code] message` plus the install hint if
present, then exits `1`.

### Codes

| Code | Likely cause | First check |
|---|---|---|
| `MISSING_QEMU` | host arch QEMU binary not on PATH | `quickchr doctor` |
| `MISSING_FIRMWARE` | edk2 UEFI firmware not installed (arm64) | install `qemu-efi-aarch64` / `edk2-aarch64` |
| `MISSING_UNZIP` | image extraction needs `unzip` | install OS package |
| `PORT_CONFLICT` | no free 10-port block | stop a machine or set `--port-base` |
| `BOOT_TIMEOUT` | CHR didn't respond within timeout | check `qemu.log`; under TCG, raise `--timeout-extra`; machine is **auto-removed** |
| `DOWNLOAD_FAILED` | image fetch failed | retry; check connectivity to MikroTik download server |
| `INVALID_VERSION` / `INVALID_ARCH` / `INVALID_NAME` / `INVALID_DISK_SIZE` | argument parse | fix the value |
| `MACHINE_EXISTS` | name already in `machines/` | choose another name or `QuickCHR.get(name)` |
| `MACHINE_NOT_FOUND` | name not in `machines/` | `quickchr list` |
| `MACHINE_RUNNING` / `MACHINE_STOPPED` | wrong lifecycle state | `start` or `stop` first |
| `MACHINE_LOCKED` | another `start` in progress | wait or pick another name |
| `EXEC_FAILED` | RouterOS rejected the command (auth, syntax) | check creds, command syntax |
| `PROCESS_FAILED` | QEMU spawn / reboot / verify failed | check `qemu.log`, host resources |
| `SPAWN_FAILED` | exec/spawn of host binary failed | check binary exists + executable |
| `QGA_UNSUPPORTED` | QGA on ARM64 | use `--via=rest` or `--via=console` |
| `QGA_TIMEOUT` | QGA daemon unreachable | KVM-only; not on macOS HVF or TCG |
| `NETWORK_UNAVAILABLE` | provisioning needs `user` NIC, none present | add `--add-network user` |
| `INVALID_NETWORK` | unknown alias / unresolvable iface / dead socket daemon | `quickchr networks` |
| `PROVISIONING_VERSION_UNSUPPORTED` | provisioning option on RouterOS < 7.20.8 | use `--channel long-term` or pin a `7.20.8+` version |
| `INSUFFICIENT_DISK_SPACE` | host data dir full | free space or move via `QUICKCHR_DATA_DIR` |
| `STATE_ERROR` | snapshot on raw boot disk | recreate with `--boot-disk-format=qcow2` |
| `INVALID_SIZE_STRING` | invalid cache/settings size string | use size syntax like `512M`, `2G`, or `1.5G` |
| `INVALID_SETTING_KEY` | `settings get`/`set`/`reset` received an unrecognized key | `quickchr settings print` to see the managed keys |
| `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 |

### Auto-cleanup on boot timeout

If `start()` hits `BOOT_TIMEOUT`, quickchr automatically calls
`stop()` then `remove()` on the failing instance. You'll see a
diagnostic and the machine will be gone from `quickchr list`. This
prevents orphan disks accumulating across attempts.

### Where to look

1. `quickchr doctor` — prerequisites, accelerator, disk space.
2. `quickchr logs <name>` — QEMU's own stderr/stdout, including boot
   panics, EFI errors, signal traces.
3. `<machineDir>/machine.json` — last-known state, ports, options.
4. `bun run check` — type/lint regressions if you've been editing.
5. `.github/instructions/` — agent-facing rules with deeper
   troubleshooting matrices for QEMU, RouterOS REST, provisioning,
   testing, CI, and Bun HTTP quirks.

---

## 15. Cross-references

- **README.md** — quickstart, install, simple examples.
- **DESIGN.md** — architecture, layers, port/storage tables, design
  principles (scope boundary, networking philosophy, SLiRP-must-be-ether1,
  exec transport design, examples philosophy).
- **CHANGELOG.md** — release history; `[Unreleased]` is the working
  set, `[0.1.1]` is the first GitHub release.
- **CONTRIBUTING.md** — dev setup and `bun run check`.
- **docs/networking-recipes.md** — "which mechanism for which traffic
  shape" decision guide (hostfwd, the guest→host gateway path, L2 capture,
  VM↔VM links, LAN). Start here when picking a networking approach.
- **docs/networking.md** — platform-specific QEMU networking
  internals (vmnet, TAP, socket netdevs).
- **docs/mndp.md** — worked recipe for receiving guest L2 broadcasts
  (MNDP) on the host via a `socket-connect` NIC.
- **docs/qga-x86-macos-qemu10-investigation.md** — root cause for the
  QGA-on-macOS-arm64 limitation.
- **docs/m4-hvf-arm64-investigation.md** — **open** investigation into why arm64
  CHR panics under HVF on Apple M4 (why arm64 falls back to TCG there); evidence
  ledger, competing theories, and the test plan to settle them.
- **`.github/instructions/`** — agent-facing rules (testing, qemu,
  routeros-rest, provisioning, ci, bun-http, general).
- **`examples/`** — runnable scripts (`bun run`): `quickstart` (single-CHR smoke),
  `grounding` (apply config → read back — the one `bun:test`), `harness` (drive an
  external tool via `subprocessEnv()`/`descriptor()`), `dude` (install a package),
  `rollback`/`service-forward`/`file-transfer`/`device-mode`/`trial-license`,
  `version-matrix` (parallel matrix), `mndp`/`udp-gateway` (networking). Coverage
  map: `examples/COVERAGE.md`; convention: `.github/instructions/examples.instructions.md`.
- **`routeros-quickchr` skill** (in
  [tikoci/routeros-skills](https://github.com/tikoci/routeros-skills)) —
  agent-facing guide to grounding RouterOS against a real router with quickchr
  (the apply→read-back loop, networking recipes, harness patterns).
- **BACKLOG.md** — a map of how work is tracked. Open work lives in GitHub
  Issues; see CONTRIBUTING.md "Tracking work".
