@xynogen/pix-runtime 0.10.1 → 0.12.2

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/src/os.ts ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * os.ts — jobs that need a different program on each OS, behind one call.
3
+ *
4
+ * Packages ask for the job ("open this URL", "read the clipboard image"); this
5
+ * module picks the program for the host and runs it through `exec.ts`, so the
6
+ * binary still comes from binary.json / `<agentDir>/bin` / known dirs / PATH.
7
+ */
8
+
9
+ import { randomUUID } from "node:crypto";
10
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { resolveTool } from "./binaries/resolve.ts";
13
+ import { quoteForCmd, runTool, runToolSync } from "./exec.ts";
14
+ import { tempDir } from "./paths.ts";
15
+ import { currentPlatform, type HostPlatform } from "./platform.ts";
16
+
17
+ export interface OsOptions {
18
+ env?: NodeJS.ProcessEnv;
19
+ host?: HostPlatform;
20
+ }
21
+
22
+ // ── Open a URL or path ──────────────────────────────────────────────────────
23
+
24
+ export interface OpenOptions extends OsOptions {
25
+ /** Browser/app name or path. Default: the OS default handler. */
26
+ app?: string;
27
+ timeoutMs?: number;
28
+ }
29
+
30
+ /** Program + args that open `target` on `host` (exported for tests). */
31
+ export function openCommand(
32
+ target: string,
33
+ host: HostPlatform,
34
+ app?: string,
35
+ ): { name: string; args: string[]; verbatim?: boolean } {
36
+ if (host.os === "darwin") return { name: "open", args: app ? ["-a", app, target] : [target] };
37
+ if (host.os === "win32") {
38
+ // `start` is a cmd.exe builtin; "" is its window-title argument.
39
+ const parts = ["start", '""', ...(app ? [quoteForCmd(app)] : []), quoteForCmd(target)];
40
+ return { name: "cmd", args: ["/d", "/s", "/c", `"${parts.join(" ")}"`], verbatim: true };
41
+ }
42
+ if (host.wsl && !app) {
43
+ // WSL: hand the target to Windows so the user's default browser opens.
44
+ return { name: "wslview", args: [target] };
45
+ }
46
+ return { name: app ?? "xdg-open", args: [target] };
47
+ }
48
+
49
+ /**
50
+ * Open a URL or file with the OS default handler (or `app`). Throws a
51
+ * BinaryMissingError with an install hint when the opener is missing, or an
52
+ * Error with the opener's stderr when it exits non-zero.
53
+ */
54
+ export async function openTarget(target: string, opts: OpenOptions = {}): Promise<void> {
55
+ const host = opts.host ?? currentPlatform();
56
+ let cmd = openCommand(target, host, opts.app);
57
+ if (cmd.name === "wslview" && !resolveTool("wslview", opts))
58
+ cmd = openCommand(target, { ...host, wsl: false });
59
+ const result = await runTool(cmd.name, cmd.args, {
60
+ env: opts.env,
61
+ host,
62
+ timeoutMs: opts.timeoutMs ?? 15_000,
63
+ verbatim: cmd.verbatim,
64
+ });
65
+ if (result.code !== 0)
66
+ throw new Error(result.stderr.trim() || `${cmd.name} exited with code ${result.code}`);
67
+ }
68
+
69
+ // ── Git ─────────────────────────────────────────────────────────────────────────
70
+
71
+ export interface GitOptions extends OsOptions {
72
+ cwd: string;
73
+ timeoutMs?: number;
74
+ signal?: AbortSignal;
75
+ maxBuffer?: number;
76
+ }
77
+
78
+ /**
79
+ * `git args…` in `cwd`: stdout on exit 0, null on any git failure (not a
80
+ * repo, timeout, abort, or output cut at `maxBuffer`). A missing git binary rejects with BinaryMissingError
81
+ * so the caller can surface the install hint once (pix-pretty warnBinaryMissing).
82
+ */
83
+ export async function runGit(args: readonly string[], opts: GitOptions): Promise<string | null> {
84
+ const r = await runTool("git", args, {
85
+ cwd: opts.cwd,
86
+ env: opts.env,
87
+ host: opts.host,
88
+ timeoutMs: opts.timeoutMs ?? 5_000,
89
+ signal: opts.signal,
90
+ maxBuffer: opts.maxBuffer,
91
+ }).catch((err: unknown) => {
92
+ if (err instanceof Error && err.name === "BinaryMissingError") throw err;
93
+ return null;
94
+ });
95
+ return r && r.code === 0 && !r.truncated ? r.stdout : null;
96
+ }
97
+
98
+ // ── Clipboard image ─────────────────────────────────────────────────────────
99
+
100
+ const LIST_TIMEOUT_MS = 1000;
101
+ const READ_TIMEOUT_MS = 3000;
102
+ const POWERSHELL_TIMEOUT_MS = 5000;
103
+
104
+ /** Preference order mirrors Pi: PNG first, then other lossless/animated formats. */
105
+ const SUPPORTED_MIME = ["image/png", "image/jpeg", "image/webp", "image/gif"];
106
+
107
+ export interface ClipboardImage {
108
+ bytes: Buffer;
109
+ mimeType: string;
110
+ }
111
+
112
+ function baseMime(mimeType: string): string {
113
+ return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
114
+ }
115
+
116
+ export function pickPreferredMime(types: readonly string[]): string | null {
117
+ const normalized = types
118
+ .map((t) => t.trim())
119
+ .filter(Boolean)
120
+ .map((t) => ({ raw: t, base: baseMime(t) }));
121
+ for (const preferred of SUPPORTED_MIME) {
122
+ const match = normalized.find((t) => t.base === preferred);
123
+ if (match) return match.raw;
124
+ }
125
+ return normalized.find((t) => t.base.startsWith("image/"))?.raw ?? null;
126
+ }
127
+
128
+ /** Output bytes when the tool exists and exits 0; null otherwise (probing, never throws). */
129
+ function probe(name: string, args: string[], timeoutMs: number, opts: OsOptions): Buffer | null {
130
+ if (!resolveTool(name, opts)) return null;
131
+ try {
132
+ const r = runToolSync(name, args, { env: opts.env, host: opts.host, timeoutMs });
133
+ return r.code === 0 ? r.stdoutBytes : null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ function lines(buf: Buffer | null): string[] {
140
+ return buf
141
+ ? buf
142
+ .toString("utf-8")
143
+ .split(/\r?\n/)
144
+ .map((t) => t.trim())
145
+ .filter(Boolean)
146
+ : [];
147
+ }
148
+
149
+ function viaWlPaste(opts: OsOptions): ClipboardImage | null {
150
+ const selected = pickPreferredMime(
151
+ lines(probe("wl-paste", ["--list-types"], LIST_TIMEOUT_MS, opts)),
152
+ );
153
+ if (!selected) return null;
154
+ const data = probe("wl-paste", ["--type", selected, "--no-newline"], READ_TIMEOUT_MS, opts);
155
+ return data?.length ? { bytes: data, mimeType: baseMime(selected) } : null;
156
+ }
157
+
158
+ function viaXclip(opts: OsOptions): ClipboardImage | null {
159
+ const targets = lines(
160
+ probe("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], LIST_TIMEOUT_MS, opts),
161
+ );
162
+ const preferred = targets.length > 0 ? pickPreferredMime(targets) : null;
163
+ for (const mimeType of preferred ? [preferred, ...SUPPORTED_MIME] : SUPPORTED_MIME) {
164
+ const data = probe(
165
+ "xclip",
166
+ ["-selection", "clipboard", "-t", mimeType, "-o"],
167
+ READ_TIMEOUT_MS,
168
+ opts,
169
+ );
170
+ if (data?.length) return { bytes: data, mimeType: baseMime(mimeType) };
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /** Windows clipboard via PowerShell — native Windows, or from WSL via powershell.exe. */
176
+ function viaPowerShell(host: HostPlatform, opts: OsOptions): ClipboardImage | null {
177
+ const tmpFile = join(tempDir(), `pix-clip-${randomUUID()}.png`);
178
+ try {
179
+ const winPath =
180
+ host.os === "win32"
181
+ ? tmpFile
182
+ : lines(probe("wslpath", ["-w", tmpFile], LIST_TIMEOUT_MS, opts))[0];
183
+ if (!winPath) return null;
184
+ const script = [
185
+ "Add-Type -AssemblyName System.Windows.Forms",
186
+ "Add-Type -AssemblyName System.Drawing",
187
+ `$path = '${winPath.split("'").join("''")}'`,
188
+ "$img = [System.Windows.Forms.Clipboard]::GetImage()",
189
+ "if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
190
+ ].join("; ");
191
+ const args = ["-NoProfile", "-STA", "-Command", script];
192
+ const out = probe("powershell", args, POWERSHELL_TIMEOUT_MS, opts);
193
+ if (out?.toString("utf-8").trim() !== "ok") return null;
194
+ const bytes = readFileSync(tmpFile);
195
+ return bytes.length ? { bytes, mimeType: "image/png" } : null;
196
+ } catch {
197
+ return null;
198
+ } finally {
199
+ try {
200
+ unlinkSync(tmpFile);
201
+ } catch {
202
+ // best-effort cleanup
203
+ }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Read an image from the system clipboard, or null when there is none (or no
209
+ * clipboard tool). Windows and WSL use PowerShell; Linux uses wl-paste/xclip.
210
+ * macOS and Termux return null (no dependency-free bridge).
211
+ */
212
+ export function readClipboardImage(opts: OsOptions = {}): ClipboardImage | null {
213
+ const host = opts.host ?? currentPlatform();
214
+ const env = opts.env ?? process.env;
215
+ if (host.os === "win32") return viaPowerShell(host, opts);
216
+ if (host.os !== "linux") return null;
217
+ const wayland = Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland";
218
+ let image: ClipboardImage | null = null;
219
+ if (wayland || host.wsl) image = viaWlPaste(opts) ?? viaXclip(opts);
220
+ if (!image && host.wsl) image = viaPowerShell(host, opts);
221
+ if (!image && !wayland) image = viaXclip(opts);
222
+ return image;
223
+ }
224
+
225
+ /** {@link readClipboardImage}, spilled to a temp file. Returns the path or null. */
226
+ export function readClipboardImageToFile(opts: OsOptions = {}): string | null {
227
+ const image = readClipboardImage(opts);
228
+ if (!image) return null;
229
+ const ext = extForMime(image.mimeType);
230
+ const filePath = join(tempDir(), `pix-clipboard-${randomUUID()}.${ext}`);
231
+ try {
232
+ writeFileSync(filePath, image.bytes);
233
+ return filePath;
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ export function extForMime(mimeType: string): string {
240
+ switch (baseMime(mimeType)) {
241
+ case "image/jpeg":
242
+ return "jpg";
243
+ case "image/webp":
244
+ return "webp";
245
+ case "image/gif":
246
+ return "gif";
247
+ default:
248
+ return "png";
249
+ }
250
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * paths.ts — canonical Pi/pix directories, pure and env-injectable.
3
+ *
4
+ * `agentDir()` mirrors Pi's `getAgentDir()` (PI_CODING_AGENT_DIR, `~`-expanded,
5
+ * else `~/.pi/agent`) so code that runs outside a Pi process (CLIs, tests) and
6
+ * code inside it agree. `binDir()` is the same folder Pi downloads fd/rg into.
7
+ *
8
+ * Home resolution never trusts `HOME` alone: it is unset in a Windows Pi process,
9
+ * which silently turned `HOME ?? ""` paths into cwd-relative ones.
10
+ */
11
+
12
+ import { homedir, tmpdir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ /**
17
+ * User home. For the live process this is exactly `os.homedir()` (what Pi's
18
+ * `getAgentDir` uses). An injected env (tests) is read instead: USERPROFILE on
19
+ * Windows, HOME elsewhere, falling back to `os.homedir()`.
20
+ */
21
+ export function homeDir(
22
+ env: NodeJS.ProcessEnv = process.env,
23
+ os: NodeJS.Platform = process.platform,
24
+ ): string {
25
+ if (env === process.env) return homedir();
26
+ const fromEnv = os === "win32" ? env.USERPROFILE : env.HOME;
27
+ return fromEnv || homedir();
28
+ }
29
+
30
+ /** Expand a leading `~` / `~/` / `~\` against {@link homeDir}. */
31
+ export function expandHome(path: string, env: NodeJS.ProcessEnv = process.env): string {
32
+ if (path === "~") return homeDir(env);
33
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(homeDir(env), path.slice(2));
34
+ return path;
35
+ }
36
+
37
+ /** Pi agent dir: `PI_CODING_AGENT_DIR` (tilde-expanded) or `~/.pi/agent`. */
38
+ export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
39
+ const override = env.PI_CODING_AGENT_DIR;
40
+ if (override) return expandHome(override, env);
41
+ return join(homeDir(env), ".pi", "agent");
42
+ }
43
+
44
+ /** Pi's managed binary dir (`<agentDir>/bin`), shared with Pi's fd/rg downloads. */
45
+ export function binDir(env: NodeJS.ProcessEnv = process.env): string {
46
+ return join(agentDir(env), "bin");
47
+ }
48
+
49
+ /**
50
+ * Project config dir: `<cwd>/.pi` (Pi's project `settings.json`, `mcp.json`,
51
+ * `agents/`, `plans/`, `lsp.json`). With no `cwd` it returns the relative `.pi`,
52
+ * for paths that resolve against the process cwd or show in text.
53
+ */
54
+ export function projectDir(cwd = "."): string {
55
+ return join(cwd, ".pi");
56
+ }
57
+
58
+ /**
59
+ * OS temp dir, shared with every other program. Never delete it whole, only
60
+ * your own entries in it. For the live process this is exactly `os.tmpdir()`.
61
+ * An injected env (tests) is read instead: TEMP/TMP on Windows, TMPDIR
62
+ * elsewhere, falling back to `os.tmpdir()`.
63
+ */
64
+ export function tempDir(
65
+ env: NodeJS.ProcessEnv = process.env,
66
+ os: NodeJS.Platform = process.platform,
67
+ ): string {
68
+ if (env === process.env) return tmpdir();
69
+ const fromEnv = os === "win32" ? env.TEMP || env.TMP : env.TMPDIR;
70
+ return fromEnv || tmpdir();
71
+ }
72
+
73
+ /**
74
+ * Absolute path to a file shipped next to a module: `moduleFile(import.meta.url, "..", "SOP.md")`.
75
+ * Use it for package assets (SOP.md, skills/). It does not use `URL.pathname`, which gives
76
+ * `/C:/...` on Windows. It does not use `require.resolve("<pkg>/package.json")`, which Node
77
+ * rejects when `exports` omits it.
78
+ */
79
+ export function moduleFile(moduleUrl: string, ...segments: string[]): string {
80
+ return resolve(dirname(fileURLToPath(moduleUrl)), ...segments);
81
+ }
82
+
83
+ /** Pi cache root: `$XDG_CACHE_HOME/pi` or `~/.cache/pi`. */
84
+ export function cacheDir(env: NodeJS.ProcessEnv = process.env): string {
85
+ const xdg = env.XDG_CACHE_HOME;
86
+ return join(xdg || join(homeDir(env), ".cache"), "pi");
87
+ }
@@ -9,6 +9,7 @@
9
9
  import {
10
10
  closeSync,
11
11
  existsSync,
12
+ linkSync,
12
13
  mkdirSync,
13
14
  openSync,
14
15
  readFileSync,
@@ -18,6 +19,7 @@ import {
18
19
  writeFileSync,
19
20
  writeSync,
20
21
  } from "node:fs";
22
+ import { hostname } from "node:os";
21
23
  import { dirname, join } from "node:path";
22
24
  import type { RawDocument } from "./schema.ts";
23
25
 
@@ -38,19 +40,62 @@ export class ConfigLockError extends ConfigWriteError {
38
40
  }
39
41
  }
40
42
 
43
+ /** The config file is not valid JSON or not a JSON object. Never overwrite it. */
44
+ export class ConfigParseError extends Error {
45
+ constructor(message: string) {
46
+ super(message);
47
+ this.name = "ConfigParseError";
48
+ }
49
+ }
50
+
41
51
  /** Filesystem adapter — tests inject an in-memory or temp-dir implementation. */
42
52
  export interface StorageAdapter {
43
53
  readonly path: string;
44
54
  readRaw(): string | undefined;
45
- /** Atomically replace the config file contents. */
46
- writeAtomic(contents: string): void;
55
+ /**
56
+ * Read-modify-write under the cross-process lock: `fn` gets the current file
57
+ * text and returns the new contents (atomically written) or undefined (no
58
+ * write). Another process cannot write between the read and the write.
59
+ */
60
+ transact(fn: (raw: string | undefined) => string | undefined): void;
47
61
  ensureDir(): void;
48
62
  }
49
63
 
50
- const LOCK_STALE_MS = 30_000;
64
+ // A healthy holder keeps the lock for milliseconds (read + write + rename).
65
+ // Stale must be shorter than the retry budget, or a crashed holder makes every
66
+ // writer fail until it expires.
67
+ const LOCK_STALE_MS = 4_000;
51
68
  const LOCK_RETRY_MS = 25;
52
69
  const LOCK_MAX_RETRIES = 200; // ~5s budget
53
70
 
71
+ interface LockOwner {
72
+ pid: number;
73
+ host: string;
74
+ }
75
+
76
+ function parseOwner(text: string): LockOwner | undefined {
77
+ try {
78
+ const o = JSON.parse(text) as Partial<LockOwner>;
79
+ return typeof o.pid === "number" && typeof o.host === "string"
80
+ ? { pid: o.pid, host: o.host }
81
+ : undefined;
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ /** True only when the owner ran on this host and its process is gone. */
88
+ function ownerIsDead(owner: LockOwner | undefined): boolean {
89
+ if (!owner || owner.host !== hostname() || owner.pid === process.pid) return false;
90
+ try {
91
+ process.kill(owner.pid, 0);
92
+ return false;
93
+ } catch (err) {
94
+ // EPERM: the process exists under another user.
95
+ return (err as NodeJS.ErrnoException).code === "ESRCH";
96
+ }
97
+ }
98
+
54
99
  /** Node/Bun filesystem storage rooted at `<agentDir>/pix.json`. */
55
100
  export class FileStorage implements StorageAdapter {
56
101
  readonly path: string;
@@ -75,28 +120,63 @@ export class FileStorage implements StorageAdapter {
75
120
  }
76
121
 
77
122
  private acquireLock(): void {
123
+ const me = JSON.stringify({ pid: process.pid, host: hostname() } satisfies LockOwner);
78
124
  for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
79
125
  try {
80
126
  const fd = openSync(this.lockPath, "wx", 0o600);
81
- writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
82
- closeSync(fd);
83
- return;
84
- } catch {
85
- // Reclaim a stale lock only when older than the threshold.
86
127
  try {
87
- const age = Date.now() - statSync(this.lockPath).mtimeMs;
88
- if (age > LOCK_STALE_MS) {
89
- rmSync(this.lockPath, { force: true });
90
- continue;
91
- }
92
- } catch {
93
- // Lock vanished between open and stat — retry immediately.
94
- continue;
128
+ writeSync(fd, me);
129
+ } finally {
130
+ closeSync(fd);
95
131
  }
96
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MS);
132
+ return;
133
+ } catch (err) {
134
+ // Only EEXIST means "another writer holds it". EACCES/EROFS/ENOSPC will
135
+ // not clear by waiting, so fail at once with the real cause.
136
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST")
137
+ throw new ConfigLockError(`could not create ${this.lockPath}`, err);
97
138
  }
139
+ if (this.reclaimIfStale()) continue;
140
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MS);
141
+ }
142
+ throw new ConfigLockError(`could not acquire ${this.lockPath} (held by another process)`);
143
+ }
144
+
145
+ /**
146
+ * Remove the lock when its owner process is dead (same host) or it is older
147
+ * than {@link LOCK_STALE_MS}. Returns true when the caller should retry now.
148
+ *
149
+ * The lock moves away by rename, which is atomic, so only one waiter takes a
150
+ * given lock file. If that file is not the one judged stale (another waiter
151
+ * already reclaimed it and a new holder wrote a fresh lock), it goes back with
152
+ * linkSync, which never overwrites.
153
+ * ponytail: a third writer can still lock in the few microseconds between the
154
+ * rename and the link. Then the restored holder loses mutual exclusion once.
155
+ * Upgrade path: an OS file lock (flock / LockFileEx) through a native addon.
156
+ */
157
+ private reclaimIfStale(): boolean {
158
+ let text: string;
159
+ let mtimeMs: number;
160
+ try {
161
+ text = readFileSync(this.lockPath, "utf-8");
162
+ mtimeMs = statSync(this.lockPath).mtimeMs;
163
+ } catch {
164
+ return true; // The lock vanished between open and read: retry at once.
165
+ }
166
+ if (!ownerIsDead(parseOwner(text)) && Date.now() - mtimeMs <= LOCK_STALE_MS) return false;
167
+ const moved = `${this.lockPath}.stale-${process.pid}-${Math.random().toString(36).slice(2)}`;
168
+ try {
169
+ renameSync(this.lockPath, moved);
170
+ } catch {
171
+ return true; // Another waiter took it first.
98
172
  }
99
- throw new ConfigLockError(`could not acquire ${this.lockPath}`);
173
+ try {
174
+ if (readFileSync(moved, "utf-8") !== text) linkSync(moved, this.lockPath);
175
+ } catch {
176
+ // EEXIST: a newer lock exists, which is correct. Unreadable: treat as stale.
177
+ }
178
+ rmSync(moved, { force: true });
179
+ return true;
100
180
  }
101
181
 
102
182
  private releaseLock(): void {
@@ -107,9 +187,19 @@ export class FileStorage implements StorageAdapter {
107
187
  }
108
188
  }
109
189
 
110
- writeAtomic(contents: string): void {
190
+ transact(fn: (raw: string | undefined) => string | undefined): void {
111
191
  this.ensureDir();
112
192
  this.acquireLock();
193
+ try {
194
+ const contents = fn(this.readRaw());
195
+ if (contents !== undefined) this.writeAtomic(contents);
196
+ } finally {
197
+ this.releaseLock();
198
+ }
199
+ }
200
+
201
+ /** Temp file + rename. The caller holds the lock. */
202
+ private writeAtomic(contents: string): void {
113
203
  const tmp = `${this.path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
114
204
  try {
115
205
  writeFileSync(tmp, contents, { mode: 0o600 });
@@ -121,8 +211,6 @@ export class FileStorage implements StorageAdapter {
121
211
  /* ignore */
122
212
  }
123
213
  throw new ConfigWriteError(`write failed: ${this.path}`, err);
124
- } finally {
125
- this.releaseLock();
126
214
  }
127
215
  }
128
216
  }
@@ -149,16 +237,22 @@ export class WriteQueue {
149
237
 
150
238
  // ── Raw document read/parse ──────────────────────────────────────────────────
151
239
 
240
+ /**
241
+ * Parse the config file. Missing or empty means `{}`. Invalid JSON or a
242
+ * non-object throws {@link ConfigParseError}: a hand-edit typo must never be
243
+ * read as "all defaults" and then written back over the user's file.
244
+ */
152
245
  export function parseRawDocument(text: string | undefined): RawDocument {
153
- if (!text) return {};
246
+ if (!text?.trim()) return {};
247
+ let parsed: unknown;
154
248
  try {
155
- const parsed = JSON.parse(text) as unknown;
156
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
157
- ? (parsed as RawDocument)
158
- : {};
159
- } catch {
160
- return {};
249
+ parsed = JSON.parse(text);
250
+ } catch (err) {
251
+ throw new ConfigParseError(`invalid JSON: ${(err as Error).message}`);
161
252
  }
253
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
254
+ throw new ConfigParseError("top level is not a JSON object");
255
+ return parsed as RawDocument;
162
256
  }
163
257
 
164
258
  export function serializeRawDocument(doc: RawDocument): string {