@tikoci/quickchr 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -19,7 +19,7 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
19
19
  ```
20
20
 
21
21
  - **CLI** — git-style subcommands + interactive wizard. Thin layer over the library.
22
- - **Library** — `QuickCHR` class with static methods: `start()`, `list()`, `get()`, `doctor()`. Returns `ChrInstance` handles with `stop()`, `remove()`, `rest()`, `monitor()`, etc.
22
+ - **Library** — `QuickCHR` class with static methods: `start()`, `add()`, `list()`, `get()`, `doctor()`. Returns `ChrInstance` handles with `stop()`, `remove()`, `rest()`, `exec()`, `monitor()`, etc.
23
23
  - **Modules** — Pure functions for QEMU arg building, image download, port allocation, state persistence.
24
24
 
25
25
  ### Key Design Decisions
@@ -36,6 +36,16 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
36
36
 
37
37
  6. **Class-based API** — `QuickCHR` is a class with static methods for clean namespacing. `ChrInstance` is an interface implemented as a plain object with closures.
38
38
 
39
+ 7. **Running-only connection descriptors** — `ChrInstance.descriptor()`, `quickchr inspect`, and `quickchr env` are live connection handoff surfaces, not stale state readers. They intentionally fail with `MACHINE_STOPPED` when the VM is not running, because ports/auth/status are only safe to consume when the machine is active. Descriptor/env output includes auth material by design for subprocess handoff; callers must treat it as credential-bearing output.
40
+
41
+ 8. **Boot respawn-once on hardware accel** — When `QuickCHR.start()` boots a fresh machine in background mode and `waitForBoot` exhausts its budget under `kvm`/`hvf`, it stops the QEMU process, clears its `server=on` socket files, and respawns **once** before raising `BOOT_TIMEOUT` (see `start()` in `src/lib/quickchr.ts`, gated on `accel`). This targets an observed CI flake: on GitHub's nested-KVM runner a single boot among many occasionally never reaches REST while siblings boot in ~30-45s — a *wedged* process that a longer timeout would not rescue, only a fresh one. Gated to hardware accel because TCG boots are legitimately long and doubling buys nothing.
42
+
43
+ ⚠️ **Watch-item / scope caveat.** The root cause is **unconfirmed** — the respawn is a pragmatic mitigation that keeps CI green, not a proven diagnosis; the trigger could be something else (runner CPU-steal, a SLiRP stall, image-specific timing). Two deliberate limits to revisit if it recurs:
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
+ - **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
+
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).
48
+
39
49
  ## Port Layout
40
50
 
41
51
  | Offset | Service | Guest Port |
@@ -87,37 +97,41 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
87
97
 
88
98
  ## CI System
89
99
 
90
- **Workflow**: `.github/workflows/ci.yml`
100
+ **Workflows**: `.github/workflows/{ci,verify-extended,publish}.yml`. Full artifact map and
101
+ failure-diagnosis guide live in `.github/instructions/ci.instructions.md` — this section is
102
+ the high-level rationale only.
91
103
 
92
- Three jobs — lint and unit-tests run in parallel; integration is gated on both:
104
+ **`ci.yml`** — core quality gate on every push/PR to `main`:
93
105
 
106
+ ```text
107
+ lint (ubuntu) ──┐
108
+ unit-tests (ubuntu, --coverage) ──┴→ integration-x86 (ubuntu) → windows-unit-tests (windows-latest)
94
109
  ```
95
- lint unit-tests windows-unit-tests
96
- Biome + tsc --noEmit bun test test/unit/ bun test test/unit/
97
- --coverage (windows-latest, always)
98
- ↘ ↙
99
- integration (matrix)
100
- linux/x86_64 (always)
101
- linux/aarch64 (always)
102
- macos/arm64 (workflow_dispatch: macos=true)
103
- macos/x86_64 (workflow_dispatch: macos=true)
104
- ```
105
110
 
