Sub-fase 3a & 3e infrastructure TypeScript (@zesdex/infrastructure): - llm/provider.ts: LlmClient (OpenAI/Anthropic-compatible fetch + SSE, retry 10x exponential backoff + jitter, abort on 401/402/403, stream quota-aware tool-call delta accumulation) - resolveApiKey: settings.api_keys → app_config.env_var → inline default - utils: writeJsonAtomic (tmp+rename+fsync), slugify, writeOsc52, truncateChars, buildWorkspaceTree, runCommand - persistence/cms: JsonSettingsRepository, JsonAppConfigRepository (Claude credential auto-detection dari ~/.claude/settings.json + env), JsonConversationRepository, JsonlEditLogRepository (append NDJSON), MarkdownMemoryRepository (YAML-ish frontmatter), FileRewindBlobRepository - persistence/iam: FileSystemSessionRepository, FileSystemSessionLockRepository, FileSystemOAuthRepository - Workspace @zesdex/infrastructure tsc clean, semua test domain+application tetap hijau (38 pass) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
120 lines
3.9 KiB
TypeScript
120 lines
3.9 KiB
TypeScript
/**
|
|
* 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<string>) => {
|
|
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 "";
|
|
}
|
|
} |