@tikoci/quickchr 0.2.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.
@@ -0,0 +1,1871 @@
1
+ /**
2
+ * QuickCHR — main API class tying together all modules.
3
+ */
4
+
5
+ import type {
6
+ Arch,
7
+ Channel,
8
+ ChrInstance,
9
+ ChrLoadSample,
10
+ DeviceModeOptions,
11
+ DoctorResult,
12
+ ExecOptions,
13
+ ExecResult,
14
+ LicenseInput,
15
+ LicenseLevel,
16
+ MachineState,
17
+ QgaCommand,
18
+ SnapshotInfo,
19
+ StartOptions,
20
+ } from "./types.ts";
21
+ import { QuickCHRError, ARCHES, CHANNELS } from "./types.ts";
22
+ import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
23
+ import {
24
+ resolveVersion,
25
+ isValidVersion,
26
+ generateMachineName,
27
+ assertProvisioningSupportedVersion,
28
+ PROVISIONING_FEATURE_LABEL,
29
+ } from "./versions.ts";
30
+ import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, buildHostfwdString, hasUserModeNetwork } from "./network.ts";
31
+ import {
32
+ getUsedPortBases,
33
+ saveMachine,
34
+ loadMachine,
35
+ removeMachine as removeState,
36
+ getMachineDir,
37
+ getMachinesDir,
38
+ listMachineNames,
39
+ refreshAllStatuses,
40
+ isMachineRunning,
41
+ ensureDir,
42
+ getCacheDir,
43
+ getDataDir,
44
+ } from "./state.ts";
45
+ import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
46
+ import { buildQemuArgs, spawnQemu, stopQemu, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
47
+ import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
48
+ import { monitorCommand, serialStreams, qgaCommand, channelEndpoint } from "./channels.ts";
49
+ import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
50
+ import { provision } from "./provision.ts";
51
+ import { renewLicense, getLicenseInfo } from "./license.ts";
52
+ import { resolveAuth } from "./auth.ts";
53
+ import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials } from "./credentials.ts";
54
+ import { restExecute } from "./exec.ts";
55
+ import { qgaExec } from "./qga.ts";
56
+ import { consoleExec } from "./console.ts";
57
+ import { restRequest, restGet, restPost } from "./rest.ts";
58
+ import { createNamedSocket, getNamedSocket, addSocketMember, removeSocketMember } from "./socket-registry.ts";
59
+ import { createLogger, type ProgressLogger } from "./log.ts";
60
+ import {
61
+ formatDeviceModeSelection,
62
+ readDeviceMode,
63
+ resolveDeviceModeOptions,
64
+ shouldApplyDeviceMode,
65
+ startDeviceModeUpdate,
66
+ verifyDeviceMode,
67
+ waitForDeviceModeApi,
68
+ } from "./device-mode.ts";
69
+ import type { LicenseOptions } from "./types.ts";
70
+ import { toChrPorts } from "./network.ts";
71
+ import { assertSufficientQuickchrStorage, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
72
+ import { existsSync, readdirSync, rmSync, copyFileSync, writeFileSync, unlinkSync, openSync, writeSync, closeSync, readFileSync } from "node:fs";
73
+ import { join } from "node:path";
74
+
75
+ // --- Architecture-aware defaults ---
76
+
77
+ /** Default mem (MiB) — more for cross-arch TCG emulation, less for native HVF/KVM. */
78
+ function defaultMem(arch: Arch, override?: number): number {
79
+ return override ?? (isCrossArchEmulation(arch) ? 1024 : 512);
80
+ }
81
+
82
+ /** Default boot timeout — accel mode and cross-arch emulation affect how slow the VM boots. */
83
+ function defaultBootTimeout(arch: Arch, withPackages?: boolean, accel?: string): number {
84
+ const cross = isCrossArchEmulation(arch);
85
+ const factor = accelTimeoutFactor(accel ?? "tcg", cross);
86
+ // Base: 120s for native, scaled by factor (TCG cross-arch = 15× = 1800s max)
87
+ const base = Math.ceil(120_000 * factor);
88
+ // Package reinstall adds a full reboot cycle — extend by the same factor.
89
+ return withPackages ? base * 2 : base;
90
+ }
91
+
92
+ function hasProvisioningMutations(opts: {
93
+ installAllPackages?: boolean;
94
+ packages?: string[];
95
+ hasDeviceModeProvisioning?: boolean;
96
+ user?: { name: string; password: string };
97
+ disableAdmin?: boolean;
98
+ license?: LicenseInput;
99
+ secureLogin?: boolean;
100
+ }): boolean {
101
+ return !!(
102
+ opts.installAllPackages ||
103
+ (opts.packages?.length ?? 0) > 0 ||
104
+ opts.hasDeviceModeProvisioning ||
105
+ opts.user ||
106
+ opts.disableAdmin ||
107
+ opts.license ||
108
+ opts.secureLogin === true
109
+ );
110
+ }
111
+
112
+ function listProvisioningMutations(opts: {
113
+ installAllPackages?: boolean;
114
+ packages?: string[];
115
+ hasDeviceModeProvisioning?: boolean;
116
+ user?: { name: string; password: string };
117
+ disableAdmin?: boolean;
118
+ license?: LicenseInput;
119
+ secureLogin?: boolean;
120
+ }): string[] {
121
+ const operations: string[] = [];
122
+ if (opts.installAllPackages) {
123
+ operations.push("install all packages");
124
+ } else if ((opts.packages?.length ?? 0) > 0) {
125
+ operations.push("install extra packages");
126
+ }
127
+ if (opts.user) {
128
+ operations.push("create a custom user");
129
+ } else if (opts.secureLogin === true) {
130
+ operations.push("create a managed login");
131
+ }
132
+ if (opts.disableAdmin) {
133
+ operations.push("disable the default admin account");
134
+ }
135
+ if (opts.license) {
136
+ operations.push("apply a license");
137
+ }
138
+ if (opts.hasDeviceModeProvisioning) {
139
+ operations.push("change device-mode");
140
+ }
141
+ return operations;
142
+ }
143
+
144
+ function joinHumanList(items: string[]): string {
145
+ if (items.length <= 1) return items[0] ?? "";
146
+ if (items.length === 2) return `${items[0]} and ${items[1]}`;
147
+ return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
148
+ }
149
+
150
+ function describeProvisioningOperation(opts: Parameters<typeof listProvisioningMutations>[0]): string {
151
+ const operations = listProvisioningMutations(opts);
152
+ if (operations.length === 0) return `use ${PROVISIONING_FEATURE_LABEL}`;
153
+ if (operations.length === 1) return operations[0] ?? `use ${PROVISIONING_FEATURE_LABEL}`;
154
+ return `use ${PROVISIONING_FEATURE_LABEL} (${joinHumanList(operations)})`;
155
+ }
156
+
157
+ // --- Socket registry lifecycle helpers ---
158
+
159
+ function getSocketNamedNetworks(state: MachineState): string[] {
160
+ return state.networks
161
+ .filter((n) => typeof n.specifier === "object" && n.specifier.type === "socket")
162
+ .map((n) => (n.specifier as { type: "socket"; name: string }).name);
163
+ }
164
+
165
+ function registerSocketMembers(state: MachineState): void {
166
+ for (const name of getSocketNamedNetworks(state)) {
167
+ try {
168
+ if (!getNamedSocket(name)) {
169
+ createNamedSocket(name);
170
+ }
171
+ addSocketMember(name, state.name);
172
+ } catch (e) {
173
+ console.warn(`Warning: failed to register socket member "${state.name}" on "${name}": ${e instanceof Error ? e.message : String(e)}`);
174
+ }
175
+ }
176
+ }
177
+
178
+ function unregisterSocketMembers(state: MachineState): void {
179
+ for (const name of getSocketNamedNetworks(state)) {
180
+ try {
181
+ removeSocketMember(name, state.name);
182
+ } catch (e) {
183
+ console.warn(`Warning: failed to unregister socket member "${state.name}" from "${name}": ${e instanceof Error ? e.message : String(e)}`);
184
+ }
185
+ }
186
+ }
187
+
188
+ // --- License helpers ---
189
+
190
+ /** Resolve a LicenseInput to a full LicenseOptions, filling in credentials from
191
+ * env vars / secret store when not provided by the caller. Returns null with a
192
+ * warning when credentials cannot be found. */
193
+ async function resolveLicenseInput(input: LicenseInput): Promise<LicenseOptions | null> {
194
+ const opts: LicenseOptions = typeof input === "string" ? { level: input } : { ...input };
195
+ if (opts.account && opts.password) return opts;
196
+ // Try to fill in missing credentials.
197
+ const stored = await getStoredCredentials();
198
+ if (stored) {
199
+ if (!opts.account) opts.account = stored.account;
200
+ if (!opts.password) opts.password = stored.password;
201
+ return opts;
202
+ }
203
+ console.warn(
204
+ "License skipped: no MikroTik web credentials found. " +
205
+ "Set MIKROTIK_WEB_ACCOUNT / MIKROTIK_WEB_PASSWORD or run 'quickchr login'.",
206
+ );
207
+ return null;
208
+ }
209
+
210
+ /** Create a ChrInstance handle from persisted MachineState. */
211
+ function createInstance(state: MachineState): ChrInstance {
212
+ const ports = toChrPorts(state.ports);
213
+ const restUrl = `http://127.0.0.1:${ports.http}`;
214
+
215
+ return {
216
+ name: state.name,
217
+ state,
218
+ ports,
219
+ restUrl,
220
+ sshPort: ports.ssh,
221
+
222
+ async waitForBoot(timeoutMs?: number): Promise<boolean> {
223
+ // Use resolved credentials so waitForBoot can validate the response body
224
+ // on authenticated machines (post-provisioning, post-install reboots).
225
+ const auth = resolveAuth(state);
226
+ return waitForBoot(ports.http, timeoutMs, auth.header);
227
+ },
228
+
229
+ async stop(): Promise<void> {
230
+ if (state.pid) {
231
+ await stopQemu(state.pid);
232
+ }
233
+ unregisterSocketMembers(state);
234
+ // Update persisted state
235
+ const current = loadMachine(state.name);
236
+ if (current) {
237
+ current.status = "stopped";
238
+ current.pid = undefined;
239
+ saveMachine(current);
240
+ }
241
+ state.status = "stopped";
242
+ state.pid = undefined;
243
+ },
244
+
245
+ async remove(): Promise<void> {
246
+ if (state.pid && isMachineRunning(state)) {
247
+ await stopQemu(state.pid);
248
+ }
249
+ unregisterSocketMembers(state);
250
+ // Clean up stored instance credentials
251
+ deleteInstanceCredentials(state.name);
252
+ removeState(state.name);
253
+ },
254
+
255
+ async clean(): Promise<void> {
256
+ if (state.pid && isMachineRunning(state)) {
257
+ await stopQemu(state.pid);
258
+ }
259
+ // Re-copy fresh image from cache
260
+ const imgPath = join(getCacheDir(), `chr-${state.version}${state.arch === "arm64" ? "-arm64" : ""}.img`);
261
+ if (!existsSync(imgPath)) {
262
+ throw new QuickCHRError("MACHINE_NOT_FOUND", `Cached image not found: ${imgPath}`);
263
+ }
264
+ const destPath = join(state.machineDir, "disk.img");
265
+ copyFileSync(imgPath, destPath);
266
+
267
+ // Clean up disk files (boot.qcow2, extra disks)
268
+ cleanDiskFiles(state.machineDir);
269
+
270
+ // Re-prepare disks if the machine had disk customizations
271
+ await ensureConfiguredDisks(
272
+ state.machineDir,
273
+ state.bootSize,
274
+ state.extraDisks,
275
+ state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
276
+ );
277
+
278
+ // Remove EFI vars to force re-creation
279
+ const efiVars = join(state.machineDir, "efi-vars.fd");
280
+ if (existsSync(efiVars)) rmSync(efiVars);
281
+
282
+ // Clean up stored instance credentials (re-provisioned on next start)
283
+ deleteInstanceCredentials(state.name);
284
+
285
+ // Update state
286
+ const current = loadMachine(state.name);
287
+ if (current) {
288
+ current.status = "stopped";
289
+ current.pid = undefined;
290
+ saveMachine(current);
291
+ }
292
+ state.status = "stopped";
293
+ state.pid = undefined;
294
+ },
295
+
296
+ async monitor(command: string): Promise<string> {
297
+ return monitorCommand(state.machineDir, command, undefined, state.portBase);
298
+ },
299
+
300
+ serial(): { readable: ReadableStream; writable: WritableStream } {
301
+ return serialStreams(state.machineDir, state.portBase);
302
+ },
303
+
304
+ async qga(command: QgaCommand, args?: object): Promise<unknown> {
305
+ return qgaCommand(state.machineDir, state.arch, command, args, undefined, state.portBase);
306
+ },
307
+
308
+ async rest(path: string, opts?: RequestInit): Promise<unknown> {
309
+ const url = `${restUrl}/rest${path.startsWith("/") ? path : "/" + path}`;
310
+
311
+ // Retry on ECONNRESET — RouterOS can transiently reset connections in the
312
+ // brief window immediately after boot or a reboot, even after waitForBoot
313
+ // returns. Three retries with 2 s backoff cover this window without hiding
314
+ // genuine errors (auth failures, 4xx/5xx codes are never retried).
315
+ const MAX_RETRIES = 3;
316
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
317
+ if (attempt > 0) await Bun.sleep(2000);
318
+
319
+ // Resolve auth and method
320
+ const authResolved = resolveAuth(state);
321
+ let authHeader = authResolved.header;
322
+ // Allow caller to override Authorization via opts.headers
323
+ if (opts?.headers) {
324
+ const h = new Headers(opts.headers);
325
+ const overrideAuth = h.get("Authorization");
326
+ if (overrideAuth) authHeader = overrideAuth;
327
+ }
328
+ const method = (opts?.method ?? "GET").toUpperCase();
329
+ const bodyStr = opts?.body != null ? String(opts.body) : null;
330
+
331
+ try {
332
+ const { status, body } = await restRequest(
333
+ url,
334
+ method,
335
+ authHeader,
336
+ bodyStr,
337
+ 10_000,
338
+ );
339
+ if (status < 200 || status >= 300) {
340
+ throw new Error(`REST ${status}: ${body}`);
341
+ }
342
+
343
+ try {
344
+ return JSON.parse(body) as unknown;
345
+ } catch {
346
+ return body;
347
+ }
348
+ } catch (e) {
349
+ if (attempt < MAX_RETRIES && (e as { code?: string }).code === "ECONNRESET") {
350
+ console.warn(`rest(${path}): ECONNRESET on attempt ${attempt + 1}, retrying...`);
351
+ continue;
352
+ }
353
+ throw e;
354
+ }
355
+ }
356
+
357
+ throw new Error("Unexpected exit from rest() retry loop");
358
+ },
359
+
360
+ async exec(command: string, opts?: ExecOptions): Promise<ExecResult> {
361
+ const via = opts?.via ?? "auto";
362
+
363
+ if (via === "qga") {
364
+ if (state.arch === "arm64") {
365
+ throw new QuickCHRError(
366
+ "QGA_UNSUPPORTED",
367
+ "QEMU Guest Agent is not yet functional on ARM64 CHR — MikroTik arm64 guest agent support is planned but not yet released",
368
+ );
369
+ }
370
+ const kvmWarning = qgaKvmWarning();
371
+ if (kvmWarning) {
372
+ console.warn(kvmWarning);
373
+ }
374
+ const endpoint = channelEndpoint(state.machineDir, "qga", state.portBase);
375
+ const result = await qgaExec(endpoint, command, opts?.timeout ?? 30_000);
376
+ return { output: result.stdout.trim(), via: "qga" };
377
+ }
378
+
379
+ if (via === "console") {
380
+ const auth = resolveAuth(state, opts?.user, opts?.password);
381
+ const result = await consoleExec(
382
+ state.machineDir,
383
+ command,
384
+ auth.user,
385
+ opts?.password ?? state.user?.password ?? "",
386
+ opts?.timeout ?? 30_000,
387
+ state.portBase,
388
+ );
389
+ return { output: result.output, via: "console" };
390
+ }
391
+
392
+ if (via !== "auto" && via !== "rest") {
393
+ throw new QuickCHRError(
394
+ "EXEC_FAILED",
395
+ `exec transport "${via}" is not yet implemented`,
396
+ );
397
+ }
398
+ const auth = resolveAuth(state, opts?.user, opts?.password);
399
+ if (via === "rest") {
400
+ return restExecute(restUrl, auth, command, opts);
401
+ }
402
+ // via === "auto": try REST, fall back to console on network/timeout errors.
403
+ // QuickCHRError from REST (HTTP errors like 401/400) are not retried — they
404
+ // represent a real command or auth failure, not an unreachable endpoint.
405
+ try {
406
+ return await restExecute(restUrl, auth, command, {
407
+ ...opts,
408
+ timeout: opts?.timeout ?? 10_000,
409
+ });
410
+ } catch (e) {
411
+ if (e instanceof QuickCHRError) throw e;
412
+ // Network error (ECONNREFUSED, timeout) — fall back to console
413
+ const consoleResult = await consoleExec(
414
+ state.machineDir,
415
+ command,
416
+ auth.user,
417
+ opts?.password ?? state.user?.password ?? "",
418
+ opts?.timeout ?? 30_000,
419
+ state.portBase,
420
+ );
421
+ return { output: consoleResult.output, via: "console" };
422
+ }
423
+ },
424
+
425
+ async license(opts: LicenseOptions): Promise<void> {
426
+ assertProvisioningSupportedVersion(state.version, "apply a license");
427
+ const auth = resolveAuth(state);
428
+ await renewLicense(ports.http, opts, undefined, undefined, undefined, auth.header);
429
+ // Persist the applied level in state
430
+ if (opts.level) {
431
+ const current = loadMachine(state.name);
432
+ if (current) {
433
+ current.licenseLevel = opts.level;
434
+ saveMachine(current);
435
+ }
436
+ state.licenseLevel = opts.level;
437
+ }
438
+ },
439
+
440
+ async setDeviceMode(options: DeviceModeOptions, logger?: ProgressLogger): Promise<void> {
441
+ assertProvisioningSupportedVersion(state.version, "set device-mode");
442
+ const log = logger ?? createLogger();
443
+ const resolved = resolveDeviceModeOptions(options);
444
+ for (const w of resolved.warnings) log.warn(`Device-mode: ${w}`);
445
+ const launchConfig = await buildLaunchConfigFromState(state);
446
+ await applyDeviceMode(this as ChrInstance, state, resolved, launchConfig, log);
447
+ // Persist updated device-mode in state
448
+ const current = loadMachine(state.name);
449
+ if (current) {
450
+ current.deviceMode = options;
451
+ saveMachine(current);
452
+ }
453
+ state.deviceMode = options;
454
+ },
455
+
456
+ async availablePackages(): Promise<string[]> {
457
+ return downloadAndListPackages(state.version, state.arch);
458
+ },
459
+
460
+ async installPackage(packages: string | string[]): Promise<string[]> {
461
+ assertProvisioningSupportedVersion(state.version, "install packages");
462
+ const names = typeof packages === "string" ? [packages] : packages;
463
+ if (names.length === 0) return [];
464
+
465
+ const extractDir = await downloadPackages(state.version, state.arch);
466
+ const packagePaths: string[] = [];
467
+ const installed: string[] = [];
468
+ for (const pkg of names) {
469
+ const pkgPath = findPackageFile(extractDir, pkg);
470
+ if (!pkgPath) {
471
+ console.warn(`Package "${pkg}" not found in all_packages for ${state.version} (${state.arch})`);
472
+ continue;
473
+ }
474
+ packagePaths.push(pkgPath);
475
+ installed.push(pkg);
476
+ }
477
+ if (packagePaths.length === 0) return [];
478
+
479
+ await uploadPackages(packagePaths, ports.ssh);
480
+
481
+ // Reboot to activate packages
482
+ let rebootAuth: string;
483
+ try {
484
+ const auth = resolveAuth(state);
485
+ rebootAuth = auth.header;
486
+ await restPost(
487
+ `http://127.0.0.1:${ports.http}/rest/system/reboot`,
488
+ rebootAuth,
489
+ {},
490
+ 5000,
491
+ );
492
+ } catch {
493
+ // Expected — connection drops during reboot
494
+ rebootAuth ??= `Basic ${btoa("admin:")}`;
495
+ }
496
+
497
+ // Wait for the instance to come back up.
498
+ // Pass resolved credentials so waitForBoot can validate the response body
499
+ // (not just check for a connection), preventing ECONNRESET on the first
500
+ // real REST call after the reboot completes.
501
+ const accel = await detectAccel(state.arch);
502
+ const timeout = defaultBootTimeout(state.arch, true, accel);
503
+ await waitForBoot(ports.http, timeout, rebootAuth);
504
+
505
+ // Persist installed packages to machine.json
506
+ const current = loadMachine(state.name);
507
+ if (current) {
508
+ const merged = new Set([...(current.packages ?? []), ...installed]);
509
+ current.packages = [...merged];
510
+ saveMachine(current);
511
+ }
512
+ state.packages = [...new Set([...(state.packages ?? []), ...installed])];
513
+
514
+ return installed;
515
+ },
516
+
517
+ async destroy(): Promise<void> {
518
+ await this.stop();
519
+ await this.remove();
520
+ },
521
+
522
+ async subprocessEnv(): Promise<Record<string, string>> {
523
+ const auth = resolveAuth(state);
524
+ // auth.header is "Basic <base64>" — extract the raw user:pass.
525
+ const rawCreds = auth.header.startsWith("Basic ")
526
+ ? Buffer.from(auth.header.slice(6), "base64").toString()
527
+ : `${auth.user}:`;
528
+ const restBase = `${restUrl}/rest`;
529
+ return {
530
+ QUICKCHR_NAME: state.name,
531
+ QUICKCHR_REST_URL: restUrl,
532
+ QUICKCHR_REST_BASE: restBase,
533
+ QUICKCHR_SSH_PORT: String(ports.ssh),
534
+ QUICKCHR_AUTH: rawCreds,
535
+ // Legacy compat keys used by restraml and similar consumers.
536
+ URLBASE: restBase,
537
+ BASICAUTH: rawCreds,
538
+ };
539
+ },
540
+
541
+ async queryLoad(): Promise<ChrLoadSample | null> {
542
+ try {
543
+ // Use QEMU monitor `info cpus` for CPU and `info balloon` for memory.
544
+ const [cpuOut, balloonOut] = await Promise.all([
545
+ monitorCommand(state.machineDir, "info cpus", 3000, state.portBase),
546
+ monitorCommand(state.machineDir, "info balloon", 3000, state.portBase),
547
+ ]);
548
+ // `info cpus` output: "* CPU #0: ... thread_id=N\n CPU #1: ..."
549
+ // Each CPU line contains a user/sys/idle percent breakdown — but the
550
+ // most portable field is just thread count (always present).
551
+ // Rough heuristic: count non-idle percentage from "info cpus" if it
552
+ // includes timing; fall back to 0 since QEMU monitor output varies by version.
553
+ let cpuPercent = 0;
554
+ const cpuMatch = cpuOut.match(/\buser=(\d+)%/);
555
+ if (cpuMatch) cpuPercent = Number(cpuMatch[1]);
556
+
557
+ // `info balloon` output: "balloon: actual=512 MB"
558
+ let memUsedMb = 0;
559
+ const memMatch = balloonOut.match(/actual=(\d+)/);
560
+ if (memMatch) memUsedMb = Number(memMatch[1]);
561
+
562
+ return { cpuPercent, memUsedMb };
563
+ } catch {
564
+ return null;
565
+ }
566
+ },
567
+
568
+ snapshot: {
569
+ async list(): Promise<SnapshotInfo[]> {
570
+ const format = state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
571
+ if (format !== "qcow2") return [];
572
+
573
+ // Try monitor first (works on running machines, gives live state)
574
+ if (state.status === "running") {
575
+ try {
576
+ const out = await monitorCommand(state.machineDir, "info snapshots", undefined, state.portBase);
577
+ return parseSnapshotList(out);
578
+ } catch { /* fall through to qemu-img */ }
579
+ }
580
+
581
+ // Fall back to qemu-img info (works on stopped machines too)
582
+ const bootPath = join(state.machineDir, "boot.qcow2");
583
+ if (existsSync(bootPath)) {
584
+ return listSnapshots(bootPath);
585
+ }
586
+ return [];
587
+ },
588
+
589
+ async save(name?: string): Promise<SnapshotInfo> {
590
+ const format = state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
591
+ if (format !== "qcow2") {
592
+ throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk. Recreate with bootDiskFormat: \"qcow2\".");
593
+ }
594
+ if (state.status !== "running") {
595
+ throw new QuickCHRError("MACHINE_STOPPED", `Machine "${state.name}" must be running to save a snapshot.`);
596
+ }
597
+
598
+ const snapName = name ?? new Date().toISOString().replace(/:/g, "-").replace(/\..+$/, "Z");
599
+ const out = await monitorCommand(state.machineDir, `savevm ${snapName}`, undefined, state.portBase);
600
+ if (/^error[:\s]/i.test(out)) {
601
+ throw new QuickCHRError("PROCESS_FAILED", `savevm failed: ${out.trim()}`);
602
+ }
603
+
604
+ // Read back the snapshot list to return the new entry
605
+ const snaps = await this.list();
606
+ return snaps.find((s) => s.name === snapName) ?? {
607
+ id: "0",
608
+ name: snapName,
609
+ vmStateSize: 0,
610
+ date: new Date().toISOString(),
611
+ vmClock: "0000:00:00.000",
612
+ };
613
+ },
614
+
615
+ async load(name: string): Promise<void> {
616
+ const format = state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
617
+ if (format !== "qcow2") {
618
+ throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk.");
619
+ }
620
+ if (state.status !== "running") {
621
+ throw new QuickCHRError("MACHINE_STOPPED", `Machine "${state.name}" must be running to load a snapshot.`);
622
+ }
623
+
624
+ const out = await monitorCommand(state.machineDir, `loadvm ${name}`, undefined, state.portBase);
625
+ if (/^error[:\s]/i.test(out)) {
626
+ throw new QuickCHRError("PROCESS_FAILED", `loadvm failed: ${out.trim()}`);
627
+ }
628
+ },
629
+
630
+ async delete(name: string): Promise<void> {
631
+ const format = state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
632
+ if (format !== "qcow2") {
633
+ throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk.");
634
+ }
635
+ if (state.status !== "running") {
636
+ throw new QuickCHRError("MACHINE_STOPPED", `Machine "${state.name}" must be running to delete a snapshot.`);
637
+ }
638
+
639
+ const out = await monitorCommand(state.machineDir, `delvm ${name}`, undefined, state.portBase);
640
+ if (/^error[:\s]/i.test(out)) {
641
+ throw new QuickCHRError("PROCESS_FAILED", `delvm failed: ${out.trim()}`);
642
+ }
643
+ },
644
+ },
645
+ };
646
+ }
647
+
648
+ /**
649
+ * Atomically acquire an exclusive start lock for a machine directory.
650
+ * Uses O_CREAT|O_EXCL so concurrent attempts are rejected by the OS — no TOCTOU race.
651
+ * If the lock is stale (owning process is dead) it is silently replaced.
652
+ * Throws MACHINE_LOCKED if the lock is held by a live process.
653
+ */
654
+ export function acquireLock(lockPath: string): void {
655
+ let fd: number;
656
+ try {
657
+ fd = openSync(lockPath, "wx"); // O_WRONLY | O_CREAT | O_EXCL — atomic
658
+ writeSync(fd, String(process.pid));
659
+ closeSync(fd);
660
+ return;
661
+ } catch {
662
+ // File already exists — check whether owner is still alive
663
+ }
664
+
665
+ let ownerPid: number | undefined;
666
+ try {
667
+ ownerPid = Number.parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
668
+ } catch { /* unreadable — treat as stale */ }
669
+
670
+ if (ownerPid && !Number.isNaN(ownerPid)) {
671
+ try {
672
+ process.kill(ownerPid, 0); // probes liveness; throws ESRCH if process not found
673
+ throw new QuickCHRError("MACHINE_LOCKED", `Machine is already being started (pid ${ownerPid})`);
674
+ } catch (e) {
675
+ if (e instanceof QuickCHRError) throw e;
676
+ // Process is dead — stale lock, overwrite
677
+ writeFileSync(lockPath, String(process.pid));
678
+ return;
679
+ }
680
+ }
681
+
682
+ // Owner unreadable or zero — treat as stale, overwrite
683
+ writeFileSync(lockPath, String(process.pid));
684
+ }
685
+
686
+ /** Resolve the host architecture to a CHR architecture. */
687
+ function hostArchToChr(): Arch {
688
+ const arch = process.arch;
689
+ if (arch === "arm64") return "arm64";
690
+ return "x86";
691
+ }
692
+
693
+ async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
694
+ const deadline = Date.now() + timeoutMs;
695
+ while (Date.now() < deadline) {
696
+ try {
697
+ process.kill(pid, 0);
698
+ } catch {
699
+ return true;
700
+ }
701
+ await Bun.sleep(100);
702
+ }
703
+ return false;
704
+ }
705
+
706
+ /** Wait for CHR to boot with periodic progress status updates every 20s. */
707
+ async function waitForBootWithProgress(
708
+ instance: ChrInstance,
709
+ timeoutMs: number,
710
+ log: ProgressLogger,
711
+ label: string,
712
+ ): Promise<boolean> {
713
+ log.status(label);
714
+ const start = Date.now();
715
+ const progressInterval = setInterval(() => {
716
+ const elapsedS = Math.round((Date.now() - start) / 1000);
717
+ const remainingS = Math.max(0, Math.round((timeoutMs - (Date.now() - start)) / 1000));
718
+ log.status(` Still waiting for CHR to boot... (${elapsedS}s elapsed, up to ${remainingS}s remaining)`);
719
+ }, 20_000);
720
+ try {
721
+ return await instance.waitForBoot(timeoutMs);
722
+ } finally {
723
+ clearInterval(progressInterval);
724
+ }
725
+ }
726
+
727
+ async function hardRebootMachine(
728
+ state: MachineState,
729
+ launchConfig: QemuLaunchConfig,
730
+ log: ProgressLogger,
731
+ ): Promise<"monitor" | "signal"> {
732
+ if (!state.pid) {
733
+ throw new QuickCHRError("PROCESS_FAILED", "Cannot hard-reboot machine: missing QEMU pid");
734
+ }
735
+
736
+ let method: "monitor" | "signal" = "monitor";
737
+
738
+ try {
739
+ await monitorCommand(state.machineDir, "quit", 4000, state.portBase);
740
+ } catch (e) {
741
+ method = "signal";
742
+ log.warn(`Device-mode: monitor quit failed, falling back to process terminate (${e instanceof Error ? e.message : String(e)})`);
743
+ }
744
+
745
+ const exited = await waitForPidExit(state.pid, 5000);
746
+ if (!exited) {
747
+ method = "signal";
748
+ await stopQemu(state.pid);
749
+ }
750
+
751
+ const restartConfig: QemuLaunchConfig = {
752
+ ...launchConfig,
753
+ background: true,
754
+ };
755
+ const qemuArgs = await buildQemuArgs(restartConfig);
756
+ const wrapper = extractWrapper(restartConfig.networks);
757
+ const { pid } = await spawnQemu(qemuArgs, state.machineDir, true, wrapper);
758
+ state.pid = pid;
759
+ state.status = "running";
760
+ state.lastStartedAt = new Date().toISOString();
761
+ saveMachine(state);
762
+
763
+ return method;
764
+ }
765
+
766
+ /** Reconstruct the QEMU launch config from persisted machine state.
767
+ * Mirrors the logic in _launchExisting so setDeviceMode() can power-cycle
768
+ * a running instance without re-running the full start flow. */
769
+ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaunchConfig> {
770
+ const diskArtifacts = await ensureConfiguredDisks(
771
+ state.machineDir,
772
+ state.bootSize,
773
+ state.extraDisks,
774
+ state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
775
+ );
776
+ const platform = await detectPlatform();
777
+ const hostfwd = buildHostfwdString(state.ports);
778
+ const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
779
+ return {
780
+ arch: state.arch,
781
+ machineDir: state.machineDir,
782
+ bootDisk: diskArtifacts.bootDisk,
783
+ extraDisks: diskArtifacts.extraDisks,
784
+ mem: state.mem,
785
+ cpu: state.cpu,
786
+ ports: state.ports,
787
+ networks: resolvedNetworks,
788
+ background: true,
789
+ portBase: state.portBase,
790
+ };
791
+ }
792
+
793
+ /** Apply a device-mode change to a running CHR instance (may require hard power-cycle).
794
+ * Extracted from _provisionInstance so setDeviceMode() can reuse the same logic. */
795
+ async function applyDeviceMode(
796
+ instance: ChrInstance,
797
+ machineState: MachineState,
798
+ resolvedDeviceMode: ReturnType<typeof resolveDeviceModeOptions>,
799
+ launchConfig: QemuLaunchConfig,
800
+ log: ProgressLogger,
801
+ ): Promise<void> {
802
+ const httpPort = toChrPorts(machineState.ports).http;
803
+ const accel = await detectAccel(machineState.arch);
804
+ const bootTimeout = defaultBootTimeout(machineState.arch, undefined, accel);
805
+ await waitForDeviceModeApi(httpPort, 60_000);
806
+ log.status(`Applying device-mode (${formatDeviceModeSelection(resolvedDeviceMode)})...`);
807
+
808
+ let alreadyActive = false;
809
+ try {
810
+ const beforeMode = await readDeviceMode(httpPort);
811
+ log.debug(`Device-mode before update: ${JSON.stringify(beforeMode)}`);
812
+ alreadyActive = verifyDeviceMode(resolvedDeviceMode, beforeMode).ok;
813
+ } catch { /* CHR unexpectedly unreachable — proceed with update */ }
814
+
815
+ if (alreadyActive) {
816
+ log.status(" Device-mode already active; no power-cycle required.");
817
+ return;
818
+ }
819
+
820
+ const maxAttempts = 5;
821
+ let pendingUpdate: Promise<{ status: number; body: string }> | undefined;
822
+ let requiresPowerCycle = false;
823
+
824
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
825
+ const request = startDeviceModeUpdate(httpPort, resolvedDeviceMode);
826
+ // RouterOS blocks this connection while waiting for hard power-cycle confirmation.
827
+ // We race against 2s: if still pending at 2s, RouterOS has entered blocking state
828
+ // and we can confirm by killing QEMU. If it resolves in <2s ("returned early"),
829
+ // RouterOS hasn't committed the pending change yet — check state and retry.
830
+ const outcome = await Promise.race([
831
+ request
832
+ .then((response) => ({ state: "resolved" as const, response }))
833
+ .catch((error: unknown) => ({ state: "rejected" as const, error })),
834
+ Bun.sleep(2000).then(() => ({ state: "pending" as const })),
835
+ ]);
836
+
837
+ if (outcome.state === "rejected") {
838
+ throw (outcome.error instanceof Error)
839
+ ? outcome.error
840
+ : new QuickCHRError("PROCESS_FAILED", `Device-mode update request failed: ${String(outcome.error)}`);
841
+ }
842
+
843
+ if (outcome.state === "pending") {
844
+ pendingUpdate = request;
845
+ // Attach .catch immediately — ECONNRESET is expected when QEMU is killed
846
+ // during the blocking power-cycle confirmation. Without this, the rejection
847
+ // fires as "unhandled" while hardRebootMachine() is awaited.
848
+ pendingUpdate.catch(() => {});
849
+ requiresPowerCycle = true;
850
+ log.debug("Device-mode update entered pending confirmation state");
851
+ break;
852
+ }
853
+
854
+ log.debug(`Device-mode update response: HTTP ${outcome.response.status}`);
855
+ await Bun.sleep(5000);
856
+
857
+ let routerOsOffline = false;
858
+ try {
859
+ // Unauthenticated probe — just check if HTTP is up (webfig login page).
860
+ await restGet(`http://127.0.0.1:${httpPort}/`, "", 2000);
861
+ } catch {
862
+ routerOsOffline = true;
863
+ }
864
+
865
+ if (routerOsOffline) {
866
+ log.debug("Device-mode accepted — waiting for RouterOS internal reboot");
867
+ const rebooted = await instance.waitForBoot(bootTimeout);
868
+ if (!rebooted) {
869
+ throw new QuickCHRError("BOOT_TIMEOUT", "RouterOS device-mode internal reboot timed out");
870
+ }
871
+ await waitForDeviceModeApi(httpPort, bootTimeout);
872
+ }
873
+
874
+ const actualNow = await readDeviceMode(httpPort);
875
+ log.debug(`Device-mode after update attempt ${attempt}: ${JSON.stringify(actualNow)}`);
876
+ const immediateVerification = verifyDeviceMode(resolvedDeviceMode, actualNow);
877
+ if (immediateVerification.ok) {
878
+ log.status(" Device-mode already active; no power-cycle required.");
879
+ return;
880
+ }
881
+
882
+ if (attempt === maxAttempts) {
883
+ throw new QuickCHRError(
884
+ "PROCESS_FAILED",
885
+ `Device-mode update did not activate after ${maxAttempts} attempts; last mismatch: ${immediateVerification.mismatches.join("; ")}`,
886
+ );
887
+ }
888
+
889
+ log.warn(`Device-mode update returned early without activation (attempt ${attempt}/${maxAttempts}); retrying...`);
890
+ await Bun.sleep(2000);
891
+ }
892
+
893
+ if (requiresPowerCycle) {
894
+ const rebootMethod = await hardRebootMachine(machineState, launchConfig, log);
895
+ log.status(` Device-mode power-cycled via ${rebootMethod === "monitor" ? "QEMU monitor quit" : "process terminate"}`);
896
+
897
+ const rebooted = await waitForBootWithProgress(instance, bootTimeout, log, " Waiting for CHR to reboot after device-mode power-cycle...");
898
+ if (!rebooted) {
899
+ throw new QuickCHRError(
900
+ "BOOT_TIMEOUT",
901
+ `Device-mode activation reboot did not come back within ${bootTimeout / 1000}s`,
902
+ );
903
+ }
904
+ await waitForDeviceModeApi(httpPort, bootTimeout);
905
+ }
906
+
907
+ const actual = await readDeviceMode(httpPort);
908
+ log.debug(`Device-mode post-reboot: ${JSON.stringify(actual)}`);
909
+ const verification = verifyDeviceMode(resolvedDeviceMode, actual);
910
+ if (!verification.ok) {
911
+ throw new QuickCHRError(
912
+ "PROCESS_FAILED",
913
+ `Device-mode verification failed after hard reboot: ${verification.mismatches.join("; ")}`,
914
+ );
915
+ }
916
+ log.status(` Device-mode verified: ${formatDeviceModeSelection(resolvedDeviceMode)}`);
917
+ }
918
+
919
+ /**
920
+ * Main entry point for managing MikroTik CHR virtual machines via QEMU.
921
+ *
922
+ * All methods are static — there is no instance state on the class itself.
923
+ * Use {@link QuickCHR.start} to create and boot a CHR, which returns a
924
+ * {@link ChrInstance} runtime handle for interacting with it.
925
+ *
926
+ * @example
927
+ * ```ts
928
+ * const chr = await QuickCHR.start({ channel: "stable", arch: "arm64" });
929
+ * const info = await chr.rest("/system/resource");
930
+ * await chr.remove();
931
+ * ```
932
+ */
933
+ // biome-ignore lint/complexity/noStaticOnlyClass: QuickCHR is the public API — class provides a clear namespace for consumers
934
+ export class QuickCHR {
935
+ /** Create a new CHR machine (download image, allocate ports, write config) without starting it.
936
+ * Provisioning options (packages, deviceMode, user, disableAdmin) are stored in machine.json
937
+ * and applied automatically on the first subsequent start(). Disk options (`bootSize`,
938
+ * `extraDisks`) are materialized immediately and require `qemu-img` on the host. */
939
+ static async add(opts: StartOptions = {}): Promise<MachineState> {
940
+ if (opts.name?.startsWith("-")) {
941
+ throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-"`);
942
+ }
943
+
944
+ let version: string;
945
+ if (opts.version) {
946
+ if (!isValidVersion(opts.version)) {
947
+ throw new QuickCHRError("INVALID_VERSION", `Invalid version: ${opts.version}`);
948
+ }
949
+ version = opts.version;
950
+ } else {
951
+ const channel = opts.channel ?? "stable";
952
+ version = await resolveVersion(channel);
953
+ }
954
+
955
+ const arch: Arch = opts.arch ?? hostArchToChr();
956
+ requireQemu(arch);
957
+ if (arch === "arm64") requireFirmware();
958
+ const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
959
+ const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
960
+ const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
961
+
962
+ if (hasProvisioningMutations({
963
+ installAllPackages: opts.installAllPackages,
964
+ packages: opts.packages,
965
+ hasDeviceModeProvisioning,
966
+ user: opts.user,
967
+ disableAdmin: opts.disableAdmin,
968
+ license: opts.license,
969
+ secureLogin: opts.secureLogin,
970
+ })) {
971
+ assertProvisioningSupportedVersion(version, describeProvisioningOperation({
972
+ installAllPackages: opts.installAllPackages,
973
+ packages: opts.packages,
974
+ hasDeviceModeProvisioning,
975
+ user: opts.user,
976
+ disableAdmin: opts.disableAdmin,
977
+ license: opts.license,
978
+ secureLogin: opts.secureLogin,
979
+ }));
980
+ }
981
+
982
+ const existingNames = listMachineNames();
983
+ const name = opts.name ?? generateMachineName(version, arch, existingNames);
984
+ if (existingNames.includes(name)) {
985
+ throw new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" already exists. Use 'quickchr start ${name}' to start it.`);
986
+ }
987
+
988
+ const usedBases = getUsedPortBases();
989
+ const portBase = opts.portBase ?? await findAvailablePortBlock(usedBases, opts.excludePorts, opts.extraPorts);
990
+ const ports = buildPortMappings(portBase, opts.excludePorts, opts.extraPorts);
991
+
992
+ const machineDir = getMachineDir(name);
993
+ assertSufficientQuickchrStorage(`prepare CHR ${name}`);
994
+ ensureDir(machineDir);
995
+ const lockPath = join(machineDir, ".start-lock");
996
+ acquireLock(lockPath);
997
+ try {
998
+ const cachedImg = await ensureCachedImage(version, arch);
999
+ copyImageToMachine(cachedImg, machineDir);
1000
+ const diskArtifacts = await ensureConfiguredDisks(
1001
+ machineDir,
1002
+ diskOpts.bootSize,
1003
+ diskOpts.extraDisks,
1004
+ diskOpts.bootDiskFormat,
1005
+ );
1006
+
1007
+ const state: MachineState = {
1008
+ name,
1009
+ version,
1010
+ arch,
1011
+ cpu: opts.cpu ?? 1,
1012
+ mem: defaultMem(arch, opts.mem),
1013
+ networks: resolveStartNetworks(opts.networks, opts.network),
1014
+ ports,
1015
+ packages: opts.packages ?? [],
1016
+ installAllPackages: opts.installAllPackages,
1017
+ deviceMode: opts.deviceMode,
1018
+ user: opts.user,
1019
+ disableAdmin: opts.disableAdmin,
1020
+ portBase,
1021
+ excludePorts: opts.excludePorts ?? [],
1022
+ extraPorts: opts.extraPorts ?? [],
1023
+ bootSize: diskOpts.bootSize,
1024
+ extraDisks: diskOpts.extraDisks,
1025
+ bootDiskFormat: diskArtifacts.bootDisk.format,
1026
+ createdAt: new Date().toISOString(),
1027
+ status: "stopped",
1028
+ machineDir,
1029
+ };
1030
+ saveMachine(state);
1031
+ return state;
1032
+ } finally {
1033
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
1034
+ }
1035
+ }
1036
+
1037
+ /**
1038
+ * Start a new or existing CHR instance.
1039
+ *
1040
+ * The returned {@link ChrInstance} is REST-ready: all provisioning (packages, license,
1041
+ * device-mode, user) has completed before this promise resolves.
1042
+ * Callers do not need to call {@link ChrInstance.waitForBoot} again unless they have
1043
+ * stopped and restarted the instance manually. Disk options (`bootSize`, `extraDisks`)
1044
+ * apply when creating a new machine and require `qemu-img` on the host.
1045
+ */
1046
+ static async start(opts: StartOptions = {}): Promise<ChrInstance> {
1047
+ // Validate name early (before any I/O) so callers get a fast, clear error
1048
+ if (opts.name?.startsWith("-")) {
1049
+ throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-"`);
1050
+ }
1051
+
1052
+ const logger = createLogger(opts.onProgress);
1053
+
1054
+ const requestedDeviceMode = opts.deviceMode;
1055
+ const resolvedDeviceMode = resolveDeviceModeOptions(requestedDeviceMode);
1056
+ for (const warning of resolvedDeviceMode.warnings) {
1057
+ logger.warn(`Device-mode: ${warning}`);
1058
+ }
1059
+ const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
1060
+
1061
+ // Resolve version
1062
+ let version: string;
1063
+ if (opts.version) {
1064
+ if ((CHANNELS as string[]).includes(opts.version)) {
1065
+ version = await resolveVersion(opts.version as Channel);
1066
+ } else if (!isValidVersion(opts.version)) {
1067
+ throw new QuickCHRError("INVALID_VERSION", `Invalid version: ${opts.version}`);
1068
+ } else {
1069
+ version = opts.version;
1070
+ }
1071
+ } else {
1072
+ const channel = opts.channel ?? "stable";
1073
+ version = await resolveVersion(channel);
1074
+ }
1075
+
1076
+ if (hasProvisioningMutations({
1077
+ installAllPackages: opts.installAllPackages,
1078
+ packages: opts.packages,
1079
+ hasDeviceModeProvisioning,
1080
+ user: opts.user,
1081
+ disableAdmin: opts.disableAdmin,
1082
+ license: opts.license,
1083
+ secureLogin: opts.secureLogin,
1084
+ })) {
1085
+ assertProvisioningSupportedVersion(version, describeProvisioningOperation({
1086
+ installAllPackages: opts.installAllPackages,
1087
+ packages: opts.packages,
1088
+ hasDeviceModeProvisioning,
1089
+ user: opts.user,
1090
+ disableAdmin: opts.disableAdmin,
1091
+ license: opts.license,
1092
+ secureLogin: opts.secureLogin,
1093
+ }));
1094
+ }
1095
+
1096
+ // Resolve architecture
1097
+ const arch: Arch = opts.arch ?? hostArchToChr();
1098
+ const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
1099
+
1100
+ // Check prerequisites (skip for dry-run — no QEMU needed)
1101
+ if (!opts.dryRun) {
1102
+ requireQemu(arch);
1103
+ if (arch === "arm64") {
1104
+ requireFirmware();
1105
+ }
1106
+ }
1107
+
1108
+ // Resolve name
1109
+ const existingNames = listMachineNames();
1110
+ const name = opts.name ?? generateMachineName(version, arch, existingNames);
1111
+
1112
+ // Check if machine already exists
1113
+ const existing = loadMachine(name);
1114
+ if (existing) {
1115
+ if (isMachineRunning(existing)) {
1116
+ return createInstance(existing);
1117
+ }
1118
+ // First boot of add()-created machine: apply pending provisioning from stored state
1119
+ if (!existing.lastStartedAt) {
1120
+ const pendingOpts = {
1121
+ installAllPackages: opts.installAllPackages ?? existing.installAllPackages,
1122
+ packages: opts.packages?.length ? opts.packages : (existing.packages.length > 0 ? existing.packages : undefined),
1123
+ deviceMode: opts.deviceMode ?? existing.deviceMode,
1124
+ user: opts.user ?? existing.user,
1125
+ disableAdmin: opts.disableAdmin ?? existing.disableAdmin,
1126
+ license: opts.license,
1127
+ secureLogin: opts.secureLogin,
1128
+ };
1129
+ const hasPending = !!(
1130
+ pendingOpts.installAllPackages ||
1131
+ (pendingOpts.packages?.length ?? 0) > 0 ||
1132
+ pendingOpts.deviceMode ||
1133
+ pendingOpts.user ||
1134
+ pendingOpts.disableAdmin ||
1135
+ pendingOpts.license
1136
+ );
1137
+ if (hasPending) {
1138
+ return QuickCHR._launchExisting(existing, opts.background ?? true, pendingOpts, logger);
1139
+ }
1140
+ }
1141
+ // Exists but stopped — restart it
1142
+ return QuickCHR._launchExisting(existing, opts.background ?? true, undefined, logger);
1143
+ }
1144
+
1145
+ // Allocate port block
1146
+ const usedBases = getUsedPortBases();
1147
+ const portBase = opts.portBase
1148
+ ? opts.portBase
1149
+ : await findAvailablePortBlock(
1150
+ usedBases,
1151
+ opts.excludePorts,
1152
+ opts.extraPorts,
1153
+ );
1154
+
1155
+ const ports = buildPortMappings(
1156
+ portBase,
1157
+ opts.excludePorts,
1158
+ opts.extraPorts,
1159
+ );
1160
+
1161
+ // Dry run — return instance handle without actually spawning
1162
+ if (opts.dryRun) {
1163
+ const machineDir = getMachineDir(name);
1164
+ const state: MachineState = {
1165
+ name,
1166
+ version,
1167
+ arch,
1168
+ cpu: opts.cpu ?? 1,
1169
+ mem: defaultMem(arch, opts.mem),
1170
+ networks: resolveStartNetworks(opts.networks, opts.network),
1171
+ ports,
1172
+ packages: opts.packages ?? [],
1173
+ deviceMode: requestedDeviceMode,
1174
+ user: opts.user,
1175
+ disableAdmin: opts.disableAdmin,
1176
+ portBase,
1177
+ excludePorts: opts.excludePorts ?? [],
1178
+ extraPorts: opts.extraPorts ?? [],
1179
+ bootSize: diskOpts.bootSize,
1180
+ extraDisks: diskOpts.extraDisks,
1181
+ bootDiskFormat: diskOpts.bootDiskFormat,
1182
+ createdAt: new Date().toISOString(),
1183
+ status: "stopped",
1184
+ machineDir,
1185
+ };
1186
+ return createInstance(state);
1187
+ }
1188
+
1189
+ // Acquire a lock to prevent concurrent starts of the same machine
1190
+ const machineDir = getMachineDir(name);
1191
+ assertSufficientQuickchrStorage(`start CHR ${name}`);
1192
+ ensureDir(machineDir);
1193
+ const lockPath = join(machineDir, ".start-lock");
1194
+ acquireLock(lockPath);
1195
+ try {
1196
+
1197
+ // Download and prepare image
1198
+ const cachedImg = await ensureCachedImage(version, arch, undefined, logger);
1199
+ copyImageToMachine(cachedImg, machineDir);
1200
+
1201
+ // Prepare disks (boot resize + extra disks)
1202
+ const diskArtifacts = await ensureConfiguredDisks(
1203
+ machineDir,
1204
+ diskOpts.bootSize,
1205
+ diskOpts.extraDisks,
1206
+ diskOpts.bootDiskFormat,
1207
+ );
1208
+ const bootDisk = diskArtifacts.bootDisk;
1209
+ const extraDiskConfigs = diskArtifacts.extraDisks;
1210
+
1211
+ // Build machine config
1212
+ const background = opts.background ?? true;
1213
+
1214
+ // Provisioning includes any work that requires the machine to have booted first.
1215
+ // When foreground mode is requested WITH provisioning, we must boot in background,
1216
+ // provision (packages/user/license), then attach the serial socket to stdio.
1217
+ // Without provisioning, foreground mode runs QEMU with stdio directly (classic path).
1218
+ const hasProvisioning = !!(
1219
+ opts.installAllPackages ||
1220
+ (opts.packages && opts.packages.length > 0) ||
1221
+ opts.user ||
1222
+ opts.disableAdmin ||
1223
+ opts.license ||
1224
+ hasDeviceModeProvisioning ||
1225
+ opts.secureLogin === true
1226
+ );
1227
+
1228
+ const networkConfigs = resolveStartNetworks(opts.networks, opts.network);
1229
+ if (hasProvisioning && !hasUserModeNetwork(networkConfigs)) {
1230
+ throw new QuickCHRError(
1231
+ "NETWORK_UNAVAILABLE",
1232
+ "Provisioning (packages, login setup, device-mode, license) requires a user-mode network interface for localhost access. " +
1233
+ "Add a user-mode network or remove provisioning options.",
1234
+ );
1235
+ }
1236
+
1237
+ const spawnInBackground = background || (!background && hasProvisioning);
1238
+ const state: MachineState = {
1239
+ name,
1240
+ version,
1241
+ arch,
1242
+ cpu: opts.cpu ?? 1,
1243
+ mem: defaultMem(arch, opts.mem),
1244
+ networks: networkConfigs,
1245
+ ports,
1246
+ packages: opts.packages ?? [],
1247
+ deviceMode: requestedDeviceMode,
1248
+ user: opts.user,
1249
+ disableAdmin: opts.disableAdmin,
1250
+ portBase,
1251
+ excludePorts: opts.excludePorts ?? [],
1252
+ extraPorts: opts.extraPorts ?? [],
1253
+ bootSize: diskOpts.bootSize,
1254
+ extraDisks: diskOpts.extraDisks,
1255
+ bootDiskFormat: bootDisk.format,
1256
+ createdAt: new Date().toISOString(),
1257
+ status: "running",
1258
+ machineDir,
1259
+ };
1260
+
1261
+ // Build QEMU args and spawn
1262
+ const platform = await detectPlatform();
1263
+ const accel = await detectAccel(arch);
1264
+ registerSocketMembers(state);
1265
+ const hostfwd = buildHostfwdString(state.ports);
1266
+ const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
1267
+
1268
+ const launchConfig: QemuLaunchConfig = {
1269
+ arch,
1270
+ machineDir,
1271
+ bootDisk,
1272
+ extraDisks: extraDiskConfigs,
1273
+ mem: state.mem,
1274
+ cpu: state.cpu,
1275
+ ports: state.ports,
1276
+ networks: resolvedNetworks,
1277
+ background: spawnInBackground,
1278
+ portBase: state.portBase,
1279
+ accel,
1280
+ };
1281
+
1282
+ const qemuArgs = await buildQemuArgs(launchConfig);
1283
+ const wrapper = extractWrapper(resolvedNetworks);
1284
+ const { pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper);
1285
+
1286
+ // Foreground (no provisioning): spawnQemu blocks until QEMU exits
1287
+ if (!background && !hasProvisioning) {
1288
+ state.status = "stopped";
1289
+ state.lastStartedAt = new Date().toISOString();
1290
+ saveMachine(state);
1291
+ return createInstance(state);
1292
+ }
1293
+
1294
+ state.pid = pid;
1295
+ state.lastStartedAt = new Date().toISOString();
1296
+
1297
+ saveMachine(state);
1298
+
1299
+ const instance = createInstance(state);
1300
+
1301
+ const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
1302
+
1303
+ // Always wait for boot in background mode — the JSDoc promises "REST-ready".
1304
+ const booted = await instance.waitForBoot(bootTimeout);
1305
+ if (!booted) {
1306
+ let qemuLogTail = "";
1307
+ try {
1308
+ const logPath = join(machineDir, "qemu.log");
1309
+ if (existsSync(logPath)) qemuLogTail = `\nqemu.log:\n${readFileSync(logPath, "utf-8").slice(-1200)}`;
1310
+ } catch { /* ignore */ }
1311
+ try { await instance.stop(); } catch { /* ignore */ }
1312
+ try { await instance.remove(); } catch { /* ignore */ }
1313
+ throw new QuickCHRError(
1314
+ "BOOT_TIMEOUT",
1315
+ `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
1316
+ (hasProvisioning ? " — provisioning could not run." : ".") +
1317
+ ` Machine "${name}" has been cleaned up automatically.${qemuLogTail}`,
1318
+ );
1319
+ }
1320
+
1321
+ if (hasProvisioning) {
1322
+ await QuickCHR._provisionInstance(instance, state, {
1323
+ installAllPackages: opts.installAllPackages,
1324
+ packages: opts.packages,
1325
+ deviceMode: requestedDeviceMode,
1326
+ user: opts.user,
1327
+ disableAdmin: opts.disableAdmin,
1328
+ license: opts.license,
1329
+ secureLogin: opts.secureLogin,
1330
+ }, launchConfig, logger);
1331
+ }
1332
+
1333
+ // Foreground + provisioning: all provisioning is done in background mode.
1334
+ // Now stop QEMU cleanly and re-launch with stdio so the user gets the real
1335
+ // QEMU mux console (Ctrl-A X to quit, Ctrl-A C for monitor — standard QEMU
1336
+ // shortcuts that are well-documented and googleable).
1337
+ if (!background && hasProvisioning) {
1338
+ await instance.stop();
1339
+ // Brief pause for QEMU to flush and release the disk image
1340
+ await Bun.sleep(1000);
1341
+ // Release lock before re-launching — _launchExisting acquires its own.
1342
+ // The outer finally block will attempt a second unlink; that is harmless.
1343
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
1344
+ return QuickCHR._launchExisting(state, false, undefined, logger);
1345
+ }
1346
+
1347
+ return instance;
1348
+ } catch (err) {
1349
+ // Clean up orphaned machine directory if spawn failed before machine.json was saved
1350
+ const machineJsonPath = join(machineDir, "machine.json");
1351
+ if (!existsSync(machineJsonPath)) {
1352
+ try { rmSync(machineDir, { recursive: true, force: true }); } catch { /* best effort */ }
1353
+ }
1354
+ throw err;
1355
+ } finally {
1356
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
1357
+ }
1358
+ }
1359
+
1360
+ /** Apply post-boot provisioning steps (packages, device-mode, license, users).
1361
+ * Assumes the machine has already booted and HTTP is responding. */
1362
+ static async _provisionInstance(
1363
+ instance: ChrInstance,
1364
+ machineState: MachineState,
1365
+ opts: {
1366
+ installAllPackages?: boolean;
1367
+ packages?: string[];
1368
+ deviceMode?: DeviceModeOptions;
1369
+ user?: { name: string; password: string };
1370
+ disableAdmin?: boolean;
1371
+ license?: LicenseInput;
1372
+ secureLogin?: boolean;
1373
+ },
1374
+ launchConfig: QemuLaunchConfig,
1375
+ logger?: ProgressLogger,
1376
+ ): Promise<void> {
1377
+ const log = logger ?? createLogger();
1378
+ const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
1379
+ const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
1380
+ assertProvisioningSupportedVersion(machineState.version, describeProvisioningOperation({
1381
+ installAllPackages: opts.installAllPackages,
1382
+ packages: opts.packages,
1383
+ hasDeviceModeProvisioning,
1384
+ user: opts.user,
1385
+ disableAdmin: opts.disableAdmin,
1386
+ license: opts.license,
1387
+ secureLogin: opts.secureLogin,
1388
+ }));
1389
+ for (const warning of resolvedDeviceMode.warnings) {
1390
+ log.warn(`Device-mode: ${warning}`);
1391
+ }
1392
+ const accel = await detectAccel(machineState.arch);
1393
+ const bootTimeout = defaultBootTimeout(machineState.arch, opts.installAllPackages || (opts.packages?.length ?? 0) > 0, accel);
1394
+ const chrPorts = toChrPorts(machineState.ports);
1395
+
1396
+ // Give SSH a moment to start after HTTP comes up
1397
+ await Bun.sleep(2000);
1398
+
1399
+ if (opts.installAllPackages) {
1400
+ const installed = await installAllPackages(machineState.version, machineState.arch, chrPorts.ssh, chrPorts.http, log);
1401
+ await waitForBootWithProgress(instance, bootTimeout, log, " Waiting for CHR to reboot after package installation...");
1402
+ const current = loadMachine(machineState.name);
1403
+ if (current) {
1404
+ current.packages = installed;
1405
+ saveMachine(current);
1406
+ }
1407
+ machineState.packages = installed;
1408
+ } else if (opts.packages && opts.packages.length > 0) {
1409
+ const installed = await installPackages(opts.packages, machineState.version, machineState.arch, chrPorts.ssh, chrPorts.http, log);
1410
+ await waitForBootWithProgress(instance, bootTimeout, log, " Waiting for CHR to reboot after package installation...");
1411
+ const current = loadMachine(machineState.name);
1412
+ if (current) {
1413
+ current.packages = installed;
1414
+ saveMachine(current);
1415
+ }
1416
+ machineState.packages = installed;
1417
+ }
1418
+
1419
+ if (hasDeviceModeProvisioning) {
1420
+ await applyDeviceMode(instance, machineState, resolvedDeviceMode, launchConfig, log);
1421
+ }
1422
+
1423
+ if (opts.license) {
1424
+ log.status("Applying CHR license...");
1425
+ const resolvedLicense = await resolveLicenseInput(opts.license);
1426
+ if (!resolvedLicense) {
1427
+ throw new QuickCHRError(
1428
+ "PROCESS_FAILED",
1429
+ "License requested but no MikroTik web credentials were available. Set MIKROTIK_WEB_ACCOUNT / MIKROTIK_WEB_PASSWORD or run 'quickchr login'.",
1430
+ );
1431
+ }
1432
+ try {
1433
+ await renewLicense(chrPorts.http, resolvedLicense, undefined, undefined, log);
1434
+ // Read back the actual applied level — RouterOS is the source of truth.
1435
+ let actualLevel: string = resolvedLicense.level ?? "p1";
1436
+ try {
1437
+ const info = await getLicenseInfo(chrPorts.http);
1438
+ if (info.level && info.level !== "free") actualLevel = info.level;
1439
+ log.debug(`License read-back: actual level=${actualLevel}`);
1440
+ } catch (e) {
1441
+ log.warn(`License read-back failed (using requested level): ${e instanceof Error ? e.message : String(e)}`);
1442
+ }
1443
+ const current = loadMachine(machineState.name);
1444
+ if (current) {
1445
+ current.licenseLevel = actualLevel as LicenseLevel;
1446
+ saveMachine(current);
1447
+ }
1448
+ machineState.licenseLevel = actualLevel as LicenseLevel;
1449
+ log.status(` License applied: free → ${actualLevel}`);
1450
+ } catch (e) {
1451
+ if (e instanceof QuickCHRError) throw e;
1452
+ throw new QuickCHRError(
1453
+ "PROCESS_FAILED",
1454
+ `License renewal failed: ${e instanceof Error ? e.message : String(e)}`,
1455
+ );
1456
+ }
1457
+ }
1458
+
1459
+ if (opts.user || opts.disableAdmin || opts.secureLogin === true) {
1460
+ const result = await provision(chrPorts.http, machineState.name, opts.user, opts.disableAdmin, opts.secureLogin, log, machineState.machineDir, machineState.portBase);
1461
+ if (result.user) {
1462
+ // Persist user info in state (password placeholder — real password in secret store)
1463
+ const current = loadMachine(machineState.name);
1464
+ if (current) {
1465
+ current.user = { name: result.user.name, password: "(stored in secrets)" };
1466
+ saveMachine(current);
1467
+ }
1468
+ machineState.user = { name: result.user.name, password: result.user.password };
1469
+ if (!opts.user) {
1470
+ // Auto-created quickchr account — credential display is handled by the caller (wizard/CLI)
1471
+ log.status(` quickchr account created (user: ${result.user.name})`);
1472
+ log.status(` Password saved to ${credentialStorageLabel()}`);
1473
+ }
1474
+ }
1475
+ }
1476
+ }
1477
+
1478
+ /** Re-launch an existing stopped machine. */
1479
+ static async _launchExisting(
1480
+ state: MachineState,
1481
+ background: boolean,
1482
+ provisioningOpts?: {
1483
+ installAllPackages?: boolean;
1484
+ packages?: string[];
1485
+ deviceMode?: DeviceModeOptions;
1486
+ user?: { name: string; password: string };
1487
+ disableAdmin?: boolean;
1488
+ license?: LicenseInput;
1489
+ secureLogin?: boolean;
1490
+ },
1491
+ logger?: ProgressLogger,
1492
+ ): Promise<ChrInstance> {
1493
+ const lockPath = join(state.machineDir, ".start-lock");
1494
+ acquireLock(lockPath);
1495
+ try {
1496
+
1497
+ const diskPath = join(state.machineDir, "disk.img");
1498
+ if (!existsSync(diskPath)) {
1499
+ throw new QuickCHRError("MACHINE_NOT_FOUND", `Disk image not found for "${state.name}"`);
1500
+ }
1501
+
1502
+ const diskArtifacts = await ensureConfiguredDisks(
1503
+ state.machineDir,
1504
+ state.bootSize,
1505
+ state.extraDisks,
1506
+ state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
1507
+ );
1508
+ const bootDisk = diskArtifacts.bootDisk;
1509
+ const extraDiskConfigs = diskArtifacts.extraDisks;
1510
+
1511
+ const hasProvisioning = !!(
1512
+ provisioningOpts && (
1513
+ provisioningOpts.installAllPackages ||
1514
+ (provisioningOpts.packages?.length ?? 0) > 0 ||
1515
+ provisioningOpts.deviceMode ||
1516
+ provisioningOpts.user ||
1517
+ provisioningOpts.disableAdmin ||
1518
+ provisioningOpts.license ||
1519
+ provisioningOpts.secureLogin === true
1520
+ )
1521
+ );
1522
+ // Always boot in background when provisioning is needed
1523
+ const spawnBackground = hasProvisioning ? true : background;
1524
+
1525
+ const platform = await detectPlatform();
1526
+ const accel = await detectAccel(state.arch);
1527
+ registerSocketMembers(state);
1528
+ const hostfwd = buildHostfwdString(state.ports);
1529
+ const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
1530
+
1531
+ const launchConfig: QemuLaunchConfig = {
1532
+ arch: state.arch,
1533
+ machineDir: state.machineDir,
1534
+ bootDisk,
1535
+ extraDisks: extraDiskConfigs,
1536
+ mem: state.mem,
1537
+ cpu: state.cpu,
1538
+ ports: state.ports,
1539
+ networks: resolvedNetworks,
1540
+ background: spawnBackground,
1541
+ portBase: state.portBase,
1542
+ accel,
1543
+ };
1544
+
1545
+ const qemuArgs = await buildQemuArgs(launchConfig);
1546
+ const wrapper = extractWrapper(resolvedNetworks);
1547
+ const { pid } = await spawnQemu(qemuArgs, state.machineDir, spawnBackground, wrapper);
1548
+
1549
+ // Foreground without provisioning: spawnQemu blocks until QEMU exits
1550
+ if (!background && !hasProvisioning) {
1551
+ state.status = "stopped";
1552
+ state.lastStartedAt = new Date().toISOString();
1553
+ saveMachine(state);
1554
+ return createInstance(state);
1555
+ }
1556
+
1557
+ state.pid = pid;
1558
+ state.status = "running";
1559
+ state.lastStartedAt = new Date().toISOString();
1560
+ saveMachine(state);
1561
+
1562
+ const instance = createInstance(state);
1563
+
1564
+ // Always wait for boot in background mode — start() promises "REST-ready".
1565
+ const bootTimeout = defaultBootTimeout(state.arch, provisioningOpts?.installAllPackages, accel);
1566
+ const booted = await instance.waitForBoot(bootTimeout);
1567
+ if (!booted) {
1568
+ let qemuLogTail = "";
1569
+ try {
1570
+ const logPath = join(state.machineDir, "qemu.log");
1571
+ if (existsSync(logPath)) qemuLogTail = `\nqemu.log:\n${readFileSync(logPath, "utf-8").slice(-1200)}`;
1572
+ } catch { /* ignore */ }
1573
+ try { await instance.stop(); } catch { /* ignore */ }
1574
+ try { await instance.remove(); } catch { /* ignore */ }
1575
+ throw new QuickCHRError(
1576
+ "BOOT_TIMEOUT",
1577
+ `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
1578
+ (hasProvisioning ? " — provisioning could not run." : ".") +
1579
+ ` Machine "${state.name}" has been cleaned up automatically.${qemuLogTail}`,
1580
+ );
1581
+ }
1582
+
1583
+ if (hasProvisioning && provisioningOpts) {
1584
+ await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
1585
+ }
1586
+
1587
+ return instance;
1588
+ } finally {
1589
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
1590
+ }
1591
+ }
1592
+
1593
+ /** List all machines (refreshes PID status). */
1594
+ static list(): MachineState[] {
1595
+ return refreshAllStatuses();
1596
+ }
1597
+
1598
+ /** Get an instance handle for an existing machine by name. */
1599
+ static get(name: string): ChrInstance | null {
1600
+ const state = loadMachine(name);
1601
+ if (!state) return null;
1602
+
1603
+ // Refresh PID status
1604
+ if (state.status === "running" && !isMachineRunning(state)) {
1605
+ state.status = "stopped";
1606
+ state.pid = undefined;
1607
+ saveMachine(state);
1608
+ }
1609
+
1610
+ return createInstance(state);
1611
+ }
1612
+
1613
+ /** Run doctor checks for prerequisites. */
1614
+ static async doctor(): Promise<DoctorResult> {
1615
+ const checks: DoctorResult["checks"] = [];
1616
+
1617
+ // Bun version
1618
+ checks.push({
1619
+ label: "Bun runtime",
1620
+ status: "ok",
1621
+ detail: `bun ${Bun.version}`,
1622
+ });
1623
+
1624
+ // QEMU for each arch
1625
+ for (const arch of ARCHES) {
1626
+ const qemuName = arch === "arm64" ? "qemu-system-aarch64" : "qemu-system-x86_64";
1627
+ try {
1628
+ const qemuBin = requireQemu(arch);
1629
+ const ver = getQemuVersion(qemuBin);
1630
+ checks.push({
1631
+ label: qemuName,
1632
+ status: "ok",
1633
+ detail: ver ?? "found (version unknown)",
1634
+ });
1635
+ } catch (_e) {
1636
+ const hint = getQemuInstallHint() ?? "Install QEMU";
1637
+ checks.push({
1638
+ label: qemuName,
1639
+ status: arch === hostArchToChr() ? "error" : "warn",
1640
+ detail: `not found — ${hint}`,
1641
+ });
1642
+ }
1643
+ }
1644
+
1645
+ // UEFI firmware
1646
+ try {
1647
+ const fw = requireFirmware();
1648
+ checks.push({
1649
+ label: "UEFI firmware",
1650
+ status: "ok",
1651
+ detail: fw.code,
1652
+ });
1653
+ } catch {
1654
+ checks.push({
1655
+ label: "UEFI firmware",
1656
+ status: hostArchToChr() === "arm64" ? "error" : "warn",
1657
+ detail: "not found (needed for arm64 CHR)",
1658
+ });
1659
+ }
1660
+
1661
+ // Acceleration
1662
+ try {
1663
+ const platform = await detectPlatform();
1664
+ if (platform.accelAvailable.length > 0) {
1665
+ checks.push({
1666
+ label: "Acceleration",
1667
+ status: "ok",
1668
+ detail: `${platform.accelAvailable.join(", ")} (host: ${platform.hostArch})`,
1669
+ });
1670
+ } else {
1671
+ checks.push({
1672
+ label: "Acceleration",
1673
+ status: "warn",
1674
+ detail: "TCG only (software emulation)",
1675
+ });
1676
+ }
1677
+ } catch {
1678
+ checks.push({
1679
+ label: "Acceleration",
1680
+ status: "warn",
1681
+ detail: "Could not detect",
1682
+ });
1683
+ }
1684
+
1685
+ // Data directory
1686
+ const dataDir = getDataDir();
1687
+ checks.push({
1688
+ label: "Data directory",
1689
+ status: "ok",
1690
+ detail: dataDir,
1691
+ });
1692
+
1693
+ // Cache
1694
+ const storage = getQuickchrStorageReport();
1695
+ const cached = listCachedImages();
1696
+ checks.push({
1697
+ label: "Cache",
1698
+ status: "ok",
1699
+ detail: cached.length > 0
1700
+ ? `${cached.length} image${cached.length !== 1 ? "s" : ""}, ${formatDiskSize(storage.cacheBytes)} cached`
1701
+ : "empty (0 B)",
1702
+ });
1703
+
1704
+ // Machines
1705
+ const machines = refreshAllStatuses();
1706
+ const running = machines.filter((m) => m.status === "running").length;
1707
+ checks.push({
1708
+ label: "Machines",
1709
+ status: "ok",
1710
+ detail: `${machines.length} instance${machines.length !== 1 ? "s" : ""} (${running} running, ${formatDiskSize(storage.machinesBytes)} on disk)`,
1711
+ });
1712
+
1713
+ let storageDetail = `${storage.path}: ${formatDiskSize(storage.freeBytes)} free; ${formatQuickchrUsage(storage)}`;
1714
+ if (storage.status === "error") {
1715
+ storageDetail += ` — below required minimum ${formatDiskSize(storage.recommendedFreeBytes)}`;
1716
+ } else if (storage.status === "warn") {
1717
+ storageDetail += ` — low headroom (target ${formatDiskSize(storage.warningFreeBytes)} free)`;
1718
+ }
1719
+ checks.push({
1720
+ label: storage.label === ".local" ? "Storage (.local)" : "Storage",
1721
+ status: storage.status,
1722
+ detail: storageDetail,
1723
+ });
1724
+
1725
+ // socat (optional, for serial console piping)
1726
+ const socatPath = findCommandOnPath("socat");
1727
+ if (socatPath) {
1728
+ checks.push({
1729
+ label: "socat",
1730
+ status: "ok",
1731
+ detail: socatPath,
1732
+ });
1733
+ } else {
1734
+ checks.push({
1735
+ label: "socat",
1736
+ status: "warn",
1737
+ detail: "not found (optional, for serial console access)",
1738
+ });
1739
+ }
1740
+
1741
+ // qemu-img (optional, for disk resize and extra disks)
1742
+ const qemuImgPath = findQemuImg();
1743
+ if (qemuImgPath) {
1744
+ checks.push({
1745
+ label: "qemu-img",
1746
+ status: "ok",
1747
+ detail: qemuImgPath,
1748
+ });
1749
+ } else {
1750
+ checks.push({
1751
+ label: "qemu-img",
1752
+ status: "warn",
1753
+ detail: `not found — required for --boot-size and --add-disk (${getQemuInstallHint()})`,
1754
+ });
1755
+ }
1756
+
1757
+ // socket_vmnet (macOS only — rootless shared/bridged networking)
1758
+ if (process.platform === "darwin") {
1759
+ const vmnet = detectSocketVmnet();
1760
+ if (vmnet) {
1761
+ const sharedRunning = vmnet.sharedSocket
1762
+ ? isSocketVmnetDaemonRunning(vmnet.sharedSocket)
1763
+ : false;
1764
+ const parts = [vmnet.client];
1765
+ if (vmnet.sharedSocket) parts.push(`shared: ${vmnet.sharedSocket}`);
1766
+ const bridgedIfaces = Object.keys(vmnet.bridgedSockets);
1767
+ if (bridgedIfaces.length > 0) parts.push(`bridged: ${bridgedIfaces.join(", ")}`);
1768
+ if (sharedRunning) {
1769
+ checks.push({
1770
+ label: "socket_vmnet",
1771
+ status: "ok",
1772
+ detail: parts.join(" — "),
1773
+ });
1774
+ } else if (vmnet.sharedSocket) {
1775
+ checks.push({
1776
+ label: "socket_vmnet",
1777
+ status: "warn",
1778
+ detail: `${vmnet.client} — installed but daemon not running. Start: sudo brew services start socket_vmnet`,
1779
+ });
1780
+ } else {
1781
+ checks.push({
1782
+ label: "socket_vmnet",
1783
+ status: "warn",
1784
+ detail: `${vmnet.client} — client found but no socket (daemon not started). Start: sudo brew services start socket_vmnet`,
1785
+ });
1786
+ }
1787
+ } else {
1788
+ checks.push({
1789
+ label: "socket_vmnet",
1790
+ status: "warn",
1791
+ detail: "not found (optional, for rootless shared/bridged networking — brew install socket_vmnet)",
1792
+ });
1793
+ }
1794
+ }
1795
+
1796
+ // Orphaned machine directories (have files but no machine.json)
1797
+ const machinesDir = getMachinesDir();
1798
+ if (existsSync(machinesDir)) {
1799
+ const dirs = readdirSync(machinesDir, { withFileTypes: true })
1800
+ .filter(e => e.isDirectory());
1801
+ const orphans: string[] = [];
1802
+ for (const dir of dirs) {
1803
+ const mjPath = join(machinesDir, dir.name, "machine.json");
1804
+ if (!existsSync(mjPath)) {
1805
+ orphans.push(dir.name);
1806
+ }
1807
+ }
1808
+ if (orphans.length > 0) {
1809
+ const removeLines = orphans.map((o) => ` rm -rf ${join(machinesDir, o)}`).join("\n");
1810
+ checks.push({
1811
+ label: "Orphaned machine dirs",
1812
+ status: "warn",
1813
+ detail: `${orphans.length} dir(s) without machine.json: ${orphans.join(", ")}\nRemove manually:\n${removeLines}`,
1814
+ });
1815
+ } else {
1816
+ checks.push({
1817
+ label: "Machine state",
1818
+ status: "ok",
1819
+ detail: `${dirs.length} machine(s), no orphans`,
1820
+ });
1821
+ }
1822
+ }
1823
+
1824
+ // Shell detection — always informational; useful for bug reports
1825
+ const { detectCurrentShell, shellBinary, completionStatusFor } = await import("./completions.ts");
1826
+ const shellInfo = detectCurrentShell();
1827
+ const binary = shellBinary(shellInfo);
1828
+ const shellDetail = shellInfo.version
1829
+ ? `${binary} ${shellInfo.version} (${shellInfo.shell})`
1830
+ : shellInfo.shell || "unknown";
1831
+ checks.push({
1832
+ label: "Shell",
1833
+ status: "ok",
1834
+ detail: shellDetail,
1835
+ });
1836
+
1837
+ // Shell completions — warn if not installed for the current shell
1838
+ if (shellInfo.supported) {
1839
+ const compStatus = completionStatusFor(binary as import("./completions.ts").SupportedShell);
1840
+ if (compStatus.installed) {
1841
+ checks.push({
1842
+ label: "Shell completions",
1843
+ status: "ok",
1844
+ detail: `${binary}: installed at ${compStatus.path}`,
1845
+ });
1846
+ } else {
1847
+ checks.push({
1848
+ label: "Shell completions",
1849
+ status: "warn",
1850
+ detail: `${binary}: not installed — run 'quickchr completions --install'`,
1851
+ });
1852
+ }
1853
+ } else {
1854
+ checks.push({
1855
+ label: "Shell completions",
1856
+ status: "warn",
1857
+ detail: `${binary || "unknown shell"}: not a supported shell (bash/zsh/fish) — manual install required`,
1858
+ });
1859
+ }
1860
+
1861
+ return {
1862
+ checks,
1863
+ ok: checks.every((c) => c.status !== "error"),
1864
+ };
1865
+ }
1866
+
1867
+ /** Resolve a channel to a version string. */
1868
+ static async resolveVersion(channel: Channel): Promise<string> {
1869
+ return resolveVersion(channel);
1870
+ }
1871
+ }