106
- **Integration matrix**: Each runner boots a CHR matching its native arch. `detectAccel()`
107
- selects KVM/HVF/TCG automatically — no per-runner overrides needed.
111
+ `lint` and `unit-tests` run in parallel. `integration-x86` boots an x86 CHR (KVM if
112
+ available, else TCG) and gates on both. `windows-unit-tests` runs last — if the core is
113
+ broken, Windows runner minutes add no signal. Only x86 integration runs on every push;
114
+ cross-arch and macOS are too slow/fragile for the per-push gate.
115
+
116
+ **`verify-extended.yml`** — `workflow_dispatch` only. Independent jobs (no cross-`needs:`)
117
+ for the platforms kept out of the per-push gate: linux/aarch64, macOS (arm64 + x86), and
118
+ Windows. Dispatch inputs `arm64` / `macos` / `windows` select platforms; `test-filter`
119
+ narrows to specific test files for fast iteration. Each runner boots a CHR matching its
120
+ native arch — `detectAccel()` selects KVM/HVF/TCG automatically, no per-runner overrides.
121
+
122
+ **`publish.yml`** — triggers on `v*` tags (or dispatch). Runs lint + unit + x86 integration +
123
+ windows-unit before `npm publish` (`--tag next` for odd/pre-release minors, `--tag latest`
124
+ for even/stable). See "Release Process" in `ci.instructions.md`.
108
125
 
109
- **Coverage**: `bun test test/unit/ --coverage` output is parsed and compared against
110
- thresholds (default 75% functions, 60% lines). Failures emit `::warning::` annotations
111
- but do NOT block merges (`continue-on-error: true`). Thresholds are overridable via
112
- dispatch inputs `min-funcs` / `min-lines`.
126
+ **Coverage**: `unit-tests` parses `bun test --coverage` output and compares against thresholds
127
+ (default 75% functions, 60% lines). Failures emit `::warning::` annotations but do NOT block
128
+ merges (`continue-on-error: true`). Thresholds are overridable via dispatch inputs
129
+ `min-funcs` / `min-lines`.
113
130
 
114
- **Artifacts** (details in `.github/instructions/ci.instructions.md`):
131
+ **Artifacts**:
115
132
  - `coverage-report` — full per-file coverage table (14 days)
116
133
  - `integration-logs-{platform}` — bun test output + machine.json + qemu.log (7 days)
117
134
 
118
- **Publish workflow** (`.github/workflows/publish.yml`): triggers on `v*` tags;
119
- runs lint + typecheck + unit tests before npm publish.
120
-
121
135
  ## Design Principles
122
136
 
123
137
  ### Scope Boundary — QEMU Expert, Not Orchestrator
@@ -144,6 +158,23 @@ The `default-route-distance=2` ensures the shared route is backup — SLiRP ethe
144
158
 
145
159
  **TCG hazard:** SLiRP half-open connections (TCP connect succeeds, data never flows) burn the full per-probe HTTP timeout in `waitForBoot`. Under cross-arch TCG where TCP round-trips are slow, this compounds badly. Lab: `test/lab/slirp-hostfwd/`.
146
160
 
161
+ #### Host-Side L2 Capture (MNDP, MAC-Telnet)
162
+
163
+ A caller can receive the guest's raw Layer-2 frames — RouterOS MNDP (UDP/5678
164
+ broadcast) being the first use case — without root or a native helper, using the
165
+ TCP `socket` netdev: the host runs a TCP server, the CHR gets a `socket-connect`
166
+ NIC, and QEMU streams every guest frame to the host length-prefixed (4-byte BE
167
+ length + raw Ethernet). Loopback-only, cross-platform. Writing a frame back over
168
+ the same connection injects L2 into the guest (the MAC-Telnet primitive). Recipe:
169
+ `docs/mndp.md`; example: `examples/mndp/`.
170
+
171
+ **Discovered constraint (2026-06-06):** the `socket-mcast` netdev — the documented
172
+ multi-VM L2 path — is **broken on macOS**. QEMU's mcast socket sets only
173
+ `SO_REUSEADDR`, while macOS/BSD need `SO_REUSEPORT` on every socket sharing a
174
+ multicast port; two CHRs on one group don't discover each other and host capture
175
+ gets nothing. mcast still works on Linux/CI. Prefer `socket-connect` for host
176
+ capture on any platform. Evidence: `test/lab/mndp/REPORT.md`.
177
+
147
178
  ### Platform Priority
148
179
 
149
180
  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.
package/README.md CHANGED
@@ -93,6 +93,11 @@ quickchr list
93
93
  # Detailed status for one instance
94
94
  quickchr status my-chr
95
95
 
96
+ # Stable connection descriptor/env for a running instance
97
+ quickchr inspect my-chr
98
+ quickchr env my-chr
99
+ quickchr env my-chr --json
100
+
96
101
  # Stop an instance
97
102
  quickchr stop my-chr
98
103
 
@@ -157,6 +162,7 @@ quickchr doctor
157
162
  | `--boot-disk-format <f>` | Boot disk format: qcow2\|raw | qcow2 |
