@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.
@@ -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
- const buildDescriptor = (): MachineDescriptor => {
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 env = buildEnv();
252
- const basic = env.QUICKCHR_AUTH ?? `${auth.user}:`;
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
- name: state.name,
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<MachineDescriptor> {
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 to return the new entry
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
- return snaps.find((s) => s.name === snapName) ?? {
705
- id: "0",
706
- name: snapName,
707
- vmStateSize: 0,
708
- date: new Date().toISOString(),
709
- vmClock: "0000:00:00.000",
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 — real password in secret store)
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({ logger: logger ? (msg) => logger.status(msg) : undefined, protectVersions: [state.version] });
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() ?? "Install QEMU";
1969
+ const hint = getQemuInstallHint();
1781
1970
  checks.push({
1782
1971
  label: qemuName,
1783
1972
  status: arch === hostArchToChr() ? "error" : "warn",
@@ -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 { homedir } from "node:os";
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(configDir(), `${safeName}.json`);
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 (${configDir()})`;
128
+ return `config file (${quickchrConfigDir()})`;
133
129
  }