/** * Utility helpers. Mirrors `apps/infrastructure/src/utils.rs`. */ import { randomUUID } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; /** Atomically write JSON `data` to `path`, with optional mode and parent fsync. */ export function writeJsonAtomic(filePath: string, data: unknown, mode?: number): void { const tmp = `${filePath}.tmp.${randomUUID()}`; const parent = path.dirname(filePath); try { fs.mkdirSync(parent, { recursive: true }); const bytes = JSON.stringify(data, null, 2); const fd = fs.openSync(tmp, "w"); try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } if (mode !== undefined) fs.chmodSync(tmp, mode); fs.renameSync(tmp, filePath); try { const dirFd = fs.openSync(parent, "r"); try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); } } catch { /* best-effort */ } } catch (e) { try { fs.unlinkSync(tmp); } catch { /* orphan cleanup */ } throw e; } } /** Convert a string into a filesystem-safe slug, or null if empty/too long. */ export function slugify(s: string): string | null { let slug = s.toLowerCase().replace(/[^a-z0-9]/g, "-"); slug = slug.split("-").filter((seg) => seg !== "").join("-"); if (slug === "" || slug.length > 80) return null; return slug; } /** Write `text` to the terminal clipboard via OSC-52 escape sequence. */ export function writeOsc52(text: string, out: { write: (s: string) => unknown } = process.stdout): void { const encoded = Buffer.from(text, "utf8").toString("base64"); out.write(`\x1b]52;c;${encoded}\x1b\\`); } /** Truncate a string to at most `maxChars` characters (UTF-8 safe). */ export function truncateChars(s: string, maxChars: number): string { if ([...s].length <= maxChars) return s; return [...s].slice(0, maxChars).join(""); } /** Build a string directory tree of `root`, respecting .gitignore semantics loosely. */ export function buildWorkspaceTree(root: string, maxFiles: number): string { const lines: string[] = []; let count = 0; const walk = (dir: string, depth: number, ignore: Set) => { if (count >= maxFiles) { lines.push("... (truncated)"); return; } let entries: fs.Dirent[] = []; try { entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.isDirectory() === b.isDirectory() ? a.name.localeCompare(b.name) : a.isDirectory() ? -1 : 1)); } catch { return; } for (const entry of entries) { if (count >= maxFiles) { lines.push("... (truncated)"); return; } if (entry.name === ".git" || ignore.has(entry.name)) continue; const indent = " ".repeat(Math.max(depth - 1, 0)); if (entry.isDirectory()) { lines.push(`${indent}${entry.name}/`); count++; const subIgnore = new Set(ignore); // Honor .gitignore briefly try { const gi = fs.readFileSync(path.join(dir, entry.name, ".gitignore"), "utf8"); for (const l of gi.split("\n")) { const t = l.trim().replace(/\/$/, ""); if (t && !t.startsWith("#")) subIgnore.add(t); } } catch { /* keep default ignore set */ } walk(path.join(dir, entry.name), depth + 1, subIgnore); } else { lines.push(`${indent}${entry.name}`); count++; } } }; lines.push("."); walk(root, 1, new Set(["node_modules", "target"])); return lines.join("\n").trimEnd(); } /** Run a shell command and return trimmed stdout (best-effort). */ export function runCommand(cmd: string, cwd?: string, args?: string[]): string { try { const res = Bun.spawnSync({ cmd: args ? [cmd, ...args] : [cmd], cwd, stdout: "pipe", stderr: "pipe" }); return res.stdout.toString().trim(); } catch { return ""; } }