@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.
@@ -12,11 +12,7 @@
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
14
14
  import { join, dirname } from "node:path";
15
- import { homedir } from "node:os";
16
-
17
- function configDir(): string {
18
- return join(process.env.HOME ?? process.env.USERPROFILE ?? homedir(), ".config", "quickchr");
19
- }
15
+ import { quickchrConfigDir } from "./paths.ts";
20
16
 
21
17
  /** True when the Bun.secrets API is available at runtime. */
22
18
  function hasBunSecrets(): boolean {
@@ -32,7 +28,7 @@ function hasBunSecrets(): boolean {
32
28
  function configPath(service: string): string {
33
29
  // One JSON file per service, keyed by name
34
30
  const safeName = service.replace(/[^a-zA-Z0-9._-]/g, "_");
35
- return join(configDir(), `${safeName}.json`);
31
+ return join(quickchrConfigDir(), `${safeName}.json`);
36
32
  }
37
33
 
38
34
  function readConfigStore(service: string): Record<string, string> {
@@ -129,5 +125,5 @@ export async function secretDelete(service: string, name: string): Promise<boole
129
125
  /** Human-readable label for where secrets are stored. */
130
126
  export function secretStorageLabel(): string {
131
127
  if (hasBunSecrets()) return "OS credential store (via Bun.secrets)";
132
- return `config file (${configDir()})`;
128
+ return `config file (${quickchrConfigDir()})`;
133
129
  }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * quickchr's own user-scoped settings — global preferences with no per-machine
3
+ * home, stored at ~/.config/quickchr/quickchr.env (dotenv-style: one
4
+ * QUICKCHR_KEY=value line per managed setting).
5
+ *
6
+ * Precedence per key (highest wins): CLI flag > QUICKCHR_<KEY> env var >
7
+ * quickchr.env > built-in default. This module only resolves the middle two
8
+ * tiers (env, file) plus the built-in default — callers compose
9
+ * `flagValue ?? resolveSetting(attr).value` themselves, so an explicit CLI
10
+ * flag never runs through the settings validator.
11
+ *
12
+ * Does not read or write machine.json / MachineState — settings are CLI-start
13
+ * input resolution, never per-machine config.
14
+ */
15
+
16
+ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, fsyncSync } from "node:fs";
17
+ import { basename, dirname, join } from "node:path";
18
+ import { ARCHES, CHANNELS, QuickCHRError } from "./types.ts";
19
+ import type { Channel } from "./types.ts";
20
+ import { parseSizeString, DEFAULT_CACHE_MAX_BYTES } from "./cache.ts";
21
+ import { quickchrConfigDir } from "./paths.ts";
22
+
23
+ export type SettingsValue = string | number | boolean;
24
+ export type SettingsSource = "env" | "file" | "default";
25
+
26
+ export interface SettingsKeyDef {
27
+ /** Kebab-case CLI-facing name, e.g. "default-channel". */
28
+ attr: string;
29
+ /** QUICKCHR_* env var name. */
30
+ envKey: string;
31
+ /** Parse + validate raw input (from env or file). Throws INVALID_SETTING_VALUE on bad input. */
32
+ parse: (raw: string) => SettingsValue;
33
+ /** Canonical on-disk/display form. */
34
+ serialize: (value: SettingsValue) => string;
35
+ /** Concrete built-in default when settings get/print should report one.
36
+ * Omit only when "unset" has meaning distinct from a concrete value. */
37
+ builtinDefault?: SettingsValue;
38
+ }
39
+
40
+ function parseChannel(raw: string): SettingsValue {
41
+ if (!(CHANNELS as readonly string[]).includes(raw)) {
42
+ throw new QuickCHRError("INVALID_SETTING_VALUE", `Invalid value "${raw}" for default-channel — must be one of ${CHANNELS.join(", ")}`);
43
+ }
44
+ return raw;
45
+ }
46
+
47
+ function parseArchSetting(raw: string): SettingsValue {
48
+ const allowed = [...ARCHES, "auto"];
49
+ if (!allowed.includes(raw)) {
50
+ throw new QuickCHRError("INVALID_SETTING_VALUE", `Invalid value "${raw}" for default-arch — must be one of ${allowed.join(", ")}`);
51
+ }
52
+ return raw;
53
+ }
54
+
55
+ function parseCacheMaxSize(raw: string): SettingsValue {
56
+ try {
57
+ return parseSizeString(raw);
58
+ } catch (e) {
59
+ throw new QuickCHRError("INVALID_SETTING_VALUE", `Invalid value "${raw}" for cache-max-size — ${e instanceof Error ? e.message : String(e)}`);
60
+ }
61
+ }
62
+
63
+ /** Shared with the CLI's own `--timeout-extra`/`-T` flag validation (cmdStart in
64
+ * src/cli/index.ts) so the same non-negative-integer rule applies whether the
65
+ * value comes from a flag, QUICKCHR_TIMEOUT_EXTRA, or quickchr.env. */
66
+ export function parseTimeoutExtraSeconds(raw: string): number {
67
+ const n = Number(raw);
68
+ if (!Number.isInteger(n) || n < 0) {
69
+ throw new QuickCHRError("INVALID_SETTING_VALUE", `Invalid value "${raw}" for timeout-extra — must be a non-negative integer number of seconds`);
70
+ }
71
+ return n;
72
+ }
73
+
74
+ function parseSecureLogin(raw: string): SettingsValue {
75
+ const v = raw.trim().toLowerCase();
76
+ if (["true", "1", "yes", "on"].includes(v)) return true;
77
+ if (["false", "0", "no", "off"].includes(v)) return false;
78
+ throw new QuickCHRError("INVALID_SETTING_VALUE", `Invalid value "${raw}" for secure-login — must be a boolean (true/false)`);
79
+ }
80
+
81
+ export const SETTINGS_KEYS: readonly SettingsKeyDef[] = [
82
+ {
83
+ attr: "default-channel",
84
+ envKey: "QUICKCHR_DEFAULT_CHANNEL",
85
+ builtinDefault: "stable" satisfies Channel,
86
+ parse: parseChannel,
87
+ serialize: (v) => String(v),
88
+ },
89
+ {
90
+ attr: "default-arch",
91
+ envKey: "QUICKCHR_DEFAULT_ARCH",
92
+ builtinDefault: "auto",
93
+ parse: parseArchSetting,
94
+ serialize: (v) => String(v),
95
+ },
96
+ {
97
+ attr: "cache-max-size",
98
+ envKey: "QUICKCHR_CACHE_MAX_SIZE",
99
+ builtinDefault: DEFAULT_CACHE_MAX_BYTES,
100
+ parse: parseCacheMaxSize,
101
+ serialize: (v) => String(v),
102
+ },
103
+ {
104
+ attr: "timeout-extra",
105
+ envKey: "QUICKCHR_TIMEOUT_EXTRA",
106
+ builtinDefault: 0,
107
+ parse: parseTimeoutExtraSeconds,
108
+ serialize: (v) => String(v),
109
+ },
110
+ {
111
+ // No builtinDefault: unlike the other 4 keys, the wizard needs to distinguish
112
+ // "not configured" from "explicitly false" (src/cli/wizard.ts's userChoice
113
+ // initialValue only pre-highlights "admin" when the setting is concretely
114
+ // false) — a builtinDefault here would collapse that distinction and always
115
+ // show "admin" even when nothing is configured. `resolveSecureLoginFlag`'s
116
+ // own `=== true` check is unaffected either way. "(unset)" in `settings print`
117
+ // means the consumer's own default (false / blank-admin) applies — see
118
+ // MANUAL.md's settings table.
119
+ attr: "secure-login",
120
+ envKey: "QUICKCHR_SECURE_LOGIN",
121
+ parse: parseSecureLogin,
122
+ serialize: (v) => (v ? "true" : "false"),
123
+ },
124
+ ];
125
+
126
+ export const SETTINGS_FILE_NAME = "quickchr.env";
127
+
128
+ /** ~/.config/quickchr/quickchr.env */
129
+ export function settingsFilePath(): string {
130
+ return join(quickchrConfigDir(), SETTINGS_FILE_NAME);
131
+ }
132
+
133
+ /** Accepts "default-channel" | "QUICKCHR_DEFAULT_CHANNEL" | "DEFAULT_CHANNEL" | "default_channel". */
134
+ export function lookupSettingsKey(input: string): SettingsKeyDef | undefined {
135
+ let name = input.trim();
136
+ if (/^quickchr[_-]/i.test(name)) {
137
+ name = name.replace(/^quickchr[_-]/i, "");
138
+ }
139
+ name = name.toLowerCase().replace(/_/g, "-");
140
+ return SETTINGS_KEYS.find((def) => def.attr === name);
141
+ }
142
+
143
+ function unknownKeyError(input: string): QuickCHRError {
144
+ return new QuickCHRError(
145
+ "INVALID_SETTING_KEY",
146
+ `"${input}" is not a recognized quickchr setting.`,
147
+ "Run 'quickchr settings print' to see the managed keys.",
148
+ );
149
+ }
150
+
151
+ // --- File IO ---
152
+
153
+ /** Raw non-empty lines, in file order. [] if the file doesn't exist. */
154
+ export function readSettingsFileLines(): string[] {
155
+ const path = settingsFilePath();
156
+ if (!existsSync(path)) return [];
157
+ const text = readFileSync(path, "utf-8");
158
+ const lines = text.split("\n");
159
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
160
+ return lines;
161
+ }
162
+
163
+ /** Parse QUICKCHR_*=value lines into a flat map. Blank/comment lines ignored; later dup wins. */
164
+ export function parseSettingsFileLines(lines: readonly string[]): Record<string, string> {
165
+ const result: Record<string, string> = {};
166
+ for (const rawLine of lines) {
167
+ const line = rawLine.trim();
168
+ if (line.length === 0 || line.startsWith("#")) continue;
169
+ const eq = line.indexOf("=");
170
+ if (eq <= 0) continue;
171
+ const key = line.slice(0, eq).trim();
172
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
173
+ result[key] = line.slice(eq + 1).trim();
174
+ }
175
+ return result;
176
+ }
177
+
178
+ /** The `file` tier every resolver call reads. {} when the file is absent. */
179
+ export function loadSettingsFileDefaults(): Record<string, string> {
180
+ return parseSettingsFileLines(readSettingsFileLines());
181
+ }
182
+
183
+ function lineEnvKey(line: string): string | undefined {
184
+ const trimmed = line.trim();
185
+ if (trimmed.length === 0 || trimmed.startsWith("#")) return undefined;
186
+ const eq = trimmed.indexOf("=");
187
+ if (eq <= 0) return undefined;
188
+ return trimmed.slice(0, eq).trim();
189
+ }
190
+
191
+ function matchingLineIndices(lines: readonly string[], envKey: string): number[] {
192
+ const indices: number[] = [];
193
+ for (let i = 0; i < lines.length; i++) {
194
+ if (lineEnvKey(lines[i] ?? "") === envKey) indices.push(i);
195
+ }
196
+ return indices;
197
+ }
198
+
199
+ function rawValueAtLine(lines: readonly string[], index: number): string {
200
+ const line = (lines[index] ?? "").trim();
201
+ return line.slice(line.indexOf("=") + 1).trim();
202
+ }
203
+
204
+ /** Atomic write: temp file in the same dir + fsync + rename, single .bak copy. Creates the dir on first write. */
205
+ function writeSettingsFileLinesSync(lines: readonly string[]): void {
206
+ const path = settingsFilePath();
207
+ const dir = dirname(path);
208
+ mkdirSync(dir, { recursive: true });
209
+ if (existsSync(path)) {
210
+ try {
211
+ copyFileSync(path, `${path}.bak`);
212
+ } catch {
213
+ // best-effort
214
+ }
215
+ }
216
+ const content = lines.length > 0 ? `${lines.join("\n")}\n` : "";
217
+ const tempPath = join(dir, `.${basename(path)}.tmp.${process.pid}.${Date.now().toString(36)}`);
218
+ writeFileSync(tempPath, content, "utf-8");
219
+ const fd = openSync(tempPath, "r+");
220
+ try {
221
+ fsyncSync(fd);
222
+ } finally {
223
+ closeSync(fd);
224
+ }
225
+ try {
226
+ renameSync(tempPath, path);
227
+ } catch (e) {
228
+ try {
229
+ unlinkSync(tempPath);
230
+ } catch {
231
+ // ignore
232
+ }
233
+ throw e;
234
+ }
235
+ }
236
+
237
+ // --- Resolution ---
238
+
239
+ export interface ResolvedSetting {
240
+ value: SettingsValue | undefined;
241
+ source: SettingsSource;
242
+ }
243
+
244
+ /**
245
+ * 3-tier resolver: env > file > builtinDefault (undefined if none). Throws
246
+ * INVALID_SETTING_KEY for an unrecognized attr, INVALID_SETTING_VALUE if the
247
+ * env var or file line's value fails validation. Never sees a CLI flag value —
248
+ * callers compose `flagValue ?? resolveSetting(attr).value` themselves.
249
+ */
250
+ export function resolveSetting(attr: string, env: NodeJS.ProcessEnv = process.env): ResolvedSetting {
251
+ const def = lookupSettingsKey(attr);
252
+ if (!def) throw unknownKeyError(attr);
253
+
254
+ const envRaw = env[def.envKey];
255
+ if (envRaw !== undefined) {
256
+ return { value: def.parse(envRaw), source: "env" };
257
+ }
258
+
259
+ const fileDefaults = loadSettingsFileDefaults();
260
+ const fileRaw = fileDefaults[def.envKey];
261
+ if (fileRaw !== undefined) {
262
+ return { value: def.parse(fileRaw), source: "file" };
263
+ }
264
+
265
+ return { value: def.builtinDefault, source: "default" };
266
+ }
267
+
268
+ export interface SettingsGetResult extends ResolvedSetting {
269
+ key: string;
270
+ }
271
+
272
+ /** Strict — throws on an unrecognized key or a malformed env/file value. */
273
+ export function settingsGet(attr: string, env: NodeJS.ProcessEnv = process.env): SettingsGetResult {
274
+ const def = lookupSettingsKey(attr);
275
+ if (!def) throw unknownKeyError(attr);
276
+ return { key: def.attr, ...resolveSetting(attr, env) };
277
+ }
278
+
279
+ /** Tolerant — a malformed single value reports its raw string + source rather than throwing,
280
+ * so one bad line doesn't break the whole table. */
281
+ export function settingsPrint(env: NodeJS.ProcessEnv = process.env): SettingsGetResult[] {
282
+ return SETTINGS_KEYS.map((def) => {
283
+ try {
284
+ return { key: def.attr, ...resolveSetting(def.attr, env) };
285
+ } catch {
286
+ const envRaw = env[def.envKey];
287
+ if (envRaw !== undefined) return { key: def.attr, value: envRaw, source: "env" as const };
288
+ const fileRaw = loadSettingsFileDefaults()[def.envKey];
289
+ return { key: def.attr, value: fileRaw, source: "file" as const };
290
+ }
291
+ });
292
+ }
293
+
294
+ export interface SettingsSetResult {
295
+ key: string;
296
+ previous: SettingsValue | undefined;
297
+ value: SettingsValue;
298
+ }
299
+
300
+ /** Validates, writes ONE line, preserves all other file content (comments, blank lines,
301
+ * foreign vars) byte-for-byte verbatim. */
302
+ export function settingsSet(attr: string, rawValue: string): SettingsSetResult {
303
+ const def = lookupSettingsKey(attr);
304
+ if (!def) throw unknownKeyError(attr);
305
+
306
+ const parsed = def.parse(rawValue);
307
+
308
+ const lines = readSettingsFileLines();
309
+ const indices = matchingLineIndices(lines, def.envKey);
310
+ const index = indices.at(-1) ?? -1;
311
+ const previousRaw = index >= 0 ? rawValueAtLine(lines, index) : undefined;
312
+ let previous: SettingsValue | undefined;
313
+ if (previousRaw !== undefined) {
314
+ try {
315
+ previous = def.parse(previousRaw);
316
+ } catch {
317
+ previous = previousRaw;
318
+ }
319
+ }
320
+
321
+ const newLine = `${def.envKey}=${def.serialize(parsed)}`;
322
+ if (index >= 0) {
323
+ lines[index] = newLine;
324
+ for (let i = indices.length - 2; i >= 0; i--) {
325
+ lines.splice(indices[i] ?? -1, 1);
326
+ }
327
+ } else {
328
+ lines.push(newLine);
329
+ }
330
+ writeSettingsFileLinesSync(lines);
331
+
332
+ return { key: def.attr, previous, value: parsed };
333
+ }
334
+
335
+ export interface SettingsResetResult {
336
+ cleared: string[];
337
+ }
338
+
339
+ /** No attr → clears every managed-key line, preserving foreign/comment lines. reset ALWAYS
340
+ * deletes the line, never blanks it (a blank "KEY=" line is still "set" to a bash-sourced file). */
341
+ export function settingsReset(attr?: string): SettingsResetResult {
342
+ if (attr !== undefined) {
343
+ const def = lookupSettingsKey(attr);
344
+ if (!def) throw unknownKeyError(attr);
345
+ const lines = readSettingsFileLines();
346
+ const indices = matchingLineIndices(lines, def.envKey);
347
+ if (indices.length === 0) return { cleared: [] };
348
+ for (let i = indices.length - 1; i >= 0; i--) {
349
+ lines.splice(indices[i] ?? -1, 1);
350
+ }
351
+ writeSettingsFileLinesSync(lines);
352
+ return { cleared: [def.attr] };
353
+ }
354
+
355
+ const lines = readSettingsFileLines();
356
+ const managedEnvKeys = new Map(SETTINGS_KEYS.map((def) => [def.envKey, def.attr]));
357
+ const cleared: string[] = [];
358
+ const kept = lines.filter((line) => {
359
+ const trimmed = line.trim();
360
+ if (trimmed.length === 0 || trimmed.startsWith("#")) return true;
361
+ const eq = trimmed.indexOf("=");
362
+ if (eq <= 0) return true;
363
+ const key = trimmed.slice(0, eq).trim();
364
+ const attrName = managedEnvKeys.get(key);
365
+ if (attrName === undefined) return true;
366
+ cleared.push(attrName);
367
+ return false;
368
+ });
369
+ if (cleared.length > 0) {
370
+ writeSettingsFileLinesSync(kept);
371
+ }
372
+ return { cleared };
373
+ }
package/src/lib/state.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Machine state persistence — JSON files in ~/.local/share/quickchr/machines/.
3
3
  */
4
4
 
5
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync, statSync } from "node:fs";
5
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync, statSync } from "node:fs";
6
6
  import { join, } from "node:path";