158
163
  | `--boot-size <size>` | Resize boot disk (e.g., 512M, 2G). Requires `qemu-img`. | |
159
164
  | `--add-disk <size>` | Attach an extra blank qcow2 disk. Repeatable. Requires `qemu-img`. | |
165
+ | `--forward <spec>` | Add or pin a QEMU SLiRP hostfwd mapping. Repeatable. | |
160
166
  | `--bg` / `--background` | Run in background (default) | true |
161
167
  | `--fg` / `--foreground` | Run in foreground — serial console on stdio | |
162
168
  | `--add-package <pkg>` | Extra package to install (repeatable) | |
@@ -261,6 +267,39 @@ Each instance gets a block of 10 ports. With the default base of 9100, the first
261
267
 
262
268
  The second instance gets 9110–9119, and so on.
263
269
 
270
+ ### Custom Port Forwards and Fixed WinBox
271
+
272
+ Use `--forward <spec>` on `add` or create-and-start `start` commands to add
273
+ extra QEMU SLiRP `hostfwd` mappings. The shorthand uses the built-in service
274
+ registry when it knows the guest port:
275
+
276
+ ```bash
277
+ quickchr add lab --forward smb # auto host port → guest 445/tcp
278
+ quickchr add dude-lab --forward winbox:8291 # host 8291 → guest WinBox 8291/tcp
279
+ quickchr add app-lab --forward myapp:9200:7777/udp
280
+ ```
281
+
282
+ Reusing a built-in service name such as `winbox` pins/replaces that service's
283
+ host port for the machine. Existing machines keep the mapping stored in
284
+ `machine.json`; recreate the machine to change fixed service ports cleanly.
285
+
286
+ ### Machine Descriptors and Subprocess Environments
287
+
288
+ `quickchr inspect <name> [--json]` emits a stable JSON descriptor for a
289
+ **running** machine: status, ports, URLs, auth, env vars, and the machine
290
+ directory. `--json` is accepted for parity; inspect output is always JSON.
291
+
292
+ `quickchr env <name> [--json]` prints the same subprocess environment as
293
+ `ChrInstance.subprocessEnv()`: shell `KEY=value` lines by default, or a JSON
294
+ map with `--json`.
295
+
296
+ > **Credential caveat:** descriptor/env output includes connection secrets
297
+ > (`auth.password`, `auth.basic`, `auth.header`, `QUICKCHR_AUTH`, `BASICAUTH`)
298
+ > so child processes can connect without reading quickchr's secret store. Treat
299
+ > it like a password: do not commit it, paste it into public issues, or leave it
300
+ > in CI logs. Stopped machines fail with `MACHINE_STOPPED`; start the machine
301
+ > before requesting a descriptor or env map.
302
+
264
303
  ### Library Usage
265
304
 
266
305
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/quickchr",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "CLI and library to download, launch, and manage MikroTik CHR virtual machines via QEMU",
5
5
  "module": "src/index.ts",
6
6
  "main": "src/index.ts",
@@ -20,11 +20,12 @@
20
20
  "DESIGN.md"
21
21
  ],
