@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,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RouterOS device-mode provisioning helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DeviceModeOptions } from "./types.ts";
|
|
6
|
+
import { QuickCHRError } from "./types.ts";
|
|
7
|
+
import { restGet, restPost } from "./rest.ts";
|
|
8
|
+
|
|
9
|
+
export const KNOWN_DEVICE_MODES = ["home", "advanced", "basic", "rose"] as const;
|
|
10
|
+
|
|
11
|
+
export const KNOWN_DEVICE_MODE_FEATURES = [
|
|
12
|
+
"scheduler",
|
|
13
|
+
"socks",
|
|
14
|
+
"fetch",
|
|
15
|
+
"pptp",
|
|
16
|
+
"l2tp",
|
|
17
|
+
"bandwidth-test",
|
|
18
|
+
"traffic-gen",
|
|
19
|
+
"sniffer",
|
|
20
|
+
"ipsec",
|
|
21
|
+
"romon",
|
|
22
|
+
"proxy",
|
|
23
|
+
"hotspot",
|
|
24
|
+
"smb",
|
|
25
|
+
"email",
|
|
26
|
+
"zerotier",
|
|
27
|
+
"container",
|
|
28
|
+
"install-any-version",
|
|
29
|
+
"partitions",
|
|
30
|
+
"routerboard",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
const KNOWN_MODE_SET = new Set<string>(KNOWN_DEVICE_MODES);
|
|
34
|
+
const KNOWN_FEATURE_SET = new Set<string>(KNOWN_DEVICE_MODE_FEATURES);
|
|
35
|
+
const SKIP_VALUES = new Set<string>(["skip", "none", "off", "disabled"]);
|
|
36
|
+
|
|
37
|
+
type DeviceModeBool = "yes" | "no";
|
|
38
|
+
|
|
39
|
+
export interface ResolvedDeviceModeOptions {
|
|
40
|
+
skip: boolean;
|
|
41
|
+
mode?: string;
|
|
42
|
+
features: Record<string, DeviceModeBool>;
|
|
43
|
+
warnings: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DeviceModeVerification {
|
|
47
|
+
ok: boolean;
|
|
48
|
+
mismatches: string[];
|
|
49
|
+
expected: Record<string, string>;
|
|
50
|
+
actual: Record<string, string>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function norm(value: string): string {
|
|
54
|
+
return value.trim().toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeFeatureList(list: string[] | undefined): string[] {
|
|
58
|
+
if (!list) return [];
|
|
59
|
+
const values = list
|
|
60
|
+
.flatMap((item) => item.split(","))
|
|
61
|
+
.map((item) => norm(item))
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
return [...new Set(values)];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toYesNo(value: unknown): string {
|
|
67
|
+
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
68
|
+
if (typeof value === "number") return value > 0 ? "yes" : "no";
|
|
69
|
+
const normalized = String(value ?? "").trim().toLowerCase();
|
|
70
|
+
if (["yes", "true", "on", "enabled", "1"].includes(normalized)) return "yes";
|
|
71
|
+
if (["no", "false", "off", "disabled", "0"].includes(normalized)) return "no";
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveDeviceModeOptions(options?: DeviceModeOptions): ResolvedDeviceModeOptions {
|
|
76
|
+
if (!options) {
|
|
77
|
+
return { skip: true, features: {}, warnings: [] };
|
|
78
|
+
}
|
|
79
|
+
const warnings: string[] = [];
|
|
80
|
+
const requestedMode = norm(options.mode ?? "auto");
|
|
81
|
+
|
|
82
|
+
if (SKIP_VALUES.has(requestedMode)) {
|
|
83
|
+
if ((options?.enable?.length ?? 0) > 0 || (options?.disable?.length ?? 0) > 0) {
|
|
84
|
+
warnings.push("device-mode=skip ignores --device-mode-enable/disable values");
|
|
85
|
+
}
|
|
86
|
+
return { skip: true, features: {}, warnings };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let mode = requestedMode;
|
|
90
|
+
if (mode === "enterprise") {
|
|
91
|
+
mode = "advanced";
|
|
92
|
+
warnings.push("device-mode mode 'enterprise' is legacy; using 'advanced'");
|
|
93
|
+
}
|
|
94
|
+
if (mode === "auto") {
|
|
95
|
+
mode = "rose";
|
|
96
|
+
}
|
|
97
|
+
if (!KNOWN_MODE_SET.has(mode)) {
|
|
98
|
+
warnings.push(`unknown device-mode '${mode}' (allowed; RouterOS will validate)`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const enable = new Set(normalizeFeatureList(options?.enable));
|
|
102
|
+
const disable = new Set(normalizeFeatureList(options?.disable));
|
|
103
|
+
|
|
104
|
+
const conflicting: string[] = [];
|
|
105
|
+
for (const feature of enable) {
|
|
106
|
+
if (disable.has(feature)) {
|
|
107
|
+
conflicting.push(feature);
|
|
108
|
+
warnings.push(`device-mode feature '${feature}' set in both enable and disable; using disable`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const feature of conflicting) {
|
|
112
|
+
enable.delete(feature);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const features: Record<string, DeviceModeBool> = {};
|
|
116
|
+
for (const feature of enable) {
|
|
117
|
+
if (!KNOWN_FEATURE_SET.has(feature)) {
|
|
118
|
+
warnings.push(`unknown device-mode feature '${feature}' in enable list (allowed; RouterOS will validate)`);
|
|
119
|
+
}
|
|
120
|
+
features[feature] = "yes";
|
|
121
|
+
}
|
|
122
|
+
for (const feature of disable) {
|
|
123
|
+
if (!KNOWN_FEATURE_SET.has(feature)) {
|
|
124
|
+
warnings.push(`unknown device-mode feature '${feature}' in disable list (allowed; RouterOS will validate)`);
|
|
125
|
+
}
|
|
126
|
+
features[feature] = "no";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
skip: false,
|
|
131
|
+
mode,
|
|
132
|
+
features,
|
|
133
|
+
warnings,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function shouldApplyDeviceMode(options: ResolvedDeviceModeOptions): boolean {
|
|
138
|
+
return !options.skip && !!(options.mode || Object.keys(options.features).length > 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function formatDeviceModeSelection(options: ResolvedDeviceModeOptions): string {
|
|
142
|
+
if (options.skip) return "skip";
|
|
143
|
+
const parts: string[] = [];
|
|
144
|
+
if (options.mode) parts.push(`mode=${options.mode}`);
|
|
145
|
+
for (const [name, value] of Object.entries(options.features)) {
|
|
146
|
+
parts.push(`${name}=${value}`);
|
|
147
|
+
}
|
|
148
|
+
return parts.join(" ");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Wait until the device-mode REST endpoint is reachable with default admin auth. */
|
|
152
|
+
export async function waitForDeviceModeApi(httpPort: number, timeoutMs: number = 60_000): Promise<void> {
|
|
153
|
+
const auth = `Basic ${btoa("admin:")}`;
|
|
154
|
+
const deadline = Date.now() + timeoutMs;
|
|
155
|
+
while (Date.now() < deadline) {
|
|
156
|
+
try {
|
|
157
|
+
const { status } = await restGet(
|
|
158
|
+
`http://127.0.0.1:${httpPort}/rest/system/device-mode`,
|
|
159
|
+
auth,
|
|
160
|
+
3000,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
if (status >= 200 && status < 300) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (status === 401) {
|
|
168
|
+
throw new QuickCHRError("PROCESS_FAILED", `Device-mode API auth failed: HTTP 401`);
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error instanceof QuickCHRError) {
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await Bun.sleep(1000);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
throw new QuickCHRError("BOOT_TIMEOUT", `Device-mode API did not become ready on port ${httpPort}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Fire the device-mode update request without awaiting its response.
|
|
184
|
+
*
|
|
185
|
+
* RouterOS blocks the HTTP connection waiting for power-cycle confirmation (up to 5 minutes).
|
|
186
|
+
* The caller MUST kill the QEMU process while this request is pending to confirm the change.
|
|
187
|
+
* The connection error (ECONNRESET) that results when QEMU exits is expected — suppress it.
|
|
188
|
+
*
|
|
189
|
+
* Uses restPost (node:http + agent:false) to bypass Bun's connection pool.
|
|
190
|
+
* The 300s timeout is a safety limit — the caller is expected to kill QEMU well before then.
|
|
191
|
+
*/
|
|
192
|
+
export function startDeviceModeUpdate(httpPort: number, options: ResolvedDeviceModeOptions): Promise<{ status: number; body: string }> {
|
|
193
|
+
if (!shouldApplyDeviceMode(options)) {
|
|
194
|
+
return Promise.resolve({ status: 200, body: "" });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const payload: Record<string, string> = {};
|
|
198
|
+
if (options.mode) payload.mode = options.mode;
|
|
199
|
+
for (const [name, value] of Object.entries(options.features)) {
|
|
200
|
+
payload[name] = value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const auth = `Basic ${btoa("admin:")}`;
|
|
204
|
+
return restPost(
|
|
205
|
+
`http://127.0.0.1:${httpPort}/rest/system/device-mode/update`,
|
|
206
|
+
auth,
|
|
207
|
+
payload,
|
|
208
|
+
300_000, // Safety timeout — QEMU kill should resolve this first
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function readDeviceMode(httpPort: number): Promise<Record<string, string>> {
|
|
213
|
+
const auth = `Basic ${btoa("admin:")}`;
|
|
214
|
+
const { status, body } = await restGet(
|
|
215
|
+
`http://127.0.0.1:${httpPort}/rest/system/device-mode`,
|
|
216
|
+
auth,
|
|
217
|
+
30_000,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
if (status < 200 || status >= 300) {
|
|
221
|
+
throw new QuickCHRError(
|
|
222
|
+
"PROCESS_FAILED",
|
|
223
|
+
`Failed to read device-mode: HTTP ${status} - ${body}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let data: unknown;
|
|
228
|
+
try { data = JSON.parse(body); } catch {
|
|
229
|
+
throw new QuickCHRError("PROCESS_FAILED", "Invalid /system/device-mode REST response (not JSON)");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const record = Array.isArray(data) ? data[0] : data;
|
|
233
|
+
if (!record || typeof record !== "object") {
|
|
234
|
+
throw new QuickCHRError("PROCESS_FAILED", "Invalid /system/device-mode REST response");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Guard: /system/resource data instead of device-mode — REST startup race.
|
|
238
|
+
if ("board-name" in (record as Record<string, unknown>) || "architecture-name" in (record as Record<string, unknown>)) {
|
|
239
|
+
throw new QuickCHRError(
|
|
240
|
+
"PROCESS_FAILED",
|
|
241
|
+
"GET /rest/system/device-mode returned system resource data — REST API not fully initialized",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const normalized: Record<string, string> = {};
|
|
246
|
+
for (const [key, value] of Object.entries(record)) {
|
|
247
|
+
normalized[norm(key)] = toYesNo(value);
|
|
248
|
+
}
|
|
249
|
+
return normalized;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function verifyDeviceMode(
|
|
253
|
+
options: ResolvedDeviceModeOptions,
|
|
254
|
+
actual: Record<string, string>,
|
|
255
|
+
): DeviceModeVerification {
|
|
256
|
+
const expected: Record<string, string> = {};
|
|
257
|
+
if (options.mode) {
|
|
258
|
+
expected.mode = norm(options.mode);
|
|
259
|
+
}
|
|
260
|
+
for (const [name, value] of Object.entries(options.features)) {
|
|
261
|
+
expected[norm(name)] = value;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const mismatches: string[] = [];
|
|
265
|
+
for (const [key, expectedValue] of Object.entries(expected)) {
|
|
266
|
+
const actualValue = actual[key];
|
|
267
|
+
if (!actualValue) {
|
|
268
|
+
mismatches.push(`${key}: expected=${expectedValue}, actual=(missing)`);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const actualNorm = key === "mode" ? norm(actualValue) : toYesNo(actualValue);
|
|
272
|
+
if (actualNorm !== expectedValue) {
|
|
273
|
+
mismatches.push(`${key}: expected=${expectedValue}, actual=${actualNorm}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
ok: mismatches.length === 0,
|
|
279
|
+
mismatches,
|
|
280
|
+
expected,
|
|
281
|
+
actual,
|
|
282
|
+
};
|
|
283
|
+
}
|
package/src/lib/disk.ts
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk management — qemu-img wrapper for boot disk resizing, extra disk creation,
|
|
3
|
+
* and qcow2 snapshot operations.
|
|
4
|
+
*
|
|
5
|
+
* All extra disks are qcow2. Boot disk stays raw unless the user selects qcow2
|
|
6
|
+
* format (or requests resize, which implies qcow2). qcow2 enables QEMU
|
|
7
|
+
* snapshot/restore via `savevm`/`loadvm` monitor commands.
|
|
8
|
+
*
|
|
9
|
+
* Snapshot operations:
|
|
10
|
+
* - {@link parseSnapshotList} — parse QEMU monitor `info snapshots` text into {@link SnapshotInfo}[]
|
|
11
|
+
* - {@link listSnapshots} — get structured snapshot data from a qcow2 image via `qemu-img info`
|
|
12
|
+
* - {@link formatSnapshotTable} — render snapshot list as a human-readable ANSI table
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
17
|
+
import type { BootDiskFormat, SnapshotInfo } from "./types.ts";
|
|
18
|
+
import { QuickCHRError } from "./types.ts";
|
|
19
|
+
import { requireQemuImg } from "./platform.ts";
|
|
20
|
+
|
|
21
|
+
const DISK_SIZE_RE = /^\d+[KMGT]?$/i;
|
|
22
|
+
|
|
23
|
+
export interface DiskInfo {
|
|
24
|
+
format: string;
|
|
25
|
+
virtualSize: number;
|
|
26
|
+
actualSize: number;
|
|
27
|
+
filename: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True when a disk size string is valid for qemu-img (e.g. 512M, 1G, 2048). */
|
|
31
|
+
export function isValidDiskSize(size: string): boolean {
|
|
32
|
+
return DISK_SIZE_RE.test(size.trim());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeDiskSize(size: string, label: string): string {
|
|
36
|
+
const normalized = size.trim().toUpperCase();
|
|
37
|
+
if (!isValidDiskSize(normalized)) {
|
|
38
|
+
throw new QuickCHRError(
|
|
39
|
+
"INVALID_DISK_SIZE",
|
|
40
|
+
`Invalid ${label}: ${JSON.stringify(size)}. Use values like 64M, 512M, 1G, or 2048.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Normalize and validate disk-related options.
|
|
47
|
+
* If any disk feature is requested, also validates that qemu-img is present. */
|
|
48
|
+
export function normalizeDiskOptions(
|
|
49
|
+
bootSize?: string,
|
|
50
|
+
extraDisks?: string[],
|
|
51
|
+
bootDiskFormat?: BootDiskFormat,
|
|
52
|
+
): { bootSize?: string; extraDisks?: string[]; bootDiskFormat: BootDiskFormat } {
|
|
53
|
+
const format: BootDiskFormat = bootDiskFormat ?? (bootSize ? "qcow2" : "raw");
|
|
54
|
+
|
|
55
|
+
if (format === "raw" && bootSize) {
|
|
56
|
+
throw new QuickCHRError(
|
|
57
|
+
"INVALID_DISK_SIZE",
|
|
58
|
+
"Raw boot disks cannot be resized. Use bootDiskFormat=\"qcow2\" (or --boot-disk-format qcow2) to enable resizing.",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (format === "raw" && !bootSize && (!extraDisks || extraDisks.length === 0)) {
|
|
63
|
+
return {
|
|
64
|
+
bootSize: bootSize?.trim() || undefined,
|
|
65
|
+
extraDisks,
|
|
66
|
+
bootDiskFormat: format,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
requireQemuImg();
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
bootSize: bootSize ? normalizeDiskSize(bootSize, "boot disk size") : undefined,
|
|
74
|
+
extraDisks: extraDisks && extraDisks.length > 0
|
|
75
|
+
? extraDisks.map((size, index) => normalizeDiskSize(size, `extra disk ${index + 1} size`))
|
|
76
|
+
: undefined,
|
|
77
|
+
bootDiskFormat: format,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Create a blank qcow2 disk image. */
|
|
82
|
+
export async function createQcow2Disk(path: string, size: string): Promise<void> {
|
|
83
|
+
const qemuImg = requireQemuImg();
|
|
84
|
+
const proc = Bun.spawn([qemuImg, "create", "-f", "qcow2", path, size], {
|
|
85
|
+
stdout: "pipe",
|
|
86
|
+
stderr: "pipe",
|
|
87
|
+
});
|
|
88
|
+
const exitCode = await proc.exited;
|
|
89
|
+
if (exitCode !== 0) {
|
|
90
|
+
const stderr = await new Response(proc.stderr).text();
|
|
91
|
+
throw new QuickCHRError("PROCESS_FAILED", `qemu-img create failed: ${stderr.trim()}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Convert a raw disk image to qcow2. */
|
|
96
|
+
export async function convertRawToQcow2(rawPath: string, qcow2Path: string): Promise<void> {
|
|
97
|
+
const qemuImg = requireQemuImg();
|
|
98
|
+
const proc = Bun.spawn([qemuImg, "convert", "-f", "raw", "-O", "qcow2", rawPath, qcow2Path], {
|
|
99
|
+
stdout: "pipe",
|
|
100
|
+
stderr: "pipe",
|
|
101
|
+
});
|
|
102
|
+
const exitCode = await proc.exited;
|
|
103
|
+
if (exitCode !== 0) {
|
|
104
|
+
const stderr = await new Response(proc.stderr).text();
|
|
105
|
+
throw new QuickCHRError("PROCESS_FAILED", `qemu-img convert failed: ${stderr.trim()}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Resize a qcow2 disk image. */
|
|
110
|
+
export async function resizeQcow2(path: string, size: string): Promise<void> {
|
|
111
|
+
const qemuImg = requireQemuImg();
|
|
112
|
+
const proc = Bun.spawn([qemuImg, "resize", path, size], {
|
|
113
|
+
stdout: "pipe",
|
|
114
|
+
stderr: "pipe",
|
|
115
|
+
});
|
|
116
|
+
const exitCode = await proc.exited;
|
|
117
|
+
if (exitCode !== 0) {
|
|
118
|
+
const stderr = await new Response(proc.stderr).text();
|
|
119
|
+
throw new QuickCHRError("PROCESS_FAILED", `qemu-img resize failed: ${stderr.trim()}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Get disk info via qemu-img info --output=json. */
|
|
124
|
+
export async function getDiskInfo(path: string): Promise<DiskInfo> {
|
|
125
|
+
const qemuImg = requireQemuImg();
|
|
126
|
+
const proc = Bun.spawn([qemuImg, "info", "--output=json", path], {
|
|
127
|
+
stdout: "pipe",
|
|
128
|
+
stderr: "pipe",
|
|
129
|
+
});
|
|
130
|
+
const exitCode = await proc.exited;
|
|
131
|
+
if (exitCode !== 0) {
|
|
132
|
+
const stderr = await new Response(proc.stderr).text();
|
|
133
|
+
throw new QuickCHRError("PROCESS_FAILED", `qemu-img info failed: ${stderr.trim()}`);
|
|
134
|
+
}
|
|
135
|
+
const stdout = await new Response(proc.stdout).text();
|
|
136
|
+
const info = JSON.parse(stdout);
|
|
137
|
+
return {
|
|
138
|
+
format: info.format,
|
|
139
|
+
virtualSize: info["virtual-size"],
|
|
140
|
+
actualSize: info["actual-size"],
|
|
141
|
+
filename: info.filename,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Prepare the boot disk for a machine.
|
|
147
|
+
* If bootSize is specified, converts the raw disk.img to boot.qcow2 and resizes it.
|
|
148
|
+
* Otherwise returns the raw disk.img as-is.
|
|
149
|
+
*/
|
|
150
|
+
export async function prepareBootDisk(
|
|
151
|
+
machineDir: string,
|
|
152
|
+
bootSize?: string,
|
|
153
|
+
bootDiskFormat?: BootDiskFormat,
|
|
154
|
+
): Promise<{ path: string; format: BootDiskFormat }> {
|
|
155
|
+
const format: BootDiskFormat = bootDiskFormat ?? (bootSize ? "qcow2" : "raw");
|
|
156
|
+
const rawPath = join(machineDir, "disk.img");
|
|
157
|
+
|
|
158
|
+
if (format === "raw") {
|
|
159
|
+
return { path: rawPath, format: "raw" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const qcow2Path = join(machineDir, "boot.qcow2");
|
|
163
|
+
if (!existsSync(qcow2Path)) {
|
|
164
|
+
await convertRawToQcow2(rawPath, qcow2Path);
|
|
165
|
+
}
|
|
166
|
+
if (bootSize) {
|
|
167
|
+
await resizeQcow2(qcow2Path, bootSize);
|
|
168
|
+
}
|
|
169
|
+
return { path: qcow2Path, format: "qcow2" };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create extra blank qcow2 disks in the machine directory.
|
|
174
|
+
* Named disk1.qcow2, disk2.qcow2, etc.
|
|
175
|
+
*/
|
|
176
|
+
export async function prepareExtraDisks(
|
|
177
|
+
machineDir: string,
|
|
178
|
+
sizes: string[],
|
|
179
|
+
): Promise<{ path: string; format: "qcow2" }[]> {
|
|
180
|
+
const results: { path: string; format: "qcow2" }[] = [];
|
|
181
|
+
for (let i = 0; i < sizes.length; i++) {
|
|
182
|
+
const diskPath = join(machineDir, `disk${i + 1}.qcow2`);
|
|
183
|
+
await createQcow2Disk(diskPath, sizes[i] as string);
|
|
184
|
+
results.push({ path: diskPath, format: "qcow2" });
|
|
185
|
+
}
|
|
186
|
+
return results;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Ensure disk artifacts referenced by machine state exist on disk.
|
|
190
|
+
* Used both for new-machine creation and to heal older add()-created states
|
|
191
|
+
* that predate eager disk materialization. */
|
|
192
|
+
export async function ensureConfiguredDisks(
|
|
193
|
+
machineDir: string,
|
|
194
|
+
bootSize?: string,
|
|
195
|
+
extraDiskSizes?: string[],
|
|
196
|
+
bootDiskFormat?: BootDiskFormat,
|
|
197
|
+
): Promise<{
|
|
198
|
+
bootDisk: { path: string; format: BootDiskFormat };
|
|
199
|
+
extraDisks?: { path: string; format: "qcow2" }[];
|
|
200
|
+
}> {
|
|
201
|
+
const format: BootDiskFormat = bootDiskFormat ?? (bootSize ? "qcow2" : "raw");
|
|
202
|
+
const bootDisk = format === "raw"
|
|
203
|
+
? { path: join(machineDir, "disk.img"), format: "raw" as const }
|
|
204
|
+
: (existsSync(join(machineDir, "boot.qcow2"))
|
|
205
|
+
? { path: join(machineDir, "boot.qcow2"), format: "qcow2" as const }
|
|
206
|
+
: await prepareBootDisk(machineDir, bootSize, "qcow2"));
|
|
207
|
+
|
|
208
|
+
let extraDisks: { path: string; format: "qcow2" }[] | undefined;
|
|
209
|
+
if (extraDiskSizes && extraDiskSizes.length > 0) {
|
|
210
|
+
extraDisks = [];
|
|
211
|
+
for (let i = 0; i < extraDiskSizes.length; i++) {
|
|
212
|
+
const diskPath = join(machineDir, `disk${i + 1}.qcow2`);
|
|
213
|
+
if (!existsSync(diskPath)) {
|
|
214
|
+
await createQcow2Disk(diskPath, extraDiskSizes[i] as string);
|
|
215
|
+
}
|
|
216
|
+
extraDisks.push({ path: diskPath, format: "qcow2" });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { bootDisk, extraDisks };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Remove extra disk files from a machine directory.
|
|
225
|
+
* Cleans up disk1.qcow2, disk2.qcow2, etc. and boot.qcow2 if present.
|
|
226
|
+
*/
|
|
227
|
+
export function cleanDiskFiles(machineDir: string): void {
|
|
228
|
+
// Remove boot.qcow2
|
|
229
|
+
const bootQcow2 = join(machineDir, "boot.qcow2");
|
|
230
|
+
if (existsSync(bootQcow2)) {
|
|
231
|
+
try { unlinkSync(bootQcow2); } catch { /* ignore */ }
|
|
232
|
+
}
|
|
233
|
+
// Remove extra disks (disk1.qcow2, disk2.qcow2, ...)
|
|
234
|
+
for (let i = 1; i <= 64; i++) {
|
|
235
|
+
const diskPath = join(machineDir, `disk${i}.qcow2`);
|
|
236
|
+
if (!existsSync(diskPath)) break;
|
|
237
|
+
try { unlinkSync(diskPath); } catch { /* ignore */ }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- Snapshot operations ---
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Parse QEMU monitor `info snapshots` text output into structured data.
|
|
245
|
+
*
|
|
246
|
+
* The monitor returns lines like:
|
|
247
|
+
* ```
|
|
248
|
+
* ID TAG VM SIZE DATE VM CLOCK ICOUNT
|
|
249
|
+
* -- test 113 MiB 2026-04-14 00:47:19 0000:01:17.163 --
|
|
250
|
+
* ```
|
|
251
|
+
*
|
|
252
|
+
* @param monitorOutput - Raw text from `instance.monitor("info snapshots")`
|
|
253
|
+
* @returns Parsed snapshot list (empty array if no snapshots exist)
|
|
254
|
+
*/
|
|
255
|
+
export function parseSnapshotList(monitorOutput: string): SnapshotInfo[] {
|
|
256
|
+
const lines = monitorOutput.split("\n");
|
|
257
|
+
const snapshots: SnapshotInfo[] = [];
|
|
258
|
+
|
|
259
|
+
for (const line of lines) {
|
|
260
|
+
// Match snapshot data lines: ID, TAG, VM SIZE, DATE (YYYY-MM-DD HH:MM:SS), VM CLOCK, ICOUNT
|
|
261
|
+
// Example: "-- test 113 MiB 2026-04-14 00:47:19 0000:01:17.163 --"
|
|
262
|
+
// Example: "1 snap1 113 MiB 2026-04-14 00:47:19 0000:01:17.163 --"
|
|
263
|
+
const match = line.match(
|
|
264
|
+
/^\s*(\S+)\s+(\S+)\s+(\d+(?:\.\d+)?\s*(?:[KMGT]i?B|bytes?))\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+(\d{4}:\d{2}:\d{2}\.\d+)\s+(\S+)/i,
|
|
265
|
+
);
|
|
266
|
+
if (!match) continue;
|
|
267
|
+
|
|
268
|
+
const [, id, name, vmSizeStr, dateStr, vmClock, icountStr] = match;
|
|
269
|
+
if (!id || !name || !vmSizeStr || !dateStr || !vmClock) continue;
|
|
270
|
+
|
|
271
|
+
snapshots.push({
|
|
272
|
+
id: id === "--" ? "0" : id,
|
|
273
|
+
name: name,
|
|
274
|
+
vmStateSize: parseVmSize(vmSizeStr),
|
|
275
|
+
date: dateStr.replace(" ", "T") + "Z",
|
|
276
|
+
vmClock: vmClock,
|
|
277
|
+
icount: icountStr === "--" ? undefined : Number(icountStr),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return snapshots;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Parse a human-readable VM size string ("113 MiB", "2.1 GiB") to bytes. */
|
|
285
|
+
function parseVmSize(sizeStr: string): number {
|
|
286
|
+
const match = sizeStr.match(/^([\d.]+)\s*([KMGT]i?B|bytes?)?$/i);
|
|
287
|
+
if (!match) return 0;
|
|
288
|
+
const value = Number.parseFloat(match[1] ?? "0");
|
|
289
|
+
const unit = (match[2] ?? "").toLowerCase();
|
|
290
|
+
const multipliers: Record<string, number> = {
|
|
291
|
+
"": 1, "b": 1, "byte": 1, "bytes": 1,
|
|
292
|
+
"kib": 1024, "kb": 1000,
|
|
293
|
+
"mib": 1024 * 1024, "mb": 1000 * 1000,
|
|
294
|
+
"gib": 1024 ** 3, "gb": 1000 ** 3,
|
|
295
|
+
"tib": 1024 ** 4, "tb": 1000 ** 4,
|
|
296
|
+
};
|
|
297
|
+
return Math.round(value * (multipliers[unit] ?? 1));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* List snapshots stored in a qcow2 disk image using `qemu-img info`.
|
|
302
|
+
*
|
|
303
|
+
* Works on both running and stopped machines (reads the qcow2 metadata directly).
|
|
304
|
+
* Returns an empty array for raw disks or images with no snapshots.
|
|
305
|
+
*
|
|
306
|
+
* @param diskPath - Path to the qcow2 disk image file
|
|
307
|
+
* @returns Structured snapshot list with size information
|
|
308
|
+
*/
|
|
309
|
+
export async function listSnapshots(diskPath: string): Promise<SnapshotInfo[]> {
|
|
310
|
+
const qemuImg = requireQemuImg();
|
|
311
|
+
const proc = Bun.spawn([qemuImg, "info", "--output=json", "-U", diskPath], {
|
|
312
|
+
stdout: "pipe",
|
|
313
|
+
stderr: "pipe",
|
|
314
|
+
});
|
|
315
|
+
const exitCode = await proc.exited;
|
|
316
|
+
if (exitCode !== 0) {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const stdout = await new Response(proc.stdout).text();
|
|
320
|
+
try {
|
|
321
|
+
const info = JSON.parse(stdout) as {
|
|
322
|
+
snapshots?: Array<{
|
|
323
|
+
id: string;
|
|
324
|
+
name: string;
|
|
325
|
+
"vm-state-size": number;
|
|
326
|
+
"date-sec": number;
|
|
327
|
+
"date-nsec": number;
|
|
328
|
+
"vm-clock-sec": number;
|
|
329
|
+
"vm-clock-nsec": number;
|
|
330
|
+
"icount"?: number;
|
|
331
|
+
}>;
|
|
332
|
+
};
|
|
333
|
+
if (!info.snapshots || info.snapshots.length === 0) return [];
|
|
334
|
+
|
|
335
|
+
return info.snapshots.map((s) => {
|
|
336
|
+
const date = new Date(s["date-sec"] * 1000);
|
|
337
|
+
const clockSec = s["vm-clock-sec"];
|
|
338
|
+
const hours = Math.floor(clockSec / 3600);
|
|
339
|
+
const mins = Math.floor((clockSec % 3600) / 60);
|
|
340
|
+
const secs = clockSec % 60;
|
|
341
|
+
const ms = Math.floor(s["vm-clock-nsec"] / 1_000_000);
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
id: s.id,
|
|
345
|
+
name: s.name,
|
|
346
|
+
vmStateSize: s["vm-state-size"],
|
|
347
|
+
date: date.toISOString(),
|
|
348
|
+
vmClock: `${String(hours).padStart(4, "0")}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}.${String(ms).padStart(3, "0")}`,
|
|
349
|
+
icount: s.icount,
|
|
350
|
+
};
|
|
351
|
+
});
|
|
352
|
+
} catch {
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Format bytes as a compact human-readable size (e.g. "113 MiB", "2.1 GiB"). */
|
|
358
|
+
export function formatDiskSize(bytes: number): string {
|
|
359
|
+
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
|
|
360
|
+
if (bytes >= 1024 ** 2) return `${(bytes / (1024 * 1024)).toFixed(0)} MiB`;
|
|
361
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KiB`;
|
|
362
|
+
return `${bytes} B`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Format a snapshot list as a human-readable table string.
|
|
367
|
+
*
|
|
368
|
+
* Produces a compact table suitable for terminal display:
|
|
369
|
+
* ```
|
|
370
|
+
* # NAME SIZE DATE VM CLOCK
|
|
371
|
+
* 1 baseline 113 MiB 2026-04-14 00:47 0001:17:00
|
|
372
|
+
* 2 after-config 118 MiB 2026-04-14 01:02 0001:32:15
|
|
373
|
+
* ```
|
|
374
|
+
*
|
|
375
|
+
* @param snapshots - Parsed snapshot list from {@link parseSnapshotList} or {@link listSnapshots}
|
|
376
|
+
* @returns Formatted table string (no trailing newline), or "No snapshots." if empty
|
|
377
|
+
*/
|
|
378
|
+
export function formatSnapshotTable(snapshots: SnapshotInfo[]): string {
|
|
379
|
+
if (snapshots.length === 0) return "No snapshots.";
|
|
380
|
+
|
|
381
|
+
const headers = ["#", "NAME", "SIZE", "DATE", "VM CLOCK"];
|
|
382
|
+
const rows = snapshots.map((s) => [
|
|
383
|
+
s.id,
|
|
384
|
+
s.name,
|
|
385
|
+
formatDiskSize(s.vmStateSize),
|
|
386
|
+
s.date.replace("T", " ").replace(/:\d{2}(?:\.\d+)?Z$/, "").replace("Z", ""),
|
|
387
|
+
s.vmClock,
|
|
388
|
+
]);
|
|
389
|
+
|
|
390
|
+
const widths = headers.map((h, i) =>
|
|
391
|
+
Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)),
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const headerLine = headers.map((h, i) => h.padEnd(widths[i] ?? 0)).join(" ");
|
|
395
|
+
const dataLines = rows.map((row) =>
|
|
396
|
+
row.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" "),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
return [headerLine, ...dataLines].join("\n");
|
|
400
|
+
}
|