feat(rewrite): tambah infrastructure — LLM client + full file persistence
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
03538a455a
commit
a2c7e5d44c
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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 "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user