@tikoci/quickchr 0.4.2 → 0.4.4
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 +1271 -0
- package/README.md +54 -10
- package/package.json +7 -4
- package/src/cli/index.ts +211 -48
- package/src/cli/wizard.ts +42 -12
- package/src/index.ts +7 -3
- 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 +279 -59
- package/src/lib/qemu.ts +52 -22
- package/src/lib/quickchr.ts +237 -48
- 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 +237 -30
package/src/lib/quickchr.ts
CHANGED
|
@@ -7,19 +7,25 @@ import type {
|
|
|
7
7
|
Channel,
|
|
8
8
|
ChrInstance,
|
|
9
9
|
ChrLoadSample,
|
|
10
|
+
CustomForward,
|
|
11
|
+
Descriptor,
|
|
10
12
|
DeviceModeOptions,
|
|
11
13
|
DoctorResult,
|
|
12
14
|
ExecOptions,
|
|
13
15
|
ExecResult,
|
|
14
16
|
LicenseInput,
|
|
15
17
|
LicenseLevel,
|
|
16
|
-
MachineDescriptor,
|
|
17
18
|
MachineState,
|
|
19
|
+
NetworkTopologyEntry,
|
|
20
|
+
PortMapping,
|
|
18
21
|
QgaCommand,
|
|
22
|
+
ServiceEndpoint,
|
|
19
23
|
SnapshotInfo,
|
|
24
|
+
SshServiceEndpoint,
|
|
20
25
|
StartOptions,
|
|
21
26
|
} from "./types.ts";
|
|
22
|
-
import { QuickCHRError, ARCHES, CHANNELS } from "./types.ts";
|
|
27
|
+
import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./types.ts";
|
|
28
|
+
import packageJson from "../../package.json";
|
|
23
29
|
import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
|
|
24
30
|
import {
|
|
25
31
|
resolveVersion,
|
|
@@ -43,9 +49,11 @@ import {
|
|
|
43
49
|
ensureDir,
|
|
44
50
|
getCacheDir,
|
|
45
51
|
getDataDir,
|
|
52
|
+
appendBootLog,
|
|
46
53
|
} from "./state.ts";
|
|
47
54
|
import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
|
|
48
55
|
import { autoPruneIfOverCap } from "./cache.ts";
|
|
56
|
+
import { resolveSetting } from "./settings.ts";
|
|
49
57
|
import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
|
|
50
58
|
import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
|
|
51
59
|
import { monitorCommand, serialStreams, qgaCommand, channelEndpoint } from "./channels.ts";
|
|
@@ -54,7 +62,7 @@ import { provision } from "./provision.ts";
|
|
|
54
62
|
import { renewLicense, getLicenseInfo } from "./license.ts";
|
|
55
63
|
import { resolveAuth, resolveCreds } from "./auth.ts";
|
|
56
64
|
import { scpPush, scpPull } from "./scp.ts";
|
|
57
|
-
import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
|
|
65
|
+
import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
|
|
58
66
|
import { restExecute } from "./exec.ts";
|
|
59
67
|
import { qgaExec } from "./qga.ts";
|
|
60
68
|
import { consoleExec } from "./console.ts";
|
|
@@ -84,7 +92,7 @@ function defaultMem(arch: Arch, override?: number): number {
|
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
/** Default boot timeout — accel mode and cross-arch emulation affect how slow the VM boots. */
|
|
87
|
-
function defaultBootTimeout(arch: Arch, withPackages?: boolean, accel?: string): number {
|
|
95
|
+
export function defaultBootTimeout(arch: Arch, withPackages?: boolean, accel?: string): number {
|
|
88
96
|
const cross = isCrossArchEmulation(arch);
|
|
89
97
|
const factor = accelTimeoutFactor(accel ?? "tcg", cross);
|
|
90
98
|
// Base: 120s for native, scaled by factor (TCG cross-arch = 15× = 1800s max)
|
|
@@ -240,46 +248,151 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
240
248
|
BASICAUTH: rawCreds,
|
|
241
249
|
};
|
|
242
250
|
};
|
|
243
|
-
|
|
251
|
+
// Service keys beyond these five are surfaced as `customForwards` (winbox, plus
|
|
252
|
+
// any user-added extraPorts) — see docs/centrs-interface.md "CustomForward".
|
|
253
|
+
const CANONICAL_PORT_NAMES = new Set(["http", "https", "api", "api-ssl", "ssh"]);
|
|
254
|
+
|
|
255
|
+
const buildDescriptor = (): Descriptor => {
|
|
244
256
|
if (state.status !== "running") {
|
|
245
257
|
throw new QuickCHRError(
|
|
246
258
|
"MACHINE_STOPPED",
|
|
247
259
|
`Machine "${state.name}" must be running to inspect its connection environment (current status: ${state.status}).`,
|
|
248
260
|
);
|
|
249
261
|
}
|
|
262
|
+
|
|
250
263
|
const auth = resolveAuth(state);
|
|
251
|
-
const
|
|
252
|
-
const basic =
|
|
264
|
+
const creds = resolveCreds(state);
|
|
265
|
+
const basic = `${creds.user}:${creds.password}`;
|
|
266
|
+
const storedCreds = getInstanceCredentials(state.name);
|
|
267
|
+
// resolveAuth/resolveCreds never throw — when disableAdmin is true and no
|
|
268
|
+
// provisioned/stored user exists, they still fall back to the meaningless
|
|
269
|
+
// admin:"" tuple (auth.ts docstring: "the caller will get a 401"). Don't
|
|
270
|
+
// report a service available:true off that fallback alone.
|
|
271
|
+
const disableAdminLockout = state.disableAdmin === true && !state.user && !storedCreds;
|
|
272
|
+
// A provisioned user whose password is the STORED_IN_SECRETS_PASSWORD sentinel
|
|
273
|
+
// only resolves a real password when the per-instance credential store actually
|
|
274
|
+
// has an entry — otherwise resolveAuth/resolveCreds fall through to returning the
|
|
275
|
+
// literal sentinel string as the "password". Don't report available:true (or leak
|
|
276
|
+
// the sentinel as a usable password) off that unresolvable case either, regardless
|
|
277
|
+
// of disableAdmin.
|
|
278
|
+
const sentinelUnresolvable = state.user?.password === STORED_IN_SECRETS_PASSWORD && !storedCreds;
|
|
279
|
+
const credentialsAvailable = !disableAdminLockout && !sentinelUnresolvable;
|
|
280
|
+
|
|
281
|
+
const buildHttpService = (
|
|
282
|
+
securePM: PortMapping | undefined,
|
|
283
|
+
plainPM: PortMapping | undefined,
|
|
284
|
+
serviceLabel: string,
|
|
285
|
+
scheme: { secure: string; plain: string },
|
|
286
|
+
authObj: { username: string; password?: string; basic?: string; header?: string },
|
|
287
|
+
urlSuffix = "",
|
|
288
|
+
): ServiceEndpoint => {
|
|
289
|
+
const chosen = securePM ?? plainPM;
|
|
290
|
+
if (!chosen) {
|
|
291
|
+
return { available: false, unavailableReason: `no forwarded port for ${serviceLabel}` };
|
|
292
|
+
}
|
|
293
|
+
const tls = chosen === securePM;
|
|
294
|
+
const echo = {
|
|
295
|
+
host: "127.0.0.1",
|
|
296
|
+
port: chosen.host,
|
|
297
|
+
guestPort: chosen.guest,
|
|
298
|
+
transport: chosen.proto,
|
|
299
|
+
tls,
|
|
300
|
+
url: `${tls ? scheme.secure : scheme.plain}://127.0.0.1:${chosen.host}${urlSuffix}`,
|
|
301
|
+
source: { provider: "quickchr" as const, portMappingName: chosen.name },
|
|
302
|
+
};
|
|
303
|
+
if (!credentialsAvailable) {
|
|
304
|
+
return { available: false, unavailableReason: "admin disabled, no user provisioned", ...echo };
|
|
305
|
+
}
|
|
306
|
+
return { available: true, ...echo, auth: authObj };
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// TLS preference is new logic (no pre-existing REST-URL preference to "keep" —
|
|
310
|
+
// see docs/centrs-interface.md's TLS section): prefer the secure port when its
|
|
311
|
+
// forward exists, fall back to plain, `available:false` only if neither does.
|
|
312
|
+
// This also closes a latent bug where excluding "http" while keeping "https"
|
|
313
|
+
// left restUrl pointing at a port that doesn't exist.
|
|
314
|
+
const restApi = buildHttpService(
|
|
315
|
+
state.ports.https,
|
|
316
|
+
state.ports.http,
|
|
317
|
+
"rest-api",
|
|
318
|
+
{ secure: "https", plain: "http" },
|
|
319
|
+
{ username: creds.user, password: creds.password, basic, header: auth.header },
|
|
320
|
+
"/rest",
|
|
321
|
+
);
|
|
322
|
+
const nativeApi = buildHttpService(
|
|
323
|
+
state.ports["api-ssl"],
|
|
324
|
+
state.ports.api,
|
|
325
|
+
"native-api",
|
|
326
|
+
{ secure: "tls", plain: "tcp" },
|
|
327
|
+
{ username: creds.user, password: creds.password },
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const sshPM = state.ports.ssh;
|
|
331
|
+
const sshUsername = state.user?.name ?? "admin";
|
|
332
|
+
const managedKey = state.managedSshKey;
|
|
333
|
+
const batchVerified = managedKey?.batchVerified === true;
|
|
334
|
+
const sshModes: Array<"private-key" | "agent-or-config" | "password"> = [];
|
|
335
|
+
if (managedKey) sshModes.push("private-key");
|
|
336
|
+
sshModes.push("agent-or-config");
|
|
337
|
+
if (credentialsAvailable) sshModes.push("password");
|
|
338
|
+
|
|
339
|
+
const sshService: SshServiceEndpoint = !sshPM
|
|
340
|
+
? { available: false, unavailableReason: "no forwarded port for ssh" }
|
|
341
|
+
: {
|
|
342
|
+
available: true,
|
|
343
|
+
host: "127.0.0.1",
|
|
344
|
+
port: sshPM.host,
|
|
345
|
+
guestPort: sshPM.guest,
|
|
346
|
+
transport: sshPM.proto,
|
|
347
|
+
tls: false,
|
|
348
|
+
url: `ssh://${sshUsername}@127.0.0.1:${sshPM.host}`,
|
|
349
|
+
source: { provider: "quickchr" as const, portMappingName: "ssh" },
|
|
350
|
+
auth: {
|
|
351
|
+
username: sshUsername,
|
|
352
|
+
...(batchVerified && managedKey ? { privateKeyPath: managedKey.privateKeyPath } : {}),
|
|
353
|
+
modes: sshModes,
|
|
354
|
+
// Only "private-key" is ever vouched for here, and only once verified —
|
|
355
|
+
// "agent-or-config" is host ssh-agent/~/.ssh/config policy quickchr has
|
|
356
|
+
// no way to check, so it stays out of batchModes (Done-when's own
|
|
357
|
+
// unverified/absent-key example expects batchModes: [], not
|
|
358
|
+
// ["agent-or-config"]).
|
|
359
|
+
batchModes: batchVerified ? ["private-key"] : [],
|
|
360
|
+
passwordAvailable: credentialsAvailable,
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const customForwards: CustomForward[] = Object.entries(state.ports)
|
|
365
|
+
.filter(([name]) => !CANONICAL_PORT_NAMES.has(name))
|
|
366
|
+
.map(([name, pm]) => ({
|
|
367
|
+
name,
|
|
368
|
+
transport: pm.proto,
|
|
369
|
+
host: "127.0.0.1",
|
|
370
|
+
hostPort: pm.host,
|
|
371
|
+
guestPort: pm.guest,
|
|
372
|
+
}));
|
|
373
|
+
|
|
374
|
+
const networks: NetworkTopologyEntry[] = state.networks.map((n) => ({ id: n.id, specifier: n.specifier }));
|
|
375
|
+
|
|
253
376
|
return {
|
|
254
|
-
|
|
377
|
+
descriptorVersion: QUICKCHR_DESCRIPTOR_VERSION,
|
|
378
|
+
quickchr: { packageVersion: packageJson.version },
|
|
255
379
|
status: "running",
|
|
380
|
+
name: state.name,
|
|
256
381
|
version: state.version,
|
|
257
382
|
arch: state.arch,
|
|
258
383
|
cpu: state.cpu,
|
|
259
384
|
mem: state.mem,
|
|
260
385
|
pid: state.pid ?? null,
|
|
261
|
-
ports,
|
|
262
|
-
portMappings: state.ports,
|
|
263
|
-
urls: {
|
|
264
|
-
http: restUrl,
|
|
265
|
-
rest: `${restUrl}/rest`,
|
|
266
|
-
restBase: `${restUrl}/rest`,
|
|
267
|
-
https: ports.https ? `https://127.0.0.1:${ports.https}` : undefined,
|
|
268
|
-
ssh: ports.ssh ? `ssh://${auth.user}@127.0.0.1:${ports.ssh}` : undefined,
|
|
269
|
-
api: ports.api ? `tcp://127.0.0.1:${ports.api}` : undefined,
|
|
270
|
-
apiSsl: ports.apiSsl ? `tls://127.0.0.1:${ports.apiSsl}` : undefined,
|
|
271
|
-
winbox: ports.winbox ? `tcp://127.0.0.1:${ports.winbox}` : undefined,
|
|
272
|
-
},
|
|
273
|
-
auth: {
|
|
274
|
-
user: auth.user,
|
|
275
|
-
password: basic.slice(auth.user.length + 1),
|
|
276
|
-
basic,
|
|
277
|
-
header: auth.header,
|
|
278
|
-
},
|
|
279
|
-
env,
|
|
280
386
|
machineDir: state.machineDir,
|
|
281
387
|
createdAt: state.createdAt,
|
|
282
388
|
lastStartedAt: state.lastStartedAt ?? null,
|
|
389
|
+
services: {
|
|
390
|
+
[SERVICE_IDS.restApi]: restApi,
|
|
391
|
+
[SERVICE_IDS.nativeApi]: nativeApi,
|
|
392
|
+
[SERVICE_IDS.ssh]: sshService,
|
|
393
|
+
},
|
|
394
|
+
...(customForwards.length > 0 ? { customForwards } : {}),
|
|
395
|
+
networks,
|
|
283
396
|
};
|
|
284
397
|
};
|
|
285
398
|
|
|
@@ -343,6 +456,12 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
343
456
|
if (state.pid && isMachineRunning(state)) {
|
|
344
457
|
await stopQemu(state.pid);
|
|
345
458
|
}
|
|
459
|
+
// NOTE: clean() deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
|
|
460
|
+
// machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
|
|
461
|
+
// disk), not OS state; wiping it forces a full device scan (~480s) on the next
|
|
462
|
+
// boot. The fresh disk re-copy below is a sufficient factory reset. Regression
|
|
463
|
+
// anchor: the "clean() resets disk to factory defaults" arm64 integration test
|
|
464
|
+
// (120s reboot wait would blow past 480s if the wipe were reintroduced).
|
|
346
465
|
// Re-copy fresh image from cache
|
|
347
466
|
const imgPath = join(getCacheDir(), `chr-${state.version}${state.arch === "arm64" ? "-arm64" : ""}.img`);
|
|
348
467
|
if (!existsSync(imgPath)) {
|
|
@@ -362,10 +481,6 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
362
481
|
state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
|
|
363
482
|
);
|
|
364
483
|
|
|
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
484
|
// Clean up stored instance credentials (re-provisioned on next start)
|
|
370
485
|
deleteInstanceCredentials(state.name);
|
|
371
486
|
|
|
@@ -632,7 +747,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
632
747
|
return buildEnv();
|
|
633
748
|
},
|
|
634
749
|
|
|
635
|
-
async descriptor(): Promise<
|
|
750
|
+
async descriptor(): Promise<Descriptor> {
|
|
636
751
|
return buildDescriptor();
|
|
637
752
|
},
|
|
638
753
|
|
|
@@ -699,15 +814,26 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
699
814
|
throw new QuickCHRError("PROCESS_FAILED", `savevm failed: ${out.trim()}`);
|
|
700
815
|
}
|
|
701
816
|
|
|
702
|
-
// Read back the snapshot list
|
|
817
|
+
// Read back the snapshot list — the new entry MUST be there. A
|
|
818
|
+
// fabricated fallback here once masked savevm failing outright on
|
|
819
|
+
// arm64 (#31): the example printed a snapshot that never existed.
|
|
703
820
|
const snaps = await this.list();
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
821
|
+
const created = snaps.find((s) => s.name === snapName);
|
|
822
|
+
if (!created) {
|
|
823
|
+
// Self-grounding error: include the raw monitor listing so a CI
|
|
824
|
+
// failure carries the evidence (QEMU output formats vary by version
|
|
825
|
+
// and a parse gap here looks identical to a real savevm failure).
|
|
826
|
+
let rawList = "(unavailable)";
|
|
827
|
+
try {
|
|
828
|
+
rawList = await monitorCommand(state.machineDir, "info snapshots", undefined, state.portBase);
|
|
829
|
+
} catch { /* keep placeholder */ }
|
|
830
|
+
throw new QuickCHRError(
|
|
831
|
+
"PROCESS_FAILED",
|
|
832
|
+
`savevm reported no error but snapshot "${snapName}" is absent from the parsed list — ` +
|
|
833
|
+
`treat as failed. savevm said: "${out.trim() || "(empty)"}"; raw 'info snapshots': "${rawList}"`,
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
return created;
|
|
711
837
|
},
|
|
712
838
|
|
|
713
839
|
async load(name: string): Promise<void> {
|
|
@@ -721,6 +847,17 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
721
847
|
|
|
722
848
|
const out = await monitorCommand(state.machineDir, `loadvm ${name}`, undefined, state.portBase);
|
|
723
849
|
if (/^error[:\s]/i.test(out)) {
|
|
850
|
+
// A failed loadvm leaves the VM in "paused (restore-vm)" — resume
|
|
851
|
+
// it so the guest isn't wedged (REST dead) on top of the error.
|
|
852
|
+
try {
|
|
853
|
+
await monitorCommand(state.machineDir, "cont", undefined, state.portBase);
|
|
854
|
+
} catch (contErr) {
|
|
855
|
+
// Best effort — the throw below is the real signal, but a failed
|
|
856
|
+
// resume means the VM may still be paused; say so.
|
|
857
|
+
console.warn(
|
|
858
|
+
`quickchr: 'cont' after failed loadvm also failed (${contErr instanceof Error ? contErr.message : String(contErr)}) — machine "${state.name}" may be paused`,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
724
861
|
throw new QuickCHRError("PROCESS_FAILED", `loadvm failed: ${out.trim()}`);
|
|
725
862
|
}
|
|
726
863
|
},
|
|
@@ -1235,7 +1372,7 @@ export class QuickCHR {
|
|
|
1235
1372
|
user: opts.user ?? existing.user,
|
|
1236
1373
|
disableAdmin: opts.disableAdmin ?? existing.disableAdmin,
|
|
1237
1374
|
license: opts.license,
|
|
1238
|
-
secureLogin: opts.secureLogin,
|
|
1375
|
+
secureLogin: opts.secureLogin ?? existing.secureLogin,
|
|
1239
1376
|
};
|
|
1240
1377
|
const hasPending = !!(
|
|
1241
1378
|
pendingOpts.installAllPackages ||
|
|
@@ -1243,7 +1380,8 @@ export class QuickCHR {
|
|
|
1243
1380
|
pendingOpts.deviceMode ||
|
|
1244
1381
|
pendingOpts.user ||
|
|
1245
1382
|
pendingOpts.disableAdmin ||
|
|
1246
|
-
pendingOpts.license
|
|
1383
|
+
pendingOpts.license ||
|
|
1384
|
+
pendingOpts.secureLogin === true
|
|
1247
1385
|
);
|
|
1248
1386
|
if (hasPending) {
|
|
1249
1387
|
return QuickCHR._launchExisting(existing, opts.background ?? true, pendingOpts, logger);
|
|
@@ -1402,6 +1540,7 @@ export class QuickCHR {
|
|
|
1402
1540
|
|
|
1403
1541
|
const qemuArgs = await buildQemuArgs(launchConfig);
|
|
1404
1542
|
const wrapper = extractWrapper(resolvedNetworks);
|
|
1543
|
+
const bootStart = Date.now();
|
|
1405
1544
|
let { pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper);
|
|
1406
1545
|
|
|
1407
1546
|
// Foreground (no provisioning): spawnQemu blocks until QEMU exits
|
|
@@ -1458,6 +1597,24 @@ export class QuickCHR {
|
|
|
1458
1597
|
);
|
|
1459
1598
|
}
|
|
1460
1599
|
|
|
1600
|
+
// Record boot timing (cumulative from first spawn, incl. the respawn-once
|
|
1601
|
+
// path) — in machine.json for `list`/`get` and in the boot-history log.
|
|
1602
|
+
// Side-band metrics must never fail a boot that succeeded.
|
|
1603
|
+
state.lastAccel = accel;
|
|
1604
|
+
state.lastBootMs = Date.now() - bootStart;
|
|
1605
|
+
saveMachine(state);
|
|
1606
|
+
try {
|
|
1607
|
+
appendBootLog({
|
|
1608
|
+
ts: new Date().toISOString(),
|
|
1609
|
+
name,
|
|
1610
|
+
version,
|
|
1611
|
+
arch,
|
|
1612
|
+
accel,
|
|
1613
|
+
bootMs: state.lastBootMs,
|
|
1614
|
+
host: process.platform,
|
|
1615
|
+
});
|
|
1616
|
+
} catch { /* never propagate */ }
|
|
1617
|
+
|
|
1461
1618
|
if (hasProvisioning) {
|
|
1462
1619
|
await QuickCHR._provisionInstance(instance, state, {
|
|
1463
1620
|
installAllPackages: opts.installAllPackages,
|
|
@@ -1597,14 +1754,25 @@ export class QuickCHR {
|
|
|
1597
1754
|
}
|
|
1598
1755
|
|
|
1599
1756
|
if (opts.user || opts.disableAdmin || opts.secureLogin === true) {
|
|
1600
|
-
const result = await provision(chrPorts.http, machineState.name, opts.user, opts.disableAdmin, opts.secureLogin, log, machineState.machineDir, machineState.portBase);
|
|
1601
|
-
if (result.user) {
|
|
1602
|
-
// Persist user info in state (password placeholder —
|
|
1757
|
+
const result = await provision(chrPorts.http, machineState.name, opts.user, opts.disableAdmin, opts.secureLogin, log, machineState.machineDir, machineState.portBase, chrPorts.ssh);
|
|
1758
|
+
if (result.user || result.managedSshKey) {
|
|
1759
|
+
// Persist user info + managed SSH key fact in state (password placeholder —
|
|
1760
|
+
// real password in secret store). The managedSshKey fact is what the #71
|
|
1761
|
+
// descriptor reads to advertise SSH private-key batch auth (only when verified).
|
|
1603
1762
|
const current = loadMachine(machineState.name);
|
|
1604
1763
|
if (current) {
|
|
1605
|
-
current.user = { name: result.user.name, password: STORED_IN_SECRETS_PASSWORD };
|
|
1764
|
+
if (result.user) current.user = { name: result.user.name, password: STORED_IN_SECRETS_PASSWORD };
|
|
1765
|
+
if (result.managedSshKey) current.managedSshKey = result.managedSshKey;
|
|
1606
1766
|
saveMachine(current);
|
|
1607
1767
|
}
|
|
1768
|
+
}
|
|
1769
|
+
if (result.managedSshKey) {
|
|
1770
|
+
machineState.managedSshKey = result.managedSshKey;
|
|
1771
|
+
if (!result.managedSshKey.batchVerified) {
|
|
1772
|
+
log.warn(" SSH key installed but batch login could not be verified — SSH transport may fall back to password");
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
if (result.user) {
|
|
1608
1776
|
machineState.user = { name: result.user.name, password: result.user.password };
|
|
1609
1777
|
if (!opts.user) {
|
|
1610
1778
|
// Auto-created quickchr account — credential display is handled by the caller (wizard/CLI)
|
|
@@ -1684,6 +1852,7 @@ export class QuickCHR {
|
|
|
1684
1852
|
|
|
1685
1853
|
const qemuArgs = await buildQemuArgs(launchConfig);
|
|
1686
1854
|
const wrapper = extractWrapper(resolvedNetworks);
|
|
1855
|
+
const bootStart = Date.now();
|
|
1687
1856
|
const { pid } = await spawnQemu(qemuArgs, state.machineDir, spawnBackground, wrapper);
|
|
1688
1857
|
|
|
1689
1858
|
// Foreground without provisioning: spawnQemu blocks until QEMU exits
|
|
@@ -1720,12 +1889,32 @@ export class QuickCHR {
|
|
|
1720
1889
|
);
|
|
1721
1890
|
}
|
|
1722
1891
|
|
|
1892
|
+
// Record boot timing — mirrors start(); side-band, never fails the boot.
|
|
1893
|
+
state.lastAccel = accel;
|
|
1894
|
+
state.lastBootMs = Date.now() - bootStart;
|
|
1895
|
+
saveMachine(state);
|
|
1896
|
+
try {
|
|
1897
|
+
appendBootLog({
|
|
1898
|
+
ts: new Date().toISOString(),
|
|
1899
|
+
name: state.name,
|
|
1900
|
+
version: state.version,
|
|
1901
|
+
arch: state.arch,
|
|
1902
|
+
accel,
|
|
1903
|
+
bootMs: state.lastBootMs,
|
|
1904
|
+
host: process.platform,
|
|
1905
|
+
});
|
|
1906
|
+
} catch { /* never propagate */ }
|
|
1907
|
+
|
|
1723
1908
|
if (hasProvisioning && provisioningOpts) {
|
|
1724
1909
|
await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
|
|
1725
1910
|
}
|
|
1726
1911
|
|
|
1727
1912
|
try {
|
|
1728
|
-
autoPruneIfOverCap({
|
|
1913
|
+
autoPruneIfOverCap({
|
|
1914
|
+
logger: logger ? (msg) => logger.status(msg) : undefined,
|
|
1915
|
+
protectVersions: [state.version],
|
|
1916
|
+
maxSizeBytes: resolveSetting("cache-max-size").value as number | undefined,
|
|
1917
|
+
});
|
|
1729
1918
|
} catch { /* never propagate */ }
|
|
1730
1919
|
|
|
1731
1920
|
return instance;
|
|
@@ -1777,7 +1966,7 @@ export class QuickCHR {
|
|
|
1777
1966
|
detail: ver ?? "found (version unknown)",
|
|
1778
1967
|
});
|
|
1779
1968
|
} catch (_e) {
|
|
1780
|
-
const hint = getQemuInstallHint()
|
|
1969
|
+
const hint = getQemuInstallHint();
|
|
1781
1970
|
checks.push({
|
|
1782
1971
|
label: qemuName,
|
|
1783
1972
|
status: arch === hostArchToChr() ? "error" : "warn",
|
package/src/lib/secrets.ts
CHANGED
|
@@ -12,11 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
14
14
|
import { join, dirname } from "node:path";
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
function configDir(): string {
|
|
18
|
-
return join(process.env.HOME ?? process.env.USERPROFILE ?? homedir(), ".config", "quickchr");
|
|
19
|
-
}
|
|
15
|
+
import { quickchrConfigDir } from "./paths.ts";
|
|
20
16
|
|
|
21
17
|
/** True when the Bun.secrets API is available at runtime. */
|
|
22
18
|
function hasBunSecrets(): boolean {
|
|
@@ -32,7 +28,7 @@ function hasBunSecrets(): boolean {
|
|
|
32
28
|
function configPath(service: string): string {
|
|
33
29
|
// One JSON file per service, keyed by name
|
|
34
30
|
const safeName = service.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
35
|
-
return join(
|
|
31
|
+
return join(quickchrConfigDir(), `${safeName}.json`);
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
function readConfigStore(service: string): Record<string, string> {
|
|
@@ -129,5 +125,5 @@ export async function secretDelete(service: string, name: string): Promise<boole
|
|
|
129
125
|
/** Human-readable label for where secrets are stored. */
|
|
130
126
|
export function secretStorageLabel(): string {
|
|
131
127
|
if (hasBunSecrets()) return "OS credential store (via Bun.secrets)";
|
|
132
|
-
return `config file (${
|
|
128
|
+
return `config file (${quickchrConfigDir()})`;
|
|
133
129
|
}
|