@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.
- package/DESIGN.md +193 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/package.json +61 -0
- package/src/cli/format.ts +154 -0
- package/src/cli/index.ts +2625 -0
- package/src/cli/wizard.ts +727 -0
- package/src/index.ts +124 -0
- package/src/lib/auth.ts +68 -0
- package/src/lib/channels.ts +222 -0
- package/src/lib/completions.ts +513 -0
- package/src/lib/console.ts +473 -0
- package/src/lib/credentials.ts +99 -0
- package/src/lib/device-mode.ts +283 -0
- package/src/lib/disk.ts +400 -0
- package/src/lib/exec.ts +101 -0
- package/src/lib/images.ts +167 -0
- package/src/lib/license.ts +292 -0
- package/src/lib/log.ts +41 -0
- package/src/lib/network.ts +635 -0
- package/src/lib/packages.ts +225 -0
- package/src/lib/password.ts +21 -0
- package/src/lib/platform.ts +458 -0
- package/src/lib/provision.ts +422 -0
- package/src/lib/qemu.ts +526 -0
- package/src/lib/qga.ts +667 -0
- package/src/lib/quickchr.ts +1871 -0
- package/src/lib/rest.ts +244 -0
- package/src/lib/secrets.ts +133 -0
- package/src/lib/socket-registry.ts +140 -0
- package/src/lib/state.ts +173 -0
- package/src/lib/storage.ts +163 -0
- package/src/lib/types.ts +690 -0
- package/src/lib/versions.ts +181 -0
- package/src/lib/zip.ts +44 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extra package download and installation for CHR instances.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, unlinkSync, chmodSync } from "node:fs";
|
|
6
|
+
import { join, basename } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import type { Arch } from "./types.ts";
|
|
9
|
+
import { QuickCHRError } from "./types.ts";
|
|
10
|
+
import { packagesDownloadUrl } from "./versions.ts";
|
|
11
|
+
import { getCacheDir, ensureDir } from "./state.ts";
|
|
12
|
+
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
13
|
+
import { restPost } from "./rest.ts";
|
|
14
|
+
import { extractZip } from "./zip.ts";
|
|
15
|
+
|
|
16
|
+
/** Download and extract the all-packages ZIP for a version/arch. Returns the extract dir. */
|
|
17
|
+
export async function downloadPackages(
|
|
18
|
+
version: string,
|
|
19
|
+
arch: Arch,
|
|
20
|
+
cacheDir?: string,
|
|
21
|
+
logger?: ProgressLogger,
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
const cache = cacheDir ?? getCacheDir();
|
|
24
|
+
ensureDir(cache);
|
|
25
|
+
|
|
26
|
+
const url = packagesDownloadUrl(version, arch);
|
|
27
|
+
const zipName = `all_packages-${arch}-${version}.zip`;
|
|
28
|
+
const zipPath = join(cache, zipName);
|
|
29
|
+
const extractDir = join(cache, `packages-${arch}-${version}`);
|
|
30
|
+
|
|
31
|
+
if (existsSync(extractDir)) {
|
|
32
|
+
const log = logger ?? createLogger();
|
|
33
|
+
log.status(` Using cached packages: ${version} (${arch})`);
|
|
34
|
+
return extractDir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Download if needed
|
|
38
|
+
if (!existsSync(zipPath)) {
|
|
39
|
+
const log = logger ?? createLogger();
|
|
40
|
+
log.status(`Downloading packages for ${version} (${arch})...`);
|
|
41
|
+
const response = await fetch(url);
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw new QuickCHRError(
|
|
44
|
+
"DOWNLOAD_FAILED",
|
|
45
|
+
`Failed to download packages: HTTP ${response.status} for ${url}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const buf = await response.arrayBuffer();
|
|
49
|
+
await Bun.write(zipPath, buf);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Extract
|
|
53
|
+
ensureDir(extractDir);
|
|
54
|
+
extractZip(zipPath, extractDir);
|
|
55
|
+
|
|
56
|
+
return extractDir;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Find the .npk file for a specific package in the extracted packages dir. */
|
|
60
|
+
export function findPackageFile(
|
|
61
|
+
extractDir: string,
|
|
62
|
+
packageName: string,
|
|
63
|
+
): string | undefined {
|
|
64
|
+
if (!existsSync(extractDir)) return undefined;
|
|
65
|
+
|
|
66
|
+
const files = readdirSync(extractDir);
|
|
67
|
+
// Package files are named like: container-7.22.1-arm64.npk or iot-7.22.1-arm64.npk
|
|
68
|
+
// Check that the char after "name-" is a digit (version), not more name letters.
|
|
69
|
+
// e.g. "iot-" must NOT match "iot-bt-extra-7.22.1-arm64.npk"
|
|
70
|
+
const prefix = packageName + "-";
|
|
71
|
+
const match = files.find(
|
|
72
|
+
(f) => f.startsWith(prefix) && f.endsWith(".npk") && /^\d/.test(f.slice(prefix.length)),
|
|
73
|
+
);
|
|
74
|
+
return match ? join(extractDir, match) : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Upload packages to a running CHR via SCP. */
|
|
78
|
+
export async function uploadPackages(
|
|
79
|
+
packagePaths: string[],
|
|
80
|
+
sshPort: number,
|
|
81
|
+
user: string = "admin",
|
|
82
|
+
password: string = "",
|
|
83
|
+
logger?: ProgressLogger,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
// Use SSH_ASKPASS to supply the password without requiring sshpass.
|
|
86
|
+
// SSH_ASKPASS_REQUIRE=prefer (OpenSSH 8.4+) works even without a display.
|
|
87
|
+
// SSH_ASKPASS: a small helper program that prints the password when invoked.
|
|
88
|
+
// On Windows: .cmd script (OpenSSH Windows calls it as a process, not a shell).
|
|
89
|
+
// On Unix: .sh script with execute bit set via chmodSync.
|
|
90
|
+
const isWindows = process.platform === "win32";
|
|
91
|
+
const ext = isWindows ? ".cmd" : ".sh";
|
|
92
|
+
const askpassPath = join(tmpdir(), `quickchr-askpass-${process.pid}${ext}`);
|
|
93
|
+
if (isWindows) {
|
|
94
|
+
// Windows .cmd: OpenSSH reads stdout from the helper
|
|
95
|
+
await Bun.write(askpassPath, `@echo off\r\necho ${password.replace(/[&|<>^%]/g, "^$&")}\r\n`);
|
|
96
|
+
} else {
|
|
97
|
+
await Bun.write(askpassPath, `#!/bin/sh\nprintf '%s' '${password.replace(/'/g, "'\\''")}'`);
|
|
98
|
+
chmodSync(askpassPath, 0o755);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const scpEnv: Record<string, string> = {
|
|
102
|
+
...(process.env as Record<string, string>),
|
|
103
|
+
DISPLAY: "",
|
|
104
|
+
SSH_ASKPASS: askpassPath,
|
|
105
|
+
SSH_ASKPASS_REQUIRE: "prefer",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const log = logger ?? createLogger();
|
|
110
|
+
for (const pkgPath of packagePaths) {
|
|
111
|
+
const filename = basename(pkgPath);
|
|
112
|
+
log.status(`Uploading: ${filename}`);
|
|
113
|
+
|
|
114
|
+
const scpArgs = [
|
|
115
|
+
"scp", "-P", String(sshPort),
|
|
116
|
+
"-o", "StrictHostKeyChecking=accept-new",
|
|
117
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
118
|
+
pkgPath, `${user}@127.0.0.1:/`,
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const result = Bun.spawnSync(scpArgs, {
|
|
122
|
+
stdout: "pipe",
|
|
123
|
+
stderr: "pipe",
|
|
124
|
+
env: scpEnv,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (result.exitCode !== 0) {
|
|
128
|
+
const stderr = new TextDecoder().decode(result.stderr);
|
|
129
|
+
throw new QuickCHRError(
|
|
130
|
+
"PROCESS_FAILED",
|
|
131
|
+
`SCP upload failed for ${filename}: ${stderr}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
try { unlinkSync(askpassPath); } catch { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Install extra packages: download, extract, upload, reboot.
|
|
141
|
+
* Returns the names of packages that were actually found and uploaded. */
|
|
142
|
+
export async function installPackages(
|
|
143
|
+
packages: string[],
|
|
144
|
+
version: string,
|
|
145
|
+
arch: Arch,
|
|
146
|
+
sshPort: number,
|
|
147
|
+
httpPort: number,
|
|
148
|
+
logger?: ProgressLogger,
|
|
149
|
+
): Promise<string[]> {
|
|
150
|
+
if (packages.length === 0) return [];
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
const log = logger ?? createLogger();
|
|
154
|
+
const extractDir = await downloadPackages(version, arch, undefined, logger);
|
|
155
|
+
|
|
156
|
+
const packagePaths: string[] = [];
|
|
157
|
+
const installed: string[] = [];
|
|
158
|
+
for (const pkg of packages) {
|
|
159
|
+
const pkgPath = findPackageFile(extractDir, pkg);
|
|
160
|
+
if (!pkgPath) {
|
|
161
|
+
log.warn(`Package "${pkg}" not found in all_packages for ${version} (${arch})`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
packagePaths.push(pkgPath);
|
|
165
|
+
installed.push(pkg);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (packagePaths.length === 0) return [];
|
|
169
|
+
|
|
170
|
+
await uploadPackages(packagePaths, sshPort, undefined, undefined, logger);
|
|
171
|
+
|
|
172
|
+
// Reboot to activate packages
|
|
173
|
+
log.status("Rebooting CHR to activate packages...");
|
|
174
|
+
try {
|
|
175
|
+
await restPost(
|
|
176
|
+
`http://127.0.0.1:${httpPort}/rest/system/reboot`,
|
|
177
|
+
`Basic ${btoa("admin:")}`,
|
|
178
|
+
{},
|
|
179
|
+
5000,
|
|
180
|
+
);
|
|
181
|
+
} catch {
|
|
182
|
+
// Expected — connection drops during reboot
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return installed;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** List all package names available in an extracted all_packages directory.
|
|
189
|
+
* Parses .npk filenames: "container-7.22.1-arm64.npk" → "container". */
|
|
190
|
+
export function listAvailablePackages(extractDir: string): string[] {
|
|
191
|
+
if (!existsSync(extractDir)) return [];
|
|
192
|
+
const nameRe = /^(.+?)-\d+\.\d+/;
|
|
193
|
+
return readdirSync(extractDir)
|
|
194
|
+
.filter((f) => f.endsWith(".npk"))
|
|
195
|
+
.map((f) => nameRe.exec(f)?.[1] ?? "")
|
|
196
|
+
.filter(Boolean)
|
|
197
|
+
.sort();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Download the all_packages ZIP and return the list of available packages.
|
|
201
|
+
* Caches the extraction — subsequent calls return immediately. */
|
|
202
|
+
export async function downloadAndListPackages(
|
|
203
|
+
version: string,
|
|
204
|
+
arch: Arch,
|
|
205
|
+
): Promise<string[]> {
|
|
206
|
+
const extractDir = await downloadPackages(version, arch);
|
|
207
|
+
return listAvailablePackages(extractDir);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Download all packages and install every one of them. Useful for API schema generation.
|
|
211
|
+
* Returns the names of packages that were installed. */
|
|
212
|
+
export async function installAllPackages(
|
|
213
|
+
version: string,
|
|
214
|
+
arch: Arch,
|
|
215
|
+
sshPort: number,
|
|
216
|
+
httpPort: number,
|
|
217
|
+
logger?: ProgressLogger,
|
|
218
|
+
): Promise<string[]> {
|
|
219
|
+
const log = logger ?? createLogger();
|
|
220
|
+
const extractDir = await downloadPackages(version, arch, undefined, logger);
|
|
221
|
+
const allPkgs = listAvailablePackages(extractDir);
|
|
222
|
+
if (allPkgs.length === 0) return [];
|
|
223
|
+
log.status(`Installing all ${allPkgs.length} packages: ${allPkgs.join(", ")}`);
|
|
224
|
+
return installPackages(allPkgs, version, arch, sshPort, httpPort, logger);
|
|
225
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptographically random password generation for quickchr managed accounts.
|
|
3
|
+
*
|
|
4
|
+
* Generates URL-safe passwords suitable for RouterOS usernames/passwords
|
|
5
|
+
* and HTTP Basic Auth. Avoids special characters that cause quoting issues
|
|
6
|
+
* in RouterOS CLI or shell contexts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
10
|
+
|
|
11
|
+
/** Generate a cryptographically random password of the given length. */
|
|
12
|
+
export function generatePassword(length = 24): string {
|
|
13
|
+
const bytes = new Uint8Array(length);
|
|
14
|
+
crypto.getRandomValues(bytes);
|
|
15
|
+
let result = "";
|
|
16
|
+
for (let i = 0; i < length; i++) {
|
|
17
|
+
const byte = bytes[i] as number;
|
|
18
|
+
result += CHARSET[byte % CHARSET.length];
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform detection — OS, arch, package manager, QEMU paths, firmware, acceleration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
6
|
+
import type { EfiFirmwarePaths, HostInterface, PackageManager, PlatformInfo, SocketVmnetInfo } from "./types.ts";
|
|
7
|
+
import { QuickCHRError as QError } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** UEFI firmware search paths by platform. */
|
|
10
|
+
const EFI_CODE_PATHS = [
|
|
11
|
+
// macOS Homebrew (Apple Silicon)
|
|
12
|
+
"/opt/homebrew/share/qemu/edk2-aarch64-code.fd",
|
|
13
|
+
// macOS Homebrew (Intel)
|
|
14
|
+
"/usr/local/share/qemu/edk2-aarch64-code.fd",
|
|
15
|
+
// Ubuntu/Debian
|
|
16
|
+
"/usr/share/AAVMF/AAVMF_CODE.fd",
|
|
17
|
+
// RHEL/Fedora
|
|
18
|
+
"/usr/share/edk2/aarch64/QEMU_EFI.fd",
|
|
19
|
+
// Arch
|
|
20
|
+
"/usr/share/edk2-armvirt/aarch64/QEMU_EFI.fd",
|
|
21
|
+
// Generic
|
|
22
|
+
"/usr/share/qemu-efi-aarch64/QEMU_EFI.fd",
|
|
23
|
+
// Windows (winget QEMU)
|
|
24
|
+
`${process.env.PROGRAMFILES || "C:\\Program Files"}\\qemu\\share\\edk2-aarch64-code.fd`,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const EFI_VARS_PATHS = [
|
|
28
|
+
"/opt/homebrew/share/qemu/edk2-arm-vars.fd",
|
|
29
|
+
"/usr/local/share/qemu/edk2-arm-vars.fd",
|
|
30
|
+
"/usr/share/AAVMF/AAVMF_VARS.fd",
|
|
31
|
+
"/usr/share/edk2/aarch64/QEMU_VARS.fd",
|
|
32
|
+
"/usr/share/edk2-armvirt/aarch64/vars-template-pflash.raw",
|
|
33
|
+
"/usr/share/qemu-efi-aarch64/QEMU_VARS.fd",
|
|
34
|
+
// Windows (winget QEMU)
|
|
35
|
+
`${process.env.PROGRAMFILES || "C:\\Program Files"}\\qemu\\share\\edk2-arm-vars.fd`,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** Detect the system package manager. */
|
|
39
|
+
export function detectPackageManager(): PackageManager {
|
|
40
|
+
const os = process.platform;
|
|
41
|
+
if (os === "darwin") return "brew";
|
|
42
|
+
if (os === "win32") return "winget";
|
|
43
|
+
if (os === "linux") {
|
|
44
|
+
// Check for common package managers
|
|
45
|
+
if (commandExists("apt")) return "apt";
|
|
46
|
+
if (commandExists("dnf")) return "dnf";
|
|
47
|
+
if (commandExists("pacman")) return "pacman";
|
|
48
|
+
}
|
|
49
|
+
return "unknown";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Find a command on PATH. Uses `where.exe` on Windows and `which` elsewhere. */
|
|
53
|
+
export function findCommandOnPath(cmd: string): string | undefined {
|
|
54
|
+
try {
|
|
55
|
+
const probe = process.platform === "win32" ? ["where.exe", cmd] : ["which", cmd];
|
|
56
|
+
const result = Bun.spawnSync(probe, { stdout: "pipe", stderr: "pipe" });
|
|
57
|
+
if (result.exitCode !== 0) return undefined;
|
|
58
|
+
const output = new TextDecoder().decode(result.stdout).trim();
|
|
59
|
+
const firstLine = output.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
60
|
+
return firstLine || undefined;
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Check if a command exists on PATH. */
|
|
67
|
+
function commandExists(cmd: string): boolean {
|
|
68
|
+
return findCommandOnPath(cmd) !== undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolve the QEMU binary path for a given guest architecture. */
|
|
72
|
+
export function findQemuBinary(guestArch: "x86" | "arm64"): string | undefined {
|
|
73
|
+
const bin = guestArch === "x86" ? "qemu-system-x86_64" : "qemu-system-aarch64";
|
|
74
|
+
|
|
75
|
+
// Check PATH
|
|
76
|
+
const pathBin = findCommandOnPath(bin);
|
|
77
|
+
if (pathBin) return pathBin;
|
|
78
|
+
|
|
79
|
+
// Windows: check common install location
|
|
80
|
+
if (process.platform === "win32") {
|
|
81
|
+
const winPath = `C:\\Program Files\\qemu\\${bin}.exe`;
|
|
82
|
+
if (existsSync(winPath)) return winPath;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Find UEFI firmware files for aarch64. */
|
|
89
|
+
export function findEfiFirmware(): EfiFirmwarePaths | undefined {
|
|
90
|
+
const code = EFI_CODE_PATHS.find((p) => existsSync(p));
|
|
91
|
+
const vars = EFI_VARS_PATHS.find((p) => existsSync(p));
|
|
92
|
+
if (code && vars) return { code, vars };
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Returns a warning string when QGA is attempted on a non-KVM platform, or null on Linux.
|
|
98
|
+
*
|
|
99
|
+
* RouterOS CHR only starts its guest agent daemon under KVM hypervisors. Under macOS (HVF)
|
|
100
|
+
* or Windows, the QGA port is presented correctly by QEMU but the guest never opens it.
|
|
101
|
+
* Linux with /dev/kvm is the confirmed working environment. If MikroTik changes this
|
|
102
|
+
* behaviour in a future build, remove this warning.
|
|
103
|
+
*/
|
|
104
|
+
export function qgaKvmWarning(): string | null {
|
|
105
|
+
if (process.platform === "linux") return null;
|
|
106
|
+
const plat = process.platform === "darwin" ? "macOS (HVF)" : "Windows";
|
|
107
|
+
return (
|
|
108
|
+
`QGA requires KVM — RouterOS guest agent only starts under KVM hypervisors. ` +
|
|
109
|
+
`On ${plat} it will likely time out. ` +
|
|
110
|
+
`Attempting anyway — remove this warning if a future RouterOS build changes behaviour.`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Whether guestArch requires cross-architecture emulation (TCG) on this host.
|
|
115
|
+
* Cross-arch emulation is significantly slower and needs more memory/timeout.
|
|
116
|
+
* arm64 guest on x86_64 host → TCG (slow).
|
|
117
|
+
* x86 guest on any host → HVF or KVM when available (fast). */
|
|
118
|
+
export function isCrossArchEmulation(guestArch: "x86" | "arm64"): boolean {
|
|
119
|
+
// x86 CHR is always runnable under HVF/KVM or TCG — never "slow" cross-arch.
|
|
120
|
+
// arm64 CHR on an arm64 host uses HVF/KVM; on an x86_64 host it falls back to TCG.
|
|
121
|
+
return guestArch === "arm64" && process.arch !== "arm64";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Timeout scaling factor for QEMU acceleration mode.
|
|
126
|
+
*
|
|
127
|
+
* KVM and HVF are near-native; TCG is software emulation and may be 4–15×
|
|
128
|
+
* slower depending on whether the emulated guest architecture matches the host.
|
|
129
|
+
*
|
|
130
|
+
* @param accel Output of detectAccel() for the guest arch.
|
|
131
|
+
* @param crossArch True when host and guest architectures differ.
|
|
132
|
+
*/
|
|
133
|
+
export function accelTimeoutFactor(accel: string, crossArch: boolean): number {
|
|
134
|
+
// KVM/HVF on CI runners is often nested-KVM (VM-inside-VM) which can be
|
|
135
|
+
// variably slower than bare-metal. Use 1.5× to handle worst-case runner load.
|
|
136
|
+
if (accel === "kvm" || accel === "hvf") return 1.5;
|
|
137
|
+
// TCG: cross-arch is 15× slower (x86-on-arm64 especially), same-arch is 4×.
|
|
138
|
+
return crossArch ? 15.0 : 4.0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Detect available QEMU acceleration for a guest architecture. */
|
|
142
|
+
export async function detectAccel(guestArch: "x86" | "arm64"): Promise<string> {
|
|
143
|
+
const hostOs = process.platform;
|
|
144
|
+
const hostArch = process.arch; // "x64" | "arm64"
|
|
145
|
+
|
|
146
|
+
if (hostOs === "linux") {
|
|
147
|
+
// KVM requires matching host/guest architecture.
|
|
148
|
+
const archMatch =
|
|
149
|
+
(guestArch === "x86" && hostArch === "x64") ||
|
|
150
|
+
(guestArch === "arm64" && hostArch === "arm64");
|
|
151
|
+
if (archMatch) {
|
|
152
|
+
try {
|
|
153
|
+
const stat = Bun.spawnSync(["test", "-w", "/dev/kvm"], { stdout: "pipe", stderr: "pipe" });
|
|
154
|
+
if (stat.exitCode === 0) return "kvm";
|
|
155
|
+
} catch { /* fall through */ }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (hostOs === "darwin") {
|
|
160
|
+
// Apple Hypervisor Framework (HVF) is a system capability, not a per-process one.
|
|
161
|
+
// It is available if kern.hv_support=1, regardless of whether the bun process
|
|
162
|
+
// itself is arm64 native or running via Rosetta (process.arch="x64").
|
|
163
|
+
try {
|
|
164
|
+
const hvResult = Bun.spawnSync(["sysctl", "-n", "kern.hv_support"], {
|
|
165
|
+
stdout: "pipe",
|
|
166
|
+
stderr: "pipe",
|
|
167
|
+
});
|
|
168
|
+
const hv = new TextDecoder().decode(hvResult.stdout).trim();
|
|
169
|
+
if (hv === "1") {
|
|
170
|
+
if (guestArch === "x86") return "hvf";
|
|
171
|
+
// arm64 guest HVF requires a native arm64 host process.
|
|
172
|
+
// Use process.arch — on Intel Macs this is "x64" so arm64 guests get TCG,
|
|
173
|
+
// which is correct (Intel can't run arm64 HVF). On Apple Silicon, native
|
|
174
|
+
// bun reports "arm64" and gets HVF. Rosetta bun reports "x64" and gets TCG
|
|
175
|
+
// (acceptable — arm64 TCG on Apple Silicon still boots in <5 min).
|
|
176
|
+
if (process.arch === "arm64") return "hvf";
|
|
177
|
+
}
|
|
178
|
+
} catch { /* fall through */ }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return "tcg";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Detect all available accelerators for both architectures. */
|
|
185
|
+
async function detectAllAccel(): Promise<string[]> {
|
|
186
|
+
const accels = new Set<string>();
|
|
187
|
+
for (const arch of ["x86", "arm64"] as const) {
|
|
188
|
+
const accel = await detectAccel(arch);
|
|
189
|
+
accels.add(accel);
|
|
190
|
+
}
|
|
191
|
+
return [...accels];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Get install command for QEMU on the current platform. */
|
|
195
|
+
export function getQemuInstallHint(pkgMgr?: PackageManager): string {
|
|
196
|
+
const mgr = pkgMgr ?? detectPackageManager();
|
|
197
|
+
switch (mgr) {
|
|
198
|
+
case "brew":
|
|
199
|
+
return "brew install qemu";
|
|
200
|
+
case "apt":
|
|
201
|
+
return "sudo apt install qemu-system-x86 qemu-system-arm qemu-efi-aarch64 qemu-utils";
|
|
202
|
+
case "dnf":
|
|
203
|
+
return "sudo dnf install qemu-kvm qemu-system-aarch64 edk2-aarch64 qemu-img";
|
|
204
|
+
case "pacman":
|
|
205
|
+
return "sudo pacman -S qemu-full";
|
|
206
|
+
case "winget":
|
|
207
|
+
return "winget install SoftwareFreedomConservancy.QEMU";
|
|
208
|
+
default:
|
|
209
|
+
return "Install QEMU (including qemu-system-* and qemu-img)";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Get QEMU version string. */
|
|
214
|
+
export function getQemuVersion(binaryPath: string): string | undefined {
|
|
215
|
+
try {
|
|
216
|
+
const result = Bun.spawnSync([binaryPath, "--version"], { stdout: "pipe", stderr: "pipe" });
|
|
217
|
+
if (result.exitCode === 0) {
|
|
218
|
+
const output = new TextDecoder().decode(result.stdout);
|
|
219
|
+
const match = output.match(/version\s+([\d.]+)/);
|
|
220
|
+
return match?.[1];
|
|
221
|
+
}
|
|
222
|
+
} catch { /* not available */ }
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Socket search directories for socket_vmnet daemons. */
|
|
227
|
+
const SOCKET_VMNET_SOCKET_DIRS = [
|
|
228
|
+
"/usr/local/var/run",
|
|
229
|
+
"/opt/homebrew/var/run",
|
|
230
|
+
"/var/run",
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
/** Candidate paths for socket_vmnet_client binary. */
|
|
234
|
+
const SOCKET_VMNET_CLIENT_PATHS = [
|
|
235
|
+
"/opt/homebrew/opt/socket_vmnet/bin/socket_vmnet_client",
|
|
236
|
+
"/usr/local/opt/socket_vmnet/bin/socket_vmnet_client",
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
function isSocket(path: string): boolean {
|
|
240
|
+
try {
|
|
241
|
+
return statSync(path).isSocket();
|
|
242
|
+
} catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Check if a socket_vmnet daemon socket is accessible (file exists and is a socket). */
|
|
248
|
+
export function isSocketVmnetDaemonRunning(socketPath: string): boolean {
|
|
249
|
+
if (!existsSync(socketPath) || !isSocket(socketPath)) return false;
|
|
250
|
+
// Socket file persists on disk after the daemon stops. Verify the process is alive.
|
|
251
|
+
const result = Bun.spawnSync(["pgrep", "socket_vmnet"], { stdout: "pipe", stderr: "pipe" });
|
|
252
|
+
return result.exitCode === 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Detect socket_vmnet installation and running daemons (macOS only). */
|
|
256
|
+
export function detectSocketVmnet(): SocketVmnetInfo | undefined {
|
|
257
|
+
if (process.platform !== "darwin") return undefined;
|
|
258
|
+
|
|
259
|
+
const client =
|
|
260
|
+
SOCKET_VMNET_CLIENT_PATHS.find((p) => existsSync(p)) ??
|
|
261
|
+
findCommandOnPath("socket_vmnet_client");
|
|
262
|
+
if (!client) return undefined;
|
|
263
|
+
|
|
264
|
+
let sharedSocket: string | undefined;
|
|
265
|
+
const bridgedSockets: Record<string, string> = {};
|
|
266
|
+
|
|
267
|
+
for (const dir of SOCKET_VMNET_SOCKET_DIRS) {
|
|
268
|
+
if (!existsSync(dir)) continue;
|
|
269
|
+
|
|
270
|
+
const sharedPath = `${dir}/socket_vmnet`;
|
|
271
|
+
if (!sharedSocket && isSocket(sharedPath)) {
|
|
272
|
+
sharedSocket = sharedPath;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
for (const entry of readdirSync(dir)) {
|
|
277
|
+
const match = entry.match(/^socket_vmnet\.bridged\.(.+)$/);
|
|
278
|
+
if (!match) continue;
|
|
279
|
+
const fullPath = `${dir}/${entry}`;
|
|
280
|
+
if (isSocket(fullPath) && match[1]) {
|
|
281
|
+
const iface = match[1];
|
|
282
|
+
if (!(iface in bridgedSockets)) {
|
|
283
|
+
bridgedSockets[iface] = fullPath;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
// directory not readable
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { client, sharedSocket, bridgedSockets };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Gather full platform information. */
|
|
296
|
+
export async function detectPlatform(): Promise<PlatformInfo> {
|
|
297
|
+
const os = process.platform as "darwin" | "linux" | "win32";
|
|
298
|
+
const hostArch = process.arch as "x64" | "arm64";
|
|
299
|
+
const packageManager = detectPackageManager();
|
|
300
|
+
|
|
301
|
+
const qemuBinX86 = findQemuBinary("x86");
|
|
302
|
+
const qemuBinArm64 = findQemuBinary("arm64");
|
|
303
|
+
const qemuImg = findQemuImg();
|
|
304
|
+
const efiFirmware = findEfiFirmware();
|
|
305
|
+
const accelAvailable = await detectAllAccel();
|
|
306
|
+
const socketVmnet = detectSocketVmnet();
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
os,
|
|
310
|
+
hostArch,
|
|
311
|
+
packageManager,
|
|
312
|
+
qemuBinX86,
|
|
313
|
+
qemuBinArm64,
|
|
314
|
+
qemuImg,
|
|
315
|
+
efiFirmware,
|
|
316
|
+
accelAvailable,
|
|
317
|
+
socketVmnet,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Resolve the qemu-img binary path. */
|
|
322
|
+
export function findQemuImg(): string | undefined {
|
|
323
|
+
const pathBin = findCommandOnPath("qemu-img");
|
|
324
|
+
if (pathBin) return pathBin;
|
|
325
|
+
|
|
326
|
+
// Windows: check common install location
|
|
327
|
+
if (process.platform === "win32") {
|
|
328
|
+
const winPath = "C:\\Program Files\\qemu\\qemu-img.exe";
|
|
329
|
+
if (existsSync(winPath)) return winPath;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Validate that qemu-img is available; throws MISSING_QEMU if not found. */
|
|
336
|
+
export function requireQemuImg(): string {
|
|
337
|
+
const bin = findQemuImg();
|
|
338
|
+
if (!bin) {
|
|
339
|
+
throw new QError(
|
|
340
|
+
"MISSING_QEMU",
|
|
341
|
+
"qemu-img not found (required for disk operations)",
|
|
342
|
+
getQemuInstallHint(),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return bin;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Validate that the required QEMU binary is available for the given architecture. */
|
|
349
|
+
export function requireQemu(arch: "x86" | "arm64"): string {
|
|
350
|
+
const bin = findQemuBinary(arch);
|
|
351
|
+
if (!bin) {
|
|
352
|
+
throw new QError(
|
|
353
|
+
"MISSING_QEMU",
|
|
354
|
+
`qemu-system-${arch === "x86" ? "x86_64" : "aarch64"} not found`,
|
|
355
|
+
getQemuInstallHint(),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return bin;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Validate that UEFI firmware is available (required for arm64). */
|
|
362
|
+
export function requireFirmware(): EfiFirmwarePaths {
|
|
363
|
+
const fw = findEfiFirmware();
|
|
364
|
+
if (!fw) {
|
|
365
|
+
throw new QError(
|
|
366
|
+
"MISSING_FIRMWARE",
|
|
367
|
+
"UEFI firmware for aarch64 not found",
|
|
368
|
+
getQemuInstallHint(),
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
return fw;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const VIRTUAL_PORT_PATTERNS = [
|
|
375
|
+
"bluetooth pan",
|
|
376
|
+
"thunderbolt bridge",
|
|
377
|
+
"vpn",
|
|
378
|
+
"multipass",
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
/** Detect physical network interfaces on the host. macOS only (Linux/Windows return []). */
|
|
382
|
+
export function detectPhysicalInterfaces(): HostInterface[] {
|
|
383
|
+
if (process.platform !== "darwin") return [];
|
|
384
|
+
|
|
385
|
+
const result = Bun.spawnSync(["networksetup", "-listallhardwareports"], {
|
|
386
|
+
stdout: "pipe",
|
|
387
|
+
stderr: "pipe",
|
|
388
|
+
});
|
|
389
|
+
if (result.exitCode !== 0) return [];
|
|
390
|
+
|
|
391
|
+
const output = new TextDecoder().decode(result.stdout);
|
|
392
|
+
const blocks = output.split(/\n\n+/).filter(Boolean);
|
|
393
|
+
const interfaces: HostInterface[] = [];
|
|
394
|
+
let assignedWifi = false;
|
|
395
|
+
let assignedEthernet = false;
|
|
396
|
+
|
|
397
|
+
for (const block of blocks) {
|
|
398
|
+
const portMatch = block.match(/^Hardware Port:\s*(.+)$/m);
|
|
399
|
+
const deviceMatch = block.match(/^Device:\s*(\S+)$/m);
|
|
400
|
+
const macMatch = block.match(/^Ethernet Address:\s*(\S+)$/m);
|
|
401
|
+
|
|
402
|
+
if (!portMatch || !deviceMatch) continue;
|
|
403
|
+
|
|
404
|
+
const name = (portMatch[1] ?? "").trim();
|
|
405
|
+
const device = (deviceMatch[1] ?? "").trim();
|
|
406
|
+
const mac = macMatch?.[1]?.trim();
|
|
407
|
+
|
|
408
|
+
const nameLower = name.toLowerCase();
|
|
409
|
+
if (VIRTUAL_PORT_PATTERNS.some((p) => nameLower.includes(p))) continue;
|
|
410
|
+
|
|
411
|
+
let alias: "wifi" | "ethernet" | undefined;
|
|
412
|
+
if (!assignedWifi && nameLower === "wi-fi") {
|
|
413
|
+
alias = "wifi";
|
|
414
|
+
assignedWifi = true;
|
|
415
|
+
} else if (
|
|
416
|
+
!assignedEthernet &&
|
|
417
|
+
(nameLower === "ethernet" || nameLower === "usb ethernet")
|
|
418
|
+
) {
|
|
419
|
+
alias = "ethernet";
|
|
420
|
+
assignedEthernet = true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
interfaces.push({ device, name, mac: mac !== "(null)" ? mac : undefined, alias });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return interfaces;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const KNOWN_ALIASES = new Set(["wifi", "ethernet", "auto"]);
|
|
430
|
+
|
|
431
|
+
/** Resolve a convenience alias ("wifi", "ethernet", "auto") to a device name. */
|
|
432
|
+
export function resolveInterfaceAlias(
|
|
433
|
+
alias: string,
|
|
434
|
+
interfaces?: HostInterface[],
|
|
435
|
+
): string {
|
|
436
|
+
if (!KNOWN_ALIASES.has(alias)) return alias;
|
|
437
|
+
|
|
438
|
+
const ifaces = interfaces ?? detectPhysicalInterfaces();
|
|
439
|
+
|
|
440
|
+
if (alias === "auto") {
|
|
441
|
+
const eth = ifaces.find((i) => i.alias === "ethernet");
|
|
442
|
+
if (eth) return eth.device;
|
|
443
|
+
const wifi = ifaces.find((i) => i.alias === "wifi");
|
|
444
|
+
if (wifi) return wifi.device;
|
|
445
|
+
throw new QError(
|
|
446
|
+
"NETWORK_UNAVAILABLE",
|
|
447
|
+
"No physical network interface found for 'auto' — no ethernet or wifi detected",
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const match = ifaces.find((i) => i.alias === alias);
|
|
452
|
+
if (match) return match.device;
|
|
453
|
+
|
|
454
|
+
throw new QError(
|
|
455
|
+
"NETWORK_UNAVAILABLE",
|
|
456
|
+
`No interface found for alias '${alias}' — run 'quickchr doctor' to see available interfaces`,
|
|
457
|
+
);
|
|
458
|
+
}
|