@tikoci/quickchr 0.4.4 → 0.4.6
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 +24 -10
- package/MANUAL.md +21 -7
- package/package.json +1 -1
- package/src/cli/index.ts +31 -0
- package/src/lib/platform.ts +153 -19
- package/src/lib/quickchr.ts +22 -8
- package/src/lib/settings-file.ts +52 -0
- package/src/lib/settings.ts +20 -42
- package/src/lib/types.ts +22 -0
package/DESIGN.md
CHANGED
|
@@ -54,6 +54,14 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
|
|
|
54
54
|
|
|
55
55
|
**Sister-project routing.** Because `fetchResilient` already absorbs a flaky resolver, a download/version-resolve failure should not be papered over downstream with `/etc/hosts` pins or IPv6 toggles in a consuming repo's workflow — fix it (or extend the failback) here in quickchr. The historical `getent ahostsv4` `/etc/hosts` workaround was both in the wrong layer and non-functional (it hit the same broken stub resolver, returning empty). The failback is best-effort, not a guarantee: it recovers connection-class DNS failures, not arbitrary network breakage. Covered by `test/unit/net.test.ts`.
|
|
56
56
|
|
|
57
|
+
10. **arm64 CHR is forced onto TCG on all Apple Silicon** — `detectAccel("arm64")` never returns `hvf` on macOS; the launch path surfaces `accelNote()` explaining why (`src/lib/platform.ts`). The cause is an **image artifact**, not an accelerator or CPU-model bug: current arm64 CHR images (7.20.8 – 7.23beta5 verified) ship an AArch64 kernel over a **32-bit ARM userspace** — the appended initramfs `/init` is `ELF 32-bit LSB ARM, EABI5, static`, and the 7.22.1 `system` package holds **101 further ARM32 executables and 18 ARM32 shared objects** (the only AArch64 executables are `kexec` and `vmcore-dmesg`). Apple Silicon implements **no AArch32 at any exception level**: `ID_AA64PFR0_EL1` reports AArch64-only for EL0/EL1, and QEMU under HVF passes that hardware register straight through (`hvf_arch_init_vcpu()` re-reads the live vCPU register and only edits the GIC bit; the `-cpu` model is inert). So the guest kernel never sets `ARM64_HAS_32BIT_EL0`, `compat_elf_check_arch()` rejects the `EM_ARM` `/init` with `-ENOEXEC`, the initramfs has no fallback init, and Linux panics at t≈0.076 s with `No working init found`. TCG's emulated models do provide AArch32 EL0, so the same unmodified image boots to `MikroTik Login:`. Grounded in the guest's own panic-time capability bitmap: the M4/HVF guest printed `0x20012,28000230` (8 caps, `ARM64_HAS_32BIT_EL0` absent) against TCG's `0x20013,28402230` (11 caps, present). Full chain in [`docs/m4-hvf-arm64-investigation.md`](docs/m4-hvf-arm64-investigation.md).
|
|
58
|
+
|
|
59
|
+
**Scope is every Apple Silicon generation, not M4+.** The first report came from an M4 (tikoci/mikropkl#11) and the original fix keyed on `hw.optional.arm.FEAT_SSBS == 0`, which M4 is the first Apple chip to report — but SSBS was a **coincident marker of the reporting host, not the mechanism**. Linux 5.6 treats SSBS as an optional mitigation and boots fine without it (`cortex-a53`/`neoverse-n1` lack it), while *no* Apple CPU since 2020 implements AArch32. The SSBS predicate therefore left M1/M2/M3 on HVF and panicking — a live bug, not merely a narrow gate. Ordinary server ARM (Ampere, Graviton) does implement AArch32 EL0, which is why arm64 CHR runs there under KVM; this is an Apple-Silicon-and-CHR-artifact interaction, not a RouterOS-on-ARM defect.
|
|
60
|
+
|
|
61
|
+
**No QEMU flag or version fixes this.** Apple's `Hypervisor.framework` exposes only `hv_vcpu_config_get_feature_reg()` — there is no setter — so no macOS VMM can present a feature the silicon lacks (UTM's fork behaves identically). A QEMU version floor is therefore *not* a valid restore signal, and the deferred `getQemuVersion()` guard the SSBS-era decision contemplated has been dropped. **The restore signal is the guest artifact:** a future arm64 CHR whose appended `/init` *and* required system-package executables/shared objects are all AArch64, confirmed by a real HVF boot. That check is mechanical (§"Reproduction" in the investigation doc) and could become a release-time guard.
|
|
62
|
+
|
|
63
|
+
**Escape hatch.** Because the fallback is unconditional, `--accel <auto|tcg|hvf|kvm>` (and the `accel` setting / `QUICKCHR_ACCEL` env var) forces the accelerator verbatim and bypasses detection — so a fixed future image can be tested on HVF without a code change, and so forcing TCG stays possible for debugging elsewhere. `--accel hvf` on an arm64 guest still prints the panic caveat rather than silently obeying. This complements, and does not replace, the reactive respawn-once net in #8. Covered by `test/unit/platform.test.ts`. → [#97](https://github.com/tikoci/quickchr/issues/97)
|
|
64
|
+
|
|
57
65
|
## Port Layout
|
|
58
66
|
|
|
59
67
|
| Offset | Service | Guest Port |
|
|
@@ -100,8 +108,9 @@ Current consumers are all first-party tikoci projects (centrs, donny, restraml),
|
|
|
100
108
|
**Global settings** live separately, under the XDG **config** tier (not the data tree
|
|
101
109
|
above): `~/.config/quickchr/quickchr.env`, dotenv-style (`QUICKCHR_KEY=value` lines).
|
|
102
110
|
Managed via `quickchr settings get|set|print|reset`. Precedence per key: CLI flag >
|
|
103
|
-
`QUICKCHR_<KEY>` env var > `quickchr.env` > built-in default. The
|
|
104
|
-
`default-channel`, `default-arch`, `cache-max-size`, `timeout-extra`,
|
|
111
|
+
`QUICKCHR_<KEY>` env var > `quickchr.env` > built-in default. The 6 managed keys:
|
|
112
|
+
`default-channel`, `default-arch`, `accel`, `cache-max-size`, `timeout-extra`,
|
|
113
|
+
`secure-login`.
|
|
105
114
|
See MANUAL.md's CLI reference and environment-variables sections for the full surface.
|
|
106
115
|
|
|
107
116
|
## Platform Support
|
|
@@ -109,16 +118,20 @@ See MANUAL.md's CLI reference and environment-variables sections for the full su
|
|
|
109
118
|
| Platform | x86 CHR | arm64 CHR | Notes |
|
|
110
119
|
|------------------------|---------|-----------|-------|
|
|
111
120
|
| macOS x86_64 | HVF | TCG | Intel Mac |
|
|
112
|
-
| macOS arm64 (native) |
|
|
113
|
-
| macOS arm64 (Rosetta) |
|
|
121
|
+
| macOS arm64 (native) | TCG | TCG¹ | Apple Silicon, bun is arm64 |
|
|
122
|
+
| macOS arm64 (Rosetta) | TCG | TCG¹ | Apple Silicon, bun reports x86_64 |
|
|
114
123
|
| Linux x86_64 | KVM | TCG | KVM requires `/dev/kvm` writable |
|
|
115
124
|
| Linux aarch64 | TCG | KVM | x86 TCG on arm64 Linux |
|
|
116
125
|
| Windows x86_64 | TCG | TCG | HVF/KVM not available |
|
|
117
126
|
|
|
127
|
+
¹ arm64 CHR is forced to **TCG on every Apple Silicon generation**: the image's required userspace is 32-bit ARM and Apple CPUs implement no AArch32, so an HVF guest panics at init (see Design Decisions #10). x86 CHR also uses TCG because HVF cannot virtualize x86 on an arm64 host. Override with `--accel hvf` to test a future AArch64-only image.
|
|
128
|
+
|
|
118
129
|
**Acceleration detection** (`detectAccel`):
|
|
119
|
-
-
|
|
130
|
+
- An explicit override (`--accel` flag > `QUICKCHR_ACCEL` env > `accel` in `quickchr.env`) short-circuits detection entirely; `auto` (the default) runs the checks below.
|
|
131
|
+
- macOS: checks `kern.hv_support` via sysctl. Intel x86 guests get HVF; **all guests on Apple Silicon get TCG** — x86 because HVF cannot virtualize across architectures, arm64 because the CHR image needs AArch32 (Design Decisions #10). A Rosetta process is identified via `sysctl.proc_translated`.
|
|
120
132
|
- Linux: checks `/dev/kvm` writability.
|
|
121
|
-
- Falling back to TCG is
|
|
133
|
+
- Falling back to TCG is safe but can be significantly slower; measure with the
|
|
134
|
+
target host, guest architecture, and RouterOS version.
|
|
122
135
|
|
|
123
136
|
## CI System
|
|
124
137
|
|
|
@@ -154,10 +167,11 @@ with a layered design:
|
|
|
154
167
|
dispatches. `test-filter` narrows; `run-examples` defaults ON. Each runner boots its
|
|
155
168
|
native CHR arch — `detectAccel()` picks KVM/HVF/TCG. Agents dispatch this to ground
|
|
156
169
|
platform hypotheses without waiting for a PR cycle.
|
|
157
|
-
- **`release.yml`** — one-click release: freshness gate
|
|
158
|
-
continuously release-able) +
|
|
159
|
-
|
|
160
|
-
--provenance` (odd minor → `next`, even → `latest`).
|
|
170
|
+
- **`release.yml`** — one-click publish from committed release state: freshness gate
|
|
171
|
+
(no suite re-run — main is kept continuously release-able) + `package.json` version
|
|
172
|
+
with a matching non-empty CHANGELOG section → tag + GitHub Release + `npm publish
|
|
173
|
+
--provenance` (odd minor → `next`, even → `latest`). CI never bumps versions or pushes
|
|
174
|
+
to protected `main`.
|
|
161
175
|
- **`ros-versions.yml`** — daily: new RouterOS versions (per channel) with no linux-x86
|
|
162
176
|
record in `ci-data/tested-versions.json` ride the `routeros-targets` matrix of a
|
|
163
177
|
single integration dispatch.
|
package/MANUAL.md
CHANGED
|
@@ -398,12 +398,18 @@ Precedence per key (highest wins): CLI flag > `QUICKCHR_<KEY>` env var >
|
|
|
398
398
|
|---|---|---|---|
|
|
399
399
|
| `default-channel` | `add`/`start`'s `--channel`, wizard's channel prompt | `stable`\|`long-term`\|`testing`\|`development` | `stable` |
|
|
400
400
|
| `default-arch` | `add`/`start`'s `--arch`, wizard's arch prompt | `arm64`\|`x86`\|`auto` | `auto` (host native) |
|
|
401
|
+
| `accel` | `add`/`start`'s `--accel`; every `detectAccel()` call in the run | `auto`\|`tcg`\|`hvf`\|`kvm` | `auto` (detect) |
|
|
401
402
|
| `cache-max-size` | Auto-prune cap applied after each successful `start` | size string (e.g. `2G`, `512M`) | `2G` |
|
|
402
403
|
| `timeout-extra` | `start`'s `--timeout-extra`/`-T` when omitted | non-negative integer seconds | `0` |
|
|
403
404
|
| `secure-login` | `add`/`start`'s `--secure-login` when neither `--secure-login` nor `--no-secure-login` is passed | boolean | `unset` (reported as `(unset)` — see note) |
|
|
404
405
|
|
|
406
|
+
Anything other than `auto` for `accel` is handed to QEMU verbatim and **skips detection
|
|
407
|
+
entirely**, including the arm64-on-Apple-Silicon TCG fallback (§Platform notes) and the
|
|
408
|
+
availability checks — force `kvm` on macOS and QEMU itself reports the error. That is
|
|
409
|
+
deliberate: the override exists to test an accelerator the detector currently refuses.
|
|
410
|
+
|
|
405
411
|
`secure-login` is the one key `settings print`/`get` report as `(unset)` rather than a
|
|
406
|
-
concrete `false`, unlike the
|
|
412
|
+
concrete `false`, unlike the others: the setup wizard's login prompt needs to tell
|
|
407
413
|
"not configured" apart from "explicitly set to false" (it only pre-highlights "Keep
|
|
408
414
|
admin with no password" when the setting is concretely `false`; leaving it unset keeps
|
|
409
415
|
the wizard's own recommended "managed login" highlighted). Giving it a real
|
|
@@ -412,7 +418,7 @@ the wizard's own recommended "managed login" highlighted). Giving it a real
|
|
|
412
418
|
the setting resolves to exactly `true`.
|
|
413
419
|
|
|
414
420
|
```bash
|
|
415
|
-
quickchr settings print # all
|
|
421
|
+
quickchr settings print # all 6 keys, resolved value + source
|
|
416
422
|
quickchr settings set default-channel testing # write one key to quickchr.env
|
|
417
423
|
quickchr settings get default-arch # print one key's resolved value
|
|
418
424
|
quickchr settings reset default-channel # delete one managed line
|
|
@@ -420,7 +426,7 @@ quickchr settings reset # clear all managed lines
|
|
|
420
426
|
```
|
|
421
427
|
|
|
422
428
|
Credentials (`--add-user`, `MIKROTIK_WEB_ACCOUNT`/`MIKROTIK_WEB_PASSWORD`) are
|
|
423
|
-
never stored here — `settings` only manages the
|
|
429
|
+
never stored here — `settings` only manages the 6 keys above, and does not
|
|
424
430
|
mutate any machine's `machine.json`.
|
|
425
431
|
|
|
426
432
|
### Global behavior
|
|
@@ -923,16 +929,20 @@ arch:
|
|
|
923
929
|
|---|---|---|
|
|
924
930
|
| macOS x86_64 | x86 | HVF |
|
|
925
931
|
| macOS x86_64 | arm64 | TCG |
|
|
926
|
-
| macOS arm64 (native bun) | arm64 |
|
|
932
|
+
| macOS arm64 (native bun) | arm64 | TCG |
|
|
927
933
|
| macOS arm64 (native bun) | x86 | TCG |
|
|
928
934
|
| macOS arm64 (Rosetta bun) | arm64 | TCG |
|
|
935
|
+
| macOS arm64 (Rosetta bun) | x86 | TCG |
|
|
929
936
|
| Linux x86_64 + `/dev/kvm` writable | x86 | KVM |
|
|
930
937
|
| Linux aarch64 + `/dev/kvm` writable | arm64 | KVM |
|
|
931
938
|
| any other combo | any | TCG |
|
|
932
939
|
|
|
933
940
|
`Bun` running under Rosetta on Apple Silicon reports `process.arch ===
|
|
934
|
-
"x64"`, so
|
|
935
|
-
|
|
941
|
+
"x64"`, so quickchr checks `sysctl.proc_translated` to identify the physical
|
|
942
|
+
arm64 host. Auto-selection uses TCG for both guest architectures there: HVF
|
|
943
|
+
cannot virtualize x86 across architectures, and current arm64 CHR images need
|
|
944
|
+
AArch32 userspace support that Apple Silicon does not provide. Use `--accel`
|
|
945
|
+
(or `QUICKCHR_ACCEL` / the `accel` setting) only to override this deliberately.
|
|
936
946
|
|
|
937
947
|
### Boot timeout
|
|
938
948
|
|
|
@@ -996,7 +1006,7 @@ managed via `quickchr settings get|set|print|reset` (§3, Meta). Atomic writes
|
|
|
996
1006
|
preserve every other line (comments, blank lines, foreign vars) byte-for-byte.
|
|
997
1007
|
`reset <key>` deletes the line entirely rather than blanking it, since a blank
|
|
998
1008
|
`KEY=` line is still "set" to a bash-sourced file. Never stores credentials —
|
|
999
|
-
the
|
|
1009
|
+
the 6 managed keys are all structural preferences, not secrets.
|
|
1000
1010
|
|
|
1001
1011
|
### Environment variables consumed
|
|
1002
1012
|
|
|
@@ -1008,6 +1018,7 @@ the 5 managed keys are all structural preferences, not secrets.
|
|
|
1008
1018
|
| `QUICKCHR_INTEGRATION` | `bun:test` gate for `test/integration/` |
|
|
1009
1019
|
| `QUICKCHR_DEFAULT_CHANNEL` | `settings.ts` — env tier for the `default-channel` setting |
|
|
1010
1020
|
| `QUICKCHR_DEFAULT_ARCH` | `settings.ts` — env tier for the `default-arch` setting |
|
|
1021
|
+
| `QUICKCHR_ACCEL` | `settings.ts` / `platform.ts` — env tier for the `accel` setting; forces `-accel` and bypasses detection |
|
|
1011
1022
|
| `QUICKCHR_CACHE_MAX_SIZE` | `settings.ts` — env tier for the `cache-max-size` setting |
|
|
1012
1023
|
| `QUICKCHR_TIMEOUT_EXTRA` | `settings.ts` — env tier for the `timeout-extra` setting |
|
|
1013
1024
|
| `QUICKCHR_SECURE_LOGIN` | `settings.ts` — env tier for the `secure-login` setting |
|
|
@@ -1255,6 +1266,9 @@ prevents orphan disks accumulating across attempts.
|
|
|
1255
1266
|
(MNDP) on the host via a `socket-connect` NIC.
|
|
1256
1267
|
- **docs/qga-x86-macos-qemu10-investigation.md** — root cause for the
|
|
1257
1268
|
QGA-on-macOS-arm64 limitation.
|
|
1269
|
+
- **docs/m4-hvf-arm64-investigation.md** — **open** investigation into why arm64
|
|
1270
|
+
CHR panics under HVF on Apple M4 (why arm64 falls back to TCG there); evidence
|
|
1271
|
+
ledger, competing theories, and the test plan to settle them.
|
|
1258
1272
|
- **`.github/instructions/`** — agent-facing rules (testing, qemu,
|
|
1259
1273
|
routeros-rest, provisioning, ci, bun-http, general).
|
|
1260
1274
|
- **`examples/`** — runnable scripts (`bun run`): `quickstart` (single-CHR smoke),
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -157,6 +157,26 @@ export async function resolveTimeoutExtraMs(flags: Record<string, string | boole
|
|
|
157
157
|
return seconds !== undefined ? seconds * 1000 : undefined;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/** Apply an explicit `--accel <auto|tcg|hvf|kvm>` as the process-level accelerator
|
|
161
|
+
* override, so every detectAccel() call in this run (QEMU args, boot timeouts,
|
|
162
|
+
* respawn logic) agrees on one value. Absent flag → the accel setting's own
|
|
163
|
+
* env/file/default tiers apply, so this only installs the top tier. Throws
|
|
164
|
+
* INVALID_SETTING_VALUE on an unrecognized mode. Shared by cmdAdd and cmdStart. */
|
|
165
|
+
export async function applyAccelFlag(flags: Record<string, string | boolean | string[]>): Promise<void> {
|
|
166
|
+
const value = flags.accel;
|
|
167
|
+
if (value === undefined) return;
|
|
168
|
+
const raw = Array.isArray(value)
|
|
169
|
+
? value.at(-1) ?? ""
|
|
170
|
+
: typeof value === "string"
|
|
171
|
+
? value
|
|
172
|
+
: "";
|
|
173
|
+
const [{ parseAccelMode }, { setAccelOverride }] = await Promise.all([
|
|
174
|
+
import("../lib/types.ts"),
|
|
175
|
+
import("../lib/platform.ts"),
|
|
176
|
+
]);
|
|
177
|
+
setAccelOverride(parseAccelMode(raw));
|
|
178
|
+
}
|
|
179
|
+
|
|
160
180
|
/** Resolve --secure-login/--no-secure-login, falling back to the secure-login setting
|
|
161
181
|
* only when neither flag was passed. Shared by cmdAdd, cmdStart, and cmdStart's
|
|
162
182
|
* --all bulk-restart path. */
|
|
@@ -535,6 +555,7 @@ async function cmdAdd(argv: string[]) {
|
|
|
535
555
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
536
556
|
const { bold, formatPorts, formatNetworks, dim } = await import("./format.ts");
|
|
537
557
|
const { resolveSetting } = await import("../lib/settings.ts");
|
|
558
|
+
await applyAccelFlag(flags);
|
|
538
559
|
|
|
539
560
|
const opts: StartOptions = {
|
|
540
561
|
version: flag(flags, "version"),
|
|
@@ -1175,6 +1196,8 @@ async function cmdSetup() {
|
|
|
1175
1196
|
async function cmdStart(argv: string[]) {
|
|
1176
1197
|
const { flags, positional } = parseFlags(argv);
|
|
1177
1198
|
applyTimeoutExtraShortFlag(argv, flags, positional);
|
|
1199
|
+
// Before the --all branch: both start paths must see the same override.
|
|
1200
|
+
await applyAccelFlag(flags);
|
|
1178
1201
|
|
|
1179
1202
|
// --all: start every stopped machine in background
|
|
1180
1203
|
if (flagBool(flags, "all")) {
|
|
@@ -2720,6 +2743,9 @@ Options:
|
|
|
2720
2743
|
--arch <arch> Architecture: arm64, x86, auto (default: host native)
|
|
2721
2744
|
--cpu <n> vCPU count (default: 1)
|
|
2722
2745
|
--mem <mb> RAM in MB (default: 512)
|
|
2746
|
+
--accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
|
|
2747
|
+
Anything but 'auto' bypasses detection, including the
|
|
2748
|
+
arm64-on-Apple-Silicon TCG fallback (tikoci/quickchr#97).
|
|
2723
2749
|
--boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
|
|
2724
2750
|
--boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
|
|
2725
2751
|
--add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
|
|
@@ -2802,6 +2828,9 @@ Options:
|
|
|
2802
2828
|
--name <name> Instance name
|
|
2803
2829
|
--cpu <n> vCPU count (default: 1)
|
|
2804
2830
|
--mem <mb> RAM in MB (default: 512)
|
|
2831
|
+
--accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
|
|
2832
|
+
Anything but 'auto' bypasses detection, including the
|
|
2833
|
+
arm64-on-Apple-Silicon TCG fallback (tikoci/quickchr#97).
|
|
2805
2834
|
--boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
|
|
2806
2835
|
--boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
|
|
2807
2836
|
--add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
|
|
@@ -3056,6 +3085,8 @@ Managed keys:
|
|
|
3056
3085
|
default-channel Default --channel for 'add'/'start' and the setup wizard.
|
|
3057
3086
|
stable|long-term|testing|development (default: stable)
|
|
3058
3087
|
default-arch Default --arch. arm64|x86|auto (default: auto = host native)
|
|
3088
|
+
accel QEMU accelerator for 'add'/'start'.
|
|
3089
|
+
auto|tcg|hvf|kvm (default: auto = detect)
|
|
3059
3090
|
cache-max-size Auto-prune cap applied after each successful start.
|
|
3060
3091
|
Size string (e.g. 2G, 512M). (default: 2G)
|
|
3061
3092
|
timeout-extra Default for 'start's --timeout-extra/-T when omitted.
|
package/src/lib/platform.ts
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
6
|
-
import type { EfiFirmwarePaths, HostInterface, PackageManager, PlatformInfo, SocketVmnetInfo } from "./types.ts";
|
|
7
|
-
import { QuickCHRError as QError } from "./types.ts";
|
|
6
|
+
import type { AccelMode, EfiFirmwarePaths, HostInterface, PackageManager, PlatformInfo, SocketVmnetInfo } from "./types.ts";
|
|
7
|
+
import { QuickCHRError as QError, parseAccelMode } from "./types.ts";
|
|
8
|
+
// settings-file.ts, not settings.ts: the latter's graph (cache → state → network
|
|
9
|
+
// → platform) is a cycle that would load eagerly on every CLI start.
|
|
10
|
+
import { loadSettingsFileDefaults } from "./settings-file.ts";
|
|
8
11
|
|
|
9
12
|
/** UEFI firmware search paths by platform. */
|
|
10
13
|
const EFI_CODE_PATHS = [
|
|
@@ -111,14 +114,11 @@ export function qgaKvmWarning(): string | null {
|
|
|
111
114
|
);
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
/** Whether guestArch
|
|
115
|
-
* Cross-arch
|
|
116
|
-
* arm64 guest on x86_64 host → TCG (slow).
|
|
117
|
-
* x86 guest on any host → HVF or KVM when available (fast). */
|
|
117
|
+
/** Whether guestArch differs from the physical host CPU architecture.
|
|
118
|
+
* Cross-arch TCG is significantly slower and needs more memory/timeout. */
|
|
118
119
|
export function isCrossArchEmulation(guestArch: "x86" | "arm64"): boolean {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
return guestArch === "arm64" && process.arch !== "arm64";
|
|
120
|
+
const hostIsArm64 = isAppleSiliconHost() || (process.platform !== "darwin" && process.arch === "arm64");
|
|
121
|
+
return (guestArch === "arm64") !== hostIsArm64;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
@@ -138,8 +138,132 @@ export function accelTimeoutFactor(accel: string, crossArch: boolean): number {
|
|
|
138
138
|
return crossArch ? 15.0 : 4.0;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Whether the physical host is Apple Silicon, including when quickchr itself
|
|
143
|
+
* runs as an x64 process under Rosetta.
|
|
144
|
+
*
|
|
145
|
+
* Apple Silicon implements **no AArch32 at any exception level**: its
|
|
146
|
+
* `ID_AA64PFR0_EL1` reports AArch64-only for EL0 and EL1, and HVF passes that
|
|
147
|
+
* hardware register straight through to the guest (the `-cpu` model is inert
|
|
148
|
+
* under HVF). Current arm64 CHR images ship an AArch64 kernel over a *32-bit
|
|
149
|
+
* ARM* userspace — the appended initramfs `/init` is `ELF 32-bit LSB ARM
|
|
150
|
+
* EABI5`, and the system package holds 101 further ARM32 executables — so the
|
|
151
|
+
* guest kernel never sets `ARM64_HAS_32BIT_EL0`, `execve("/init")` returns
|
|
152
|
+
* -ENOEXEC, and Linux panics ~0.08 s in with "No working init found".
|
|
153
|
+
* TCG's emulated CPU models do provide AArch32 EL0, so the same image boots.
|
|
154
|
+
*
|
|
155
|
+
* This is an image-artifact incompatibility, not an accelerator or CPU-model
|
|
156
|
+
* bug: no QEMU flag and no macOS VMM can present a feature the silicon does not
|
|
157
|
+
* implement. See docs/m4-hvf-arm64-investigation.md and tikoci/quickchr#97.
|
|
158
|
+
*/
|
|
159
|
+
let appleSiliconHostMemo: boolean | undefined;
|
|
160
|
+
|
|
161
|
+
export function isAppleSiliconHost(): boolean {
|
|
162
|
+
if (process.platform !== "darwin") return false;
|
|
163
|
+
if (process.arch === "arm64") return true;
|
|
164
|
+
if (process.arch !== "x64") return false;
|
|
165
|
+
if (appleSiliconHostMemo !== undefined) return appleSiliconHostMemo;
|
|
166
|
+
try {
|
|
167
|
+
const result = Bun.spawnSync(["sysctl", "-n", "sysctl.proc_translated"], {
|
|
168
|
+
stdout: "pipe",
|
|
169
|
+
stderr: "pipe",
|
|
170
|
+
});
|
|
171
|
+
appleSiliconHostMemo =
|
|
172
|
+
result.exitCode === 0 &&
|
|
173
|
+
new TextDecoder().decode(result.stdout).trim() === "1";
|
|
174
|
+
} catch {
|
|
175
|
+
appleSiliconHostMemo = false;
|
|
176
|
+
}
|
|
177
|
+
return appleSiliconHostMemo;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @internal Test-only: clear the memoized Rosetta probe between mocked runs. */
|
|
181
|
+
export function resetAppleSiliconHostCache(): void {
|
|
182
|
+
appleSiliconHostMemo = undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Process-level accelerator override, set by `--accel` (CLI) or by a library
|
|
187
|
+
* caller. Highest tier of the documented precedence chain
|
|
188
|
+
* (flag > QUICKCHR_ACCEL env > quickchr.env > built-in "auto"); `undefined`
|
|
189
|
+
* means "not set, fall through to the settings tiers".
|
|
190
|
+
*/
|
|
191
|
+
let accelOverride: AccelMode | undefined;
|
|
192
|
+
|
|
193
|
+
/** Force the accelerator for the rest of this process, or clear with undefined. */
|
|
194
|
+
export function setAccelOverride(mode: AccelMode | undefined): void {
|
|
195
|
+
accelOverride = mode;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resolve the accelerator override: process override > QUICKCHR_ACCEL env >
|
|
200
|
+
* quickchr.env > "auto". Mirrors settings.ts's `accel` key resolution (same
|
|
201
|
+
* tiers, same validator) but reads the file tier directly to stay out of that
|
|
202
|
+
* module's import cycle. Throws INVALID_SETTING_VALUE on an unrecognized mode.
|
|
203
|
+
*/
|
|
204
|
+
export function resolveAccelOverride(): AccelMode {
|
|
205
|
+
return resolveAccelOverrideWithSource().mode;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Which tier supplied the accelerator, for messages that must say *why*. */
|
|
209
|
+
export type AccelOverrideSource = "flag" | "env" | "file" | "default";
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* As resolveAccelOverride(), but also reports the tier the value came from.
|
|
213
|
+
* With three override tiers, "TCG because ~/.config/quickchr/quickchr.env says
|
|
214
|
+
* so" and "TCG because you passed --accel" are very different support answers,
|
|
215
|
+
* so the launch note names the source rather than guessing at it.
|
|
216
|
+
*/
|
|
217
|
+
export function resolveAccelOverrideWithSource(): { mode: AccelMode; source: AccelOverrideSource } {
|
|
218
|
+
if (accelOverride !== undefined) return { mode: accelOverride, source: "flag" };
|
|
219
|
+
const envRaw = process.env.QUICKCHR_ACCEL;
|
|
220
|
+
if (envRaw !== undefined) return { mode: parseAccelMode(envRaw), source: "env" };
|
|
221
|
+
const fileRaw = loadSettingsFileDefaults().QUICKCHR_ACCEL;
|
|
222
|
+
if (fileRaw !== undefined) return { mode: parseAccelMode(fileRaw), source: "file" };
|
|
223
|
+
return { mode: "auto", source: "default" };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* User-facing name of the override tier — the thing the user would actually go
|
|
228
|
+
* edit. Shared by the launch note and `doctor` so both name the same source;
|
|
229
|
+
* never print the raw AccelOverrideSource enum, which is internal.
|
|
230
|
+
*/
|
|
231
|
+
export function accelSourceLabel(source: AccelOverrideSource): string {
|
|
232
|
+
if (source === "flag") return "--accel";
|
|
233
|
+
if (source === "env") return "QUICKCHR_ACCEL";
|
|
234
|
+
if (source === "file") return "quickchr.env";
|
|
235
|
+
return "auto-detection";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* One-line note about how the accelerator was chosen, or null when there is
|
|
240
|
+
* nothing worth saying. Surfaced at launch so a user on slow emulation — or
|
|
241
|
+
* one who forced a mode that is known not to boot — understands why.
|
|
242
|
+
*/
|
|
243
|
+
export function accelNote(guestArch: "x86" | "arm64", accel: string): string | null {
|
|
244
|
+
const { mode: forced, source } = resolveAccelOverrideWithSource();
|
|
245
|
+
if (forced !== "auto") {
|
|
246
|
+
const base = `Accelerator configured as "${forced}" via ${accelSourceLabel(source)} — auto-detection bypassed.`;
|
|
247
|
+
if (guestArch === "arm64" && forced === "hvf" && isAppleSiliconHost()) {
|
|
248
|
+
return `${base} Note: arm64 CHR images still ship a 32-bit ARM userspace, which Apple Silicon cannot execute under HVF — expect "No working init found" unless MikroTik has shipped an AArch64-only image (tikoci/quickchr#97).`;
|
|
249
|
+
}
|
|
250
|
+
return base;
|
|
251
|
+
}
|
|
252
|
+
if (guestArch !== "arm64" || accel !== "tcg" || !isAppleSiliconHost()) return null;
|
|
253
|
+
return (
|
|
254
|
+
"arm64 CHR is running under TCG on Apple Silicon: the image's appended " +
|
|
255
|
+
"initramfs /init and most of its system package are 32-bit ARM, and Apple " +
|
|
256
|
+
"CPUs implement no AArch32 at any exception level, so an HVF guest panics " +
|
|
257
|
+
'with "No working init found". TCG is slower but boots. Override with ' +
|
|
258
|
+
"--accel hvf once MikroTik ships an AArch64-only arm64 image (tikoci/quickchr#97)."
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
141
262
|
/** Detect available QEMU acceleration for a guest architecture. */
|
|
142
263
|
export async function detectAccel(guestArch: "x86" | "arm64"): Promise<string> {
|
|
264
|
+
const forced = resolveAccelOverride();
|
|
265
|
+
if (forced !== "auto") return forced;
|
|
266
|
+
|
|
143
267
|
const hostOs = process.platform;
|
|
144
268
|
const hostArch = process.arch; // "x64" | "arm64"
|
|
145
269
|
|
|
@@ -157,9 +281,10 @@ export async function detectAccel(guestArch: "x86" | "arm64"): Promise<string> {
|
|
|
157
281
|
}
|
|
158
282
|
|
|
159
283
|
if (hostOs === "darwin") {
|
|
160
|
-
// Apple Hypervisor Framework
|
|
161
|
-
//
|
|
162
|
-
//
|
|
284
|
+
// Apple Hypervisor Framework only runs guests matching the physical host
|
|
285
|
+
// architecture. Rosetta can translate the quickchr process, but it cannot
|
|
286
|
+
// make x86 HVF guests run on Apple Silicon.
|
|
287
|
+
const appleSilicon = isAppleSiliconHost();
|
|
163
288
|
try {
|
|
164
289
|
const hvResult = Bun.spawnSync(["sysctl", "-n", "kern.hv_support"], {
|
|
165
290
|
stdout: "pipe",
|
|
@@ -167,13 +292,22 @@ export async function detectAccel(guestArch: "x86" | "arm64"): Promise<string> {
|
|
|
167
292
|
});
|
|
168
293
|
const hv = new TextDecoder().decode(hvResult.stdout).trim();
|
|
169
294
|
if (hv === "1") {
|
|
170
|
-
if (guestArch === "x86") return "hvf";
|
|
171
|
-
// arm64 guest
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
295
|
+
if (guestArch === "x86" && !appleSilicon) return "hvf";
|
|
296
|
+
// arm64 guest: falls through to TCG on every macOS host.
|
|
297
|
+
//
|
|
298
|
+
// On Intel Macs process.arch is "x64" and arm64 HVF is impossible.
|
|
299
|
+
// On Apple Silicon HVF *is* available but must not be used: no Apple
|
|
300
|
+
// CPU implements AArch32 at any exception level, and current arm64 CHR
|
|
301
|
+
// images require a 32-bit ARM userspace, so an HVF guest panics at init
|
|
302
|
+
// (see isAppleSiliconHost()). arm64 TCG still boots, but is slower.
|
|
303
|
+
//
|
|
304
|
+
// Restore signal (#97) is the *guest artifact*, not the host or QEMU
|
|
305
|
+
// version: a future arm64 CHR whose appended /init and system-package
|
|
306
|
+
// executables are all AArch64, verified by a real HVF boot. Until then
|
|
307
|
+
// `--accel hvf` / QUICKCHR_ACCEL=hvf is the escape hatch for testing.
|
|
308
|
+
//
|
|
309
|
+
// x86 guests also fall through on Apple Silicon because HVF cannot
|
|
310
|
+
// virtualize across architectures; their escape hatch is the same.
|
|
177
311
|
}
|
|
178
312
|
} catch { /* fall through */ }
|
|
179
313
|
}
|
package/src/lib/quickchr.ts
CHANGED
|
@@ -26,7 +26,7 @@ import type {
|
|
|
26
26
|
} from "./types.ts";
|
|
27
27
|
import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./types.ts";
|
|
28
28
|
import packageJson from "../../package.json";
|
|
29
|
-
import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
|
|
29
|
+
import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
|
|
30
30
|
import {
|
|
31
31
|
resolveVersion,
|
|
32
32
|
isValidVersion,
|
|
@@ -286,7 +286,14 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
286
286
|
authObj: { username: string; password?: string; basic?: string; header?: string },
|
|
287
287
|
urlSuffix = "",
|
|
288
288
|
): ServiceEndpoint => {
|
|
289
|
-
|
|
289
|
+
// Plain-first (#95): on a stock CHR the TLS services are not dialable —
|
|
290
|
+
// `www-ssl` is disabled and `api-ssl` is certificate-less (TLS alert 40) —
|
|
291
|
+
// so preferring the secure forward advertised dead endpoints. Prefer the
|
|
292
|
+
// plain forward for each service; the secure forward remains the fallback
|
|
293
|
+
// so excluded plain ports still resolve when a secure forward is available
|
|
294
|
+
// (with `tls: true`). Secure preference can return once boot provisioning
|
|
295
|
+
// installs a certificate and enables www-ssl.
|
|
296
|
+
const chosen = plainPM ?? securePM;
|
|
290
297
|
if (!chosen) {
|
|
291
298
|
return { available: false, unavailableReason: `no forwarded port for ${serviceLabel}` };
|
|
292
299
|
}
|
|
@@ -306,11 +313,10 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
306
313
|
return { available: true, ...echo, auth: authObj };
|
|
307
314
|
};
|
|
308
315
|
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
// left restUrl pointing at a port that doesn't exist.
|
|
316
|
+
// Preference order lives in buildHttpService (plain-first, #95); this also
|
|
317
|
+
// keeps the excludePorts fix: excluding "http" while keeping "https" now
|
|
318
|
+
// resolves rest-api onto the surviving secure port instead of a port that
|
|
319
|
+
// doesn't exist.
|
|
314
320
|
const restApi = buildHttpService(
|
|
315
321
|
state.ports.https,
|
|
316
322
|
state.ports.http,
|
|
@@ -1520,6 +1526,8 @@ export class QuickCHR {
|
|
|
1520
1526
|
// Build QEMU args and spawn
|
|
1521
1527
|
const platform = await detectPlatform();
|
|
1522
1528
|
const accel = await detectAccel(arch);
|
|
1529
|
+
const note = accelNote(arch, accel);
|
|
1530
|
+
if (note) logger.warn(note);
|
|
1523
1531
|
registerSocketMembers(state);
|
|
1524
1532
|
const hostfwd = buildHostfwdString(state.ports);
|
|
1525
1533
|
const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
|
|
@@ -1832,6 +1840,8 @@ export class QuickCHR {
|
|
|
1832
1840
|
|
|
1833
1841
|
const platform = await detectPlatform();
|
|
1834
1842
|
const accel = await detectAccel(state.arch);
|
|
1843
|
+
const note = accelNote(state.arch, accel);
|
|
1844
|
+
if (note) (logger ?? createLogger()).warn(note);
|
|
1835
1845
|
registerSocketMembers(state);
|
|
1836
1846
|
const hostfwd = buildHostfwdString(state.ports);
|
|
1837
1847
|
const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
|
|
@@ -1995,10 +2005,14 @@ export class QuickCHR {
|
|
|
1995
2005
|
try {
|
|
1996
2006
|
const platform = await detectPlatform();
|
|
1997
2007
|
if (platform.accelAvailable.length > 0) {
|
|
2008
|
+
// Under an override, accelAvailable is just the forced mode for every
|
|
2009
|
+
// arch — say so, or the row reads as a capability report it isn't.
|
|
2010
|
+
const { mode: forced, source } = resolveAccelOverrideWithSource();
|
|
2011
|
+
const suffix = forced === "auto" ? "" : ` — configured override via ${accelSourceLabel(source)}`;
|
|
1998
2012
|
checks.push({
|
|
1999
2013
|
label: "Acceleration",
|
|
2000
2014
|
status: "ok",
|
|
2001
|
-
detail: `${platform.accelAvailable.join(", ")} (host: ${platform.hostArch})`,
|
|
2015
|
+
detail: `${platform.accelAvailable.join(", ")} (host: ${platform.hostArch})${suffix}`,
|
|
2002
2016
|
});
|
|
2003
2017
|
} else {
|
|
2004
2018
|
checks.push({
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `quickchr.env` file tier of the settings system — path, raw read, and
|
|
3
|
+
* dotenv-style parse.
|
|
4
|
+
*
|
|
5
|
+
* Split out of settings.ts as a **leaf** module (node:fs + paths.ts only) so
|
|
6
|
+
* platform.ts can resolve the `accel` override's file tier without pulling in
|
|
7
|
+
* settings.ts's transitive graph (settings → cache → state → network →
|
|
8
|
+
* platform). That cycle loads eagerly at CLI startup and measurably slows every
|
|
9
|
+
* `quickchr` invocation. settings.ts re-exports everything here, so this split
|
|
10
|
+
* is invisible to callers.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { quickchrConfigDir } from "./paths.ts";
|
|
16
|
+
|
|
17
|
+
export const SETTINGS_FILE_NAME = "quickchr.env";
|
|
18
|
+
|
|
19
|
+
/** ~/.config/quickchr/quickchr.env */
|
|
20
|
+
export function settingsFilePath(): string {
|
|
21
|
+
return join(quickchrConfigDir(), SETTINGS_FILE_NAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Raw non-empty lines, in file order. [] if the file doesn't exist. */
|
|
25
|
+
export function readSettingsFileLines(): string[] {
|
|
26
|
+
const path = settingsFilePath();
|
|
27
|
+
if (!existsSync(path)) return [];
|
|
28
|
+
const text = readFileSync(path, "utf-8");
|
|
29
|
+
const lines = text.split("\n");
|
|
30
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
31
|
+
return lines;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse QUICKCHR_*=value lines into a flat map. Blank/comment lines ignored; later dup wins. */
|
|
35
|
+
export function parseSettingsFileLines(lines: readonly string[]): Record<string, string> {
|
|
36
|
+
const result: Record<string, string> = {};
|
|
37
|
+
for (const rawLine of lines) {
|
|
38
|
+
const line = rawLine.trim();
|
|
39
|
+
if (line.length === 0 || line.startsWith("#")) continue;
|
|
40
|
+
const eq = line.indexOf("=");
|
|
41
|
+
if (eq <= 0) continue;
|
|
42
|
+
const key = line.slice(0, eq).trim();
|
|
43
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
44
|
+
result[key] = line.slice(eq + 1).trim();
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The `file` tier every resolver call reads. {} when the file is absent. */
|
|
50
|
+
export function loadSettingsFileDefaults(): Record<string, string> {
|
|
51
|
+
return parseSettingsFileLines(readSettingsFileLines());
|
|
52
|
+
}
|
package/src/lib/settings.ts
CHANGED
|
@@ -13,12 +13,16 @@
|
|
|
13
13
|
* input resolution, never per-machine config.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync,
|
|
16
|
+
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync, fsyncSync } from "node:fs";
|
|
17
17
|
import { basename, dirname, join } from "node:path";
|
|
18
|
-
import { ARCHES, CHANNELS, QuickCHRError } from "./types.ts";
|
|
19
|
-
import type { Channel } from "./types.ts";
|
|
18
|
+
import { ARCHES, CHANNELS, QuickCHRError, parseAccelMode } from "./types.ts";
|
|
19
|
+
import type { AccelMode, Channel } from "./types.ts";
|
|
20
20
|
import { parseSizeString, DEFAULT_CACHE_MAX_BYTES } from "./cache.ts";
|
|
21
|
-
import {
|
|
21
|
+
import { loadSettingsFileDefaults, readSettingsFileLines, settingsFilePath } from "./settings-file.ts";
|
|
22
|
+
|
|
23
|
+
// The quickchr.env file tier lives in the leaf module settings-file.ts (see its
|
|
24
|
+
// header for why); re-exported here so settings.ts stays the single public entry.
|
|
25
|
+
export { SETTINGS_FILE_NAME, loadSettingsFileDefaults, parseSettingsFileLines, readSettingsFileLines, settingsFilePath } from "./settings-file.ts";
|
|
22
26
|
|
|
23
27
|
export type SettingsValue = string | number | boolean;
|
|
24
28
|
export type SettingsSource = "env" | "file" | "default";
|
|
@@ -93,6 +97,17 @@ export const SETTINGS_KEYS: readonly SettingsKeyDef[] = [
|
|
|
93
97
|
parse: parseArchSetting,
|
|
94
98
|
serialize: (v) => String(v),
|
|
95
99
|
},
|
|
100
|
+
{
|
|
101
|
+
// "auto" = let detectAccel() choose, including its platform fallbacks
|
|
102
|
+
// (notably arm64-on-Apple-Silicon → TCG, see DESIGN.md #10). Any other
|
|
103
|
+
// value is passed to QEMU verbatim and skips detection entirely — the
|
|
104
|
+
// escape hatch for testing HVF against a future AArch64-only arm64 image.
|
|
105
|
+
attr: "accel",
|
|
106
|
+
envKey: "QUICKCHR_ACCEL",
|
|
107
|
+
builtinDefault: "auto" satisfies AccelMode,
|
|
108
|
+
parse: (raw) => parseAccelMode(raw),
|
|
109
|
+
serialize: (v) => String(v),
|
|
110
|
+
},
|
|
96
111
|
{
|
|
97
112
|
attr: "cache-max-size",
|
|
98
113
|
envKey: "QUICKCHR_CACHE_MAX_SIZE",
|
|
@@ -108,7 +123,7 @@ export const SETTINGS_KEYS: readonly SettingsKeyDef[] = [
|
|
|
108
123
|
serialize: (v) => String(v),
|
|
109
124
|
},
|
|
110
125
|
{
|
|
111
|
-
// No builtinDefault: unlike the other
|
|
126
|
+
// No builtinDefault: unlike the other 5 keys, the wizard needs to distinguish
|
|
112
127
|
// "not configured" from "explicitly false" (src/cli/wizard.ts's userChoice
|
|
113
128
|
// initialValue only pre-highlights "admin" when the setting is concretely
|
|
114
129
|
// false) — a builtinDefault here would collapse that distinction and always
|
|
@@ -123,13 +138,6 @@ export const SETTINGS_KEYS: readonly SettingsKeyDef[] = [
|
|
|
123
138
|
},
|
|
124
139
|
];
|
|
125
140
|
|
|
126
|
-
export const SETTINGS_FILE_NAME = "quickchr.env";
|
|
127
|
-
|
|
128
|
-
/** ~/.config/quickchr/quickchr.env */
|
|
129
|
-
export function settingsFilePath(): string {
|
|
130
|
-
return join(quickchrConfigDir(), SETTINGS_FILE_NAME);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
141
|
/** Accepts "default-channel" | "QUICKCHR_DEFAULT_CHANNEL" | "DEFAULT_CHANNEL" | "default_channel". */
|
|
134
142
|
export function lookupSettingsKey(input: string): SettingsKeyDef | undefined {
|
|
135
143
|
let name = input.trim();
|
|
@@ -150,36 +158,6 @@ function unknownKeyError(input: string): QuickCHRError {
|
|
|
150
158
|
|
|
151
159
|
// --- File IO ---
|
|
152
160
|
|
|
153
|
-
/** Raw non-empty lines, in file order. [] if the file doesn't exist. */
|
|
154
|
-
export function readSettingsFileLines(): string[] {
|
|
155
|
-
const path = settingsFilePath();
|
|
156
|
-
if (!existsSync(path)) return [];
|
|
157
|
-
const text = readFileSync(path, "utf-8");
|
|
158
|
-
const lines = text.split("\n");
|
|
159
|
-
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
160
|
-
return lines;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Parse QUICKCHR_*=value lines into a flat map. Blank/comment lines ignored; later dup wins. */
|
|
164
|
-
export function parseSettingsFileLines(lines: readonly string[]): Record<string, string> {
|
|
165
|
-
const result: Record<string, string> = {};
|
|
166
|
-
for (const rawLine of lines) {
|
|
167
|
-
const line = rawLine.trim();
|
|
168
|
-
if (line.length === 0 || line.startsWith("#")) continue;
|
|
169
|
-
const eq = line.indexOf("=");
|
|
170
|
-
if (eq <= 0) continue;
|
|
171
|
-
const key = line.slice(0, eq).trim();
|
|
172
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
173
|
-
result[key] = line.slice(eq + 1).trim();
|
|
174
|
-
}
|
|
175
|
-
return result;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** The `file` tier every resolver call reads. {} when the file is absent. */
|
|
179
|
-
export function loadSettingsFileDefaults(): Record<string, string> {
|
|
180
|
-
return parseSettingsFileLines(readSettingsFileLines());
|
|
181
|
-
}
|
|
182
|
-
|
|
183
161
|
function lineEnvKey(line: string): string | undefined {
|
|
184
162
|
const trimmed = line.trim();
|
|
185
163
|
if (trimmed.length === 0 || trimmed.startsWith("#")) return undefined;
|
package/src/lib/types.ts
CHANGED
|
@@ -13,6 +13,28 @@ export const CHANNELS: readonly Channel[] = Object.freeze(["stable", "long-term"
|
|
|
13
13
|
export type Arch = "arm64" | "x86";
|
|
14
14
|
export const ARCHES: Arch[] = ["arm64", "x86"];
|
|
15
15
|
|
|
16
|
+
// --- Acceleration ---
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* QEMU accelerator selection. "auto" means detectAccel() picks, including its
|
|
20
|
+
* platform safety fallbacks; the other three force `-accel <mode>` verbatim and
|
|
21
|
+
* bypass detection entirely (see DESIGN.md #10).
|
|
22
|
+
*/
|
|
23
|
+
export type AccelMode = "auto" | "tcg" | "hvf" | "kvm";
|
|
24
|
+
export const ACCEL_MODES: readonly AccelMode[] = Object.freeze(["auto", "tcg", "hvf", "kvm"] as AccelMode[]);
|
|
25
|
+
|
|
26
|
+
/** Validate an accelerator name from a CLI flag, env var, or settings file. */
|
|
27
|
+
export function parseAccelMode(raw: string): AccelMode {
|
|
28
|
+
const v = raw.trim().toLowerCase();
|
|
29
|
+
if (!(ACCEL_MODES as readonly string[]).includes(v)) {
|
|
30
|
+
throw new QuickCHRError(
|
|
31
|
+
"INVALID_SETTING_VALUE",
|
|
32
|
+
`Invalid value "${raw}" for accel — must be one of ${ACCEL_MODES.join(", ")}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return v as AccelMode;
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
// --- Networking ---
|
|
17
39
|
|
|
18
40
|
/** @deprecated Use NetworkSpecifier instead. Kept for backward compatibility with old machine.json files. */
|