@wuyaos/pi-sync 1.1.0

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,477 @@
1
+ /**
2
+ * Shared Enhanced Select component for Pi extensions.
3
+ *
4
+ * Improvements over built-in ctx.ui.select():
5
+ * - Keyboard shortcut support: items starting with a single letter + space
6
+ * (e.g. "s Save", "x Discard") can be triggered by pressing that letter key
7
+ * - Cyclic navigation: pressing Up at the first item wraps to the last item,
8
+ * and pressing Down at the last item wraps to the first item
9
+ *
10
+ * Usage:
11
+ * import { enhancedSelect } from "../_shared/enhanced-select";
12
+ * const result = await enhancedSelect(ctx, "Title", ["s Save", "x Discard"]);
13
+ *
14
+ * Returns the selected item string, or undefined if cancelled.
15
+ */
16
+
17
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
18
+ import {
19
+ matchesKey,
20
+ Key,
21
+ truncateToWidth,
22
+ visibleWidth,
23
+ } from "@earendil-works/pi-tui";
24
+ import { topBorder, bottomBorder, midBorder, lineInBox, sidePad, V } from "./box-drawing";
25
+
26
+ // ── Extract shortcut key from item text ──────────────────────────────────
27
+
28
+ /**
29
+ * Parse a shortcut key from an item like "s Save" → "s".
30
+ * Only matches pattern: single letter + space + rest of text.
31
+ * Returns lowercase letter or undefined.
32
+ */
33
+ function parseShortcut(item: string): string | undefined {
34
+ const match = item.match(/^([a-zA-Z])\s/);
35
+ return match ? match[1].toLowerCase() : undefined;
36
+ }
37
+
38
+ // ── Fuzzy match ──────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Case-insensitive fuzzy match: every character in `filter` must appear
42
+ * in order within `text`, but they need not be consecutive.
43
+ * Returns true when filter is empty.
44
+ */
45
+ export function fuzzyMatch(text: string, filter: string): boolean {
46
+ if (!filter) return true;
47
+ const t = text.toLowerCase();
48
+ const f = filter.toLowerCase();
49
+ let fi = 0;
50
+ for (let i = 0; i < t.length && fi < f.length; i++) {
51
+ if (t[i] === f[fi]) fi++;
52
+ }
53
+ return fi === f.length;
54
+ }
55
+
56
+ // ── Enhanced Select Component ───────────────────────────────────────────
57
+
58
+ /** Options for the enhanced select dialog. */
59
+ export interface EnhancedSelectOptions {
60
+ /**
61
+ * Enable fuzzy filtering: as the user types printable characters, items are
62
+ * filtered in real-time. Characters must appear in order (case-insensitive)
63
+ * but need not be consecutive.
64
+ *
65
+ * Shortcut keys (single-letter prefix) still work when the filter is empty.
66
+ * Backspace removes the last character; Escape clears the filter first, then
67
+ * cancels on the second press.
68
+ *
69
+ * Default: false (backward-compatible).
70
+ */
71
+ fuzzy?: boolean;
72
+ /**
73
+ * Sort items before display.
74
+ * - "name": case-insensitive, numeric-aware localeCompare (Chinese-friendly, "file2" before "file10").
75
+ * - "none"/undefined: keep caller-provided order (backward-compatible).
76
+ */
77
+ sort?: "name" | "none";
78
+ /**
79
+ * Action keys: single-letter keys that, when pressed while the fuzzy filter
80
+ * is empty, resolve the select with a sentinel-encoded result indicating
81
+ * which action was triggered on the *currently highlighted* item (rather than
82
+ * plain selection). Use `parseAction(result)` to decode.
83
+ *
84
+ * Only active in fuzzy mode (where a-z would otherwise filter). The action
85
+ * key takes precedence over filter input, mirroring shortcut-key behavior.
86
+ */
87
+ actionKeys?: Array<{ key: string; label: string }>;
88
+ }
89
+
90
+ /** Sentinel prefix marking an action-key result from enhancedSelect. */
91
+ const ACTION_SENTINEL = "\u0000action\u0000";
92
+ const ACTION_SEP = "\u0000";
93
+
94
+ /**
95
+ * Decode an enhancedSelect result produced by an `actionKeys` press.
96
+ * Returns `{ key, item }` if the result is an action, else `null`.
97
+ */
98
+ export function parseAction(result: string | undefined): { key: string; item: string } | null {
99
+ if (!result || !result.startsWith(ACTION_SENTINEL)) return null;
100
+ const rest = result.slice(ACTION_SENTINEL.length);
101
+ const sep = rest.indexOf(ACTION_SEP);
102
+ if (sep === -1) return null;
103
+ return { key: rest.slice(0, sep), item: rest.slice(sep + 1) };
104
+ }
105
+
106
+ class EnhancedSelectComponent {
107
+ private title: string;
108
+ private items: string[];
109
+ private selectedIdx = 0;
110
+ private scrollOffset = 0;
111
+ private theme: any;
112
+ private tui: any;
113
+ private done: (result?: string) => void;
114
+ private cachedWidth?: number;
115
+ private cachedLines?: string[];
116
+ private maxVisibleRows = 16;
117
+
118
+ /** Map of shortcut key → item index */
119
+ private shortcutMap: Map<string, number> = new Map();
120
+
121
+ constructor(
122
+ title: string,
123
+ items: string[],
124
+ tui: any,
125
+ theme: any,
126
+ done: (result?: string) => void,
127
+ private options: EnhancedSelectOptions = {},
128
+ ) {
129
+ this.title = title;
130
+ this.items = items;
131
+ this.tui = tui;
132
+ this.theme = theme;
133
+ this.done = done;
134
+
135
+ // Optional pre-sort (name: case-insensitive, numeric-aware, Chinese-friendly)
136
+ if (this.options.sort === "name") {
137
+ this.items = [...items].sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
138
+ }
139
+
140
+ for (let i = 0; i < this.items.length; i++) {
141
+ const key = parseShortcut(this.items[i]!);
142
+ if (key) {
143
+ this.shortcutMap.set(key, i);
144
+ }
145
+ }
146
+ }
147
+
148
+ // ── Fuzzy filter state ──────────────────────────────────────────────
149
+
150
+ private filterText = "";
151
+ /** Indices into this.items that match the current filter (only in fuzzy mode). */
152
+ private filteredIndices: number[] = [];
153
+
154
+ /** Return the list of items currently visible (filtered or full). */
155
+ private get effectiveItems(): string[] {
156
+ if (!this.options.fuzzy || this.filterText.length === 0) return this.items;
157
+ return this.filteredIndices.map((i) => this.items[i]!);
158
+ }
159
+
160
+ /** Map a filtered-list index back to the original this.items index. */
161
+ private origIndex(filteredIdx: number): number {
162
+ if (!this.options.fuzzy || this.filterText.length === 0) return filteredIdx;
163
+ return this.filteredIndices[filteredIdx] ?? filteredIdx;
164
+ }
165
+
166
+ private applyFilter(): void {
167
+ const f = this.filterText.toLowerCase();
168
+ this.filteredIndices = [];
169
+ for (let i = 0; i < this.items.length; i++) {
170
+ if (fuzzyMatch(this.items[i]!, f)) this.filteredIndices.push(i);
171
+ }
172
+ this.selectedIdx = 0;
173
+ this.scrollOffset = 0;
174
+ }
175
+
176
+ private clearFilter(): void {
177
+ this.filterText = "";
178
+ this.filteredIndices = [];
179
+ this.selectedIdx = 0;
180
+ this.scrollOffset = 0;
181
+ }
182
+
183
+ handleInput(data: string): void {
184
+ const th = this.theme;
185
+
186
+ // ── Shortcut keys ──
187
+ // Check single-character shortcuts (e.g. pressing "s" for "s Save").
188
+ // Shortcuts are suppressed while a fuzzy filter is active so the user can
189
+ // continue refining their filter string.
190
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
191
+ if (this.filterText.length === 0) {
192
+ const key = data.toLowerCase();
193
+ // Action keys: trigger an action on the currently highlighted item.
194
+ const action = this.options.actionKeys?.find((a) => a.key.toLowerCase() === key);
195
+ if (action) {
196
+ const cur = this.effectiveItems[this.selectedIdx];
197
+ if (cur !== undefined) {
198
+ this.done(ACTION_SENTINEL + action.key.toLowerCase() + ACTION_SEP + cur);
199
+ return;
200
+ }
201
+ }
202
+ const idx = this.shortcutMap.get(key);
203
+ if (idx !== undefined) {
204
+ this.done(this.items[idx]);
205
+ return;
206
+ }
207
+ }
208
+ // ── Fuzzy filter input ──
209
+ if (this.options.fuzzy) {
210
+ this.filterText += data;
211
+ this.applyFilter();
212
+ this.invalidate();
213
+ this.tui.requestRender();
214
+ return;
215
+ }
216
+ }
217
+
218
+ // ── Navigation ──
219
+ if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
220
+ const len = this.effectiveItems.length;
221
+ if (len === 0) return;
222
+ // Cyclic: wrap from top to bottom
223
+ if (this.selectedIdx > 0) {
224
+ this.selectedIdx--;
225
+ } else {
226
+ this.selectedIdx = len - 1;
227
+ }
228
+ this.invalidate();
229
+ this.tui.requestRender();
230
+ return;
231
+ }
232
+
233
+ if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
234
+ const len = this.effectiveItems.length;
235
+ if (len === 0) return;
236
+ // Cyclic: wrap from bottom to top
237
+ if (this.selectedIdx < len - 1) {
238
+ this.selectedIdx++;
239
+ } else {
240
+ this.selectedIdx = 0;
241
+ }
242
+ this.invalidate();
243
+ this.tui.requestRender();
244
+ return;
245
+ }
246
+
247
+ if (matchesKey(data, Key.pageUp)) {
248
+ const len = this.effectiveItems.length;
249
+ if (len === 0) return;
250
+ this.selectedIdx = Math.max(0, this.selectedIdx - 10);
251
+ // Clamp to valid range
252
+ if (this.selectedIdx >= len) this.selectedIdx = len - 1;
253
+ this.invalidate();
254
+ this.tui.requestRender();
255
+ return;
256
+ }
257
+
258
+ if (matchesKey(data, Key.pageDown)) {
259
+ const len = this.effectiveItems.length;
260
+ if (len === 0) return;
261
+ this.selectedIdx = Math.min(len - 1, this.selectedIdx + 10);
262
+ this.invalidate();
263
+ this.tui.requestRender();
264
+ return;
265
+ }
266
+
267
+ // ── Home / End ──
268
+ if (matchesKey(data, Key.home)) {
269
+ this.selectedIdx = 0;
270
+ this.invalidate();
271
+ this.tui.requestRender();
272
+ return;
273
+ }
274
+
275
+ if (matchesKey(data, Key.end)) {
276
+ this.selectedIdx = this.effectiveItems.length - 1;
277
+ this.invalidate();
278
+ this.tui.requestRender();
279
+ return;
280
+ }
281
+
282
+ // ── Confirm ──
283
+ if (matchesKey(data, Key.enter)) {
284
+ const items = this.effectiveItems;
285
+ if (items.length > 0) {
286
+ this.done(items[this.selectedIdx]);
287
+ }
288
+ return;
289
+ }
290
+
291
+ // ── Cancel / backspace ──
292
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
293
+ // In fuzzy mode: first Escape clears the filter, second Escape cancels.
294
+ if (this.options.fuzzy && this.filterText.length > 0) {
295
+ this.clearFilter();
296
+ this.invalidate();
297
+ this.tui.requestRender();
298
+ return;
299
+ }
300
+ this.done(undefined);
301
+ return;
302
+ }
303
+
304
+ if (matchesKey(data, Key.backspace)) {
305
+ if (this.options.fuzzy && this.filterText.length > 0) {
306
+ this.filterText = this.filterText.slice(0, -1);
307
+ this.applyFilter();
308
+ this.invalidate();
309
+ this.tui.requestRender();
310
+ return;
311
+ }
312
+ return; // backspace is a no-op without an active filter
313
+ }
314
+ }
315
+
316
+ render(width: number): string[] {
317
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
318
+
319
+ const th = this.theme;
320
+ const lines: string[] = [];
321
+ const boxW = Math.min(width - 2, 76);
322
+ const items = this.effectiveItems;
323
+
324
+ lines.push("");
325
+
326
+ // ── Title ──
327
+ const title = th.fg("accent", th.bold(` ${this.title} `));
328
+ lines.push(topBorder(boxW, title, th));
329
+
330
+ // ── Filter indicator (fuzzy mode) ──
331
+ if (this.options.fuzzy && this.filterText.length > 0) {
332
+ const matchInfo = `Filter: "${this.filterText}" → ${items.length}/${this.items.length} matches`;
333
+ lines.push(lineInBox(th.fg("dim", matchInfo), boxW, th));
334
+ }
335
+
336
+ if (items.length === 0) {
337
+ const msg = this.filterText.length > 0
338
+ ? th.fg("dim", ` (no items match "${this.filterText}")`)
339
+ : th.fg("dim", " (no items)");
340
+ lines.push(lineInBox(msg, boxW, th));
341
+ lines.push(bottomBorder(boxW, th));
342
+ this.cachedWidth = width;
343
+ this.cachedLines = lines;
344
+ return lines;
345
+ }
346
+
347
+ // ── Adjust scroll ──
348
+ const maxVisible = this.maxVisibleRows;
349
+ if (this.selectedIdx < this.scrollOffset) {
350
+ this.scrollOffset = this.selectedIdx;
351
+ }
352
+ if (this.selectedIdx >= this.scrollOffset + maxVisible) {
353
+ this.scrollOffset = this.selectedIdx - maxVisible + 1;
354
+ }
355
+
356
+ const visible = items.slice(
357
+ this.scrollOffset,
358
+ this.scrollOffset + maxVisible
359
+ );
360
+
361
+ // ── Render items ──
362
+ for (let i = 0; i < visible.length; i++) {
363
+ const item = visible[i]!;
364
+ const idx = this.scrollOffset + i;
365
+ const isSelected = idx === this.selectedIdx;
366
+ const shortcut = parseShortcut(item);
367
+
368
+ // Separator lines (───────────────)
369
+ if (item.match(/^─+$/)) {
370
+ lines.push(midBorder(boxW, th));
371
+ continue;
372
+ }
373
+
374
+ const contentW = boxW - 6; // minus borders and prefix (3 prefix + 3 trailing pad in box)
375
+
376
+ if (isSelected) {
377
+ // Selected row: prefer full-width background highlight (theme.bg("selectedBg"))
378
+ // when available; fall back to accent+bold prefix for themes without bg.
379
+ const hasBg = typeof (th as any).bg === "function";
380
+ if (hasBg && contentW > 0) {
381
+ const sel = th.fg("accent", th.bold(item));
382
+ const inner = " " + sidePad(sel, contentW) + " ";
383
+ lines.push(th.fg("borderMuted", V) + th.bg("selectedBg", inner) + th.fg("borderMuted", V));
384
+ } else {
385
+ const sel = th.fg("accent", th.bold(item));
386
+ const line = th.fg("accent", " ▶ ") + (contentW > 0 ? truncateToWidth(sel, contentW) : sel);
387
+ lines.push(lineInBox(line, boxW, th));
388
+ }
389
+ } else {
390
+ // Non-selected: shortcut key highlighted in accent, rest in text
391
+ let styledItem: string;
392
+ if (shortcut) {
393
+ const keyChar = item[0]!;
394
+ const rest = item.slice(1);
395
+ styledItem = th.fg("accent", keyChar) + th.fg("text", rest);
396
+ } else {
397
+ styledItem = th.fg("text", item);
398
+ }
399
+ const line = " " + truncateToWidth(styledItem, contentW);
400
+ lines.push(lineInBox(line, boxW, th));
401
+ }
402
+ }
403
+
404
+ // ── Scroll indicator ──
405
+ if (items.length > maxVisible) {
406
+ const from = this.scrollOffset + 1;
407
+ const to = Math.min(this.scrollOffset + maxVisible, items.length);
408
+ const total = items.length;
409
+ const scrollText = th.fg("dim", `─ ${from}-${to} of ${total} ─`);
410
+ lines.push(lineInBox(scrollText, boxW, th));
411
+ }
412
+
413
+ lines.push(bottomBorder(boxW, th));
414
+
415
+ // ── Help line ──
416
+ const hintParts: string[] = [];
417
+ hintParts.push(th.fg("dim", "↵") + th.fg("muted", ":select"));
418
+ hintParts.push(th.fg("dim", "↑↓") + th.fg("muted", ":navigate"));
419
+ hintParts.push(th.fg("dim", "Esc") + th.fg("muted", ":cancel"));
420
+ if (this.options.fuzzy) {
421
+ hintParts.push(th.fg("dim", "⌫") + th.fg("muted", ":backspace"));
422
+ hintParts.push(th.fg("dim", "a-z") + th.fg("muted", ":filter"));
423
+ } else if (this.shortcutMap.size > 0) {
424
+ const keys = Array.from(this.shortcutMap.keys()).join("/");
425
+ hintParts.push(th.fg("dim", keys) + th.fg("muted", ":shortcut"));
426
+ }
427
+ if (this.options.actionKeys && this.options.actionKeys.length > 0 && this.filterText.length === 0) {
428
+ for (const a of this.options.actionKeys) {
429
+ hintParts.push(th.fg("dim", a.key) + th.fg("muted", ":" + a.label));
430
+ }
431
+ }
432
+ lines.push(" " + hintParts.join(" "));
433
+
434
+ this.cachedWidth = width;
435
+ this.cachedLines = lines;
436
+ return lines;
437
+ }
438
+
439
+ invalidate(): void {
440
+ this.cachedWidth = undefined;
441
+ this.cachedLines = undefined;
442
+ }
443
+ }
444
+
445
+ // ── Public API ──────────────────────────────────────────────────────────
446
+
447
+ /**
448
+ * Show an enhanced select dialog with:
449
+ * - Keyboard shortcut support (single-letter prefix)
450
+ * - Cyclic navigation (Up wraps to bottom, Down wraps to top)
451
+ *
452
+ * @param ctx Extension command context
453
+ * @param title Dialog title
454
+ * @param items Array of option strings. Items like "s Save" can be
455
+ * triggered by pressing "s" directly.
456
+ * @returns Selected item string, or undefined if cancelled
457
+ */
458
+ export async function enhancedSelect(
459
+ ctx: ExtensionCommandContext,
460
+ title: string,
461
+ items: string[],
462
+ options?: EnhancedSelectOptions
463
+ ): Promise<string | undefined> {
464
+ if (!ctx.hasUI) {
465
+ // Fallback to built-in select in non-TUI modes
466
+ return ctx.ui.select(title, items);
467
+ }
468
+
469
+ return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
470
+ const component = new EnhancedSelectComponent(title, items, tui, theme, done, options);
471
+ return {
472
+ handleInput: (data: string) => component.handleInput(data),
473
+ render: (w: number) => component.render(w),
474
+ invalidate: () => component.invalidate(),
475
+ };
476
+ });
477
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared fetch-with-timeout utility for Pi extensions.
3
+ *
4
+ * Usage:
5
+ * import { fetchWithTimeout } from "../_shared/fetch-utils";
6
+ * const resp = await fetchWithTimeout(url, { method: "GET" }, 10_000, ctx.signal);
7
+ */
8
+
9
+ const DEFAULT_TIMEOUT_MS = 30_000;
10
+
11
+ export async function fetchWithTimeout(
12
+ url: RequestInfo | URL,
13
+ init: RequestInit = {},
14
+ timeoutMs = DEFAULT_TIMEOUT_MS,
15
+ upstreamSignal?: AbortSignal,
16
+ ): Promise<Response> {
17
+ const controller = new AbortController();
18
+ const cleanup: Array<() => void> = [];
19
+
20
+ // Merge external signals
21
+ for (const signal of [init.signal, upstreamSignal]) {
22
+ if (!signal) continue;
23
+ if (signal.aborted) {
24
+ controller.abort();
25
+ continue;
26
+ }
27
+ const abort = () => controller.abort();
28
+ signal.addEventListener("abort", abort, { once: true });
29
+ cleanup.push(() => signal.removeEventListener("abort", abort));
30
+ }
31
+
32
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
33
+
34
+ try {
35
+ // Strip signal from init to avoid conflicts — ours is the merged one
36
+ const { signal: _s, headers, ...rest } = init;
37
+ return await fetch(url, {
38
+ ...rest,
39
+ headers: headers as HeadersInit | undefined,
40
+ signal: controller.signal,
41
+ });
42
+ } finally {
43
+ clearTimeout(timeout);
44
+ for (const removeListener of cleanup) removeListener();
45
+ }
46
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Shared JSON file I/O utilities for Pi extensions.
3
+ *
4
+ * Provides atomic write, backup, and safe read with consistent patterns.
5
+ * Import: import { … } from "../_shared/json-io"
6
+ */
7
+
8
+ import {
9
+ copyFileSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ renameSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { dirname } from "node:path";
18
+
19
+ // ── Timestamp ───────────────────────────────────────────────────────────
20
+
21
+ /** Compact timestamp for backup file names, e.g. "20260616T143045". */
22
+ export function timestampForBackup(): string {
23
+ return new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
24
+ }
25
+
26
+ /** ISO-8601 timestamp string, e.g. "2026-06-17T12:30:45.123Z". */
27
+ export function nowIso(): string {
28
+ return new Date().toISOString();
29
+ }
30
+
31
+ /** Compact timestamp for file/dir names, e.g. "20260617T123045". */
32
+ export function tsCompact(): string {
33
+ return new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 15);
34
+ }
35
+
36
+ // ── Safe read ──────────────────────────────────────────────────────────
37
+
38
+ /** Read JSON with a fallback default. Missing/corrupt file returns fallback. */
39
+ export function readJsonSafe<T>(file: string, fallback: T): T {
40
+ try {
41
+ const data = JSON.parse(readFileSync(file, "utf8"));
42
+ return { ...fallback, ...data };
43
+ } catch (_error) {
44
+ return fallback;
45
+ }
46
+ }
47
+
48
+ // ── Ensure directory ───────────────────────────────────────────────────
49
+
50
+ /** Create directory (and parents) if it doesn't exist. */
51
+ export function ensureDir(dir: string): void {
52
+ mkdirSync(dir, { recursive: true });
53
+ }
54
+
55
+ // ── Atomic JSON write ──────────────────────────────────────────────────
56
+
57
+ export interface WriteJsonOptions {
58
+ /** If true, back up the existing file before overwriting. */
59
+ backup?: boolean;
60
+ }
61
+
62
+ /**
63
+ * Atomically write a JSON file:
64
+ * 1. Write to a temp file
65
+ * 2. Validate the temp file parses as JSON
66
+ * 3. Optionally back up the existing file
67
+ * 4. Rename temp → target
68
+ *
69
+ * On failure the temp file is cleaned up and the original is preserved.
70
+ */
71
+ export function writeJsonAtomic(
72
+ file: string,
73
+ value: unknown,
74
+ options: WriteJsonOptions = {},
75
+ ): void {
76
+ ensureDir(dirname(file));
77
+ const dir = dirname(file);
78
+ const tempFile = `${dir}/.${basename(file)}.${process.pid}.${Date.now()}.tmp`;
79
+ try {
80
+ const text = JSON.stringify(value, null, 2);
81
+ writeFileSync(tempFile, text, "utf8");
82
+ // Validate what we just wrote
83
+ JSON.parse(readFileSync(tempFile, "utf8"));
84
+
85
+ if (options.backup && existsSync(file)) {
86
+ copyFileSync(file, `${file}.bak-${timestampForBackup()}`);
87
+ }
88
+ renameSync(tempFile, file);
89
+ } catch (error) {
90
+ try {
91
+ if (existsSync(tempFile)) unlinkSync(tempFile);
92
+ } catch (_cleanupError) {
93
+ // Best-effort cleanup only; preserve the original write failure.
94
+ }
95
+ throw error;
96
+ }
97
+ }
98
+
99
+ // ── Simple JSON write (no atomic guarantee, no backup) ─────────────────
100
+
101
+ /** Write JSON file directly (no atomic rename, no backup). For non-critical data. */
102
+ export function writeJson(file: string, value: unknown): void {
103
+ ensureDir(dirname(file));
104
+ writeFileSync(file, JSON.stringify(value, null, 2), "utf8");
105
+ }
106
+
107
+ // ── Append JSONL ───────────────────────────────────────────────────────
108
+
109
+ /** Append a JSON line to a JSONL file, creating the directory if needed. */
110
+ export function appendJsonl(file: string, value: unknown): void {
111
+ ensureDir(dirname(file));
112
+ const { appendFileSync } = require("node:fs");
113
+ appendFileSync(file, JSON.stringify(value) + "\n", "utf8");
114
+ }
115
+
116
+ // ── Internal helper (avoid importing path.basename at module level) ────
117
+
118
+ function basename(file: string): string {
119
+ return file.split(/[\\/]/).pop() || file;
120
+ }