22
22
  "scripts": {
23
- "check": "bun run lint:biome && bun run lint:typecheck && bun run lint:markdown",
23
+ "check": "bun run lint:biome && bun run lint:typecheck && bun run lint:markdown && bun run lint:cspell",
24
24
  "lint": "biome check . && tsc --noEmit",
25
25
  "lint:biome": "biome check .",
26
26
  "lint:typecheck": "tsc --noEmit",
27
27
  "lint:markdown": "bunx markdownlint-cli2 '**/*.md'",
28
+ "lint:cspell": "cspell lint . --no-progress",
28
29
  "test": "bun test test/unit/",
29
30
  "test:examples": "bun test examples/",
30
31
  "test:integration": "QUICKCHR_INTEGRATION=1 bun test test/integration/",
@@ -36,6 +37,7 @@
36
37
  "devDependencies": {
37
38
  "@biomejs/biome": "^2.4.10",
38
39
  "@types/bun": "^1.3.11",
40
+ "cspell": "^10.0.0",
39
41
  "markdownlint-cli2": "^0.21.0"
40
42
  },
41
43
  "dependencies": {
package/src/cli/index.ts CHANGED
@@ -152,6 +152,12 @@ async function main() {
152
152
  case "get":
153
153
  await cmdGet(args.slice(1));
154
154
  break;
155
+ case "inspect":
156
+ await cmdInspect(args.slice(1));
157
+ break;
158
+ case "env":
159
+ await cmdEnv(args.slice(1));
160
+ break;
155
161
  case "license":
156
162
  console.error("Note: 'quickchr license' is deprecated. Use 'quickchr set <name> --license' instead.");
157
163
  await cmdLicense(args.slice(1));
@@ -1432,6 +1438,50 @@ async function cmdList(argv: string[] = []) {
1432
1438
  console.log(table(headers, rows));
1433
1439
  }
1434
1440
 
1441
+ function shellQuoteEnv(value: string): string {
1442
+ if (/^[A-Za-z0-9_./:@+-]*$/.test(value)) return value;
1443
+ return `'${value.replaceAll("'", "'\\''")}'`;
1444
+ }
1445
+
1446
+ async function getRunningDescriptor(name: string) {
1447
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1448
+ const { machineNotFoundMessage } = await import("./format.ts");
1449
+ const instance = QuickCHR.get(name);
1450
+ if (!instance) {
1451
+ console.error(machineNotFoundMessage(name));
1452
+ process.exit(1);
1453
+ }
1454
+ return instance.descriptor();
1455
+ }
1456
+
1457
+ async function cmdInspect(argv: string[] = []) {
1458
+ const { positional } = parseFlags(argv);
1459
+ const name = positional[0];
1460
+ if (!name) {
1461
+ console.error("Usage: quickchr inspect <name> [--json]");
1462
+ process.exit(1);
1463
+ }
1464
+ const descriptor = await getRunningDescriptor(name);
1465
+ console.log(JSON.stringify(descriptor, null, 2));
1466
+ }
1467
+
1468
+ async function cmdEnv(argv: string[] = []) {
1469
+ const { flags, positional } = parseFlags(argv);
1470
+ const name = positional[0];
1471
+ const asJson = flagBool(flags, "json");
1472
+ if (!name) {
1473
+ console.error("Usage: quickchr env <name> [--json]");
1474
+ process.exit(1);
1475
+ }
1476
+ const descriptor = await getRunningDescriptor(name);
1477
+ if (asJson) {
1478
+ console.log(JSON.stringify(descriptor.env, null, 2));
1479
+ return;
1480
+ }
1481
+ for (const [key, value] of Object.entries(descriptor.env)) {
1482
+ console.log(`${key}=${shellQuoteEnv(value)}`);
1483
+ }
1484
+ }
1435
1485
 
1436
1486
  async function cmdGet(argv: string[]) {
1437
1487
  const { flags, positional } = parseFlags(argv);
@@ -2457,6 +2507,8 @@ Commands:
2457
2507
  stop [<name>|--all] Stop instance(s) — print list if no name
2458
2508
  list [<name>] [--json] List all instances, or detail for one
2459
2509
  status Alias for list
2510
+ inspect <name> [--json] Print running machine descriptor as JSON
2511
+ env <name> [--json] Print subprocess environment for a running machine
2460
2512
  console <name> Attach to serial console of a running instance
2461
2513
  exec <name> <command> Run a RouterOS CLI command on a running instance
2462
2514
  remove [<name>|--all] Remove instance(s) and disk
@@ -2637,6 +2689,23 @@ List all CHR instances or show detailed info for one.
2637
2689
 
2638
2690
  'quickchr status' is an alias for this command.`);
2639
2691
  break;
2692
+ case "inspect":
2693
+ console.log(`quickchr inspect <name> [--json]
2694
+
2695
+ Print a stable JSON descriptor for a running CHR instance: ports, URLs,
2696
+ credentials, status, and subprocess env vars.
2697
+
2698
+ <name> Name of a running CHR instance.
2699
+ --json Accepted for parity; output is always JSON.`);
2700
+ break;
2701
+ case "env":
2702
+ console.log(`quickchr env <name> [--json]
2703
+
2704
+ Print environment variables for subprocesses that connect to a running CHR.
2705
+
2706
+ <name> Name of a running CHR instance.
2707
+ --json Output the env map as JSON instead of KEY=value lines.`);
2708
+ break;
2640
2709
  case "doctor":
2641
2710
  console.log(`quickchr doctor
2642
2711
 
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * - {@link QuickCHR} — Main class with static methods: `start()`, `add()`, `list()`, `get()`, `doctor()`
18
18
  * - {@link ChrInstance} — Runtime handle returned by `start()`: `stop()`, `remove()`, `rest()`, `exec()`, `upload()`, `download()`, `snapshot`, etc.
19
19
  * - {@link StartOptions} — All options for creating/starting a CHR instance
20
- * - {@link QuickCHRError} — Typed errors with codes like `MISSING_QEMU`, `TIMEOUT`, `EXEC_FAILED`
20
+ * - {@link QuickCHRError} — Typed errors with codes like `MISSING_QEMU`, `BOOT_TIMEOUT`, `EXEC_FAILED` (full list: {@link ErrorCode})
21
21
  *
22
22
  * @packageDocumentation
23
23
  */
@@ -44,6 +44,7 @@ export type {
44
44
  LicenseLevel,
45
45
  LicenseOptions,
46
46
  MachineConfig,
47
+ MachineDescriptor,
47
48
  MachineState,
48
49
  NetworkConfig,
49
50
  NetworkMode,
package/src/lib/auth.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { MachineState } from "./types.ts";
11
- import { getInstanceCredentials } from "./credentials.ts";
11
+ import { getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
12
12
 
13
13
  export interface ResolvedAuth {
14
14
  /** HTTP Basic Authorization header value. */
@@ -48,9 +48,16 @@ export function resolveAuth(
48
48
  };
49
49
  }
50
50
 
51
- // state.user is the canonical provisioned user written to machine.json.
52
- // Check this first — no I/O, no keychain, no blocking.
53
51
  if (state.user) {
52
+ if (state.user.password === STORED_IN_SECRETS_PASSWORD) {
53
+ const stored = getInstanceCredentials(state.name);
54
+ if (stored) {
55
+ return {
56
+ header: `Basic ${btoa(`${stored.user}:${stored.password}`)}`,
57
+ user: stored.user,
58
+ };
59
+ }
60
+ }
54
61
  return {
55
62
  header: `Basic ${btoa(`${state.user.name}:${state.user.password}`)}`,
56
63
  user: state.user.name,
@@ -82,7 +89,13 @@ export function resolveCreds(
82
89
  password?: string,
83
90
  ): ResolvedCreds {
84
91
  if (user !== undefined) return { user, password: password ?? "" };
85
- if (state.user) return { user: state.user.name, password: state.user.password };
92
+ if (state.user) {
93
+ if (state.user.password === STORED_IN_SECRETS_PASSWORD) {
94
+ const stored = getInstanceCredentials(state.name);
95
+ if (stored) return { user: stored.user, password: stored.password };
96
+ }
97
+ return { user: state.user.name, password: state.user.password };
98
+ }
86
99
  const stored = getInstanceCredentials(state.name);
87
100
  if (stored) return { user: stored.user, password: stored.password };
88
101
  return { user: "admin", password: "" };
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Communication channels — QEMU monitor, serial console, QGA (QEMU Guest Agent).
3
3
  *
4
- * Uses Unix domain sockets stored in the machine directory.
4
+ * Uses Unix domain sockets stored in the machine directory on POSIX. On Windows,
5
+ * launched machines use TCP localhost endpoints derived from portBase; named pipes
6
+ * remain only as the no-portBase fallback path.
5
7
  * QGA protocol implementation lives in qga.ts — this module provides
6
8
  * the high-level qgaCommand() wrapper with arch guards and path resolution.
7
9
  */
@@ -328,7 +328,7 @@ _quickchr() {
328
328
  cword=${D}COMP_CWORD
329
329
  }
330
330
 
331
- local commands="add start stop list ls status remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help"
331
+ local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help"
332
332
 
333
333
  if [[ ${D}cword -eq 1 ]]; then
334
334
  COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
@@ -337,7 +337,7 @@ _quickchr() {
337
337
 
338
338
  local cmd="${D}{words[1]}"
339
339
  case "${D}cmd" in
340
- start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status)
340
+ start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
341
341
  local machines
342
342
  machines=$(quickchr completions --machines 2>/dev/null)
343
343
  COMPREPLY=( $(compgen -W "${D}machines" -- "${D}cur") )
@@ -387,6 +387,8 @@ _quickchr() {
387
387
  'list:List all instances'
388
388
  'ls:List all instances (alias)'
389
389
  'status:Detailed status'
390
+ 'inspect:Print running machine descriptor'
391
+ 'env:Print subprocess environment'
390
392
  'remove:Remove instance(s) and disk'
391
393
  'rm:Remove instance(s) (alias)'
392
394
  'clean:Reset instance disk to fresh image'
@@ -410,7 +412,7 @@ _quickchr() {
410
412
  args)
411
413
  local cmd="${D}{words[2]}"
412
414
  case ${D}cmd in
413
- start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status)
415
+ start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
414
416
  local machines
415
417
  machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
416
418
  _describe 'machine' machines
@@ -432,7 +434,7 @@ _quickchr() {
432
434
  _describe 'option' opts
433
435
  ;;
434
436
  help)
435
- local commands=(add start stop list status remove clean console exec qga license disk snapshot networks doctor setup completions version)
437
+ local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks doctor setup completions version)
436
438
  _describe 'command' commands
437
439
  ;;
438
440
  esac
@@ -448,7 +450,7 @@ function generateFishCompletion(): string {
448
450
  return `# quickchr fish completion
449
451
  # Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
450
452
 
451
- set -l quickchr_commands add start stop list ls status remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help
453
+ set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help
452
454
 
453
455
  # Disable file completions globally for quickchr
454
456
  complete -c quickchr -f
@@ -464,6 +466,10 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
464
466
  -a "list ls" -d "List all instances"
465
467
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
466
468
  -a "status" -d "Detailed status"
469
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
470
+ -a "inspect" -d "Print running machine descriptor"
471
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
472
+ -a "env" -d "Print subprocess environment"
467
473
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
468
474
  -a "remove rm" -d "Remove instance(s) and disk"
469
475
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
@@ -499,7 +505,7 @@ complete -c quickchr -n "__fish_seen_subcommand_from stop" \\
499
505
  -d "machine"
500
506
 
501
507
  # Machine name completions for commands that take any machine
502
- complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status" \\
508
+ complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status inspect env" \\
503
509
  -a "(quickchr completions --machines 2>/dev/null)" \\
504
510
  -d "machine"
505
511
 
@@ -26,6 +26,8 @@ export interface InstanceCredentials {
26
26
  password: string;
27
27
  }
28
28
 
29
+ export const STORED_IN_SECRETS_PASSWORD = "(stored in secrets)";
30
+
29
31
  const WEB_SERVICE = "com.quickchr.mikrotik-web";
30
32
  const INSTANCE_SERVICE = "com.quickchr.instance";
31
33
 
package/src/lib/qemu.ts CHANGED
@@ -437,23 +437,23 @@ export async function stopQemu(pid: number): Promise<boolean> {
437
437
  return true;
438
438
  }
439
439
 
440
- /** Stop a machine by name. */
441
- export async function stopMachineByName(_name: string, state: MachineState): Promise<boolean> {
442
- if (!state.pid) return false;
443
- const stopped = await stopQemu(state.pid);
444
-
445
- // Clean up socket files
446
- const machineDir = state.machineDir;
440
+ /** Remove the listening unix-socket files QEMU binds (monitor/serial/qga).
441
+ * A SIGKILLed QEMU leaves these behind; a respawn with `server=on` then fails
442
+ * to bind ("Address already in use"). Call before re-spawning into the same dir. */
443
+ export function cleanupQemuSockets(machineDir: string): void {
447
444
  for (const sock of ["monitor.sock", "serial.sock", "qga.sock"]) {
448
445
  const path = join(machineDir, sock);
449
446
  try {
450
- if (existsSync(path)) {
451
- const { unlinkSync } = await import("node:fs");
452
- unlinkSync(path);
453
- }
447
+ if (existsSync(path)) unlinkSync(path);
454
448
  } catch { /* ignore */ }
455
449
  }
450
+ }
456
451
 
452
+ /** Stop a machine by name. */
453
+ export async function stopMachineByName(_name: string, state: MachineState): Promise<boolean> {
454
+ if (!state.pid) return false;
455
+ const stopped = await stopQemu(state.pid);
456
+ cleanupQemuSockets(state.machineDir);
457
457
  return stopped;
458
458
  }
459
459
 
@@ -13,6 +13,7 @@ import type {
13
13
  ExecResult,
14
14
  LicenseInput,
15
15
  LicenseLevel,
16
+ MachineDescriptor,
16
17
  MachineState,
17
18
  QgaCommand,
18
19
  SnapshotInfo,
@@ -45,7 +46,7 @@ import {
45
46
  } from "./state.ts";
46
47
  import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
47
48
  import { autoPruneIfOverCap } from "./cache.ts";
48
- import { buildQemuArgs, spawnQemu, stopQemu, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
49
+ import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
49
50
  import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
50
51
  import { monitorCommand, serialStreams, qgaCommand, channelEndpoint } from "./channels.ts";
51
52
  import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
@@ -53,7 +54,7 @@ import { provision } from "./provision.ts";
53
54
  import { renewLicense, getLicenseInfo } from "./license.ts";
54
55
  import { resolveAuth, resolveCreds } from "./auth.ts";
55
56
  import { scpPush, scpPull } from "./scp.ts";
56
- import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials } from "./credentials.ts";
57
+ import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
57
58
  import { restExecute } from "./exec.ts";
58
59
  import { qgaExec } from "./qga.ts";
59
60
  import { consoleExec } from "./console.ts";
@@ -224,6 +225,63 @@ async function resolveLicenseInput(input: LicenseInput): Promise<LicenseOptions
224
225
  function createInstance(state: MachineState): ChrInstance {
225
226
  const ports = toChrPorts(state.ports);
226
227
  const restUrl = `http://127.0.0.1:${ports.http}`;
228
+ const buildEnv = (): Record<string, string> => {
229
+ const creds = resolveCreds(state);
230
+ const rawCreds = `${creds.user}:${creds.password}`;
231
+ const restBase = `${restUrl}/rest`;
232
+ return {
233
+ QUICKCHR_NAME: state.name,
234
+ QUICKCHR_REST_URL: restUrl,
235
+ QUICKCHR_REST_BASE: restBase,
236
+ QUICKCHR_SSH_PORT: String(ports.ssh),
237
+ QUICKCHR_AUTH: rawCreds,
238
+ // Legacy compat keys used by restraml and similar consumers.
239
+ URLBASE: restBase,
240
+ BASICAUTH: rawCreds,
241
+ };
242
+ };
243
+ const buildDescriptor = (): MachineDescriptor => {
244
+ if (state.status !== "running") {
245
+ throw new QuickCHRError(
246
+ "MACHINE_STOPPED",
247
+ `Machine "${state.name}" must be running to inspect its connection environment (current status: ${state.status}).`,
248
+ );
249
+ }
250
+ const auth = resolveAuth(state);
251
+ const env = buildEnv();
252
+ const basic = env.QUICKCHR_AUTH ?? `${auth.user}:`;
253
+ return {
254
+ name: state.name,
255
+ status: "running",
256
+ version: state.version,
257
+ arch: state.arch,
258
+ cpu: state.cpu,
259
+ mem: state.mem,
260
+ pid: state.pid ?? null,
261
+ ports,
262
+ portMappings: state.ports,
263
+ urls: {
264
+ http: restUrl,
265
+ rest: `${restUrl}/rest`,
266
+ restBase: `${restUrl}/rest`,
267
+ https: ports.https ? `https://127.0.0.1:${ports.https}` : undefined,
268
+ ssh: ports.ssh ? `ssh://${auth.user}@127.0.0.1:${ports.ssh}` : undefined,
269
+ api: ports.api ? `tcp://127.0.0.1:${ports.api}` : undefined,
270
+ apiSsl: ports.apiSsl ? `tls://127.0.0.1:${ports.apiSsl}` : undefined,
271
+ winbox: ports.winbox ? `tcp://127.0.0.1:${ports.winbox}` : undefined,
272
+ },
273
+ auth: {
274
+ user: auth.user,
275
+ password: basic.slice(auth.user.length + 1),
276
+ basic,
277
+ header: auth.header,
278
+ },
279
+ env,
280
+ machineDir: state.machineDir,
281
+ createdAt: state.createdAt,
282
+ lastStartedAt: state.lastStartedAt ?? null,
283
+ };
284
+ };
227
285
 
228
286
  return {
229
287
  name: state.name,
@@ -571,22 +629,11 @@ function createInstance(state: MachineState): ChrInstance {
571
629
  },
572
630
 
573
631
  async subprocessEnv(): Promise<Record<string, string>> {
574
- const auth = resolveAuth(state);
575
- // auth.header is "Basic <base64>" — extract the raw user:pass.
576
- const rawCreds = auth.header.startsWith("Basic ")
577
- ? Buffer.from(auth.header.slice(6), "base64").toString()
578
- : `${auth.user}:`;
579
- const restBase = `${restUrl}/rest`;
580
- return {
581
- QUICKCHR_NAME: state.name,
582
- QUICKCHR_REST_URL: restUrl,
583
- QUICKCHR_REST_BASE: restBase,
584
- QUICKCHR_SSH_PORT: String(ports.ssh),
585
- QUICKCHR_AUTH: rawCreds,
586
- // Legacy compat keys used by restraml and similar consumers.
587
- URLBASE: restBase,
588
- BASICAUTH: rawCreds,
589
- };
632
+ return buildEnv();
633
+ },
634
+
635
+ async descriptor(): Promise<MachineDescriptor> {
636
+ return buildDescriptor();
590
637
  },
591
638
 
592
639
  async queryLoad(): Promise<ChrLoadSample | null> {
@@ -1355,7 +1402,7 @@ export class QuickCHR {
1355
1402
 
1356
1403
  const qemuArgs = await buildQemuArgs(launchConfig);
1357
1404
  const wrapper = extractWrapper(resolvedNetworks);
1358
- const { pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper);
1405
+ let { pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper);
1359
1406
 
1360
1407
  // Foreground (no provisioning): spawnQemu blocks until QEMU exits
1361
1408
  if (!background && !hasProvisioning) {
@@ -1375,7 +1422,26 @@ export class QuickCHR {
1375
1422
  const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
1376
1423
 
1377
1424
  // Always wait for boot in background mode — the JSDoc promises "REST-ready".
1378
- const booted = await instance.waitForBoot(bootTimeout);
1425
+ let booted = await instance.waitForBoot(bootTimeout);
1426
+
1427
+ // Hardware-accelerated runners (nested-KVM on CI, HVF on virtualized macOS
1428
+ // runners) occasionally produce a single QEMU process that boots but never
1429
+ // reaches REST, while sibling boots on the same host succeed in ~30-45s. The
1430
+ // process is wedged, not merely slow — so a fresh respawn recovers reliably
1431
+ // where a longer timeout would only stretch the failure. Retry once, gated to
1432
+ // hardware accel (TCG boots are legitimately long; doubling buys nothing there).
1433
+ if (!booted && (accel === "kvm" || accel === "hvf")) {
1434
+ logger.warn(
1435
+ `CHR "${name}" did not reach REST within ${bootTimeout / 1000}s (accel=${accel}) — respawning QEMU once.`,
1436
+ );
1437
+ try { await stopQemu(pid); } catch { /* ignore */ }
1438
+ cleanupQemuSockets(machineDir);
1439
+ ({ pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper));
1440
+ state.pid = pid;
1441
+ saveMachine(state);
1442
+ booted = await instance.waitForBoot(bootTimeout);
1443
+ }
1444
+
1379
1445
  if (!booted) {
1380
1446
  let qemuLogTail = "";
1381
1447
  try {
@@ -1536,7 +1602,7 @@ export class QuickCHR {
1536
1602
  // Persist user info in state (password placeholder — real password in secret store)
1537
1603
  const current = loadMachine(machineState.name);
1538
1604
  if (current) {
1539
- current.user = { name: result.user.name, password: "(stored in secrets)" };
1605
+ current.user = { name: result.user.name, password: STORED_IN_SECRETS_PASSWORD };
1540
1606
  saveMachine(current);
1541
1607
  }
1542
1608
  machineState.user = { name: result.user.name, password: result.user.password };
package/src/lib/types.ts CHANGED
@@ -290,6 +290,38 @@ export interface ChrPorts {
290
290
  [key: string]: number;
291
291
  }
292
292
 
293
+ export interface MachineDescriptor {
294
+ name: string;
295
+ status: "running";
296
+ version: string;
297
+ arch: Arch;
298
+ cpu: number;
299
+ mem: number;
300
+ pid: number | null;
301
+ ports: ChrPorts;
302
+ portMappings: Record<string, PortMapping>;
303
+ urls: {
304
+ http: string;
305
+ rest: string;
306
+ restBase: string;
307
+ https?: string;
308
+ ssh?: string;
309
+ api?: string;
310
+ apiSsl?: string;
311
+ winbox?: string;
312
+ };
313
+ auth: {
314
+ user: string;
315
+ password: string;
316
+ basic: string;
317
+ header: string;
318
+ };
319
+ env: Record<string, string>;
320
+ machineDir: string;
321
+ createdAt: string;
322
+ lastStartedAt: string | null;
323
+ }
324
+
293
325
  /** Return value of ChrInstance.queryLoad(). */
294
326
  export interface ChrLoadSample {
295
327
  /** Host CPU percent across all virtual CPUs (0–100 × vCPU count). */
@@ -437,6 +469,8 @@ export interface ChrInstance {
437
469
  * Bun.spawn(["bun", "my-script.ts"], { env: { ...process.env, ...await chr.subprocessEnv() } })
438
470
  */
439
471
  subprocessEnv(): Promise<Record<string, string>>;
472
+ /** Build a stable machine-readable connection descriptor for a running instance. */
473
+ descriptor(): Promise<MachineDescriptor>;
440
474
 
441
475
  /** Sample QEMU guest load from the monitor. Returns null when the monitor is
442
476
  * unavailable (machine stopped or running in foreground mode). */