@tikoci/quickchr 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +161 -37
- package/MANUAL.md +1259 -0
- package/README.md +35 -1
- package/package.json +7 -4
- package/src/cli/index.ts +191 -42
- package/src/cli/wizard.ts +42 -12
- package/src/index.ts +1 -1
- package/src/lib/cache.ts +19 -1
- package/src/lib/channels.ts +19 -4
- package/src/lib/completions.ts +6 -3
- package/src/lib/disk.ts +5 -2
- package/src/lib/forward-spec.ts +140 -3
- package/src/lib/paths.ts +13 -0
- package/src/lib/provision.ts +25 -27
- package/src/lib/qemu.ts +52 -22
- package/src/lib/quickchr.ts +83 -16
- package/src/lib/secrets.ts +3 -7
- package/src/lib/settings.ts +373 -0
- package/src/lib/state.ts +42 -1
- package/src/lib/types.ts +90 -8
package/src/lib/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
|
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quickchr's own user-scoped settings — global preferences with no per-machine
|
|
3
|
+
* home, stored at ~/.config/quickchr/quickchr.env (dotenv-style: one
|
|
4
|
+
* QUICKCHR_KEY=value line per managed setting).
|
|
5
|
+
*
|
|
6
|
+
* Precedence per key (highest wins): CLI flag > QUICKCHR_<KEY> env var >
|
|
7
|
+
* quickchr.env > built-in default. This module only resolves the middle two
|
|
8
|
+
* tiers (env, file) plus the built-in default — callers compose
|
|
9
|
+
* `flagValue ?? resolveSetting(attr).value` themselves, so an explicit CLI
|
|
10
|
+
* flag never runs through the settings validator.
|
|
11
|
+
*
|
|
12
|
+
* Does not read or write machine.json / MachineState — settings are CLI-start
|
|
13
|
+
* input resolution, never per-machine config.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, fsyncSync } from "node:fs";
|
|
17
|
+
import { basename, dirname, join } from "node:path";
|
|
18
|
+
import { ARCHES, CHANNELS, QuickCHRError } from "./types.ts";
|
|
19
|
+
import type { Channel } from "./types.ts";
|
|
20
|
+
import { parseSizeString, DEFAULT_CACHE_MAX_BYTES } from "./cache.ts";
|
|
21
|
+
import { quickchrConfigDir } from "./paths.ts";
|
|
22
|
+
|
|
23
|
+
export type SettingsValue = string | number | boolean;
|
|
24
|
+
export type SettingsSource = "env" | "file" | "default";
|
|
25
|
+
|
|
26
|
+
export interface SettingsKeyDef {
|
|
27
|
+
/** Kebab-case CLI-facing name, e.g. "default-channel". */
|
|
28
|
+
attr: string;
|
|
29
|
+
/** QUICKCHR_* env var name. */
|
|
30
|
+
envKey: string;
|
|
31
|
+
/** Parse + validate raw input (from env or file). Throws INVALID_SETTING_VALUE on bad input. */
|
|
32
|
+
parse: (raw: string) => SettingsValue;
|
|
33
|
+
/** Canonical on-disk/display form. */
|
|
34
|
+
serialize: (value: SettingsValue) => string;
|
|
35
|
+
/** Concrete built-in default when settings get/print should report one.
|
|
36
|
+
* Omit only when "unset" has meaning distinct from a concrete value. */
|
|
37
|
+
builtinDefault?: SettingsValue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseChannel(raw: string): SettingsValue {
|
|
41
|
+
if (!(CHANNELS as readonly string[]).includes(raw)) {
|
|
42
|
+
throw new QuickCHRError("INVALID_SETTING_VALUE", `Invalid value "${raw}" for default-channel — must be one of ${CHANNELS.join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
return raw;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseArchSetting(raw: string): SettingsValue {
|
|
48
|
+
const allowed = [...ARCHES, "auto"];
|
|
49
|
+
if (!allowed.includes(raw)) {
|
|
50
|
+
throw new QuickCHRError("INVALID_SETTING_VALUE", `Invalid value "${raw}" for default-arch — must be one of ${allowed.join(", ")}`);
|
|
51
|
+
}
|
|
52
|
+
return raw;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseCacheMaxSize(raw: string): SettingsValue {
|
|
56
|
+
try {
|
|
57
|
+
return parseSizeString(raw);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
throw new QuickCHRError("INVALID_SETTING_VALUE", `Invalid value "${raw}" for cache-max-size — ${e instanceof Error ? e.message : String(e)}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Shared with the CLI's own `--timeout-extra`/`-T` flag validation (cmdStart in
|
|
64
|
+
* src/cli/index.ts) so the same non-negative-integer rule applies whether the
|
|
65
|
+
* value comes from a flag, QUICKCHR_TIMEOUT_EXTRA, or quickchr.env. */
|
|
66
|
+
export function parseTimeoutExtraSeconds(raw: string): number {
|
|
67
|
+
const n = Number(raw);
|
|
68
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
69
|
+
throw new QuickCHRError("INVALID_SETTING_VALUE", `Invalid value "${raw}" for timeout-extra — must be a non-negative integer number of seconds`);
|
|
70
|
+
}
|
|
71
|
+
return n;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseSecureLogin(raw: string): SettingsValue {
|
|
75
|
+
const v = raw.trim().toLowerCase();
|
|
76
|
+
if (["true", "1", "yes", "on"].includes(v)) return true;
|
|
77
|
+
if (["false", "0", "no", "off"].includes(v)) return false;
|
|
78
|
+
throw new QuickCHRError("INVALID_SETTING_VALUE", `Invalid value "${raw}" for secure-login — must be a boolean (true/false)`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const SETTINGS_KEYS: readonly SettingsKeyDef[] = [
|
|
82
|
+
{
|
|
83
|
+
attr: "default-channel",
|
|
84
|
+
envKey: "QUICKCHR_DEFAULT_CHANNEL",
|
|
85
|
+
builtinDefault: "stable" satisfies Channel,
|
|
86
|
+
parse: parseChannel,
|
|
87
|
+
serialize: (v) => String(v),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
attr: "default-arch",
|
|
91
|
+
envKey: "QUICKCHR_DEFAULT_ARCH",
|
|
92
|
+
builtinDefault: "auto",
|
|
93
|
+
parse: parseArchSetting,
|
|
94
|
+
serialize: (v) => String(v),
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
attr: "cache-max-size",
|
|
98
|
+
envKey: "QUICKCHR_CACHE_MAX_SIZE",
|
|
99
|
+
builtinDefault: DEFAULT_CACHE_MAX_BYTES,
|
|
100
|
+
parse: parseCacheMaxSize,
|
|
101
|
+
serialize: (v) => String(v),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
attr: "timeout-extra",
|
|
105
|
+
envKey: "QUICKCHR_TIMEOUT_EXTRA",
|
|
106
|
+
builtinDefault: 0,
|
|
107
|
+
parse: parseTimeoutExtraSeconds,
|
|
108
|
+
serialize: (v) => String(v),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
// No builtinDefault: unlike the other 4 keys, the wizard needs to distinguish
|
|
112
|
+
// "not configured" from "explicitly false" (src/cli/wizard.ts's userChoice
|
|
113
|
+
// initialValue only pre-highlights "admin" when the setting is concretely
|
|
114
|
+
// false) — a builtinDefault here would collapse that distinction and always
|
|
115
|
+
// show "admin" even when nothing is configured. `resolveSecureLoginFlag`'s
|
|
116
|
+
// own `=== true` check is unaffected either way. "(unset)" in `settings print`
|
|
117
|
+
// means the consumer's own default (false / blank-admin) applies — see
|
|
118
|
+
// MANUAL.md's settings table.
|
|
119
|
+
attr: "secure-login",
|
|
120
|
+
envKey: "QUICKCHR_SECURE_LOGIN",
|
|
121
|
+
parse: parseSecureLogin,
|
|
122
|
+
serialize: (v) => (v ? "true" : "false"),
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
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
|
+
/** Accepts "default-channel" | "QUICKCHR_DEFAULT_CHANNEL" | "DEFAULT_CHANNEL" | "default_channel". */
|
|
134
|
+
export function lookupSettingsKey(input: string): SettingsKeyDef | undefined {
|
|
135
|
+
let name = input.trim();
|
|
136
|
+
if (/^quickchr[_-]/i.test(name)) {
|
|
137
|
+
name = name.replace(/^quickchr[_-]/i, "");
|
|
138
|
+
}
|
|
139
|
+
name = name.toLowerCase().replace(/_/g, "-");
|
|
140
|
+
return SETTINGS_KEYS.find((def) => def.attr === name);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function unknownKeyError(input: string): QuickCHRError {
|
|
144
|
+
return new QuickCHRError(
|
|
145
|
+
"INVALID_SETTING_KEY",
|
|
146
|
+
`"${input}" is not a recognized quickchr setting.`,
|
|
147
|
+
"Run 'quickchr settings print' to see the managed keys.",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- File IO ---
|
|
152
|
+
|
|
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
|
+
function lineEnvKey(line: string): string | undefined {
|
|
184
|
+
const trimmed = line.trim();
|
|
185
|
+
if (trimmed.length === 0 || trimmed.startsWith("#")) return undefined;
|
|
186
|
+
const eq = trimmed.indexOf("=");
|
|
187
|
+
if (eq <= 0) return undefined;
|
|
188
|
+
return trimmed.slice(0, eq).trim();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function matchingLineIndices(lines: readonly string[], envKey: string): number[] {
|
|
192
|
+
const indices: number[] = [];
|
|
193
|
+
for (let i = 0; i < lines.length; i++) {
|
|
194
|
+
if (lineEnvKey(lines[i] ?? "") === envKey) indices.push(i);
|
|
195
|
+
}
|
|
196
|
+
return indices;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function rawValueAtLine(lines: readonly string[], index: number): string {
|
|
200
|
+
const line = (lines[index] ?? "").trim();
|
|
201
|
+
return line.slice(line.indexOf("=") + 1).trim();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Atomic write: temp file in the same dir + fsync + rename, single .bak copy. Creates the dir on first write. */
|
|
205
|
+
function writeSettingsFileLinesSync(lines: readonly string[]): void {
|
|
206
|
+
const path = settingsFilePath();
|
|
207
|
+
const dir = dirname(path);
|
|
208
|
+
mkdirSync(dir, { recursive: true });
|
|
209
|
+
if (existsSync(path)) {
|
|
210
|
+
try {
|
|
211
|
+
copyFileSync(path, `${path}.bak`);
|
|
212
|
+
} catch {
|
|
213
|
+
// best-effort
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const content = lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
217
|
+
const tempPath = join(dir, `.${basename(path)}.tmp.${process.pid}.${Date.now().toString(36)}`);
|
|
218
|
+
writeFileSync(tempPath, content, "utf-8");
|
|
219
|
+
const fd = openSync(tempPath, "r+");
|
|
220
|
+
try {
|
|
221
|
+
fsyncSync(fd);
|
|
222
|
+
} finally {
|
|
223
|
+
closeSync(fd);
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
renameSync(tempPath, path);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
try {
|
|
229
|
+
unlinkSync(tempPath);
|
|
230
|
+
} catch {
|
|
231
|
+
// ignore
|
|
232
|
+
}
|
|
233
|
+
throw e;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Resolution ---
|
|
238
|
+
|
|
239
|
+
export interface ResolvedSetting {
|
|
240
|
+
value: SettingsValue | undefined;
|
|
241
|
+
source: SettingsSource;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 3-tier resolver: env > file > builtinDefault (undefined if none). Throws
|
|
246
|
+
* INVALID_SETTING_KEY for an unrecognized attr, INVALID_SETTING_VALUE if the
|
|
247
|
+
* env var or file line's value fails validation. Never sees a CLI flag value —
|
|
248
|
+
* callers compose `flagValue ?? resolveSetting(attr).value` themselves.
|
|
249
|
+
*/
|
|
250
|
+
export function resolveSetting(attr: string, env: NodeJS.ProcessEnv = process.env): ResolvedSetting {
|
|
251
|
+
const def = lookupSettingsKey(attr);
|
|
252
|
+
if (!def) throw unknownKeyError(attr);
|
|
253
|
+
|
|
254
|
+
const envRaw = env[def.envKey];
|
|
255
|
+
if (envRaw !== undefined) {
|
|
256
|
+
return { value: def.parse(envRaw), source: "env" };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const fileDefaults = loadSettingsFileDefaults();
|
|
260
|
+
const fileRaw = fileDefaults[def.envKey];
|
|
261
|
+
if (fileRaw !== undefined) {
|
|
262
|
+
return { value: def.parse(fileRaw), source: "file" };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { value: def.builtinDefault, source: "default" };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface SettingsGetResult extends ResolvedSetting {
|
|
269
|
+
key: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Strict — throws on an unrecognized key or a malformed env/file value. */
|
|
273
|
+
export function settingsGet(attr: string, env: NodeJS.ProcessEnv = process.env): SettingsGetResult {
|
|
274
|
+
const def = lookupSettingsKey(attr);
|
|
275
|
+
if (!def) throw unknownKeyError(attr);
|
|
276
|
+
return { key: def.attr, ...resolveSetting(attr, env) };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Tolerant — a malformed single value reports its raw string + source rather than throwing,
|
|
280
|
+
* so one bad line doesn't break the whole table. */
|
|
281
|
+
export function settingsPrint(env: NodeJS.ProcessEnv = process.env): SettingsGetResult[] {
|
|
282
|
+
return SETTINGS_KEYS.map((def) => {
|
|
283
|
+
try {
|
|
284
|
+
return { key: def.attr, ...resolveSetting(def.attr, env) };
|
|
285
|
+
} catch {
|
|
286
|
+
const envRaw = env[def.envKey];
|
|
287
|
+
if (envRaw !== undefined) return { key: def.attr, value: envRaw, source: "env" as const };
|
|
288
|
+
const fileRaw = loadSettingsFileDefaults()[def.envKey];
|
|
289
|
+
return { key: def.attr, value: fileRaw, source: "file" as const };
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export interface SettingsSetResult {
|
|
295
|
+
key: string;
|
|
296
|
+
previous: SettingsValue | undefined;
|
|
297
|
+
value: SettingsValue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Validates, writes ONE line, preserves all other file content (comments, blank lines,
|
|
301
|
+
* foreign vars) byte-for-byte verbatim. */
|
|
302
|
+
export function settingsSet(attr: string, rawValue: string): SettingsSetResult {
|
|
303
|
+
const def = lookupSettingsKey(attr);
|
|
304
|
+
if (!def) throw unknownKeyError(attr);
|
|
305
|
+
|
|
306
|
+
const parsed = def.parse(rawValue);
|
|
307
|
+
|
|
308
|
+
const lines = readSettingsFileLines();
|
|
309
|
+
const indices = matchingLineIndices(lines, def.envKey);
|
|
310
|
+
const index = indices.at(-1) ?? -1;
|
|
311
|
+
const previousRaw = index >= 0 ? rawValueAtLine(lines, index) : undefined;
|
|
312
|
+
let previous: SettingsValue | undefined;
|
|
313
|
+
if (previousRaw !== undefined) {
|
|
314
|
+
try {
|
|
315
|
+
previous = def.parse(previousRaw);
|
|
316
|
+
} catch {
|
|
317
|
+
previous = previousRaw;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const newLine = `${def.envKey}=${def.serialize(parsed)}`;
|
|
322
|
+
if (index >= 0) {
|
|
323
|
+
lines[index] = newLine;
|
|
324
|
+
for (let i = indices.length - 2; i >= 0; i--) {
|
|
325
|
+
lines.splice(indices[i] ?? -1, 1);
|
|
326
|
+
}
|
|
327
|
+
} else {
|
|
328
|
+
lines.push(newLine);
|
|
329
|
+
}
|
|
330
|
+
writeSettingsFileLinesSync(lines);
|
|
331
|
+
|
|
332
|
+
return { key: def.attr, previous, value: parsed };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface SettingsResetResult {
|
|
336
|
+
cleared: string[];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** No attr → clears every managed-key line, preserving foreign/comment lines. reset ALWAYS
|
|
340
|
+
* deletes the line, never blanks it (a blank "KEY=" line is still "set" to a bash-sourced file). */
|
|
341
|
+
export function settingsReset(attr?: string): SettingsResetResult {
|
|
342
|
+
if (attr !== undefined) {
|
|
343
|
+
const def = lookupSettingsKey(attr);
|
|
344
|
+
if (!def) throw unknownKeyError(attr);
|
|
345
|
+
const lines = readSettingsFileLines();
|
|
346
|
+
const indices = matchingLineIndices(lines, def.envKey);
|
|
347
|
+
if (indices.length === 0) return { cleared: [] };
|
|
348
|
+
for (let i = indices.length - 1; i >= 0; i--) {
|
|
349
|
+
lines.splice(indices[i] ?? -1, 1);
|
|
350
|
+
}
|
|
351
|
+
writeSettingsFileLinesSync(lines);
|
|
352
|
+
return { cleared: [def.attr] };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const lines = readSettingsFileLines();
|
|
356
|
+
const managedEnvKeys = new Map(SETTINGS_KEYS.map((def) => [def.envKey, def.attr]));
|
|
357
|
+
const cleared: string[] = [];
|
|
358
|
+
const kept = lines.filter((line) => {
|
|
359
|
+
const trimmed = line.trim();
|
|
360
|
+
if (trimmed.length === 0 || trimmed.startsWith("#")) return true;
|
|
361
|
+
const eq = trimmed.indexOf("=");
|
|
362
|
+
if (eq <= 0) return true;
|
|
363
|
+
const key = trimmed.slice(0, eq).trim();
|
|
364
|
+
const attrName = managedEnvKeys.get(key);
|
|
365
|
+
if (attrName === undefined) return true;
|
|
366
|
+
cleared.push(attrName);
|
|
367
|
+
return false;
|
|
368
|
+
});
|
|
369
|
+
if (cleared.length > 0) {
|
|
370
|
+
writeSettingsFileLinesSync(kept);
|
|
371
|
+
}
|
|
372
|
+
return { cleared };
|
|
373
|
+
}
|
package/src/lib/state.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Machine state persistence — JSON files in ~/.local/share/quickchr/machines/.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync, statSync } from "node:fs";
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync, statSync } from "node:fs";
|
|
6
6
|
import { join, } from "node:path";
|
|
7
7
|
import type { MachineState, } from "./types.ts";
|
|
8
8
|
import { QuickCHRError } from "./types.ts";
|
|
@@ -24,6 +24,47 @@ export function getMachinesDir(): string {
|
|
|
24
24
|
return join(getDataDir(), "machines");
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** One line per successful boot, appended to <dataDir>/boot-log.ndjson — local
|
|
28
|
+
* boot-history for users ("how slow are my boots") and the durable CI metrics
|
|
29
|
+
* feed (machine dirs are removed by tests, machine.json alone is not enough). */
|
|
30
|
+
export interface BootLogEntry {
|
|
31
|
+
ts: string;
|
|
32
|
+
name: string;
|
|
33
|
+
version: string;
|
|
34
|
+
arch: string;
|
|
35
|
+
accel: string;
|
|
36
|
+
bootMs: number;
|
|
37
|
+
/** Host platform, `process.platform` ("linux" | "darwin" | "win32"). */
|
|
38
|
+
host: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const BOOT_LOG_MAX_LINES = 1000;
|
|
42
|
+
const BOOT_LOG_KEEP_LINES = 500;
|
|
43
|
+
|
|
44
|
+
/** Path to the boot-history log. */
|
|
45
|
+
export function bootLogPath(): string {
|
|
46
|
+
return join(getDataDir(), "boot-log.ndjson");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Append a boot record; rotates to the newest BOOT_LOG_KEEP_LINES when the
|
|
50
|
+
* log exceeds BOOT_LOG_MAX_LINES.
|
|
51
|
+
*
|
|
52
|
+
* Append-first (single appendFileSync call, atomic for one-line writes on
|
|
53
|
+
* every platform we run on) so concurrent boots — parallel bun test workers
|
|
54
|
+
* each booting a CHR — never clobber each other's entries in the common path.
|
|
55
|
+
* Only the rare rotation (once per ~500 boots) does a read-rewrite; an append
|
|
56
|
+
* racing that millisecond window CAN be lost. Accepted: this is a boot-history
|
|
57
|
+
* metrics log, not a ledger, and a lockfile is not worth the complexity. */
|
|
58
|
+
export function appendBootLog(entry: BootLogEntry): void {
|
|
59
|
+
const path = bootLogPath();
|
|
60
|
+
ensureDir(getDataDir());
|
|
61
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`);
|
|
62
|
+
const lines = readFileSync(path, "utf-8").split("\n").filter(Boolean);
|
|
63
|
+
if (lines.length > BOOT_LOG_MAX_LINES) {
|
|
64
|
+
writeFileSync(path, `${lines.slice(-BOOT_LOG_KEEP_LINES).join("\n")}\n`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
27
68
|
/** Get the cache directory. */
|
|
28
69
|
export function getCacheDir(): string {
|
|
29
70
|
return join(getDataDir(), "cache");
|
package/src/lib/types.ts
CHANGED
|
@@ -25,17 +25,44 @@ export type NetworkMode =
|
|
|
25
25
|
* Network specifier — expresses user intent for a network interface.
|
|
26
26
|
* Generic specifiers (user, socket, shared, bridged) are cross-platform;
|
|
27
27
|
* explicit specifiers (vmnet-shared, vmnet-bridged, tap) bypass platform resolution.
|
|
28
|
+
*
|
|
29
|
+
* Pick by traffic shape (full guide: `docs/networking-recipes.md`):
|
|
30
|
+
* - **Management / host→guest TCP+UDP services** → `"user"` (SLIRP + `hostfwd`).
|
|
31
|
+
* Keep `"user"` first (ether1) so RouterOS' DHCP client gets `10.0.2.15`.
|
|
32
|
+
* - **Host needs to see guest L2 frames** (MNDP, MAC-Telnet, raw Ethernet,
|
|
33
|
+
* broadcasts) → `{type:"socket-connect", port}` against a host TCP server
|
|
34
|
+
* (rootless, cross-platform; recipe in `docs/mndp.md`).
|
|
35
|
+
* - **L2 link between two VMs** → `{type:"socket", name}` (named pair) or
|
|
36
|
+
* `socket-listen`/`socket-connect`/`socket-mcast` (low-level).
|
|
37
|
+
* - **Real LAN / DHCP-from-host presence** → `"shared"` or `{type:"bridged", iface}`
|
|
38
|
+
* (rootless via socket_vmnet on macOS / pre-created TAP on Linux).
|
|
39
|
+
*
|
|
40
|
+
* Note: receiving guest-*originated* UDP (e.g. a guest replying to `10.0.2.2`)
|
|
41
|
+
* needs no extra NIC and no forward — see {@link ChrInstance.tzspGatewayIp}.
|
|
28
42
|
*/
|
|
29
43
|
export type NetworkSpecifier =
|
|
44
|
+
/** SLIRP user-mode NAT + `hostfwd` port mapping. Management default; the only
|
|
45
|
+
* NIC that makes `extraPorts`/`--forward` reachable. Terminates Layer 2. */
|
|
30
46
|
| "user"
|
|
47
|
+
/** L2 socket pair between two VMs sharing a registered `name` (`quickchr networks sockets`). */
|
|
31
48
|
| { type: "socket"; name: string }
|
|
49
|
+
/** Low-level QEMU `socket` netdev that binds and listens (`0.0.0.0:port`). */
|
|
32
50
|
| { type: "socket-listen"; port: number }
|
|
51
|
+
/** Low-level QEMU `socket` netdev that connects to `127.0.0.1:port`. The
|
|
52
|
+
* rootless, cross-platform way for a host process to receive guest L2 frames. */
|
|
33
53
|
| { type: "socket-connect"; port: number }
|
|
54
|
+
/** Multi-VM L2 segment over UDP multicast. Works on Linux/CI; **broken on macOS**
|
|
55
|
+
* (QEMU sets only `SO_REUSEADDR`). */
|
|
34
56
|
| { type: "socket-mcast"; group: string; port: number }
|
|
57
|
+
/** NAT'd L3 with DHCP from the host; resolves to socket_vmnet/vmnet (macOS) or TAP (Linux). */
|
|
35
58
|
| "shared"
|
|
59
|
+
/** L2 bridged onto a host interface; resolves like `"shared"` but bridged. */
|
|
36
60
|
| { type: "bridged"; iface: string }
|
|
61
|
+
/** Explicit macOS vmnet-shared (bypasses resolution; runs QEMU as root). */
|
|
37
62
|
| "vmnet-shared"
|
|
63
|
+
/** Explicit macOS vmnet-bridged (bypasses resolution; runs QEMU as root). */
|
|
38
64
|
| { type: "vmnet-bridged"; iface: string }
|
|
65
|
+
/** Pre-created user-owned TAP on Linux (no quickchr setup). */
|
|
39
66
|
| { type: "tap"; ifname: string };
|
|
40
67
|
|
|
41
68
|
/** Result of resolving a NetworkSpecifier on a specific platform. */
|
|
@@ -82,10 +109,24 @@ export interface SocketVmnetInfo {
|
|
|
82
109
|
bridgedSockets: Record<string, string>;
|
|
83
110
|
}
|
|
84
111
|
|
|
112
|
+
/** A single host→guest port forward, realized as one QEMU SLIRP `hostfwd`
|
|
113
|
+
* directive (`hostfwd=<proto>::<host>-:<guest>`).
|
|
114
|
+
*
|
|
115
|
+
* This is the programmatic equivalent of one CLI `--forward` spec. Build them
|
|
116
|
+
* by hand, or parse spec strings with {@link parseForwardSpec} (single port) /
|
|
117
|
+
* {@link expandForwardSpec} (port ranges). Forwards only reach the guest over a
|
|
118
|
+
* user-mode (`"user"`) NIC — see the networking recipes guide
|
|
119
|
+
* (`docs/networking-recipes.md`) for which mechanism fits which traffic shape. */
|
|
85
120
|
export interface PortMapping {
|
|
121
|
+
/** Label for this forward; also the key in `MachineState.ports` and what
|
|
122
|
+
* `quickchr list` shows. Must be unique within a machine. */
|
|
86
123
|
name: string;
|
|
124
|
+
/** Host-side port. `0` (or omitted) auto-allocates from the instance's port block. */
|
|
87
125
|
host: number;
|
|
126
|
+
/** Guest-side (RouterOS) port the forward targets. */
|
|
88
127
|
guest: number;
|
|
128
|
+
/** Transport — carried verbatim into the `hostfwd` directive. `udp` is fully
|
|
129
|
+
* supported (e.g. SNMP, syslog, bandwidth-test data ports). */
|
|
89
130
|
proto: "tcp" | "udp";
|
|
90
131
|
}
|
|
91
132
|
|
|
@@ -181,6 +222,10 @@ export interface MachineState extends MachineConfig {
|
|
|
181
222
|
pid?: number;
|
|
182
223
|
/** Path to the machine directory. */
|
|
183
224
|
machineDir: string;
|
|
225
|
+
/** Accelerator resolved for the most recent boot ("kvm" | "hvf" | "tcg"). */
|
|
226
|
+
lastAccel?: string;
|
|
227
|
+
/** Wall-clock ms from QEMU spawn to REST-ready for the most recent boot. */
|
|
228
|
+
lastBootMs?: number;
|
|
184
229
|
}
|
|
185
230
|
|
|
186
231
|
// --- Start Options ---
|
|
@@ -241,13 +286,38 @@ export interface StartOptions {
|
|
|
241
286
|
portBase?: number;
|
|
242
287
|
/** Services to exclude from port mappings (e.g. ["winbox", "api-ssl"]). */
|
|
243
288
|
excludePorts?: ServiceName[];
|
|
244
|
-
/** Additional
|
|
289
|
+
/** Additional host→guest port forwards appended to the default service set —
|
|
290
|
+
* the library equivalent of the CLI `--forward` flag. Each becomes one SLIRP
|
|
291
|
+
* `hostfwd` directive and only works over a `"user"` NIC.
|
|
292
|
+
*
|
|
293
|
+
* `proto` may be `"tcp"` or `"udp"`. To turn CLI-style spec strings into this
|
|
294
|
+
* array, use {@link parseForwardSpec} (single port) or {@link expandForwardSpec}
|
|
295
|
+
* (port ranges, e.g. dynamic UDP data ports). `WELL_KNOWN_GUEST_PORTS` /
|
|
296
|
+
* {@link lookupGuestPort} resolve guest port + proto for common service names.
|
|
297
|
+
*
|
|
298
|
+
* For *receiving* guest-originated UDP (no forward needed), see
|
|
299
|
+
* {@link ChrInstance.tzspGatewayIp} and `docs/networking-recipes.md`.
|
|
300
|
+
* @example
|
|
301
|
+
* extraPorts: [{ name: "snmp", host: 9161, guest: 161, proto: "udp" }]
|
|
302
|
+
* @example
|
|
303
|
+
* // dynamic UDP data ports via a range spec:
|
|
304
|
+
* extraPorts: expandForwardSpec("btest:9200-9210:2000-2010/udp")
|
|
305
|
+
*/
|
|
245
306
|
extraPorts?: PortMapping[];
|
|
246
307
|
/** @deprecated Use `networks` instead. Kept for backward compatibility. */
|
|
247
308
|
network?: NetworkMode;
|
|
248
|
-
/** Network interfaces
|
|
249
|
-
*
|
|
250
|
-
*
|
|
309
|
+
/** Network interfaces — the library equivalent of repeated `--add-network` flags.
|
|
310
|
+
* Each entry becomes a NIC on the CHR, in order (so `networks[0]` is ether1).
|
|
311
|
+
* Default (when omitted): a single `"user"` NIC.
|
|
312
|
+
* When specified: count of entries = count of NICs (explicit control).
|
|
313
|
+
*
|
|
314
|
+
* Keep `"user"` first for management/`hostfwd`; add a second NIC for L2 work.
|
|
315
|
+
* See {@link NetworkSpecifier} for which specifier fits which traffic shape,
|
|
316
|
+
* and `docs/networking-recipes.md` / `docs/mndp.md` for runnable recipes.
|
|
317
|
+
* @example
|
|
318
|
+
* // management + host-visible L2 (e.g. to receive MNDP on the host):
|
|
319
|
+
* networks: ["user", { type: "socket-connect", port: hostTcpServerPort }]
|
|
320
|
+
*/
|
|
251
321
|
networks?: NetworkSpecifier[];
|
|
252
322
|
/** Install OS-level dependencies (QEMU, firmware) automatically if missing. */
|
|
253
323
|
installDeps?: boolean;
|
|
@@ -361,10 +431,19 @@ export interface ChrInstance {
|
|
|
361
431
|
* Pass directly to tshark: `tshark -i ${instance.captureInterface} -f "udp port 37008"`
|
|
362
432
|
* Only meaningful when the instance has a user-mode (`"user"`) network interface. */
|
|
363
433
|
captureInterface: string;
|
|
364
|
-
/** QEMU user-mode
|
|
365
|
-
*
|
|
366
|
-
*
|
|
434
|
+
/** QEMU user-mode (SLIRP) gateway IP — the host's address as seen from inside the VM.
|
|
435
|
+
* Always `"10.0.2.2"` for QEMU slirp user-mode networking.
|
|
436
|
+
*
|
|
437
|
+
* This is the general **guest→host UDP** primitive, not just for TZSP: any
|
|
438
|
+
* datagram the guest sends to `10.0.2.2:<port>` is delivered to a host process
|
|
439
|
+
* bound on loopback at `<port>` (capture it on {@link captureInterface}) with
|
|
440
|
+
* **no `hostfwd` and no extra NIC**. The host socket must be left **unconnected**
|
|
441
|
+
* (use `recvfrom`, not `connect`) — a connected socket filters out the
|
|
442
|
+
* gateway-origin datagrams. This covers guest-originated UDP (syslog, NetFlow,
|
|
443
|
+
* TZSP) and a guest server replying to the gateway. Recipe + lab evidence:
|
|
444
|
+
* `docs/networking-recipes.md`, `examples/udp-gateway/`.
|
|
367
445
|
* @example
|
|
446
|
+
* // RouterOS TZSP sniffer streaming to the host:
|
|
368
447
|
* await instance.exec(`/tool/sniffer/set streaming-server=${instance.tzspGatewayIp}:37008`);
|
|
369
448
|
*/
|
|
370
449
|
tzspGatewayIp: string;
|
|
@@ -575,7 +654,10 @@ export type ErrorCode =
|
|
|
575
654
|
| "INVALID_FORWARD_SPEC"
|
|
576
655
|
| "PROVISIONING_VERSION_UNSUPPORTED"
|
|
577
656
|
| "INSUFFICIENT_DISK_SPACE"
|
|
578
|
-
| "STATE_ERROR"
|
|
657
|
+
| "STATE_ERROR"
|
|
658
|
+
| "INVALID_SIZE_STRING"
|
|
659
|
+
| "INVALID_SETTING_KEY"
|
|
660
|
+
| "INVALID_SETTING_VALUE";
|
|
579
661
|
|
|
580
662
|
export class QuickCHRError extends Error {
|
|
581
663
|
code: ErrorCode;
|