7
7
  import type { MachineState, } from "./types.ts";
8
8
  import { QuickCHRError } from "./types.ts";
@@ -24,6 +24,47 @@ export function getMachinesDir(): string {
24
24
  return join(getDataDir(), "machines");
25
25
  }
26
26
 
27
+ /** One line per successful boot, appended to <dataDir>/boot-log.ndjson — local
28
+ * boot-history for users ("how slow are my boots") and the durable CI metrics
29
+ * feed (machine dirs are removed by tests, machine.json alone is not enough). */
30
+ export interface BootLogEntry {
31
+ ts: string;
32
+ name: string;
33
+ version: string;
34
+ arch: string;
35
+ accel: string;
36
+ bootMs: number;
37
+ /** Host platform, `process.platform` ("linux" | "darwin" | "win32"). */
38
+ host: string;
39
+ }
40
+
41
+ const BOOT_LOG_MAX_LINES = 1000;
42
+ const BOOT_LOG_KEEP_LINES = 500;
43
+
44
+ /** Path to the boot-history log. */
45
+ export function bootLogPath(): string {
46
+ return join(getDataDir(), "boot-log.ndjson");
47
+ }
48
+
49
+ /** Append a boot record; rotates to the newest BOOT_LOG_KEEP_LINES when the
50
+ * log exceeds BOOT_LOG_MAX_LINES.
51
+ *
52
+ * Append-first (single appendFileSync call, atomic for one-line writes on
53
+ * every platform we run on) so concurrent boots — parallel bun test workers
54
+ * each booting a CHR — never clobber each other's entries in the common path.
55
+ * Only the rare rotation (once per ~500 boots) does a read-rewrite; an append
56
+ * racing that millisecond window CAN be lost. Accepted: this is a boot-history
57
+ * metrics log, not a ledger, and a lockfile is not worth the complexity. */
58
+ export function appendBootLog(entry: BootLogEntry): void {
59
+ const path = bootLogPath();
60
+ ensureDir(getDataDir());
61
+ appendFileSync(path, `${JSON.stringify(entry)}\n`);
62
+ const lines = readFileSync(path, "utf-8").split("\n").filter(Boolean);
63
+ if (lines.length > BOOT_LOG_MAX_LINES) {
64
+ writeFileSync(path, `${lines.slice(-BOOT_LOG_KEEP_LINES).join("\n")}\n`);
65
+ }
66
+ }
67
+
27
68
  /** Get the cache directory. */
