@tikoci/quickchr 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -44,7 +44,7 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
44
44
  - **Not applied to `_launchExisting`** (the restart-existing-machine path) — that path also spawns + `waitForBoot`s but has never been observed to flake. Extend the same respawn there only with evidence.
45
45
  - **Timeout factor left at 1.5×** (`accelTimeoutFactor`, `src/lib/platform.ts`) rather than inflated further — the respawn is the recovery mechanism, not a bigger ceiling.
46
46
 
47
- How to tell it's firing: grep CI `qemu.log`/run logs for `respawning QEMU once`. A frequent occurrence, or a `BOOT_TIMEOUT` that *survives* the respawn (or appears on the `_launchExisting` path), is the signal to stop treating it as a flake and find the real cause. Tracked in `BACKLOG.md` (boot-respawn watch-item).
47
+ How to tell it's firing: grep CI `qemu.log`/run logs for `respawning QEMU once`. A frequent occurrence, or a `BOOT_TIMEOUT` that *survives* the respawn (or appears on the `_launchExisting` path), is the signal to stop treating it as a flake and find the real cause. Tracked as a watch-item in [#45](https://github.com/tikoci/quickchr/issues/45).
48
48
 
49
49
  9. **Resilient downloads: normal `fetch` first, public-DNS IPv4 as a failback** — All fetches to MikroTik's `upgrade`/`download` hosts go through `fetchResilient()` (`src/lib/net.ts`), never bare `fetch()`. The intent is narrow: a **plain `fetch` is the path** — dual-stack (happy eyeballs), on par with curl/most tools, and honoring local DNS (`/etc/hosts` pins, VPN/split-horizon, mirror redirects, IPv6-only egress) — and we layer one **failback** beneath it to ride out *DNS misconfiguration somewhere in the environment*, wherever it comes from. We deliberately do **not** invert this (public-DNS+IPv4 first) — that would override local DNS on every machine and could introduce its own failures (e.g. an IPv4-only connect on an IPv6-only network). Normal first keeps us on par with most tools; the failback only adds smarts when the normal path actually breaks.
50
50
 
@@ -66,6 +66,17 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
66
66
  | +5 | WinBox | 8291 |
67
67
  | +6—9 | Custom | — |
68
68
 
