@tikoci/quickchr 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +161 -37
- package/MANUAL.md +1259 -0
- package/README.md +35 -1
- package/package.json +7 -4
- package/src/cli/index.ts +191 -42
- package/src/cli/wizard.ts +42 -12
- package/src/index.ts +1 -1
- package/src/lib/cache.ts +19 -1
- package/src/lib/channels.ts +19 -4
- package/src/lib/completions.ts +6 -3
- package/src/lib/disk.ts +5 -2
- package/src/lib/forward-spec.ts +140 -3
- package/src/lib/paths.ts +13 -0
- package/src/lib/provision.ts +25 -27
- package/src/lib/qemu.ts +52 -22
- package/src/lib/quickchr.ts +83 -16
- package/src/lib/secrets.ts +3 -7
- package/src/lib/settings.ts +373 -0
- package/src/lib/state.ts +42 -1
- package/src/lib/types.ts +90 -8
package/src/lib/channels.ts
CHANGED
|
@@ -54,7 +54,22 @@ export function channelFileExists(path: string): boolean {
|
|
|
54
54
|
return existsSync(path);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
57
|
+
/** Strip ANSI escapes and the monitor's per-character command echo, leaving
|
|
58
|
+
* just the response body. The echo hid real errors from callers: QEMU renders
|
|
59
|
+
* the typed command as incremental `cmd\x1b[K\x1b[D…` fragments before the
|
|
60
|
+
* first newline, so an `Error: …` response never appeared at string start and
|
|
61
|
+
* `savevm`/`loadvm` failures were silently swallowed (#31). */
|
|
62
|
+
export function cleanMonitorResponse(raw: string): string {
|
|
63
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes are the point
|
|
64
|
+
const noAnsi = raw.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
|
|
65
|
+
const lines = noAnsi.split(/\r?\n/);
|
|
66
|
+
// Everything before the first newline is the echoed command; the response
|
|
67
|
+
// (possibly multi-line, possibly empty) follows.
|
|
68
|
+
return lines.slice(1).join("\n").trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Send a command to the QEMU monitor and return the cleaned response
|
|
72
|
+
* (command echo and ANSI control sequences stripped — see cleanMonitorResponse). */
|
|
58
73
|
export async function monitorCommand(
|
|
59
74
|
machineDir: string,
|
|
60
75
|
command: string,
|
|
@@ -92,9 +107,9 @@ export async function monitorCommand(
|
|
|
92
107
|
buffer = "";
|
|
93
108
|
socket.write(command + "\n");
|
|
94
109
|
} else if (sentCommand && buffer.includes("(qemu)")) {
|
|
95
|
-
// Got response — strip the prompt
|
|
110
|
+
// Got response — strip the prompt, the command echo, and ANSI noise
|
|
96
111
|
clearTimeout(timeout);
|
|
97
|
-
const response = buffer.replace(/\(qemu\)\s*/g, "")
|
|
112
|
+
const response = cleanMonitorResponse(buffer.replace(/\(qemu\)\s*/g, ""));
|
|
98
113
|
socket.destroy();
|
|
99
114
|
resolve(response);
|
|
100
115
|
}
|
|
@@ -105,7 +120,7 @@ export async function monitorCommand(
|
|
|
105
120
|
socket.on("close", () => {
|
|
106
121
|
clearTimeout(timeout);
|
|
107
122
|
if (sentCommand) {
|
|
108
|
-
resolve(buffer.replace(/\(qemu\)\s*/g, "")
|
|
123
|
+
resolve(cleanMonitorResponse(buffer.replace(/\(qemu\)\s*/g, "")));
|
|
109
124
|
} else {
|
|
110
125
|
reject(new QuickCHRError("MACHINE_STOPPED", "Monitor socket closed before command could be sent"));
|
|
111
126
|
}
|
package/src/lib/completions.ts
CHANGED
|
@@ -328,7 +328,7 @@ _quickchr() {
|
|
|
328
328
|
cword=${D}COMP_CWORD
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help"
|
|
331
|
+
local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor settings setup completions version help"
|
|
332
332
|
|
|
333
333
|
if [[ ${D}cword -eq 1 ]]; then
|
|
334
334
|
COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
|
|
@@ -402,6 +402,7 @@ _quickchr() {
|
|
|
402
402
|
'networks:Network discovery and socket management'
|
|
403
403
|
'net:Network discovery (alias)'
|
|
404
404
|
'doctor:Check prerequisites'
|
|
405
|
+
'settings:Manage global quickchr settings'
|
|
405
406
|
'setup:Interactive setup wizard'
|
|
406
407
|
'completions:Manage shell completions'
|
|
407
408
|
'version:Show version info'
|
|
@@ -434,7 +435,7 @@ _quickchr() {
|
|
|
434
435
|
_describe 'option' opts
|
|
435
436
|
;;
|
|
436
437
|
help)
|
|
437
|
-
local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks doctor setup completions version)
|
|
438
|
+
local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks doctor settings setup completions version)
|
|
438
439
|
_describe 'command' commands
|
|
439
440
|
;;
|
|
440
441
|
esac
|
|
@@ -450,7 +451,7 @@ function generateFishCompletion(): string {
|
|
|
450
451
|
return `# quickchr fish completion
|
|
451
452
|
# Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
|
|
452
453
|
|
|
453
|
-
set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help
|
|
454
|
+
set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor settings setup completions version help
|
|
454
455
|
|
|
455
456
|
# Disable file completions globally for quickchr
|
|
456
457
|
complete -c quickchr -f
|
|
@@ -490,6 +491,8 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
490
491
|
-a "networks net" -d "Network discovery and socket management"
|
|
491
492
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
492
493
|
-a "doctor" -d "Check prerequisites"
|
|
494
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
495
|
+
-a "settings" -d "Manage global quickchr settings"
|
|
493
496
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
494
497
|
-a "setup" -d "Interactive setup wizard"
|
|
495
498
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
package/src/lib/disk.ts
CHANGED
|
@@ -260,8 +260,11 @@ export function parseSnapshotList(monitorOutput: string): SnapshotInfo[] {
|
|
|
260
260
|
// Match snapshot data lines: ID, TAG, VM SIZE, DATE (YYYY-MM-DD HH:MM:SS), VM CLOCK, ICOUNT
|
|
261
261
|
// Example: "-- test 113 MiB 2026-04-14 00:47:19 0000:01:17.163 --"
|
|
262
262
|
// Example: "1 snap1 113 MiB 2026-04-14 00:47:19 0000:01:17.163 --"
|
|
263
|
+
// VM CLOCK hour width varies by QEMU version (2-digit on 8.x runners,
|
|
264
|
+
// 4-digit on 11.x brew builds) and the ICOUNT column may be absent —
|
|
265
|
+
// accept both so list() doesn't silently return [] on older QEMU.
|
|
263
266
|
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+)
|
|
267
|
+
/^\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{2,4}:\d{2}:\d{2}\.\d+)(?:\s+(\S+))?/i,
|
|
265
268
|
);
|
|
266
269
|
if (!match) continue;
|
|
267
270
|
|
|
@@ -274,7 +277,7 @@ export function parseSnapshotList(monitorOutput: string): SnapshotInfo[] {
|
|
|
274
277
|
vmStateSize: parseVmSize(vmSizeStr),
|
|
275
278
|
date: dateStr.replace(" ", "T") + "Z",
|
|
276
279
|
vmClock: vmClock,
|
|
277
|
-
icount: icountStr
|
|
280
|
+
icount: icountStr && icountStr !== "--" ? Number(icountStr) : undefined,
|
|
278
281
|
});
|
|
279
282
|
}
|
|
280
283
|
|
package/src/lib/forward-spec.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Parse a CLI `--forward` spec into
|
|
2
|
+
* Parse a CLI `--forward` spec into one or more `PortMapping`s.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Two grammars share this file:
|
|
5
|
+
*
|
|
6
|
+
* **Single port** — `parseForwardSpec`, grammar `name[:host[:guest]][/proto]`:
|
|
5
7
|
*
|
|
6
8
|
* - `name` — required. If it matches a key/alias in `WELL_KNOWN_GUEST_PORTS`,
|
|
7
9
|
* missing `guest` and `proto` are filled from the registry. The original
|
|
@@ -14,7 +16,16 @@
|
|
|
14
16
|
* - `proto` — optional. Defaults from registry, else `tcp`. Must be `tcp` or
|
|
15
17
|
* `udp`.
|
|
16
18
|
*
|
|
17
|
-
*
|
|
19
|
+
* **Port range** — `expandForwardSpec`, grammar
|
|
20
|
+
* `name:hostStart-hostEnd[:guestStart-guestEnd][/proto]`. Expands to one
|
|
21
|
+
* `PortMapping` per port (QEMU `hostfwd` has no native range — each port needs
|
|
22
|
+
* its own directive). Useful for L3 peer protocols with dynamic data ports
|
|
23
|
+
* (e.g. RouterOS bandwidth-test allocates UDP ports at runtime). See the rules
|
|
24
|
+
* on `expandForwardSpec` below. `expandForwardSpec` is the range-aware entry
|
|
25
|
+
* point and delegates to `parseForwardSpec` for non-range specs, so callers can
|
|
26
|
+
* always use `expandForwardSpec` (the CLI does).
|
|
27
|
+
*
|
|
28
|
+
* Both throw `QuickCHRError("INVALID_FORWARD_SPEC", ...)` with the original spec
|
|
18
29
|
* embedded in the message on any parse error.
|
|
19
30
|
*/
|
|
20
31
|
|
|
@@ -90,3 +101,129 @@ export function parseForwardSpec(spec: string): PortMapping {
|
|
|
90
101
|
|
|
91
102
|
return { name, host, guest, proto };
|
|
92
103
|
}
|
|
104
|
+
|
|
105
|
+
/** Max ports a single range `--forward` may span, bounding the generated
|
|
106
|
+
* `hostfwd` string (one directive per port). */
|
|
107
|
+
export const FORWARD_RANGE_MAX = 64;
|
|
108
|
+
|
|
109
|
+
function parseRange(
|
|
110
|
+
spec: string,
|
|
111
|
+
raw: string,
|
|
112
|
+
label: string,
|
|
113
|
+
): { start: number; end: number } {
|
|
114
|
+
const dash = raw.indexOf("-");
|
|
115
|
+
const startRaw = raw.slice(0, dash);
|
|
116
|
+
const endRaw = raw.slice(dash + 1);
|
|
117
|
+
const start = parsePort(spec, startRaw, `${label} range start`, false);
|
|
118
|
+
const end = parsePort(spec, endRaw, `${label} range end`, false);
|
|
119
|
+
if (end < start) {
|
|
120
|
+
fail(spec, `${label} range end ${end} is below start ${start}`);
|
|
121
|
+
}
|
|
122
|
+
return { start, end };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Range-aware front door for `--forward` specs. Returns a `PortMapping[]`.
|
|
127
|
+
*
|
|
128
|
+
* For a single-port spec (no `-` in the host/guest segment) this returns
|
|
129
|
+
* `[parseForwardSpec(spec)]` unchanged. For a range spec
|
|
130
|
+
* `name:hostStart-hostEnd[:guestStart-guestEnd][/proto]` it expands to one
|
|
131
|
+
* `PortMapping` per port, named `\`${name}-${hostPort}\`` so each is a distinct
|
|
132
|
+
* key in the machine's port map.
|
|
133
|
+
*
|
|
134
|
+
* Range rules:
|
|
135
|
+
* - **Host range is required** — ranges are not auto-allocated. The per-instance
|
|
136
|
+
* port block (10 ports) can't guarantee a contiguous run, so range host ports
|
|
137
|
+
* must be explicit. They are then collision-checked like any explicit
|
|
138
|
+
* `extraPorts` host (`validateExplicitExtraPorts`).
|
|
139
|
+
* - **Guest range is optional** — defaults to the same numbers as the host
|
|
140
|
+
* range. When given it must be the **same length** as the host range.
|
|
141
|
+
* - **`proto`** defaults from the `WELL_KNOWN_GUEST_PORTS` registry (by `name`),
|
|
142
|
+
* else `tcp`; an explicit `/proto` suffix wins.
|
|
143
|
+
* - Reversed ranges (end < start) and spans over {@link FORWARD_RANGE_MAX} ports
|
|
144
|
+
* throw `INVALID_FORWARD_SPEC`.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* expandForwardSpec("btest:9200-9202:2000-2002/udp");
|
|
148
|
+
* // → [{name:"btest-9200",host:9200,guest:2000,proto:"udp"}, … 9201/2001, 9202/2002]
|
|
149
|
+
* @example
|
|
150
|
+
* expandForwardSpec("smb"); // single port → [{name:"smb",host:0,guest:445,proto:"tcp"}]
|
|
151
|
+
*/
|
|
152
|
+
export function expandForwardSpec(spec: string): PortMapping[] {
|
|
153
|
+
if (!spec?.trim()) {
|
|
154
|
+
throw new QuickCHRError("INVALID_FORWARD_SPEC", "Empty --forward spec");
|
|
155
|
+
}
|
|
156
|
+
const trimmed = spec.trim();
|
|
157
|
+
|
|
158
|
+
// Split off /proto suffix (so a range's "-" can't be confused with anything).
|
|
159
|
+
let body = trimmed;
|
|
160
|
+
let protoStr: string | undefined;
|
|
161
|
+
const slash = trimmed.lastIndexOf("/");
|
|
162
|
+
if (slash !== -1) {
|
|
163
|
+
body = trimmed.slice(0, slash);
|
|
164
|
+
protoStr = trimmed.slice(slash + 1).toLowerCase();
|
|
165
|
+
if (protoStr !== "tcp" && protoStr !== "udp") {
|
|
166
|
+
fail(spec, `protocol must be "tcp" or "udp", got "${protoStr}"`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const parts = body.split(":");
|
|
171
|
+
const hasRange = Boolean(parts[1]?.includes("-") || parts[2]?.includes("-"));
|
|
172
|
+
if (!hasRange) {
|
|
173
|
+
return [parseForwardSpec(spec)];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (parts.length > 3) {
|
|
177
|
+
fail(spec, `too many ":" segments (expected name:hostStart-hostEnd[:guestStart-guestEnd])`);
|
|
178
|
+
}
|
|
179
|
+
const name = parts[0]?.trim() ?? "";
|
|
180
|
+
if (!name) {
|
|
181
|
+
fail(spec, "name is required");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const hostSegment = parts[1];
|
|
185
|
+
if (!hostSegment?.includes("-")) {
|
|
186
|
+
fail(
|
|
187
|
+
spec,
|
|
188
|
+
"a range --forward requires an explicit host range (e.g. 9200-9210); ranges are not auto-allocated",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const hostRange = parseRange(spec, hostSegment, "host");
|
|
192
|
+
|
|
193
|
+
const guestSegment = parts[2];
|
|
194
|
+
let guestRange: { start: number; end: number };
|
|
195
|
+
if (guestSegment !== undefined && guestSegment !== "") {
|
|
196
|
+
if (!guestSegment.includes("-")) {
|
|
197
|
+
fail(
|
|
198
|
+
spec,
|
|
199
|
+
`guest segment "${guestSegment}" must be a range matching the host range (e.g. 2000-2010), not a single port`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
guestRange = parseRange(spec, guestSegment, "guest");
|
|
203
|
+
} else {
|
|
204
|
+
// guest range omitted → default to the host range's port numbers
|
|
205
|
+
guestRange = { start: hostRange.start, end: hostRange.end };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const hostCount = hostRange.end - hostRange.start + 1;
|
|
209
|
+
const guestCount = guestRange.end - guestRange.start + 1;
|
|
210
|
+
if (hostCount !== guestCount) {
|
|
211
|
+
fail(
|
|
212
|
+
spec,
|
|
213
|
+
`host range (${hostCount} ports) and guest range (${guestCount} ports) must be the same length`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (hostCount > FORWARD_RANGE_MAX) {
|
|
217
|
+
fail(spec, `range spans ${hostCount} ports, exceeding the ${FORWARD_RANGE_MAX}-port cap`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const known = lookupGuestPort(name);
|
|
221
|
+
const proto: "tcp" | "udp" = (protoStr as "tcp" | "udp" | undefined) ?? known?.proto ?? "tcp";
|
|
222
|
+
|
|
223
|
+
const mappings: PortMapping[] = [];
|
|
224
|
+
for (let i = 0; i < hostCount; i++) {
|
|
225
|
+
const host = hostRange.start + i;
|
|
226
|
+
mappings.push({ name: `${name}-${host}`, host, guest: guestRange.start + i, proto });
|
|
227
|
+
}
|
|
228
|
+
return mappings;
|
|
229
|
+
}
|
package/src/lib/paths.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared filesystem paths for quickchr's config-tier (XDG_CONFIG-like) files.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
/** ~/.config/quickchr — used by secrets.ts (credential fallback files) and
|
|
9
|
+
* settings.ts (quickchr.env). Single source of truth so those modules never
|
|
10
|
+
* drift on the HOME/USERPROFILE fallback chain. */
|
|
11
|
+
export function quickchrConfigDir(): string {
|
|
12
|
+
return join(process.env.HOME ?? process.env.USERPROFILE ?? homedir(), ".config", "quickchr");
|
|
13
|
+
}
|
package/src/lib/provision.ts
CHANGED
|
@@ -364,14 +364,12 @@ export async function provision(
|
|
|
364
364
|
// Try REST provisioning. When machineDir is provided, use a shorter timeout
|
|
365
365
|
// so the console fallback can engage sooner if REST isn't available.
|
|
366
366
|
const restTimeout = machineDir ? 30_000 : 60_000;
|
|
367
|
-
let usedConsolePath = false;
|
|
368
367
|
try {
|
|
369
368
|
await waitForRest(httpPort, restTimeout);
|
|
370
369
|
} catch (e) {
|
|
371
370
|
if (machineDir) {
|
|
372
371
|
log.warn("REST API unavailable — falling back to serial console provisioning");
|
|
373
372
|
const result = await consoleProvision(machineDir, machineName, effectiveUser, !!shouldDisableAdmin, logger, portBase);
|
|
374
|
-
usedConsolePath = true;
|
|
375
373
|
// After console provisioning the machine is booted; REST comes up shortly.
|
|
376
374
|
// Attempt SSH key install for the managed quickchr user — non-fatal if REST is still unavailable.
|
|
377
375
|
if (secureLogin && !user && effectiveUser) {
|
|
@@ -387,35 +385,35 @@ export async function provision(
|
|
|
387
385
|
throw e;
|
|
388
386
|
}
|
|
389
387
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
388
|
+
// REST provisioning path. The console fallback above always returns early,
|
|
389
|
+
// so reaching here means REST came up and we provision over it.
|
|
390
|
+
if (effectiveUser) {
|
|
391
|
+
await createUser(httpPort, effectiveUser.name, effectiveUser.password);
|
|
392
|
+
// Persist to secret store so resolveAuth() picks it up
|
|
393
|
+
saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
|
|
394
|
+
}
|
|
396
395
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
396
|
+
if (secureLogin && !user && effectiveUser && machineDir) {
|
|
397
|
+
// Brief pause so RouterOS propagates the new user to all subsystems
|
|
398
|
+
// (including the SSH key store) before we attempt key installation.
|
|
399
|
+
await Bun.sleep(1000);
|
|
400
|
+
try {
|
|
401
|
+
await installSshKey(httpPort, effectiveUser.name, machineName, machineDir, portBase);
|
|
402
|
+
} catch (e) {
|
|
403
|
+
log.warn(`SSH key install failed (SSH transport will fall back to password): ${e}`);
|
|
406
404
|
}
|
|
405
|
+
}
|
|
407
406
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
412
|
-
// Pass the new user's auth for verification — after disabling admin,
|
|
413
|
-
// admin creds may stop working for REST queries.
|
|
414
|
-
const verifyAuth = effectiveUser
|
|
415
|
-
? `Basic ${btoa(`${effectiveUser.name}:${effectiveUser.password}`)}`
|
|
416
|
-
: undefined;
|
|
417
|
-
await disableAdmin(httpPort, verifyAuth);
|
|
407
|
+
if (shouldDisableAdmin) {
|
|
408
|
+
if (!effectiveUser) {
|
|
409
|
+
log.warn("Warning: disabling admin without creating another user — you may lose access");
|
|
418
410
|
}
|
|
411
|
+
// Pass the new user's auth for verification — after disabling admin,
|
|
412
|
+
// admin creds may stop working for REST queries.
|
|
413
|
+
const verifyAuth = effectiveUser
|
|
414
|
+
? `Basic ${btoa(`${effectiveUser.name}:${effectiveUser.password}`)}`
|
|
415
|
+
: undefined;
|
|
416
|
+
await disableAdmin(httpPort, verifyAuth);
|
|
419
417
|
}
|
|
420
418
|
|
|
421
419
|
return { user: effectiveUser };
|
package/src/lib/qemu.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { existsSync, writeFileSync, readFileSync, copyFileSync, statSync, openSy
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import type { Arch, BootDiskFormat, MachineState, NetworkMode, NetworkConfig, PortMapping } from "./types.ts";
|
|
11
11
|
import { QuickCHRError } from "./types.ts";
|
|
12
|
-
import { detectAccel, requireQemu, requireFirmware } from "./platform.ts";
|
|
12
|
+
import { detectAccel, requireQemu, requireFirmware, requireQemuImg } from "./platform.ts";
|
|
13
13
|
import { buildHostfwdString } from "./network.ts";
|
|
14
14
|
import { ensureDir } from "./state.ts";
|
|
15
15
|
import { restGet } from "./rest.ts";
|
|
@@ -80,16 +80,19 @@ export async function buildQemuArgs(config: QemuLaunchConfig): Promise<string[]>
|
|
|
80
80
|
// UEFI firmware (arm64 only)
|
|
81
81
|
if (arch === "arm64") {
|
|
82
82
|
const fw = requireFirmware();
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
83
|
+
// The per-machine EFI vars pflash is qcow2, NOT raw: QEMU refuses
|
|
84
|
+
// savevm/loadvm while any writable block device is non-qcow2 ("Device
|
|
85
|
+
// 'pflash1' is writable but does not support snapshots"), which made
|
|
86
|
+
// snapshots impossible on arm64 (#31). Legacy raw efi-vars.fd machines
|
|
87
|
+
// are converted in place, preserving their NVRAM state.
|
|
88
|
+
const varsPath = join(machineDir, "efi-vars.qcow2");
|
|
86
89
|
if (!existsSync(varsPath)) {
|
|
87
|
-
prepareEfiVars(fw.code, fw.vars, varsPath);
|
|
90
|
+
await prepareEfiVars(fw.code, fw.vars, varsPath, join(machineDir, "efi-vars.fd"));
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
args.push(
|
|
91
94
|
"-drive", `if=pflash,format=raw,readonly=on,unit=0,file=${fw.code}`,
|
|
92
|
-
"-drive", `if=pflash,format=
|
|
95
|
+
"-drive", `if=pflash,format=qcow2,unit=1,file=${varsPath}`,
|
|
93
96
|
);
|
|
94
97
|
}
|
|
95
98
|
|
|
@@ -248,26 +251,53 @@ function buildChannelArgs(
|
|
|
248
251
|
}
|
|
249
252
|
}
|
|
250
253
|
|
|
251
|
-
/**
|
|
252
|
-
|
|
254
|
+
/** Build the per-machine qcow2 EFI vars pflash: start from the legacy raw
|
|
255
|
+
* vars file if the machine has one (preserves NVRAM state), else from the
|
|
256
|
+
* firmware template; size-match to the code ROM; convert raw → qcow2 so
|
|
257
|
+
* savevm/loadvm work (see the pflash comment at the call site / #31). */
|
|
258
|
+
async function prepareEfiVars(codePath: string, varsTemplatePath: string, destPath: string, legacyRawPath: string): Promise<void> {
|
|
253
259
|
ensureDir(join(destPath, ".."));
|
|
254
|
-
copyFileSync(varsTemplatePath, destPath);
|
|
255
260
|
|
|
256
|
-
//
|
|
261
|
+
// Stage the raw image (legacy per-machine file wins over the template).
|
|
262
|
+
const rawStage = `${destPath}.raw-stage`;
|
|
263
|
+
copyFileSync(existsSync(legacyRawPath) ? legacyRawPath : varsTemplatePath, rawStage);
|
|
264
|
+
|
|
265
|
+
// pflash units must be identical size — pad/truncate vars to match code.
|
|
266
|
+
// truncateSync extends with zero bytes when target is larger.
|
|
257
267
|
const codeSize = statSync(codePath).size;
|
|
258
|
-
|
|
268
|
+
try {
|
|
269
|
+
if (statSync(rawStage).size !== codeSize) truncateSync(rawStage, codeSize);
|
|
270
|
+
} catch (e) {
|
|
271
|
+
try { unlinkSync(rawStage); } catch { /* best effort */ }
|
|
272
|
+
throw new QuickCHRError(
|
|
273
|
+
"PROCESS_FAILED",
|
|
274
|
+
`Failed to size-match EFI vars: ${e instanceof Error ? e.message : String(e)}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
259
277
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
278
|
+
const qemuImg = requireQemuImg();
|
|
279
|
+
const conversion = Bun.spawn([qemuImg, "convert", "-f", "raw", "-O", "qcow2", rawStage, destPath], {
|
|
280
|
+
stdout: "pipe",
|
|
281
|
+
stderr: "pipe",
|
|
282
|
+
});
|
|
283
|
+
const exitCode = await conversion.exited;
|
|
284
|
+
const [stderr, stdout] = await Promise.all([
|
|
285
|
+
new Response(conversion.stderr).text(),
|
|
286
|
+
new Response(conversion.stdout).text(),
|
|
287
|
+
]);
|
|
288
|
+
try { unlinkSync(rawStage); } catch { /* best effort */ }
|
|
289
|
+
if (exitCode !== 0) {
|
|
290
|
+
// A failed convert can leave a partial destPath behind — remove it so the
|
|
291
|
+
// next launch regenerates instead of booting (and re-failing on) the stub.
|
|
292
|
+
try { unlinkSync(destPath); } catch { /* best effort */ }
|
|
293
|
+
throw new QuickCHRError(
|
|
294
|
+
"PROCESS_FAILED",
|
|
295
|
+
`qemu-img convert of EFI vars failed: ${stderr.trim() || stdout.trim() || `exit ${exitCode}`}`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
// Legacy raw file is superseded — remove so nothing boots the stale copy.
|
|
299
|
+
if (existsSync(legacyRawPath)) {
|
|
300
|
+
try { unlinkSync(legacyRawPath); } catch { /* best effort */ }
|
|
271
301
|
}
|
|
272
302
|
}
|
|
273
303
|
|
package/src/lib/quickchr.ts
CHANGED
|
@@ -43,9 +43,11 @@ import {
|
|
|
43
43
|
ensureDir,
|
|
44
44
|
getCacheDir,
|
|
45
45
|
getDataDir,
|
|
46
|
+
appendBootLog,
|
|
46
47
|
} from "./state.ts";
|
|
47
48
|
import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
|
|
48
49
|
import { autoPruneIfOverCap } from "./cache.ts";
|
|
50
|
+
import { resolveSetting } from "./settings.ts";
|
|
49
51
|
import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
|
|
50
52
|
import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
|
|
51
53
|
import { monitorCommand, serialStreams, qgaCommand, channelEndpoint } from "./channels.ts";
|
|
@@ -343,6 +345,12 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
343
345
|
if (state.pid && isMachineRunning(state)) {
|
|
344
346
|
await stopQemu(state.pid);
|
|
345
347
|
}
|
|
348
|
+
// NOTE: clean() deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
|
|
349
|
+
// machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
|
|
350
|
+
// disk), not OS state; wiping it forces a full device scan (~480s) on the next
|
|
351
|
+
// boot. The fresh disk re-copy below is a sufficient factory reset. Regression
|
|
352
|
+
// anchor: the "clean() resets disk to factory defaults" arm64 integration test
|
|
353
|
+
// (120s reboot wait would blow past 480s if the wipe were reintroduced).
|
|
346
354
|
// Re-copy fresh image from cache
|
|
347
355
|
const imgPath = join(getCacheDir(), `chr-${state.version}${state.arch === "arm64" ? "-arm64" : ""}.img`);
|
|
348
356
|
if (!existsSync(imgPath)) {
|
|
@@ -362,10 +370,6 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
362
370
|
state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
|
|
363
371
|
);
|
|
364
372
|
|
|
365
|
-
// Remove EFI vars to force re-creation
|
|
366
|
-
const efiVars = join(state.machineDir, "efi-vars.fd");
|
|
367
|
-
if (existsSync(efiVars)) rmSync(efiVars);
|
|
368
|
-
|
|
369
373
|
// Clean up stored instance credentials (re-provisioned on next start)
|
|
370
374
|
deleteInstanceCredentials(state.name);
|
|
371
375
|
|
|
@@ -699,15 +703,26 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
699
703
|
throw new QuickCHRError("PROCESS_FAILED", `savevm failed: ${out.trim()}`);
|
|
700
704
|
}
|
|
701
705
|
|
|
702
|
-
// Read back the snapshot list
|
|
706
|
+
// Read back the snapshot list — the new entry MUST be there. A
|
|
707
|
+
// fabricated fallback here once masked savevm failing outright on
|
|
708
|
+
// arm64 (#31): the example printed a snapshot that never existed.
|
|
703
709
|
const snaps = await this.list();
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
710
|
+
const created = snaps.find((s) => s.name === snapName);
|
|
711
|
+
if (!created) {
|
|
712
|
+
// Self-grounding error: include the raw monitor listing so a CI
|
|
713
|
+
// failure carries the evidence (QEMU output formats vary by version
|
|
714
|
+
// and a parse gap here looks identical to a real savevm failure).
|
|
715
|
+
let rawList = "(unavailable)";
|
|
716
|
+
try {
|
|
717
|
+
rawList = await monitorCommand(state.machineDir, "info snapshots", undefined, state.portBase);
|
|
718
|
+
} catch { /* keep placeholder */ }
|
|
719
|
+
throw new QuickCHRError(
|
|
720
|
+
"PROCESS_FAILED",
|
|
721
|
+
`savevm reported no error but snapshot "${snapName}" is absent from the parsed list — ` +
|
|
722
|
+
`treat as failed. savevm said: "${out.trim() || "(empty)"}"; raw 'info snapshots': "${rawList}"`,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
return created;
|
|
711
726
|
},
|
|
712
727
|
|
|
713
728
|
async load(name: string): Promise<void> {
|
|
@@ -721,6 +736,17 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
721
736
|
|
|
722
737
|
const out = await monitorCommand(state.machineDir, `loadvm ${name}`, undefined, state.portBase);
|
|
723
738
|
if (/^error[:\s]/i.test(out)) {
|
|
739
|
+
// A failed loadvm leaves the VM in "paused (restore-vm)" — resume
|
|
740
|
+
// it so the guest isn't wedged (REST dead) on top of the error.
|
|
741
|
+
try {
|
|
742
|
+
await monitorCommand(state.machineDir, "cont", undefined, state.portBase);
|
|
743
|
+
} catch (contErr) {
|
|
744
|
+
// Best effort — the throw below is the real signal, but a failed
|
|
745
|
+
// resume means the VM may still be paused; say so.
|
|
746
|
+
console.warn(
|
|
747
|
+
`quickchr: 'cont' after failed loadvm also failed (${contErr instanceof Error ? contErr.message : String(contErr)}) — machine "${state.name}" may be paused`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
724
750
|
throw new QuickCHRError("PROCESS_FAILED", `loadvm failed: ${out.trim()}`);
|
|
725
751
|
}
|
|
726
752
|
},
|
|
@@ -1235,7 +1261,7 @@ export class QuickCHR {
|
|
|
1235
1261
|
user: opts.user ?? existing.user,
|
|
1236
1262
|
disableAdmin: opts.disableAdmin ?? existing.disableAdmin,
|
|
1237
1263
|
license: opts.license,
|
|
1238
|
-
secureLogin: opts.secureLogin,
|
|
1264
|
+
secureLogin: opts.secureLogin ?? existing.secureLogin,
|
|
1239
1265
|
};
|
|
1240
1266
|
const hasPending = !!(
|
|
1241
1267
|
pendingOpts.installAllPackages ||
|
|
@@ -1243,7 +1269,8 @@ export class QuickCHR {
|
|
|
1243
1269
|
pendingOpts.deviceMode ||
|
|
1244
1270
|
pendingOpts.user ||
|
|
1245
1271
|
pendingOpts.disableAdmin ||
|
|
1246
|
-
pendingOpts.license
|
|
1272
|
+
pendingOpts.license ||
|
|
1273
|
+
pendingOpts.secureLogin === true
|
|
1247
1274
|
);
|
|
1248
1275
|
if (hasPending) {
|
|
1249
1276
|
return QuickCHR._launchExisting(existing, opts.background ?? true, pendingOpts, logger);
|
|
@@ -1402,6 +1429,7 @@ export class QuickCHR {
|
|
|
1402
1429
|
|
|
1403
1430
|
const qemuArgs = await buildQemuArgs(launchConfig);
|
|
1404
1431
|
const wrapper = extractWrapper(resolvedNetworks);
|
|
1432
|
+
const bootStart = Date.now();
|
|
1405
1433
|
let { pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper);
|
|
1406
1434
|
|
|
1407
1435
|
// Foreground (no provisioning): spawnQemu blocks until QEMU exits
|
|
@@ -1458,6 +1486,24 @@ export class QuickCHR {
|
|
|
1458
1486
|
);
|
|
1459
1487
|
}
|
|
1460
1488
|
|
|
1489
|
+
// Record boot timing (cumulative from first spawn, incl. the respawn-once
|
|
1490
|
+
// path) — in machine.json for `list`/`get` and in the boot-history log.
|
|
1491
|
+
// Side-band metrics must never fail a boot that succeeded.
|
|
1492
|
+
state.lastAccel = accel;
|
|
1493
|
+
state.lastBootMs = Date.now() - bootStart;
|
|
1494
|
+
saveMachine(state);
|
|
1495
|
+
try {
|
|
1496
|
+
appendBootLog({
|
|
1497
|
+
ts: new Date().toISOString(),
|
|
1498
|
+
name,
|
|
1499
|
+
version,
|
|
1500
|
+
arch,
|
|
1501
|
+
accel,
|
|
1502
|
+
bootMs: state.lastBootMs,
|
|
1503
|
+
host: process.platform,
|
|
1504
|
+
});
|
|
1505
|
+
} catch { /* never propagate */ }
|
|
1506
|
+
|
|
1461
1507
|
if (hasProvisioning) {
|
|
1462
1508
|
await QuickCHR._provisionInstance(instance, state, {
|
|
1463
1509
|
installAllPackages: opts.installAllPackages,
|
|
@@ -1684,6 +1730,7 @@ export class QuickCHR {
|
|
|
1684
1730
|
|
|
1685
1731
|
const qemuArgs = await buildQemuArgs(launchConfig);
|
|
1686
1732
|
const wrapper = extractWrapper(resolvedNetworks);
|
|
1733
|
+
const bootStart = Date.now();
|
|
1687
1734
|
const { pid } = await spawnQemu(qemuArgs, state.machineDir, spawnBackground, wrapper);
|
|
1688
1735
|
|
|
1689
1736
|
// Foreground without provisioning: spawnQemu blocks until QEMU exits
|
|
@@ -1720,12 +1767,32 @@ export class QuickCHR {
|
|
|
1720
1767
|
);
|
|
1721
1768
|
}
|
|
1722
1769
|
|
|
1770
|
+
// Record boot timing — mirrors start(); side-band, never fails the boot.
|
|
1771
|
+
state.lastAccel = accel;
|
|
1772
|
+
state.lastBootMs = Date.now() - bootStart;
|
|
1773
|
+
saveMachine(state);
|
|
1774
|
+
try {
|
|
1775
|
+
appendBootLog({
|
|
1776
|
+
ts: new Date().toISOString(),
|
|
1777
|
+
name: state.name,
|
|
1778
|
+
version: state.version,
|
|
1779
|
+
arch: state.arch,
|
|
1780
|
+
accel,
|
|
1781
|
+
bootMs: state.lastBootMs,
|
|
1782
|
+
host: process.platform,
|
|
1783
|
+
});
|
|
1784
|
+
} catch { /* never propagate */ }
|
|
1785
|
+
|
|
1723
1786
|
if (hasProvisioning && provisioningOpts) {
|
|
1724
1787
|
await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
|
|
1725
1788
|
}
|
|
1726
1789
|
|
|
1727
1790
|
try {
|
|
1728
|
-
autoPruneIfOverCap({
|
|
1791
|
+
autoPruneIfOverCap({
|
|
1792
|
+
logger: logger ? (msg) => logger.status(msg) : undefined,
|
|
1793
|
+
protectVersions: [state.version],
|
|
1794
|
+
maxSizeBytes: resolveSetting("cache-max-size").value as number | undefined,
|
|
1795
|
+
});
|
|
1729
1796
|
} catch { /* never propagate */ }
|
|
1730
1797
|
|
|
1731
1798
|
return instance;
|
|
@@ -1777,7 +1844,7 @@ export class QuickCHR {
|
|
|
1777
1844
|
detail: ver ?? "found (version unknown)",
|
|
1778
1845
|
});
|
|
1779
1846
|
} catch (_e) {
|
|
1780
|
-
const hint = getQemuInstallHint()
|
|
1847
|
+
const hint = getQemuInstallHint();
|
|
1781
1848
|
checks.push({
|
|
1782
1849
|
label: qemuName,
|
|
1783
1850
|
status: arch === hostArchToChr() ? "error" : "warn",
|