28
69
  export function getCacheDir(): string {
29
70
  return join(getDataDir(), "cache");
package/src/lib/types.ts CHANGED
@@ -25,17 +25,44 @@ export type NetworkMode =
25
25
  * Network specifier — expresses user intent for a network interface.
26
26
  * Generic specifiers (user, socket, shared, bridged) are cross-platform;
27
27
  * explicit specifiers (vmnet-shared, vmnet-bridged, tap) bypass platform resolution.
28
+ *
29
+ * Pick by traffic shape (full guide: `docs/networking-recipes.md`):
30
+ * - **Management / host→guest TCP+UDP services** → `"user"` (SLIRP + `hostfwd`).
31
+ * Keep `"user"` first (ether1) so RouterOS' DHCP client gets `10.0.2.15`.
32
+ * - **Host needs to see guest L2 frames** (MNDP, MAC-Telnet, raw Ethernet,
33
+ * broadcasts) → `{type:"socket-connect", port}` against a host TCP server
34
+ * (rootless, cross-platform; recipe in `docs/mndp.md`).
35
+ * - **L2 link between two VMs** → `{type:"socket", name}` (named pair) or
36
+ * `socket-listen`/`socket-connect`/`socket-mcast` (low-level).
37
+ * - **Real LAN / DHCP-from-host presence** → `"shared"` or `{type:"bridged", iface}`
38
+ * (rootless via socket_vmnet on macOS / pre-created TAP on Linux).
39
+ *
40
+ * Note: receiving guest-*originated* UDP (e.g. a guest replying to `10.0.2.2`)
41
+ * needs no extra NIC and no forward — see {@link ChrInstance.tzspGatewayIp}.
28
42
  */
29
43
  export type NetworkSpecifier =
44
+ /** SLIRP user-mode NAT + `hostfwd` port mapping. Management default; the only
45
+ * NIC that makes `extraPorts`/`--forward` reachable. Terminates Layer 2. */
30
46
  | "user"
47
+ /** L2 socket pair between two VMs sharing a registered `name` (`quickchr networks sockets`). */
31
48
  | { type: "socket"; name: string }
49
+ /** Low-level QEMU `socket` netdev that binds and listens (`0.0.0.0:port`). */
32
50
  | { type: "socket-listen"; port: number }
51
+ /** Low-level QEMU `socket` netdev that connects to `127.0.0.1:port`. The
52
+ * rootless, cross-platform way for a host process to receive guest L2 frames. */
33
53
  | { type: "socket-connect"; port: number }
54
+ /** Multi-VM L2 segment over UDP multicast. Works on Linux/CI; **broken on macOS**
55
+ * (QEMU sets only `SO_REUSEADDR`). */
34
56
  | { type: "socket-mcast"; group: string; port: number }
57
+ /** NAT'd L3 with DHCP from the host; resolves to socket_vmnet/vmnet (macOS) or TAP (Linux). */
35
58
  | "shared"
59
+ /** L2 bridged onto a host interface; resolves like `"shared"` but bridged. */
36
60
  | { type: "bridged"; iface: string }
61
+ /** Explicit macOS vmnet-shared (bypasses resolution; runs QEMU as root). */
37
62
  | "vmnet-shared"
63
+ /** Explicit macOS vmnet-bridged (bypasses resolution; runs QEMU as root). */
38
64
  | { type: "vmnet-bridged"; iface: string }
65
+ /** Pre-created user-owned TAP on Linux (no quickchr setup). */
39
66
  | { type: "tap"; ifname: string };
40
67
 
41
68
  /** Result of resolving a NetworkSpecifier on a specific platform. */
@@ -82,10 +109,24 @@ export interface SocketVmnetInfo {
82
109
  bridgedSockets: Record<string, string>;
83
110
  }
84
111
 
112
+ /** A single host→guest port forward, realized as one QEMU SLIRP `hostfwd`
113
+ * directive (`hostfwd=<proto>::<host>-:<guest>`).
114
+ *
115
+ * This is the programmatic equivalent of one CLI `--forward` spec. Build them
116
+ * by hand, or parse spec strings with {@link parseForwardSpec} (single port) /
117
+ * {@link expandForwardSpec} (port ranges). Forwards only reach the guest over a
118
+ * user-mode (`"user"`) NIC — see the networking recipes guide
119
+ * (`docs/networking-recipes.md`) for which mechanism fits which traffic shape. */
85
120
  export interface PortMapping {
121
+ /** Label for this forward; also the key in `MachineState.ports` and what
122
+ * `quickchr list` shows. Must be unique within a machine. */
86
123
  name: string;
124
+ /** Host-side port. `0` (or omitted) auto-allocates from the instance's port block. */
87
125
  host: number;
126
+ /** Guest-side (RouterOS) port the forward targets. */
88
127
  guest: number;
128
+ /** Transport — carried verbatim into the `hostfwd` directive. `udp` is fully
129
+ * supported (e.g. SNMP, syslog, bandwidth-test data ports). */
89
130
  proto: "tcp" | "udp";
90
131
  }
91
132
 
@@ -181,6 +222,10 @@ export interface MachineState extends MachineConfig {
181
222
  pid?: number;
182
223
  /** Path to the machine directory. */
183
224
  machineDir: string;
225
+ /** Accelerator resolved for the most recent boot ("kvm" | "hvf" | "tcg"). */
226
+ lastAccel?: string;
227
+ /** Wall-clock ms from QEMU spawn to REST-ready for the most recent boot. */
228
+ lastBootMs?: number;
184
229
  }
185
230
 
186
231
  // --- Start Options ---
@@ -241,13 +286,38 @@ export interface StartOptions {
241
286
  portBase?: number;
242
287
  /** Services to exclude from port mappings (e.g. ["winbox", "api-ssl"]). */
243
288
  excludePorts?: ServiceName[];
244
- /** Additional custom port mappings appended to the default set. */
289
+ /** Additional host→guest port forwards appended to the default service set —
290
+ * the library equivalent of the CLI `--forward` flag. Each becomes one SLIRP
291
+ * `hostfwd` directive and only works over a `"user"` NIC.
292
+ *
293
+ * `proto` may be `"tcp"` or `"udp"`. To turn CLI-style spec strings into this
294
+ * array, use {@link parseForwardSpec} (single port) or {@link expandForwardSpec}
295
+ * (port ranges, e.g. dynamic UDP data ports). `WELL_KNOWN_GUEST_PORTS` /
296
+ * {@link lookupGuestPort} resolve guest port + proto for common service names.
297
+ *
298
+ * For *receiving* guest-originated UDP (no forward needed), see
299
+ * {@link ChrInstance.tzspGatewayIp} and `docs/networking-recipes.md`.
300
+ * @example
301
+ * extraPorts: [{ name: "snmp", host: 9161, guest: 161, proto: "udp" }]
302
+ * @example
303
+ * // dynamic UDP data ports via a range spec:
304
+ * extraPorts: expandForwardSpec("btest:9200-9210:2000-2010/udp")
305
+ */
245
306
  extraPorts?: PortMapping[];
246
307
  /** @deprecated Use `networks` instead. Kept for backward compatibility. */
247
308
  network?: NetworkMode;
248
- /** Network interfaces. Each entry becomes a NIC on the CHR.
249
- * Default (when omitted): single user-mode NIC.
250
- * When specified: count of entries = count of NICs (explicit control). */
309
+ /** Network interfaces — the library equivalent of repeated `--add-network` flags.
310
+ * Each entry becomes a NIC on the CHR, in order (so `networks[0]` is ether1).
311
+ * Default (when omitted): a single `"user"` NIC.
312
+ * When specified: count of entries = count of NICs (explicit control).
313
+ *
314
+ * Keep `"user"` first for management/`hostfwd`; add a second NIC for L2 work.
315
+ * See {@link NetworkSpecifier} for which specifier fits which traffic shape,
316
+ * and `docs/networking-recipes.md` / `docs/mndp.md` for runnable recipes.
317
+ * @example
318
+ * // management + host-visible L2 (e.g. to receive MNDP on the host):
319
+ * networks: ["user", { type: "socket-connect", port: hostTcpServerPort }]
320
+ */
251
321
  networks?: NetworkSpecifier[];
252
322
  /** Install OS-level dependencies (QEMU, firmware) automatically if missing. */
253
323
  installDeps?: boolean;
@@ -361,10 +431,19 @@ export interface ChrInstance {
361
431
  * Pass directly to tshark: `tshark -i ${instance.captureInterface} -f "udp port 37008"`
362
432
  * Only meaningful when the instance has a user-mode (`"user"`) network interface. */
363
433
  captureInterface: string;
364
- /** QEMU user-mode networking gateway IP — the host's address as seen from inside the VM.
365
- * Use this as the RouterOS `/tool/sniffer` streaming-server target so TZSP traffic
366
- * reaches the host. Always `"10.0.2.2"` for QEMU slirp user-mode networking.
434
+ /** QEMU user-mode (SLIRP) gateway IP — the host's address as seen from inside the VM.
435
+ * Always `"10.0.2.2"` for QEMU slirp user-mode networking.
436
+ *
437
+ * This is the general **guest→host UDP** primitive, not just for TZSP: any
438
+ * datagram the guest sends to `10.0.2.2:<port>` is delivered to a host process
439
+ * bound on loopback at `<port>` (capture it on {@link captureInterface}) with
440
+ * **no `hostfwd` and no extra NIC**. The host socket must be left **unconnected**
441
+ * (use `recvfrom`, not `connect`) — a connected socket filters out the
442
+ * gateway-origin datagrams. This covers guest-originated UDP (syslog, NetFlow,
443
+ * TZSP) and a guest server replying to the gateway. Recipe + lab evidence:
444
+ * `docs/networking-recipes.md`, `examples/udp-gateway/`.
367
445
  * @example
446
+ * // RouterOS TZSP sniffer streaming to the host:
368
447
  * await instance.exec(`/tool/sniffer/set streaming-server=${instance.tzspGatewayIp}:37008`);
369
448
  */
370
449
  tzspGatewayIp: string;
@@ -575,7 +654,10 @@ export type ErrorCode =
575
654
  | "INVALID_FORWARD_SPEC"
576
655
  | "PROVISIONING_VERSION_UNSUPPORTED"
577
656
  | "INSUFFICIENT_DISK_SPACE"
578
- | "STATE_ERROR";
657
+ | "STATE_ERROR"
658
+ | "INVALID_SIZE_STRING"
659
+ | "INVALID_SETTING_KEY"
660
+ | "INVALID_SETTING_VALUE";
579
661
 
580
662
  export class QuickCHRError extends Error {
581
663
  code: ErrorCode;