src / utils.ts
import { resolve, relative, isAbsolute } from "path";
/**
* Valida que el path pedido no salga del workspace.
* Devuelve el path absoluto resuelto.
*/
export function safePath(base: string, requested: string): string {
const resolved = resolve(base, requested);
const rel = relative(base, resolved);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new Error(
`Acceso denegado: '${requested}' está fuera del workspace (${base}).`
);
}
return resolved;
}
/**
* Ejecuta un comando de shell y devuelve { stdout, stderr, exitCode }.
* Tiene timeout configurable (por defecto 30 s).
*/
export async function runShell(
command: string,
cwd: string,
timeoutMs = 30_000
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const { spawn } = await import("child_process");
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
const proc = spawn("bash", ["-c", command], {
cwd,
env: { ...process.env, TERM: "dumb" },
});
const timer = setTimeout(() => {
proc.kill("SIGTERM");
stderr += "\n[timeout]";
resolve({ stdout: stdout.substring(0, 8000), stderr, exitCode: -1 });
}, timeoutMs);
proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
proc.on("close", (code) => {
clearTimeout(timer);
resolve({
stdout: stdout.substring(0, 8000),
stderr: stderr.substring(0, 3000),
exitCode: code ?? 0,
});
});
});
}
/** Trunca texto largo para no saturar el contexto */
export function truncate(text: string, maxChars = 6000): string {
if (text.length <= maxChars) return text;
return text.substring(0, maxChars) + `\n... [truncado: ${text.length - maxChars} chars más]`;
}