@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.
@@ -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");