69
+ On Windows, the monitor/serial/QGA channels use TCP localhost at `portBase+6/+7/+8` (Winsock can't bind `\\.\pipe\` paths), so the `+6..+8` "Custom" slots are effectively reserved there.
70
+
71
+ ### Allocation rationale & open questions
72
+
73
+ Ports are assigned in fixed 10-port blocks from `DEFAULT_PORT_BASE` (9100), scanning existing machines and probe-binding to avoid collisions. Two rough edges are **maintainer decisions, not yet settled** — don't "fix" them unilaterally, since they change the persisted-state / port contract (tracked as `needs-decision` work):
74
+
75
+ 1. **Base 9100 is a poor default** — collides with JetDirect/PDL (printers) and trains agents to memorize "quickchr = 9100 for REST", which only holds for the *first* instance (the second gets 9110). Open: random base in a clean high range (persisted per machine) vs caller-requested range vs both; and whether that's a breaking change or a setting with 9100 as the default. → [#56](https://github.com/tikoci/quickchr/issues/56)
76
+ 2. **Fixed blocks are overfit** — extras bolt on at `+6..+9` and collide with the Windows IPC offsets above (one slot before spilling); manual `extraPorts` bypass collision checking. Open: dynamic variable-size blocks vs a fixed core pool + a separate extras pool. → [#57](https://github.com/tikoci/quickchr/issues/57)
77
+
78
+ Current consumers are all first-party tikoci projects (centrs, donny, restraml), so a port-scheme migration can be coordinated across them — but `@tikoci/quickchr` is published and public, so a change to the persisted port contract still needs an issue, a CHANGELOG/docs note, and (for the `ChrInstance`/`StartOptions` surface) a deprecation/migration path, not a silent break.
79
+
69
80
  ## Storage Layout
70
81
 
71
82
  ```text
@@ -78,15 +89,21 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
78
89
  │ └── 7.22.1-arm64-1/
79
90
  │ ├── machine.json # Config + state
80
91
  │ ├── disk.img # Working copy
81
- │ ├── efi-vars.fd # UEFI vars (arm64)
92
+ │ ├── efi-vars.qcow2 # UEFI vars (arm64; qcow2 so savevm works — #31)
82
93
  │ ├── monitor.sock # QEMU monitor
83
94
  │ ├── serial.sock # Serial console
84
95
  │ ├── qga.sock # QGA (x86 only)
85
96
  │ ├── qemu.pid # PID file
86
97
  │ └── qemu.log # Output log
87
- └── config.json # Global config
88
98
  ```
89
99
 
100
+ **Global settings** live separately, under the XDG **config** tier (not the data tree
101
+ above): `~/.config/quickchr/quickchr.env`, dotenv-style (`QUICKCHR_KEY=value` lines).
102
+ Managed via `quickchr settings get|set|print|reset`. Precedence per key: CLI flag >
103
+ `QUICKCHR_<KEY>` env var > `quickchr.env` > built-in default. The 5 managed keys:
104
+ `default-channel`, `default-arch`, `cache-max-size`, `timeout-extra`, `secure-login`.
105
+ See MANUAL.md's CLI reference and environment-variables sections for the full surface.
106
+
90
107
  ## Platform Support
91
108
 
92
109
  | Platform | x86 CHR | arm64 CHR | Notes |
@@ -105,31 +122,63 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
105
122
 
106
123
  ## CI System
107
124
 
108
- **Workflows**: `.github/workflows/{ci,verify-extended,publish}.yml`. Full artifact map and
109
- failure-diagnosis guide live in `.github/instructions/ci.instructions.md` — this section is
110
- the high-level rationale only.
111
-
112
- **`ci.yml`** — core quality gate on every push/PR to `main`:
113
-
114
- ```text
115
- lint (ubuntu) ──┐
116
- unit-tests (ubuntu, --coverage) ──┴→ integration-x86 (ubuntu) → windows-unit-tests (windows-latest)
117
- ```
118
-
119
- `lint` and `unit-tests` run in parallel. `integration-x86` boots an x86 CHR (KVM if
120
- available, else TCG) and gates on both. `windows-unit-tests` runs last — if the core is
121
- broken, Windows runner minutes add no signal. Only x86 integration runs on every push;
122
- cross-arch and macOS are too slow/fragile for the per-push gate.
123
-
124
- **`verify-extended.yml`** — `workflow_dispatch` only. Independent jobs (no cross-`needs:`)
125
- for the platforms kept out of the per-push gate: linux/aarch64, macOS (arm64 + x86), and
126
- Windows. Dispatch inputs `arm64` / `macos` / `windows` select platforms; `test-filter`
127
- narrows to specific test files for fast iteration. Each runner boots a CHR matching its
128
- native arch — `detectAccel()` selects KVM/HVF/TCG automatically, no per-runner overrides.
129
-
130
- **`publish.yml`** — triggers on `v*` tags (or dispatch). Runs lint + unit + x86 integration +
131
- windows-unit before `npm publish` (`--tag next` for odd/pre-release minors, `--tag latest`
132
- for even/stable). See "Release Process" in `ci.instructions.md`.
125
+ **Workflows**: `.github/workflows/{ci,main,sweep,integration,release,ros-versions,lint-powershell}.yml`.
126
+ Full artifact map, dispatch recipes, and failure-diagnosis guide live in
127
+ `.github/instructions/ci.instructions.md` — this section is the high-level rationale only.
128
+
129
+ **The 2026-07 refactor (#29)** replaced the organically-grown scheme (integration on every
130
+ PR push, three parallel copies of the runner logic, `continue-on-error` green-washing)
131
+ with a layered design:
132
+
133
+ - **`ci.yml`** — fast PR/push gate (~3-5 min, no QEMU): lint ∥ unit+coverage ∥ Windows
134
+ unit ∥ `Integration freshness` (PR-only, required). Integration left the PR path
135
+ because a 25-minute CHR suite per PR push bought little signal per minute; the quality
136
+ bar moved to main.
137
+ - **`main.yml`** — full integration suite + examples smoke (+ PowerShell lint) on
138
+ linux/x86_64 + linux/aarch64 (KVM when available; hosted arm64 may fall back to TCG)
139
+ on every push to `main`; examples are part of the
140
+ default flow, not a weekly extra. The **freshness gate** (`scripts/ci-freshness.ts`) makes this honest:
141
+ PRs merge only while the latest completed main run is green, so a red main visibly
142
+ blocks everything instead of rotting in the Actions list. Superseded push runs cancel;
143
+ cancelled runs carry no signal (the gate skips them).
144
+ - **`sweep.yml`** — weekly all-platform sweep (+ examples smoke). A separate file from
145
+ main.yml *by design*: its TCG legs (macos-x86, windows-x86 — bounded to the anchor
146
+ smoke subset via `tcg-smoke: true` to cap weekly cost) may red without blocking PRs,
147
+ but are never green-washed — red is red. On manual `integration.yml` dispatches TCG
148
+ legs run the FULL suite by default (`tcg-smoke` defaults off): `platforms=all` means
149
+ all platforms, full set — smoke is opt-in, never an implicit narrowing.
150
+ - **`integration.yml`** — the single reusable integration unit (`workflow_call` +
151
+ `workflow_dispatch`). A plan job resolves **`platforms` × `routeros-targets`** (both
152
+ comma lists; `gating`/`all` platform aliases) into one cross-OS matrix — one dispatch
153
+ can cover e.g. gating platforms × three RouterOS versions, no wrapper jobs or repeat
154
+ dispatches. `test-filter` narrows; `run-examples` defaults ON. Each runner boots its
155
+ native CHR arch — `detectAccel()` picks KVM/HVF/TCG. Agents dispatch this to ground
156
+ platform hypotheses without waiting for a PR cycle.
157
+ - **`release.yml`** — one-click release: freshness gate (no suite re-run — main is kept
158
+ continuously release-able) + non-empty CHANGELOG `[Unreleased]` →
159
+ `scripts/release-prep.ts` bump/rollover → tag + GitHub Release + `npm publish
160
+ --provenance` (odd minor → `next`, even → `latest`).
161
+ - **`ros-versions.yml`** — daily: new RouterOS versions (per channel) with no linux-x86
162
+ record in `ci-data/tested-versions.json` ride the `routeros-targets` matrix of a
163
+ single integration dispatch.
164
+
165
+ **Metrics as byproduct (#30)**: the library appends every successful boot to
166
+ `<dataDir>/boot-log.ndjson` (and stamps `lastAccel`/`lastBootMs` into machine.json);
167
+ integration jobs assemble that + per-file timing into `metrics.ndjson`
168
+ (`scripts/ci-metrics.ts`); collect-metrics callers push per-run files + the
169
+ `tested-versions.json` rollup to the orphan **`ci-data`** branch. Never a second test
170
+ run; never affects pass/fail — the aggregate job is best-effort by contract (fold/push
171
+ failures warn and stay green, because a red there would trip the PR freshness gate over
172
+ side-band data). A full run marks exactly **its target's resolved version** tested —
173
+ never every version it happened to boot (upgrade/pinned-channel tests boot others
174
+ incidentally; crediting those suppressed the scheduler for versions no full suite ever
175
+ targeted). The per-run files are the source of truth; `ci-metrics refold` rebuilds the
176
+ rollup after fold-logic changes.
177
+
178
+ **Merge policy**: squash-only, PR title → main commit subject (write PR titles as
179
+ conventional commits), PR body → commit body, branches auto-delete. Review threads must
180
+ be resolved before merge (`required_conversation_resolution`) and automated reviews must
181
+ have actually posted — see CONTRIBUTING.md "Pull Requests & Merging".
133
182
 
134
183
  **Coverage**: `unit-tests` parses `bun test --coverage` output and compares against thresholds
135
184
  (default 75% functions, 60% lines). Failures emit `::warning::` annotations but do NOT block
@@ -138,13 +187,14 @@ merges (`continue-on-error: true`). Thresholds are overridable via dispatch inpu
138
187
 
139
188
  **Artifacts**:
140
189
  - `coverage-report` — full per-file coverage table (14 days)
141
- - `integration-logs-{platform}` — bun test output + machine.json + qemu.log (7 days)
190
+ - `{integration|main|sweep}-logs-<platform>` — bun test output + timing + metrics.ndjson +
191
+ machine.json + qemu.log (7 days); durable timing lives on the `ci-data` branch
142
192
 
143
193
  ## Design Principles
144
194
 
145
195
  ### Scope Boundary — QEMU Expert, Not Orchestrator
146
196
 
147
- quickchr manages individual CHR instances. Multi-router topologies, test matrices, and workflow orchestration are **out of scope** for the CLI and library. Provide `examples/` with Makefiles and `bun:test` scripts to inspire, but don't build a framework. Users (and their AI agents) compose quickchr instances into whatever topology they need — we give them reliable building blocks.
197
+ quickchr manages individual CHR instances. Multi-router topologies, test matrices, and workflow orchestration are **out of scope** for the CLI and library. Provide `examples/` as runnable scripts (a `bun run`-able `<name>.ts` plus `.sh`/`.ps1`/`.py` siblings; `grounding/` is the one `bun:test` reference) to inspire, but don't build a framework. The example convention lives in `.github/instructions/examples.instructions.md`; Python examples prefer `uv run` over a venv. Users (and their AI agents) compose quickchr instances into whatever topology they need — we give them reliable building blocks.
148
198
 
149
199
  ### Networking — Discover, Don't Configure
150
200
 
@@ -183,6 +233,32 @@ multicast port; two CHRs on one group don't discover each other and host capture
183
233
  gets nothing. mcast still works on Linux/CI. Prefer `socket-connect` for host
184
234
  capture on any platform. Evidence: `test/lab/mndp/REPORT.md`.
185
235
 
236
+ #### Guest→Host UDP via the Gateway — No Forward (discovered 2026-06-25)
237
+
238
+ The dual of `hostfwd`: SLIRP's gateway `10.0.2.2` *is* the host from inside the
239
+ guest, so guest-originated UDP to `10.0.2.2:<port>` reaches a host socket bound on
240
+ loopback `<port>` with **no forward and no extra NIC**. This generalizes the TZSP
241
+ path (`tzspGatewayIp`/`captureInterface`) — it is not TZSP-specific and reaches an
242
+ ordinary bound socket, not just a `tshark`/pcap capture. The host socket **must be
243
+ unconnected**: SLIRP re-emits from a rewritten loopback source
244
+ (`127.0.0.1:<ephemeral>`), which a `connect()`-ed socket would filter. This closed
245
+ centrs' btest UDP-coverage gap (issue #18) with no new quickchr feature — it was a
246
+ discoverability gap. Recipe: `docs/networking-recipes.md`; evidence:
247
+ `test/lab/gateway-udp/REPORT.md`.
248
+
249
+ #### Port-Range Forwards — Explicit-Host-Only
250
+
251
+ `--forward`/`extraPorts` accept a range (`name:hostStart-hostEnd[:guest…][/proto]`)
252
+ that expands to one `PortMapping`/`hostfwd` per port — QEMU has no native range.
253
+ **Design choice:** range host ports must be *explicit*. Auto-allocation draws from
254
+ the per-instance 10-port block, which cannot guarantee a contiguous run; requiring
255
+ explicit host ports keeps the change additive (the existing
256
+ `validateExplicitExtraPorts` collision check covers them) and avoids reworking the
257
+ port-block contract. A 64-port cap bounds the generated `hostfwd` string. For
258
+ guest-chosen *unpredictable* ports, the gateway path (above) is the better fit for
259
+ the guest→host direction. `expandForwardSpec` is the range-aware entry point;
260
+ `parseForwardSpec` stays single-port for backward compatibility.
261
+
186
262
  ### Platform Priority
187
263
 
188
264
  macOS → Linux → Windows. Mac and Linux share most code paths with minor `#ifdef`-style branches. Windows is tracked but lower priority — larger RouterOS admin audience there, but fewer recipes and harder to test. Windows CI runner planned after the existing macOS/Linux matrix is stable.
@@ -191,6 +267,13 @@ macOS → Linux → Windows. Mac and Linux share most code paths with minor `#if
191
267
 
192
268
  Tighten before expanding. New subcommands (`logs`, `exec`, `console`) wait for a full command tree review. The CLI should be discoverable without paging `--help` — shell completions help more than a long command list. Use multipass and virsh as reference points for symmetry, not to copy.
193
269
 
270
+ **Command-surface principles** (locked — these shape every new subcommand):
271
+
272
+ - **Interactive prompts are confined to `setup`.** Every other command is non-interactive — no selectors. Without a `<name>` argument, print the list + a tip; don't prompt.
273
+ - **`start`/`stop` are pure operations** — no wizard, no creation. `add` creates; `setup` is the wizard.
274
+ - **`set`/`get` are for machine config, not re-provisioning.** After first provisioning, don't add commands that re-provision — the surface grows and each post-provision capability needs its own RouterOS edge-case testing. A drifted machine is recreated, not mutated (see *Out of Scope*).
275
+ - **`--json` on read commands only** — same content as console output (richer metadata OK), pipe-friendly for `jq`. No `--yaml`/`--serialize`/TSV/CSV; callers pipe `--json` through `jq`/`yq`. For `exec`, `--json` wraps the quickchr response — the RouterOS result stays a string (use `:serialize` in-script for structured RouterOS output).
276
+
194
277
  ### RouterOS Verification
195
278
 
196
279
  Always read back what we write. One extra REST API call after a provisioning action (license, user, package) catches version-specific command drift early. Surface errors with actionable hints rather than silent failures.
@@ -220,13 +303,54 @@ Output formatting via `--json` flag. RouterOS trick for structured output: wrap
220
303
 
221
304
  ### Examples Philosophy
222
305
 
223
- Three representations of each scenario, targeting different audiences:
224
- - **Makefile** — recipe-driven, targets as documentation (tikoci tradition, see tikoci/netinstall). Agents read targets; humans run `make`.
225
- - **bun:test** — library API, TypeScript. First-class integration tests. The "source of truth."
226
- - **Python** — subprocess around CLI. The language agents and network engineers both reach for. Demonstrates quickchr is a real tool, not just a library.
227
-
228
- Building examples early is a form of "anchor testing" for the CLI surface — it finds ergonomic issues before we commit to new commands.
306
+ `examples/` is **load-bearing agent-onboarding surface** — agents (and humans) open
307
+ it before `src/lib/`, so a *wrong* example is worse than none: it teaches the wrong
308
+ lesson and costs a later code-review to unwind. Examples are therefore held to the
309
+ same bar as the code, and a broken one **gates** extended verification.
310
+
311
+ Each `examples/<name>/` is a **runnable artifact that does something real** against a
312
+ CHR, in the canonical shape (full rules in
313
+ [`.github/instructions/examples.instructions.md`](.github/instructions/examples.instructions.md)):
314
+
315
+ - **`<name>.ts`** — the **primary**: a `bun run`-able script using the library API,
316
+ with `runExample()` guaranteeing teardown (success *or* failure). Not a test.
317
+ - **`<name>.sh` / `<name>.ps1`** — the **CLI** mirror (POSIX `sh` + PowerShell),
318
+ sourcing `examples/common.{sh,ps1}`. New examples ship both; existing ones add
319
+ `.ps1` where the CLI flow is simple.
320
+ - **`<name>.py`** — optional CLI driver for a non-TS audience, run with `uv run`.
321
+ - **`grounding/` is the one `bun:test` example** — kept as a test on purpose, because
322
+ there the *assertions are the documentation* (it's the "how to write CHR
323
+ integration tests" reference). Everywhere else, an agent can wrap a script in
324
+ `test()` trivially, so a runnable script is the better teaching surface.
325
+
326
+ Makefiles were the old convention and are now **disallowed** in `examples/`
327
+ (`scripts/validate-examples.ts` enforces this) — they mixed CLI orchestration with
328
+ raw `scp`/`curl`/`ssh`, the opposite of "one example teaches one capability."
329
+
330
+ Building examples early is a form of "anchor testing" for the CLI surface — it finds
331
+ ergonomic issues before we commit to new commands, and each gap surfaced gets a
332
+ "friction found" note in the example's README plus a GitHub issue rather than
333
+ being papered over (this pass surfaced the missing `quickchr cp`, #23). Coverage of
334
+ the CLI/library surface is tracked in
335
+ [`examples/COVERAGE.md`](examples/COVERAGE.md).
336
+
337
+ ### Agent-Friendliness — Discoverability Over Features
338
+
339
+ A recurring lesson from downstream agents (donny, centrs, restraml): the friction is usually **finding** an existing capability, not a missing one. Issue #18 (centrs) nearly rebuilt UDP forwarding, `socket-connect` L2, and the guest→host gateway — all of which already existed — because the only way to choose among them was reading `src/lib/network.ts`. Rule: **every CLI-documented capability also needs a library-facing, by-goal surface** — JSDoc at the call site, a by-goal recipe (`docs/networking-recipes.md`), and coverage in the `routeros-quickchr` skill — or agents won't find it. Connection handoff for harnesses goes through `ChrInstance.descriptor()` / `quickchr inspect` / `quickchr env` (credential-bearing by design), never by reading `machine.json`.
340
+
341
+ ### Out of Scope (decided)
342
+
343
+ Explicitly rejected, with rationale, so they aren't re-proposed:
344
+
345
+ - **Cloud deployment** — `tikoci/chr-armed` already does OCI + AWS; revisit only once local CHR is solid and provisioning/image layers can be reused.
346
+ - **Multi-CHR orchestration** — building blocks + `examples/`, not a framework (see *Scope Boundary*). Users/agents compose topologies.
347
+ - **`quickchr upgrade <name>` / post-provisioning mutation** — replaced by a future *config audit/verify* report (flags drift; user recreates). Re-provisioning has too many failure modes (version bumps, rotated creds, changed deps).
348
+ - **Packaging (Homebrew/Deb)** and **service management (launchd/systemd)** — lower priority than core; optional later.
349
+ - **Machine templates** — CLI flags are the template, API objects are reusable, the wizard always prompts. No separate template system.
350
+ - **`--no-ansi` flag** — ANSI is fine in text output (`grep`/`jq` cope); the real discipline (separate presentation from content) belongs in a centralized error-message surface instead.
351
+ - **`machine.json` → YAML** — staying JSON (pretty-printed) for `jq` users; YAML adds complexity without a matching benefit.
352
+ - **Separate multi-version/arch matrix runner** — the CI matrix + `examples/version-matrix` cover it.
229
353
 
230
354
  ### Document Maintenance
231
355
 
232
- DESIGN.md and BACKLOG.md are living documents. At the end of any significant work session, agents should review whether new implementation details, design decisions, or discovered constraints belong in DESIGN.md, and whether completed/new work should update BACKLOG.md. Treat this as a lightweight checklist, not a gate.
356
+ DESIGN.md is the living home for design decisions and rationale. At the end of any significant work session, agents should review whether new implementation details, design decisions, or discovered constraints belong here. Open/close work in **GitHub Issues** — not BACKLOG.md (see CONTRIBUTING.md "Tracking work"); record grounded RouterOS/QEMU behaviour facts in the narrowest scoped doc (`.github/instructions/*.md`, `docs/`, or `test/lab/<topic>/REPORT.md`); add a CHANGELOG.md entry for user-facing changes. Treat this as a lightweight checklist, not a gate.