1770 lines
82 KiB
TypeScript
1770 lines
82 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { HWPOD_NODE_OPS_CONTRACT_VERSION } from "./hwpod-node-ops-contract.ts";
|
|
|
|
const NODE_VERSION = "0.1.0-thin-node-ops";
|
|
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
const BOOLEAN_OPTIONS = new Set(["help", "h", "json"]);
|
|
|
|
type ParsedArgs = Record<string, unknown> & { _: string[] };
|
|
|
|
export async function mainHwpodNode(argv = process.argv.slice(2), options: any = {}) {
|
|
const stdinText = options.stdinText ?? await readCliStdinForRun(argv);
|
|
const result = await runHwpodNode(argv, { ...options, stdinText });
|
|
if (result?.keepAlive) return;
|
|
console.log(JSON.stringify(result.payload, null, 2));
|
|
process.exitCode = result.exitCode;
|
|
}
|
|
|
|
async function readCliStdinForRun(argv: string[]) {
|
|
const command = argv.find((token) => !token.startsWith("-")) || "help";
|
|
const hasInlinePlan = argv.some((token) => token === "--plan-json" || token === "--plan" || token.startsWith("--plan-json=") || token.startsWith("--plan="));
|
|
if (command !== "run" || hasInlinePlan || process.stdin.isTTY) return undefined;
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
const textValue = Buffer.concat(chunks).toString("utf8");
|
|
return textValue.trim() ? textValue : undefined;
|
|
}
|
|
|
|
export async function runHwpodNode(argv: string[], options: { stdinText?: string; now?: () => string } = {}) {
|
|
const now = options.now ?? (() => new Date().toISOString());
|
|
try {
|
|
const parsed = parseOptions(argv);
|
|
const command = parsed._[0] || "help";
|
|
if (["help", "--help", "-h"].includes(command)) return result(0, help(), now);
|
|
if (command === "run") {
|
|
const plan = planFromParsed(parsed, options.stdinText);
|
|
return result(0, await executeHwpodNodeOpsPlan(plan, { now }), now);
|
|
}
|
|
if (command === "serve") {
|
|
const server = createHwpodNodeServer({ now });
|
|
const host = text(parsed.host) || "127.0.0.1";
|
|
const port = numberValue(parsed.port) ?? 19678;
|
|
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
console.log(JSON.stringify({ ok: true, action: "hwpod-node.serve", status: "listening", host, port: server.address()?.port ?? port, observedAt: now() }, null, 2));
|
|
return { exitCode: 0, payload: null, keepAlive: true, server };
|
|
}
|
|
if (command === "connect") {
|
|
const nodeId = text(parsed.nodeId ?? parsed.node) || process.env.HWPOD_NODE_ID || os.hostname();
|
|
const connector = connectHwpodNodeWs({
|
|
wsUrl: text(parsed.wsUrl),
|
|
cloudUrl: text(parsed.cloudUrl ?? parsed.apiUrl),
|
|
token: text(parsed.token) || process.env.HWPOD_NODE_WS_TOKEN || process.env.HWLAB_HWPOD_NODE_WS_TOKEN || "",
|
|
nodeId,
|
|
name: text(parsed.name) || nodeId,
|
|
heartbeatIntervalMs: numberValue(parsed.heartbeatIntervalMs) ?? 15000,
|
|
reconnectBaseMs: numberValue(parsed.reconnectBaseMs) ?? 1000,
|
|
reconnectMaxMs: numberValue(parsed.reconnectMaxMs) ?? 30000,
|
|
now
|
|
});
|
|
console.log(JSON.stringify({ ok: true, action: "hwpod-node.connect", status: "connecting", nodeId, wsUrl: connector.wsUrl, observedAt: now() }, null, 2));
|
|
return { exitCode: 0, payload: null, keepAlive: true, connector };
|
|
}
|
|
throw cliError("unsupported_hwpod_node_command", `unsupported hwpod-node command: ${command}`);
|
|
} catch (error) {
|
|
return result(1, failure("hwpod-node", error), now);
|
|
}
|
|
}
|
|
|
|
export function connectHwpodNodeWs(config: any, options: any = {}) {
|
|
const now = config.now ?? options.now ?? (() => new Date().toISOString());
|
|
const nodeId = requiredText(config.nodeId || process.env.HWPOD_NODE_ID || os.hostname(), "nodeId");
|
|
const wsUrl = hwpodNodeWsUrl(config);
|
|
const heartbeatIntervalMs = Math.max(1000, numberValue(config.heartbeatIntervalMs) ?? 15000);
|
|
const reconnectBaseMs = Math.max(250, numberValue(config.reconnectBaseMs) ?? 1000);
|
|
const reconnectMaxMs = Math.max(reconnectBaseMs, numberValue(config.reconnectMaxMs) ?? 30000);
|
|
const reconnect = config.reconnect !== false;
|
|
const executePlan = options.executePlan ?? ((plan: any) => executeHwpodNodeOpsPlan(plan, { now, nodeId }));
|
|
let socket: WebSocket | null = null;
|
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let closed = false;
|
|
let attempt = 0;
|
|
let readyResolve: ((value: unknown) => void) | null = null;
|
|
const operations = new Map<string, Promise<any>>();
|
|
const operationCompletedAt = new Map<string, number>();
|
|
let operationRetentionMs = 0;
|
|
const ready = new Promise((resolve) => { readyResolve = resolve; });
|
|
|
|
function connect() {
|
|
if (closed) return;
|
|
socket = new WebSocket(wsUrl);
|
|
socket.addEventListener("open", () => {
|
|
logWsEvent("open");
|
|
attempt = 0;
|
|
sendJson({
|
|
type: "register",
|
|
nodeId,
|
|
name: text(config.name) || nodeId,
|
|
capabilities: ["hwpod-node-ops", "cmd.run", "workspace.ls", "workspace.cat", "workspace.rg", "workspace.apply-patch", "workspace.write", "workspace.replace", "workspace.insert-after", "debug.build", "debug.download", "debug.reset", "io.uart.read"],
|
|
labels: { platform: process.platform, arch: process.arch, hostname: os.hostname(), version: NODE_VERSION, startedAt: now() }
|
|
});
|
|
sendHeartbeat();
|
|
heartbeatTimer = setInterval(sendHeartbeat, heartbeatIntervalMs);
|
|
});
|
|
socket.addEventListener("message", (event: MessageEvent) => {
|
|
void handleWsMessage(event.data).catch((error) => {
|
|
logWsEvent("message_error", { message: error?.message ?? String(error), code: error?.code ?? "hwpod_node_ws_message_failed" });
|
|
sendJson({ type: "hwpod-node-error", nodeId, error: { message: error?.message ?? String(error), code: error?.code ?? "hwpod_node_ws_message_failed" }, observedAt: now() });
|
|
});
|
|
});
|
|
socket.addEventListener("close", (event) => {
|
|
logWsEvent("close", { code: event.code, reason: event.reason || null, reconnect });
|
|
scheduleReconnect();
|
|
});
|
|
socket.addEventListener("error", () => logWsEvent("error", { readyState: socket?.readyState ?? null }));
|
|
}
|
|
|
|
function scheduleReconnect() {
|
|
clearTimers();
|
|
if (closed || !reconnect) return;
|
|
attempt += 1;
|
|
const delayMs = Math.min(reconnectMaxMs, reconnectBaseMs * 2 ** Math.min(attempt, 8));
|
|
reconnectTimer = setTimeout(connect, delayMs);
|
|
}
|
|
|
|
async function handleWsMessage(raw: any) {
|
|
const textValue = typeof raw === "string" ? raw : await new Response(raw).text();
|
|
const message = JSON.parse(textValue);
|
|
if (message?.type === "ack" && message.requestId === "register" && readyResolve) {
|
|
operationRetentionMs = Math.max(0, (numberValue(message.operationRetentionSeconds) ?? 0) * 1000);
|
|
logWsEvent("registered", { ok: message.ok === true, message: text(message.message) || null });
|
|
readyResolve(message);
|
|
readyResolve = null;
|
|
return;
|
|
}
|
|
if (message?.type !== "hwpod-node-ops") return;
|
|
pruneOperations();
|
|
const requestId = text(message.requestId) || "req_unknown";
|
|
const planId = text(message.plan?.planId) || requestId;
|
|
sendJson({ type: "hwpod-node-ops-accepted", nodeId, requestId, planId, observedAt: now() });
|
|
let operation = operations.get(planId);
|
|
if (!operation) {
|
|
operation = Promise.resolve().then(() => executePlan(message.plan)).catch((error) => failure("hwpod-node.ws.run", error));
|
|
operations.set(planId, operation);
|
|
void operation.finally(() => operationCompletedAt.set(planId, Date.now()));
|
|
}
|
|
const resultPayload = await operation;
|
|
sendJson({ type: "hwpod-node-ops-result", nodeId, requestId, result: resultPayload, observedAt: now() });
|
|
}
|
|
|
|
function sendHeartbeat() {
|
|
sendJson({ type: "heartbeat", nodeId, at: now() });
|
|
}
|
|
|
|
function pruneOperations() {
|
|
if (!operationRetentionMs) return;
|
|
const cutoff = Date.now() - operationRetentionMs;
|
|
for (const [planId, completedAt] of operationCompletedAt) {
|
|
if (completedAt >= cutoff) continue;
|
|
operationCompletedAt.delete(planId);
|
|
operations.delete(planId);
|
|
}
|
|
}
|
|
|
|
function sendJson(value: any) {
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
|
|
socket.send(JSON.stringify(value));
|
|
return true;
|
|
}
|
|
|
|
function clearTimers() {
|
|
if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
|
|
if (reconnectTimer !== null) clearTimeout(reconnectTimer);
|
|
heartbeatTimer = null;
|
|
reconnectTimer = null;
|
|
}
|
|
|
|
function close() {
|
|
closed = true;
|
|
clearTimers();
|
|
socket?.close();
|
|
}
|
|
|
|
function logWsEvent(event: string, extra: Record<string, unknown> = {}) {
|
|
process.stderr.write(`${JSON.stringify({ event: `hwpod-node.ws.${event}`, nodeId, wsUrl: redactUrlToken(wsUrl), observedAt: now(), ...extra })}\n`);
|
|
}
|
|
|
|
connect();
|
|
return { wsUrl, nodeId, ready, close };
|
|
}
|
|
|
|
function redactUrlToken(urlValue: string) {
|
|
const parsed = new URL(urlValue);
|
|
if (parsed.searchParams.has("token")) parsed.searchParams.set("token", "<redacted>");
|
|
return parsed.toString();
|
|
}
|
|
|
|
function hwpodNodeWsUrl(config: any) {
|
|
const explicit = text(config.wsUrl);
|
|
if (explicit) return appendToken(explicit, text(config.token));
|
|
const cloudUrl = text(config.cloudUrl).replace(/\/+$/u, "");
|
|
if (!cloudUrl) throw cliError("hwpod_node_ws_url_required", "connect requires --ws-url or --cloud-url");
|
|
const parsed = new URL(cloudUrl);
|
|
parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:";
|
|
parsed.pathname = "/v1/hwpod-node/ws";
|
|
parsed.search = "";
|
|
return appendToken(parsed.toString(), text(config.token));
|
|
}
|
|
|
|
function appendToken(urlValue: string, token: string) {
|
|
if (!token) return urlValue;
|
|
const parsed = new URL(urlValue);
|
|
parsed.searchParams.set("token", token);
|
|
return parsed.toString();
|
|
}
|
|
|
|
export function createHwpodNodeServer(options: any = {}) {
|
|
const now = options.now ?? (() => new Date().toISOString());
|
|
const localNodeId = localHwpodNodeId(options);
|
|
return createServer(async (request, response) => {
|
|
try {
|
|
const url = new URL(request.url || "/", "http://hwpod-node.local");
|
|
if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1/hwpod-node-ops")) {
|
|
return sendJson(response, 200, {
|
|
ok: true,
|
|
status: "ready",
|
|
serviceId: "hwpod-node",
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
version: NODE_VERSION,
|
|
nodeRole: "single-host-hwpod-node",
|
|
acceptedInput: "hwpod-node-ops",
|
|
specAuthority: "none",
|
|
nodeId: localNodeId,
|
|
observedAt: now()
|
|
});
|
|
}
|
|
if (request.method === "POST" && url.pathname === "/v1/hwpod-node-ops") {
|
|
const body = await readBody(request, options.bodyLimitBytes);
|
|
const plan = body ? JSON.parse(body) : {};
|
|
return sendJson(response, 200, await executeHwpodNodeOpsPlan(plan, { now, nodeId: localNodeId }));
|
|
}
|
|
return sendJson(response, 404, { ok: false, error: { code: "not_found", message: "hwpod-node route not found" } });
|
|
} catch (error) {
|
|
return sendJson(response, 500, failure("hwpod-node.http", error));
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function executeHwpodNodeOpsPlan(plan: any, options: any = {}) {
|
|
const now = options.now ?? (() => new Date().toISOString());
|
|
if (!plan || typeof plan !== "object" || Array.isArray(plan)) throw cliError("invalid_hwpod_node_ops_plan", "plan must be a JSON object");
|
|
if (plan.contractVersion !== HWPOD_NODE_OPS_CONTRACT_VERSION) throw cliError("invalid_hwpod_node_ops_contract", `contractVersion must be ${HWPOD_NODE_OPS_CONTRACT_VERSION}`);
|
|
if (!Array.isArray(plan.ops) || plan.ops.length === 0) throw cliError("invalid_hwpod_node_ops", "ops must be a non-empty array");
|
|
const localNodeId = localHwpodNodeId(options);
|
|
const requestedNodeId = text(plan.nodeId);
|
|
const enforceLocalNodeId = Boolean(text(options.nodeId) || text(process.env.HWPOD_NODE_ID));
|
|
if (enforceLocalNodeId && requestedNodeId && requestedNodeId !== localNodeId) {
|
|
return hwpodNodeMismatchPayload(plan, {
|
|
localNodeId,
|
|
requestedNodeId,
|
|
now,
|
|
summary: `hwpod-node ${localNodeId} cannot execute plan for ${requestedNodeId}`
|
|
});
|
|
}
|
|
const results = [];
|
|
for (const op of plan.ops) {
|
|
results.push(await executeOp(op, { plan, now }));
|
|
}
|
|
const failed = results.some((item) => item.ok === false);
|
|
return {
|
|
ok: !failed,
|
|
status: failed ? "failed" : "completed",
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
serviceId: "hwpod-node",
|
|
nodeVersion: NODE_VERSION,
|
|
nodeRole: "single-host-hwpod-node",
|
|
acceptedInput: "hwpod-node-ops",
|
|
specAuthority: "none",
|
|
planId: plan.planId ?? null,
|
|
hwpodId: plan.hwpodId ?? null,
|
|
nodeId: requestedNodeId || localNodeId,
|
|
localNodeId,
|
|
results,
|
|
observedAt: now()
|
|
};
|
|
}
|
|
|
|
function localHwpodNodeId(options: any = {}) {
|
|
return text(options.nodeId) || text(process.env.HWPOD_NODE_ID) || os.hostname();
|
|
}
|
|
|
|
function hwpodNodeMismatchPayload(plan: any, input: { localNodeId: string; requestedNodeId: string; now: () => string; summary: string }) {
|
|
const ops = Array.isArray(plan.ops) ? plan.ops : [];
|
|
const blocker = {
|
|
code: "hwpod_node_id_mismatch",
|
|
layer: "hwpod-node",
|
|
retryable: true,
|
|
summary: input.summary,
|
|
details: {
|
|
localNodeId: input.localNodeId,
|
|
requestedNodeId: input.requestedNodeId,
|
|
platform: process.platform,
|
|
hostname: os.hostname(),
|
|
cwd: process.cwd(),
|
|
nodeVersion: NODE_VERSION
|
|
}
|
|
};
|
|
return {
|
|
ok: false,
|
|
status: "blocked",
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
serviceId: "hwpod-node",
|
|
nodeVersion: NODE_VERSION,
|
|
nodeRole: "single-host-hwpod-node",
|
|
acceptedInput: "hwpod-node-ops",
|
|
specAuthority: "none",
|
|
planId: plan.planId ?? null,
|
|
hwpodId: plan.hwpodId ?? null,
|
|
nodeId: input.requestedNodeId,
|
|
localNodeId: input.localNodeId,
|
|
results: ops.map((op: any, index: number) => ({
|
|
opId: text(op?.opId) || `op_${index + 1}`,
|
|
op: text(op?.op) || "unknown",
|
|
ok: false,
|
|
status: "blocked",
|
|
blocker
|
|
})),
|
|
blocker,
|
|
observedAt: input.now()
|
|
};
|
|
}
|
|
|
|
async function executeOp(op: any, context: any) {
|
|
const opId = text(op?.opId) || "op_unknown";
|
|
const name = text(op?.op);
|
|
const args = op?.args && typeof op.args === "object" && !Array.isArray(op.args) ? op.args : {};
|
|
try {
|
|
if (name === "node.health") return opOk(opId, name, { platform: process.platform, arch: process.arch, hostname: os.hostname(), cwd: process.cwd() });
|
|
if (name === "node.version") return opOk(opId, name, { version: NODE_VERSION });
|
|
if (name === "node.inventory") return opOk(opId, name, await nodeInventory(args));
|
|
if (name === "workspace.ls") return opOk(opId, name, await workspaceLs(args));
|
|
if (name === "workspace.cat") return opOk(opId, name, await workspaceCat(args));
|
|
if (name === "workspace.rg") return opOk(opId, name, await workspaceRg(args));
|
|
if (name === "workspace.apply-patch") return opOk(opId, name, await workspaceApplyPatch(args));
|
|
if (name === "workspace.write") return opOk(opId, name, await workspaceWrite(args));
|
|
if (name === "workspace.replace") return opOk(opId, name, await workspaceReplace(args));
|
|
if (name === "workspace.insert-after") return opOk(opId, name, await workspaceInsertAfter(args));
|
|
if (name === "cmd.run") {
|
|
const output = await cmdRun(args);
|
|
if (output.blockerCode) return opBlocked(opId, name, output.blockerCode, output.stderr || "cmd.run failed before process start", { output });
|
|
return output.ok ? opOk(opId, name, output) : opFailed(opId, name, output, "hwpod_node_command_failed", `cmd.run node-side command exited with ${output.exitCode}`);
|
|
}
|
|
if (["debug.build", "debug.download", "debug.reset"].includes(name)) {
|
|
const output = await debugCommand(name, args);
|
|
return output.ok ? opOk(opId, name, output) : opFailed(opId, name, output, "hwpod_node_command_failed", `${name} node-side command exited with ${output.exitCode}`);
|
|
}
|
|
if (name === "io.uart.read") {
|
|
const output = await uartRead(args);
|
|
return output.ok ? opOk(opId, name, output) : opBlocked(opId, name, output.blockerCode, output.summary, output.details);
|
|
}
|
|
if (["io.uart.write", "io.uart.jsonrpc"].includes(name)) {
|
|
return opBlocked(opId, name, "hwpod_node_op_not_configured", `${name} requires node-side UART tool binding on this hwpod-node`, uartBindingDiagnostics(name, args));
|
|
}
|
|
return opBlocked(opId, name, "unsupported_hwpod_node_op", `unsupported hwpod-node op: ${name}`);
|
|
} catch (error) {
|
|
return opBlockedFromError(opId, name, error);
|
|
}
|
|
}
|
|
|
|
async function nodeInventory(args: any) {
|
|
const root = workspaceRoot(args);
|
|
const info = await stat(root).catch(() => null);
|
|
return { workspaceRoot: root, workspaceExists: Boolean(info), workspaceIsDirectory: info?.isDirectory?.() ?? false };
|
|
}
|
|
|
|
async function workspaceLs(args: any) {
|
|
const target = resolveWorkspacePath(args, text(args.path) || ".");
|
|
const entries = await readdir(target, { withFileTypes: true });
|
|
return {
|
|
path: target,
|
|
entries: entries.map((entry) => ({ name: entry.name, type: entry.isDirectory() ? "dir" : entry.isFile() ? "file" : "other" }))
|
|
};
|
|
}
|
|
|
|
async function workspaceCat(args: any) {
|
|
const target = resolveWorkspacePath(args, requiredText(args.path, "path"));
|
|
const maxBytes = numberValue(args.maxBytes) ?? 65536;
|
|
const content = await readFile(target, "utf8");
|
|
const truncated = Buffer.byteLength(content, "utf8") > maxBytes;
|
|
return { path: target, content: truncated ? content.slice(0, maxBytes) : content, truncated };
|
|
}
|
|
|
|
async function workspaceRg(args: any) {
|
|
const pattern = requiredText(args.pattern, "pattern");
|
|
const cwd = workspaceRoot(args);
|
|
const searchPath = text(args.path) || ".";
|
|
const target = resolveWorkspacePath(args, searchPath);
|
|
const flags = args.ignoreCase === true ? "iu" : "u";
|
|
let matcher: RegExp;
|
|
try {
|
|
matcher = new RegExp(pattern, flags);
|
|
} catch (error) {
|
|
throw cliError("workspace_rg_invalid_pattern", `invalid workspace.rg pattern: ${pattern}`, { pattern, message: error?.message ?? String(error) });
|
|
}
|
|
|
|
const maxFiles = numberValue(args.maxFiles) ?? 5000;
|
|
const maxMatches = numberValue(args.maxMatches) ?? 200;
|
|
const maxBytesPerFile = numberValue(args.maxBytesPerFile) ?? 1024 * 1024;
|
|
const lineLimit = numberValue(args.maxLineBytes) ?? 600;
|
|
const contextLines = Math.max(numberValue(args.context) ?? 0, 0);
|
|
const beforeContext = Math.max(numberValue(args.beforeContext) ?? contextLines, 0);
|
|
const afterContext = Math.max(numberValue(args.afterContext) ?? contextLines, 0);
|
|
const matches: Array<{ path: string; line: number; text: string; before?: Array<{ line: number; text: string }>; after?: Array<{ line: number; text: string }> }> = [];
|
|
const state = { scannedFiles: 0, skippedFiles: 0, skippedDirectories: 0, truncated: false };
|
|
|
|
async function scanFile(filePath: string) {
|
|
if (state.truncated || state.scannedFiles >= maxFiles || matches.length >= maxMatches) {
|
|
state.truncated = true;
|
|
return;
|
|
}
|
|
const info = await stat(filePath).catch(() => null);
|
|
if (!info?.isFile?.()) {
|
|
state.skippedFiles += 1;
|
|
return;
|
|
}
|
|
if (info.size > maxBytesPerFile) {
|
|
state.skippedFiles += 1;
|
|
return;
|
|
}
|
|
const buffer = await readFile(filePath).catch(() => null);
|
|
if (!buffer || buffer.includes(0)) {
|
|
state.skippedFiles += 1;
|
|
return;
|
|
}
|
|
state.scannedFiles += 1;
|
|
const displayPath = workspaceDisplayPath(cwd, filePath);
|
|
const lines = buffer.toString("utf8").split(/\r\n|\n|\r/u);
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
if (matcher.test(lines[index])) {
|
|
matches.push(workspaceSearchMatch(displayPath, lines, index, beforeContext, afterContext, lineLimit));
|
|
if (matches.length >= maxMatches) {
|
|
state.truncated = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function walk(directory: string) {
|
|
if (state.truncated) return;
|
|
const entries = await readdir(directory, { withFileTypes: true }).catch(() => null);
|
|
if (!entries) {
|
|
state.skippedDirectories += 1;
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
if (state.truncated) return;
|
|
const child = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (isWorkspaceSearchSkippedDirectory(entry.name)) {
|
|
state.skippedDirectories += 1;
|
|
continue;
|
|
}
|
|
await walk(child);
|
|
} else if (entry.isFile()) {
|
|
await scanFile(child);
|
|
} else {
|
|
state.skippedFiles += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
const info = await stat(target).catch(() => null);
|
|
if (!info) throw cliError("workspace_rg_path_not_found", `workspace.rg path not found: ${searchPath}`, { workspacePath: cwd, path: searchPath, target });
|
|
if (info.isDirectory()) await walk(target);
|
|
else await scanFile(target);
|
|
|
|
const stdout = renderWorkspaceSearchStdout(matches);
|
|
return {
|
|
ok: true,
|
|
engine: "node-recursive-search",
|
|
cwd,
|
|
path: searchPath,
|
|
target,
|
|
pattern,
|
|
ignoreCase: args.ignoreCase === true,
|
|
exitCode: 0,
|
|
stdout,
|
|
stderr: "",
|
|
scannedFiles: state.scannedFiles,
|
|
skippedFiles: state.skippedFiles,
|
|
skippedDirectories: state.skippedDirectories,
|
|
matchCount: matches.length,
|
|
matches,
|
|
truncated: state.truncated,
|
|
limits: { maxFiles, maxMatches, maxBytesPerFile, maxLineBytes: lineLimit, beforeContext, afterContext }
|
|
};
|
|
}
|
|
|
|
function workspaceSearchMatch(displayPath: string, lines: string[], index: number, beforeContext: number, afterContext: number, lineLimit: number) {
|
|
const before = [];
|
|
const after = [];
|
|
for (let lineIndex = Math.max(0, index - beforeContext); lineIndex < index; lineIndex += 1) {
|
|
before.push({ line: lineIndex + 1, text: truncateText(lines[lineIndex], lineLimit) });
|
|
}
|
|
for (let lineIndex = index + 1; lineIndex <= Math.min(lines.length - 1, index + afterContext); lineIndex += 1) {
|
|
after.push({ line: lineIndex + 1, text: truncateText(lines[lineIndex], lineLimit) });
|
|
}
|
|
const match: { path: string; line: number; text: string; before?: Array<{ line: number; text: string }>; after?: Array<{ line: number; text: string }> } = {
|
|
path: displayPath,
|
|
line: index + 1,
|
|
text: truncateText(lines[index], lineLimit)
|
|
};
|
|
if (before.length) match.before = before;
|
|
if (after.length) match.after = after;
|
|
return match;
|
|
}
|
|
|
|
function renderWorkspaceSearchStdout(matches: Array<{ path: string; line: number; text: string; before?: Array<{ line: number; text: string }>; after?: Array<{ line: number; text: string }> }>) {
|
|
const lines: string[] = [];
|
|
for (const match of matches) {
|
|
for (const context of match.before ?? []) lines.push(`${match.path}-${context.line}-${context.text}`);
|
|
lines.push(`${match.path}:${match.line}:${match.text}`);
|
|
for (const context of match.after ?? []) lines.push(`${match.path}-${context.line}-${context.text}`);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function isWorkspaceSearchSkippedDirectory(name: string) {
|
|
return new Set([".git", ".worktree", "node_modules", "Objects", "Listings", "Output", "build", "dist"]).has(name);
|
|
}
|
|
|
|
function workspaceDisplayPath(root: string, filePath: string) {
|
|
const relative = path.relative(root, filePath) || path.basename(filePath);
|
|
return relative.replace(/[\\]+/gu, "/");
|
|
}
|
|
|
|
function truncateText(value: string, maxBytes: number) {
|
|
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
|
|
let output = "";
|
|
for (const char of value) {
|
|
if (Buffer.byteLength(`${output}${char}`, "utf8") > maxBytes) break;
|
|
output += char;
|
|
}
|
|
return `${output}...<truncated>`;
|
|
}
|
|
|
|
async function workspaceApplyPatch(args: any) {
|
|
const patch = text(args.patch) || text(args.patchContent) || (text(args.patchBase64) ? Buffer.from(text(args.patchBase64), "base64").toString("utf8") : "");
|
|
if (!patch) throw cliError("patch_required", "patch, patchContent, or patchBase64 is required", { acceptedArgs: ["patch", "patchContent", "patchBase64"], engine: APPLY_PATCH_ENGINE });
|
|
const cwd = workspaceRoot(args);
|
|
const result = await applyPatchEnvelope(cwd, patch);
|
|
return { cwd, ...result };
|
|
}
|
|
|
|
async function workspaceWrite(args: any) {
|
|
const relativePath = requiredText(args.path, "path");
|
|
const target = resolveWorkspacePath(args, relativePath);
|
|
const before = await readTextFileState(target).catch((error) => {
|
|
if (error?.code === "ENOENT") return null;
|
|
throw error;
|
|
});
|
|
assertExpectedSha(before, args.expectedSha, relativePath);
|
|
const content = contentFromArgs(args, "content");
|
|
const lineEnding = lineEndingFromArgs(args, before?.lineEnding ?? "\n");
|
|
const finalContent = normalizeContentLineEndings(content, lineEnding, args.finalNewline === true);
|
|
const afterSha = sha256Text(finalContent);
|
|
const dryRun = args.dryRun === true;
|
|
if (!dryRun) {
|
|
await mkdir(path.dirname(target), { recursive: true });
|
|
await writeFile(target, finalContent, "utf8");
|
|
}
|
|
return editResult({ action: "write", path: relativePath, before, afterContent: finalContent, dryRun });
|
|
}
|
|
|
|
async function workspaceReplace(args: any) {
|
|
const relativePath = requiredText(args.path, "path");
|
|
const target = resolveWorkspacePath(args, relativePath);
|
|
const before = await readTextFileState(target);
|
|
assertExpectedSha(before, args.expectedSha, relativePath);
|
|
const find = requiredRawString(args.find, "find");
|
|
const replace = rawString(args.replace ?? args.replacement);
|
|
const unescapedFind = String(find).replace(/\\r\\n/gu, "\r\n").replace(/\\n/gu, "\n").replace(/\\r(?!\n)/gu, "\r");
|
|
const normalizedFind = normalizeContentLineEndings(unescapedFind, "\n", false);
|
|
const normalizedReplace = normalizeContentLineEndings(replace, "\n", false);
|
|
const normalizedContent = before.normalizedContent;
|
|
const occurrences = countOccurrences(normalizedContent, normalizedFind);
|
|
if (occurrences === 0) {
|
|
throw cliError("workspace_replace_anchor_not_found", `replace text not found for ${relativePath}`, diagnosticDetails(before, normalizedFind, relativePath));
|
|
}
|
|
if (args.all !== true && occurrences !== 1) {
|
|
throw cliError("workspace_replace_ambiguous", `replace text matched ${occurrences} times for ${relativePath}; pass --all for global replacement`, { path: relativePath, occurrences, fileSha256: before.sha256 });
|
|
}
|
|
const afterNormalized = args.all === true ? normalizedContent.split(normalizedFind).join(normalizedReplace) : normalizedContent.replace(normalizedFind, normalizedReplace);
|
|
const afterContent = normalizeContentLineEndings(afterNormalized, before.lineEnding, false);
|
|
const dryRun = args.dryRun === true;
|
|
if (!dryRun) await writeFile(target, afterContent, "utf8");
|
|
return editResult({ action: "replace", path: relativePath, before, afterContent, dryRun, extra: { occurrences, all: args.all === true } });
|
|
}
|
|
|
|
async function workspaceInsertAfter(args: any) {
|
|
const relativePath = requiredText(args.path, "path");
|
|
const target = resolveWorkspacePath(args, relativePath);
|
|
const before = await readTextFileState(target);
|
|
assertExpectedSha(before, args.expectedSha, relativePath);
|
|
const anchor = requiredRawString(args.anchor, "anchor");
|
|
const insertText = requiredRawString(args.line ?? args.insert ?? args.text, "line");
|
|
const unescapedAnchor = String(anchor).replace(/\\r\\n/gu, "\r\n").replace(/\\n/gu, "\n").replace(/\\r(?!\n)/gu, "\r");
|
|
const normalizedAnchor = normalizeContentLineEndings(unescapedAnchor, "\n", false);
|
|
const normalizedInsert = normalizeContentLineEndings(insertText, "\n", false);
|
|
const lines = splitNormalizedLines(before.normalizedContent, before.hadFinalNewline);
|
|
const anchorLines = splitNormalizedLines(normalizedAnchor, false);
|
|
const match = findInsertAnchor(lines, anchorLines, args.allowMultiple === true);
|
|
if (match.line < 0) {
|
|
throw cliError("workspace_insert_anchor_not_found", `insert anchor not found for ${relativePath}`, diagnosticDetails(before, normalizedAnchor, relativePath));
|
|
}
|
|
if (match.ambiguous && args.allowMultiple !== true) {
|
|
throw cliError("workspace_insert_anchor_ambiguous", `insert anchor matched more than once for ${relativePath}; pass --allow-multiple to use the first match`, { path: relativePath, firstLine: match.line + 1, secondLine: match.secondLine + 1, fileSha256: before.sha256, anchorMatch: match.kind });
|
|
}
|
|
const insertLines = splitNormalizedLines(normalizedInsert, false);
|
|
const outputLines = [...lines.slice(0, match.line + match.length), ...insertLines, ...lines.slice(match.line + match.length)];
|
|
const afterNormalized = `${outputLines.join("\n")}${before.hadFinalNewline ? "\n" : ""}`;
|
|
const afterContent = normalizeContentLineEndings(afterNormalized, before.lineEnding, false);
|
|
const dryRun = args.dryRun === true;
|
|
if (!dryRun) await writeFile(target, afterContent, "utf8");
|
|
return editResult({ action: "insert-after", path: relativePath, before, afterContent, dryRun, extra: { anchorLine: match.line + 1, insertedLines: insertLines.length, anchorMatch: match.kind } });
|
|
}
|
|
|
|
async function debugCommand(op: string, args: any) {
|
|
const commandRuns = commandRunsFromArgs(args);
|
|
if (commandRuns.length > 0) return await runCommandRuns(op, args, commandRuns);
|
|
const command = text(args.commandLine) || text(args.command);
|
|
if (!command) throw cliError("hwpod_node_op_not_configured", `${op} requires an explicit node-side command binding in the compiled hwpod-node-ops plan`);
|
|
const cwd = workspaceRoot(args);
|
|
const output = await spawnShellOutput(command, { cwd, timeoutMs: numberValue(args.timeoutMs) ?? 120000 });
|
|
return { cwd, command, bindingSource: "hwpod-node-ops.command", ...output };
|
|
}
|
|
|
|
function commandRunsFromArgs(args: any) {
|
|
if (Array.isArray(args.commandRuns)) return args.commandRuns;
|
|
if (args.commandRun && typeof args.commandRun === "object" && !Array.isArray(args.commandRun)) return [args.commandRun];
|
|
return [];
|
|
}
|
|
|
|
async function runCommandRuns(op: string, args: any, commandRuns: any[]) {
|
|
const cwd = workspaceRoot(args);
|
|
const outputs = [];
|
|
let exitCode = 0;
|
|
for (let index = 0; index < commandRuns.length; index += 1) {
|
|
const run = normalizeCommandRun(commandRuns[index], index);
|
|
const timeoutMs = numberValue(run.timeoutMs) ?? numberValue(args.timeoutMs) ?? 120000;
|
|
const output = await spawnOutput([run.command, ...run.argv], { cwd, timeoutMs });
|
|
const item = { index, command: [run.command, ...run.argv], commandLine: run.commandLine || null, timeoutMs, ...output };
|
|
outputs.push(item);
|
|
exitCode = output.exitCode;
|
|
if (!output.ok) break;
|
|
}
|
|
return {
|
|
cwd,
|
|
op,
|
|
bindingSource: "hwpod-node-ops.commandRuns",
|
|
commandCount: outputs.length,
|
|
commands: outputs,
|
|
exitCode,
|
|
stdout: outputs.map((item) => item.stdout).join(""),
|
|
stderr: outputs.map((item) => item.stderr).join(""),
|
|
ok: outputs.length > 0 && outputs.every((item) => item.ok)
|
|
};
|
|
}
|
|
|
|
function normalizeCommandRun(value: any, index: number) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw cliError("invalid_command_run", `commandRuns[${index}] must be an object`);
|
|
const command = requiredText(value.command, `commandRuns[${index}].command`);
|
|
const argv = Array.isArray(value.argv) ? value.argv.map(String) : [];
|
|
return { command, argv, commandLine: text(value.commandLine), timeoutMs: value.timeoutMs };
|
|
}
|
|
|
|
const APPLY_PATCH_ENGINE = "codex-apply-patch-v2-compatible";
|
|
const APPLY_PATCH_BEGIN_MARKER = "*** Begin Patch";
|
|
const APPLY_PATCH_END_MARKER = "*** End Patch";
|
|
const APPLY_PATCH_ENVIRONMENT_MARKER = "*** Environment ID: ";
|
|
const APPLY_PATCH_ADD_FILE_MARKER = "*** Add File: ";
|
|
const APPLY_PATCH_DELETE_FILE_MARKER = "*** Delete File: ";
|
|
const APPLY_PATCH_UPDATE_FILE_MARKER = "*** Update File: ";
|
|
const APPLY_PATCH_MOVE_TO_MARKER = "*** Move to: ";
|
|
const APPLY_PATCH_EOF_MARKER = "*** End of File";
|
|
|
|
type HwpodPatchHunk =
|
|
| { kind: "add"; path: string; content: string }
|
|
| { kind: "delete"; path: string }
|
|
| { kind: "update"; path: string; movePath: string | null; chunks: HwpodPatchChunk[] };
|
|
|
|
type HwpodPatchChunk = {
|
|
changeContext: string | null;
|
|
sourceStartLine: number | null;
|
|
oldLines: string[];
|
|
newLines: string[];
|
|
contextLinePairs: Array<{ oldIndex: number; newIndex: number }>;
|
|
contextLineCount: number;
|
|
addedLineCount: number;
|
|
deletedLineCount: number;
|
|
isEndOfFile: boolean;
|
|
};
|
|
|
|
type HwpodPatchReplacement = [start: number, oldLength: number, newLines: string[]];
|
|
|
|
async function applyPatchEnvelope(root: string, patch: string) {
|
|
const parsed = parseHwpodApplyPatchV2(patch);
|
|
const changes: any[] = [];
|
|
const outcomes: any[] = [];
|
|
for (let index = 0; index < parsed.hunks.length; index += 1) {
|
|
const hunk = parsed.hunks[index];
|
|
try {
|
|
const change = await applyParsedPatchHunk(root, hunk);
|
|
changes.push(change);
|
|
outcomes.push({ hunk: index + 1, action: change.action, path: change.path, targetPath: change.targetPath ?? undefined, status: "applied", change });
|
|
} catch (error: any) {
|
|
const failed = {
|
|
hunk: index + 1,
|
|
action: hunk.kind,
|
|
path: hunk.path,
|
|
targetPath: hunk.kind === "update" ? hunk.movePath ?? undefined : undefined,
|
|
status: "failed",
|
|
error: { code: error?.code || "apply_patch_v2_failed", message: error?.message || String(error), details: error?.details }
|
|
};
|
|
outcomes.push(failed);
|
|
throw cliError(error?.code || "apply_patch_v2_failed", error?.message || String(error), { ...(error?.details ?? {}), engine: APPLY_PATCH_ENGINE, partialChanges: changes, outcomes, failed, cause: error?.details });
|
|
}
|
|
}
|
|
if (changes.length === 0) throw cliError("apply_patch_no_changes", "No files were modified.", { engine: APPLY_PATCH_ENGINE });
|
|
return { engine: APPLY_PATCH_ENGINE, changes, hints: parsed.hints, outcomes };
|
|
}
|
|
|
|
async function applyParsedPatchHunk(root: string, hunk: HwpodPatchHunk) {
|
|
if (hunk.kind === "add") {
|
|
const filePath = resolvePatchFile(root, hunk.path);
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, hunk.content, "utf8");
|
|
return { action: "add", path: hunk.path, lines: splitPatchContentLines(hunk.content).length };
|
|
}
|
|
if (hunk.kind === "delete") {
|
|
await rm(resolvePatchFile(root, hunk.path), { force: false });
|
|
return { action: "delete", path: hunk.path };
|
|
}
|
|
const filePath = resolvePatchFile(root, hunk.path);
|
|
const update = await derivePatchUpdate(filePath, hunk.chunks, hunk.path);
|
|
if (hunk.movePath !== null && hunk.movePath !== hunk.path) {
|
|
const targetPath = resolvePatchFile(root, hunk.movePath);
|
|
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
await writeFile(targetPath, update.afterContent, "utf8");
|
|
await rm(filePath, { force: false });
|
|
return { action: "move", path: hunk.path, targetPath: hunk.movePath, hunks: hunk.chunks.length, replacements: update.replacements };
|
|
}
|
|
await writeFile(filePath, update.afterContent, "utf8");
|
|
return { action: "update", path: hunk.path, hunks: hunk.chunks.length, replacements: update.replacements };
|
|
}
|
|
|
|
function parseHwpodApplyPatchV2(patch: string): { hunks: HwpodPatchHunk[]; hints: string[] } {
|
|
const patchText = stripLenientPatchHeredoc(patch).trim();
|
|
const lines = patchText.length === 0 ? [] : patchText.split(/\r?\n/u);
|
|
const first = lines[0]?.trim();
|
|
const last = lines[lines.length - 1]?.trim();
|
|
if (first !== APPLY_PATCH_BEGIN_MARKER || last !== APPLY_PATCH_END_MARKER) throwInvalidApplyPatchEnvelope(patchText, first, last);
|
|
|
|
const hunks: HwpodPatchHunk[] = [];
|
|
const hints: string[] = [];
|
|
let index = 1;
|
|
if ((lines[index] ?? "").trimStart().startsWith(APPLY_PATCH_ENVIRONMENT_MARKER)) {
|
|
const environmentId = (lines[index] ?? "").slice(APPLY_PATCH_ENVIRONMENT_MARKER.length).trim();
|
|
if (!environmentId) throw cliError("invalid_apply_patch_environment", "apply_patch environment_id cannot be empty", { engine: APPLY_PATCH_ENGINE, line: index + 1 });
|
|
index += 1;
|
|
}
|
|
while (index < lines.length - 1) {
|
|
const line = (lines[index] ?? "").trim();
|
|
if (!line) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (line === APPLY_PATCH_BEGIN_MARKER || line === APPLY_PATCH_END_MARKER) {
|
|
pushUniquePatchHint(hints, `ignored nested ${line}`, `apply-patch hint: ignored nested ${line} marker on line ${index + 1}; keep one outer envelope around all hunks.`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (line.startsWith(APPLY_PATCH_ADD_FILE_MARKER)) {
|
|
const filePath = validateApplyPatchPath(line.slice(APPLY_PATCH_ADD_FILE_MARKER.length), index + 1);
|
|
index += 1;
|
|
const added: string[] = [];
|
|
while (index < lines.length - 1 && !isApplyPatchFileHeader(lines[index] ?? "")) {
|
|
const addLine = lines[index] ?? "";
|
|
if (addLine.startsWith("+")) {
|
|
added.push(addLine.length > 1 && addLine.slice(1).trim().length === 0 ? "" : addLine.slice(1));
|
|
} else if (addLine.trimStart().startsWith("@@")) {
|
|
hints.push(`apply-patch hint: accepted @@ inside Add File ${filePath} on line ${index + 1}; Add File does not need @@.`);
|
|
} else if (addLine.trim().length === 0) {
|
|
hints.push(`apply-patch hint: accepted a bare blank line inside Add File ${filePath} on line ${index + 1}; prefer a line containing only +.`);
|
|
added.push("");
|
|
} else {
|
|
hints.push(`apply-patch hint: accepted unprefixed Add File content in ${filePath} on line ${index + 1}; prefix new-file content lines with +.`);
|
|
added.push(addLine);
|
|
}
|
|
index += 1;
|
|
}
|
|
hunks.push({ kind: "add", path: filePath, content: joinPatchLinesWithFinalNewline(added) });
|
|
continue;
|
|
}
|
|
if (line.startsWith(APPLY_PATCH_DELETE_FILE_MARKER)) {
|
|
const filePath = validateApplyPatchPath(line.slice(APPLY_PATCH_DELETE_FILE_MARKER.length), index + 1);
|
|
index += 1;
|
|
const extraLines: number[] = [];
|
|
while (index < lines.length - 1 && !isApplyPatchFileHeader(lines[index] ?? "")) {
|
|
if ((lines[index] ?? "").trim().length > 0) extraLines.push(index + 1);
|
|
index += 1;
|
|
}
|
|
if (extraLines.length > 0) hints.push(`apply-patch hint: ignored extra hunk/body lines after Delete File ${filePath} on line ${extraLines[0]}; Delete File only needs the header.`);
|
|
hunks.push({ kind: "delete", path: filePath });
|
|
continue;
|
|
}
|
|
if (line.startsWith(APPLY_PATCH_UPDATE_FILE_MARKER)) {
|
|
const filePath = validateApplyPatchPath(line.slice(APPLY_PATCH_UPDATE_FILE_MARKER.length), index + 1);
|
|
index += 1;
|
|
let movePath: string | null = null;
|
|
if ((lines[index] ?? "").startsWith(APPLY_PATCH_MOVE_TO_MARKER)) {
|
|
movePath = validateApplyPatchPath((lines[index] ?? "").slice(APPLY_PATCH_MOVE_TO_MARKER.length), index + 1);
|
|
index += 1;
|
|
}
|
|
const chunks: HwpodPatchChunk[] = [];
|
|
while (index < lines.length - 1 && !isApplyPatchFileHeader(lines[index] ?? "")) {
|
|
if ((lines[index] ?? "").trim().length === 0) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const parsed = parseApplyPatchUpdateChunk(lines, index, chunks.length === 0, filePath, hints);
|
|
chunks.push(parsed.chunk);
|
|
index = parsed.nextIndex;
|
|
}
|
|
if (chunks.length === 0) throw cliError("invalid_update_hunk", "update file hunk is empty", { engine: APPLY_PATCH_ENGINE, line: index + 1, path: filePath });
|
|
hunks.push({ kind: "update", path: filePath, movePath, chunks });
|
|
continue;
|
|
}
|
|
throw cliError("unsupported_apply_patch_operation", `unsupported patch operation: ${line}`, { engine: APPLY_PATCH_ENGINE, line: index + 1, text: line });
|
|
}
|
|
return { hunks, hints };
|
|
}
|
|
|
|
function parseApplyPatchUpdateChunk(lines: string[], startIndex: number, allowMissingContext: boolean, filePath: string, hints: string[]) {
|
|
let index = startIndex;
|
|
let changeContext: string | null = null;
|
|
let sourceStartLine: number | null = null;
|
|
const first = lines[index] ?? "";
|
|
if (first === "@@") {
|
|
index += 1;
|
|
} else {
|
|
const unifiedHeader = parseApplyPatchUnifiedHunkHeader(first);
|
|
if (unifiedHeader !== null) {
|
|
sourceStartLine = unifiedHeader.oldStart;
|
|
hints.push(`apply-patch hint: accepted unified-diff hunk header in ${filePath} on line ${startIndex + 1}; canonical apply_patch uses @@ or @@ context without line ranges.`);
|
|
index += 1;
|
|
} else if (first.startsWith("@@ ")) {
|
|
changeContext = first.slice("@@ ".length);
|
|
index += 1;
|
|
} else if (!allowMissingContext) {
|
|
throw cliError("invalid_update_hunk", "expected update chunk to start with @@ context marker", { engine: APPLY_PATCH_ENGINE, line: startIndex + 1, text: first });
|
|
}
|
|
}
|
|
|
|
const oldLines: string[] = [];
|
|
const newLines: string[] = [];
|
|
const contextLinePairs: Array<{ oldIndex: number; newIndex: number }> = [];
|
|
let parsed = 0;
|
|
let isEndOfFile = false;
|
|
let contextLineCount = 0;
|
|
let addedLineCount = 0;
|
|
let deletedLineCount = 0;
|
|
function pushContextLine(value: string) {
|
|
contextLinePairs.push({ oldIndex: oldLines.length, newIndex: newLines.length });
|
|
oldLines.push(value);
|
|
newLines.push(value);
|
|
contextLineCount += 1;
|
|
}
|
|
while (index < lines.length - 1) {
|
|
const line = lines[index] ?? "";
|
|
if (isApplyPatchFileHeader(line)) break;
|
|
if (parsed > 0 && isApplyPatchUpdateChunkHeader(line)) break;
|
|
if (line === APPLY_PATCH_EOF_MARKER) {
|
|
if (parsed === 0) throw cliError("invalid_update_hunk", "update chunk does not contain any lines", { engine: APPLY_PATCH_ENGINE, line: index + 1 });
|
|
isEndOfFile = true;
|
|
index += 1;
|
|
break;
|
|
}
|
|
const marker = line[0] ?? "";
|
|
if (marker === " ") {
|
|
pushContextLine(line.slice(1));
|
|
} else if (marker === "+") {
|
|
newLines.push(line.slice(1));
|
|
addedLineCount += 1;
|
|
} else if (marker === "-") {
|
|
oldLines.push(line.slice(1));
|
|
deletedLineCount += 1;
|
|
} else if (line.length === 0) {
|
|
pushContextLine("");
|
|
} else {
|
|
pushUniquePatchHint(hints, `unprefixed context ${filePath}`, `apply-patch hint: accepted unprefixed Update File context line in ${filePath} on line ${index + 1}; prefix context lines with one extra space in addition to source indentation.`);
|
|
pushContextLine(line);
|
|
}
|
|
parsed += 1;
|
|
index += 1;
|
|
}
|
|
if (parsed === 0) throw cliError("invalid_update_hunk", "update chunk does not contain any lines", { engine: APPLY_PATCH_ENGINE, line: startIndex + 1 });
|
|
return { chunk: { changeContext, sourceStartLine, oldLines, newLines, contextLinePairs, contextLineCount, addedLineCount, deletedLineCount, isEndOfFile }, nextIndex: index };
|
|
}
|
|
|
|
async function derivePatchUpdate(filePath: string, chunks: HwpodPatchChunk[], relativePath: string) {
|
|
const before = await readTextFileState(filePath);
|
|
const originalLines = splitPatchContentLines(before.normalizedContent);
|
|
const replacements = computePatchReplacements(relativePath, before, originalLines, chunks);
|
|
const afterNormalized = joinPatchLinesWithFinalNewline(applyPatchReplacements(originalLines, replacements));
|
|
const afterContent = normalizeContentLineEndings(afterNormalized, before.lineEnding, false);
|
|
return { before, afterContent, replacements: replacements.length };
|
|
}
|
|
|
|
function computePatchReplacements(relativePath: string, before: any, originalLines: string[], chunks: HwpodPatchChunk[]): HwpodPatchReplacement[] {
|
|
const replacements: HwpodPatchReplacement[] = [];
|
|
let lineIndex = 0;
|
|
for (const [chunkIndex, chunk] of chunks.entries()) {
|
|
if (chunk.changeContext !== null) {
|
|
const foundContext = seekPatchSequence(originalLines, [chunk.changeContext], lineIndex, false);
|
|
if (foundContext === null) throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`, { ...diagnosticDetails(before, chunk.changeContext, relativePath), engine: APPLY_PATCH_ENGINE, chunk: chunkIndex + 1, context: chunk.changeContext });
|
|
lineIndex = foundContext + 1;
|
|
}
|
|
if (chunk.oldLines.length === 0) {
|
|
replacements.push([originalLines.length, 0, chunk.newLines]);
|
|
continue;
|
|
}
|
|
let pattern = chunk.oldLines;
|
|
const preferredStart = chunk.sourceStartLine === null ? lineIndex : Math.max(lineIndex, chunk.sourceStartLine - 1);
|
|
let found = seekPatchSequenceWithFallback(originalLines, pattern, preferredStart, lineIndex, chunk.isEndOfFile);
|
|
let newLines = chunk.newLines;
|
|
if (found === null && pattern[pattern.length - 1] === "") {
|
|
pattern = pattern.slice(0, -1);
|
|
newLines = newLines[newLines.length - 1] === "" ? newLines.slice(0, -1) : newLines;
|
|
found = seekPatchSequenceWithFallback(originalLines, pattern, preferredStart, lineIndex, chunk.isEndOfFile);
|
|
}
|
|
if (found === null) {
|
|
const expected = chunk.oldLines.join("\n");
|
|
throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`, {
|
|
...diagnosticDetails(before, expected, relativePath),
|
|
engine: APPLY_PATCH_ENGINE,
|
|
chunk: chunkIndex + 1,
|
|
expected,
|
|
diagnostics: expectedPatchLineDiagnostics(originalLines, chunk, preferredStart)
|
|
});
|
|
}
|
|
replacements.push([found, pattern.length, preservePatchMatchedContextLines(originalLines, found, newLines, chunk.contextLinePairs, pattern.length)]);
|
|
lineIndex = found + pattern.length;
|
|
}
|
|
assertPatchReplacements(relativePath, replacements, originalLines.length);
|
|
return replacements;
|
|
}
|
|
|
|
function throwInvalidApplyPatchEnvelope(patchText: string, first?: string, last?: string): never {
|
|
const format = detectPatchTextFormat(patchText);
|
|
if (format === "unified-diff") {
|
|
throw cliError("unsupported_apply_patch_format", "hwpod workspace.apply-patch uses the Codex/UniDesk apply_patch v2 envelope; raw unified diff is not accepted", {
|
|
engine: APPLY_PATCH_ENGINE,
|
|
format,
|
|
expectedFirstLine: APPLY_PATCH_BEGIN_MARKER,
|
|
expectedLastLine: APPLY_PATCH_END_MARKER,
|
|
hint: "Wrap edits in *** Begin Patch / *** Update File: <relative-path> / @@ / *** End Patch, or use workspace.write for a whole-file rewrite."
|
|
});
|
|
}
|
|
throw cliError("invalid_apply_patch_envelope", `patch must start with ${APPLY_PATCH_BEGIN_MARKER} and end with ${APPLY_PATCH_END_MARKER}`, { engine: APPLY_PATCH_ENGINE, firstLine: first ?? null, lastLine: last ?? null });
|
|
}
|
|
|
|
function detectPatchTextFormat(patchText: string) {
|
|
const trimmed = patchText.trimStart();
|
|
if (trimmed.startsWith("diff --git") || trimmed.startsWith("--- ") || trimmed.startsWith("@@ ")) return "unified-diff";
|
|
return "unknown";
|
|
}
|
|
|
|
function stripLenientPatchHeredoc(textValue: string) {
|
|
const trimmed = textValue.trim();
|
|
const lines = trimmed.length === 0 ? [] : trimmed.split(/\r?\n/u);
|
|
const first = lines[0] ?? "";
|
|
const last = lines[lines.length - 1] ?? "";
|
|
if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last.endsWith("EOF") && lines.length >= 4) return lines.slice(1, -1).join("\n");
|
|
return textValue;
|
|
}
|
|
|
|
function validateApplyPatchPath(value: string, line: number) {
|
|
const filePath = value.trim();
|
|
if (!filePath || path.isAbsolute(filePath)) throw cliError("invalid_patch_path", "patch file paths must be relative", { engine: APPLY_PATCH_ENGINE, line, path: filePath });
|
|
return filePath;
|
|
}
|
|
|
|
function isApplyPatchFileHeader(line: string) {
|
|
const trimmed = line.trim();
|
|
return trimmed.startsWith(APPLY_PATCH_ADD_FILE_MARKER) || trimmed.startsWith(APPLY_PATCH_DELETE_FILE_MARKER) || trimmed.startsWith(APPLY_PATCH_UPDATE_FILE_MARKER) || trimmed === APPLY_PATCH_BEGIN_MARKER || trimmed === APPLY_PATCH_END_MARKER;
|
|
}
|
|
|
|
function isApplyPatchUpdateChunkHeader(line: string) {
|
|
return line === "@@" || line.startsWith("@@ ") || parseApplyPatchUnifiedHunkHeader(line) !== null;
|
|
}
|
|
|
|
function parseApplyPatchUnifiedHunkHeader(line: string) {
|
|
const match = /^@@\s+-(\d+)(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@(?:\s+.*)?$/u.exec(line);
|
|
if (match === null) return null;
|
|
const oldStart = Number(match[1] ?? "0");
|
|
return Number.isSafeInteger(oldStart) && oldStart >= 0 ? { oldStart } : null;
|
|
}
|
|
|
|
function splitPatchContentLines(content: string) {
|
|
const lines = content.split("\n");
|
|
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
return lines;
|
|
}
|
|
|
|
function joinPatchLinesWithFinalNewline(lines: string[]) {
|
|
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function applyPatchReplacements(lines: string[], replacements: HwpodPatchReplacement[]) {
|
|
const result = [...lines];
|
|
for (const [start, oldLength, newSegment] of [...replacements].reverse()) result.splice(start, oldLength, ...newSegment);
|
|
return result;
|
|
}
|
|
|
|
function assertPatchReplacements(relativePath: string, replacements: HwpodPatchReplacement[], lineCount: number) {
|
|
const sorted = [...replacements].sort((left, right) => left[0] - right[0]);
|
|
let previousEnd = 0;
|
|
for (const [start, oldLength] of sorted) {
|
|
if (start < 0 || oldLength < 0 || start + oldLength > lineCount) throw cliError("apply_patch_replacement_out_of_bounds", "computed replacement is outside file bounds", { engine: APPLY_PATCH_ENGINE, path: relativePath, start, oldLength, lineCount });
|
|
if (start < previousEnd) throw cliError("apply_patch_replacement_overlap", "computed replacements overlap", { engine: APPLY_PATCH_ENGINE, path: relativePath, start, previousEnd });
|
|
previousEnd = Math.max(previousEnd, start + oldLength);
|
|
}
|
|
}
|
|
|
|
function seekPatchSequenceWithFallback(lines: string[], pattern: string[], preferredStart: number, fallbackStart: number, eof: boolean) {
|
|
const preferred = seekPatchSequence(lines, pattern, preferredStart, eof);
|
|
if (preferred !== null || preferredStart === fallbackStart) return preferred;
|
|
return seekPatchSequence(lines, pattern, fallbackStart, eof);
|
|
}
|
|
|
|
function seekPatchSequence(lines: string[], pattern: string[], start: number, eof: boolean) {
|
|
if (pattern.length === 0) return start;
|
|
if (pattern.length > lines.length) return null;
|
|
const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : Math.max(0, start);
|
|
const attempts: Array<(value: string) => string> = [
|
|
(value) => value,
|
|
(value) => value.trimEnd(),
|
|
(value) => value.trim(),
|
|
normalizePatchLine
|
|
];
|
|
for (const normalize of attempts) {
|
|
for (let index = searchStart; index <= lines.length - pattern.length; index += 1) {
|
|
let ok = true;
|
|
for (let offset = 0; offset < pattern.length; offset += 1) {
|
|
if (normalize(lines[index + offset] ?? "") !== normalize(pattern[offset] ?? "")) {
|
|
ok = false;
|
|
break;
|
|
}
|
|
}
|
|
if (ok) return index;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function preservePatchMatchedContextLines(originalLines: string[], found: number, newLines: string[], contextLinePairs: HwpodPatchChunk["contextLinePairs"], matchedOldLength: number) {
|
|
if (contextLinePairs.length === 0) return newLines;
|
|
const result = [...newLines];
|
|
for (const pair of contextLinePairs) {
|
|
if (pair.oldIndex >= matchedOldLength || pair.newIndex >= result.length) continue;
|
|
const originalLine = originalLines[found + pair.oldIndex];
|
|
if (originalLine !== undefined) result[pair.newIndex] = originalLine;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function expectedPatchLineDiagnostics(originalLines: string[], chunk: HwpodPatchChunk, preferredStart: number) {
|
|
const firstExpectedLine = chunk.oldLines.find((line) => line.trim().length > 0) ?? "";
|
|
const firstExpectedLineCandidates = firstExpectedLine ? patchCandidateLineNumbers(originalLines, firstExpectedLine, 8) : [];
|
|
const prefix = bestPatchPrefixMatch(originalLines, chunk.oldLines, firstExpectedLine, preferredStart);
|
|
const missingAddedPrefixes = likelyPatchMissingAddedPrefixes(chunk, prefix.matchedLines);
|
|
return {
|
|
firstExpectedLine,
|
|
firstExpectedLineCandidates,
|
|
firstExpectedLineCandidatesTruncated: firstExpectedLine.length > 0 && patchCandidateLineNumbers(originalLines, firstExpectedLine, 9).length > firstExpectedLineCandidates.length,
|
|
bestPrefixMatchedLines: prefix.matchedLines,
|
|
bestPrefixStartLine: prefix.startLine,
|
|
likelyMissingAddedPrefixes: missingAddedPrefixes,
|
|
likelyStaleOrOversizedContext: !missingAddedPrefixes && likelyPatchStaleOrOversizedContext(chunk, prefix.matchedLines)
|
|
};
|
|
}
|
|
|
|
function patchCandidateLineNumbers(lines: string[], expectedLine: string, limit: number) {
|
|
const result: number[] = [];
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
if (patchLineEquivalent(lines[index] ?? "", expectedLine)) {
|
|
result.push(index + 1);
|
|
if (result.length >= limit) break;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function bestPatchPrefixMatch(lines: string[], expectedLines: string[], firstExpectedLine: string, preferredStart: number) {
|
|
let best = { startLine: null as number | null, matchedLines: 0 };
|
|
if (expectedLines.length === 0) return best;
|
|
for (let index = Math.max(0, preferredStart); index < lines.length; index += 1) {
|
|
if (firstExpectedLine.length > 0 && !patchLineEquivalent(lines[index] ?? "", firstExpectedLine)) continue;
|
|
let matched = 0;
|
|
while (index + matched < lines.length && matched < expectedLines.length && patchLineEquivalent(lines[index + matched] ?? "", expectedLines[matched] ?? "")) matched += 1;
|
|
if (matched > best.matchedLines) best = { startLine: index + 1, matchedLines: matched };
|
|
if (matched === expectedLines.length) break;
|
|
}
|
|
if (best.matchedLines > 0 || preferredStart <= 0) return best;
|
|
for (let index = 0; index < Math.min(preferredStart, lines.length); index += 1) {
|
|
if (firstExpectedLine.length > 0 && !patchLineEquivalent(lines[index] ?? "", firstExpectedLine)) continue;
|
|
let matched = 0;
|
|
while (index + matched < lines.length && matched < expectedLines.length && patchLineEquivalent(lines[index + matched] ?? "", expectedLines[matched] ?? "")) matched += 1;
|
|
if (matched > best.matchedLines) best = { startLine: index + 1, matchedLines: matched };
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function likelyPatchMissingAddedPrefixes(chunk: HwpodPatchChunk, bestPrefixMatchedLines: number) {
|
|
if (chunk.deletedLineCount > 0) return false;
|
|
if (chunk.oldLines.length < 8) return false;
|
|
if (chunk.addedLineCount > 2) return false;
|
|
if (chunk.contextLineCount < 8) return false;
|
|
return bestPrefixMatchedLines > 0 && bestPrefixMatchedLines < chunk.oldLines.length;
|
|
}
|
|
|
|
function likelyPatchStaleOrOversizedContext(chunk: HwpodPatchChunk, bestPrefixMatchedLines: number) {
|
|
if (chunk.oldLines.length < 4) return false;
|
|
if (bestPrefixMatchedLines < 2 || bestPrefixMatchedLines >= chunk.oldLines.length) return false;
|
|
return chunk.addedLineCount > 0 || chunk.deletedLineCount > 0 || chunk.contextLineCount >= 4;
|
|
}
|
|
|
|
function patchLineEquivalent(left: string, right: string) {
|
|
return left === right || left.trimEnd() === right.trimEnd() || left.trim() === right.trim() || normalizePatchLine(left) === normalizePatchLine(right);
|
|
}
|
|
|
|
function normalizePatchLine(value: string) {
|
|
return value.trim().replace(/[\u2010-\u2015\u2212]/gu, "-")
|
|
.replace(/[\u2018\u2019\u201A\u201B]/gu, "'")
|
|
.replace(/[\u201C\u201D\u201E\u201F]/gu, "\"")
|
|
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/gu, " ");
|
|
}
|
|
|
|
function pushUniquePatchHint(hints: string[], prefix: string, hint: string) {
|
|
if (!hints.some((existing) => existing.includes(prefix))) hints.push(hint);
|
|
}
|
|
|
|
async function readTextFileState(filePath: string) {
|
|
const content = await readFile(filePath, "utf8");
|
|
const crlfCount = (content.match(/\r\n/gu) || []).length;
|
|
const loneLfCount = (content.replace(/\r\n/gu, "").match(/\n/gu) || []).length;
|
|
const loneCrCount = (content.replace(/\r\n/gu, "").match(/\r/gu) || []).length;
|
|
const lineEnding = crlfCount > 0 && loneLfCount === 0 && loneCrCount === 0 ? "\r\n" : "\n";
|
|
const normalizedContent = content.replace(/\r\n?/gu, "\n");
|
|
return {
|
|
content,
|
|
normalizedContent,
|
|
bytes: Buffer.byteLength(content, "utf8"),
|
|
sha256: sha256Text(content),
|
|
lineEnding,
|
|
lineEndingCounts: { crlf: crlfCount, lf: loneLfCount, cr: loneCrCount },
|
|
hadFinalNewline: normalizedContent.endsWith("\n")
|
|
};
|
|
}
|
|
|
|
function splitNormalizedLines(content: string, hadFinalNewline: boolean) {
|
|
const lines = content.split("\n");
|
|
if (hadFinalNewline) lines.pop();
|
|
return lines;
|
|
}
|
|
|
|
function diagnosticDetails(fileState: any, expected: string, relativePath: string) {
|
|
const expectedLines = splitNormalizedLines(normalizeContentLineEndings(expected, "\n", false), false).filter((line) => line.length > 0);
|
|
const normalizedLines = splitNormalizedLines(fileState.normalizedContent, fileState.hadFinalNewline);
|
|
const candidates = expectedLines.slice(0, 4).flatMap((line) => candidateLines(normalizedLines, line)).slice(0, 12);
|
|
return {
|
|
path: relativePath,
|
|
fileSha256: fileState.sha256,
|
|
fileBytes: fileState.bytes,
|
|
lineEnding: fileState.lineEnding,
|
|
lineEndingCounts: fileState.lineEndingCounts,
|
|
expectedPreview: previewLines(expectedLines.length ? expectedLines : [expected]),
|
|
candidates,
|
|
nodeVersion: NODE_VERSION,
|
|
normalized: true
|
|
};
|
|
}
|
|
|
|
function candidateLines(lines: string[], needle: string) {
|
|
const trimmed = needle.trim();
|
|
if (!trimmed) return [];
|
|
return lines
|
|
.map((line, index) => ({ lineNumber: index + 1, line }))
|
|
.filter((item) => item.line.includes(trimmed) || item.line.trim() === trimmed)
|
|
.slice(0, 4)
|
|
.map((item) => ({ lineNumber: item.lineNumber, preview: item.line.slice(0, 160) }));
|
|
}
|
|
|
|
function previewLines(lines: string[]) {
|
|
return lines.slice(0, 8).map((line, index) => ({ offset: index + 1, text: line.slice(0, 160) }));
|
|
}
|
|
|
|
function editResult({ action, path: relativePath, before, afterContent, dryRun, extra = {} }: any) {
|
|
const afterSha = sha256Text(afterContent);
|
|
const summary = diffSummary(before?.normalizedContent ?? "", afterContent.replace(/\r\n?/gu, "\n"));
|
|
return {
|
|
action,
|
|
path: relativePath,
|
|
dryRun: Boolean(dryRun),
|
|
before: before ? { sha256: before.sha256, bytes: before.bytes, lineEnding: before.lineEnding, lineEndingCounts: before.lineEndingCounts } : null,
|
|
after: { sha256: afterSha, bytes: Buffer.byteLength(afterContent, "utf8"), lineEnding: detectLineEnding(afterContent) },
|
|
diff: summary,
|
|
...extra
|
|
};
|
|
}
|
|
|
|
function diffSummary(beforeNormalized: string, afterNormalized: string) {
|
|
const beforeLines = splitNormalizedLines(beforeNormalized, beforeNormalized.endsWith("\n"));
|
|
const afterLines = splitNormalizedLines(afterNormalized, afterNormalized.endsWith("\n"));
|
|
let prefix = 0;
|
|
while (prefix < beforeLines.length && prefix < afterLines.length && beforeLines[prefix] === afterLines[prefix]) prefix += 1;
|
|
let suffix = 0;
|
|
while (suffix < beforeLines.length - prefix && suffix < afterLines.length - prefix && beforeLines[beforeLines.length - 1 - suffix] === afterLines[afterLines.length - 1 - suffix]) suffix += 1;
|
|
const removed = beforeLines.slice(prefix, beforeLines.length - suffix);
|
|
const added = afterLines.slice(prefix, afterLines.length - suffix);
|
|
return {
|
|
firstChangedLine: prefix + 1,
|
|
removedLines: removed.length,
|
|
addedLines: added.length,
|
|
preview: [
|
|
...removed.slice(0, 6).map((line) => `-${line}`),
|
|
...added.slice(0, 6).map((line) => `+${line}`)
|
|
]
|
|
};
|
|
}
|
|
|
|
function assertExpectedSha(before: any, expectedSha: unknown, relativePath: string) {
|
|
const expected = text(expectedSha);
|
|
if (!expected) return;
|
|
if (!before) throw cliError("workspace_expected_sha_missing_file", `expectedSha was provided but ${relativePath} does not exist`, { path: relativePath, expectedSha: expected });
|
|
if (before.sha256 !== expected) {
|
|
throw cliError("workspace_expected_sha_mismatch", `expectedSha mismatch for ${relativePath}`, { path: relativePath, expectedSha: expected, actualSha: before.sha256, bytes: before.bytes });
|
|
}
|
|
}
|
|
|
|
function contentFromArgs(args: any, name: string) {
|
|
const direct = typeof args[name] === "string" ? args[name] : typeof args.contentText === "string" ? args.contentText : "";
|
|
if (typeof direct === "string" && direct.length > 0) return direct;
|
|
const base64 = text(args.contentBase64);
|
|
if (base64) return Buffer.from(base64, "base64").toString("utf8");
|
|
throw cliError("content_required", `${name} or contentBase64 is required`);
|
|
}
|
|
|
|
function lineEndingFromArgs(args: any, fallback: string) {
|
|
const value = text(args.lineEnding).toLowerCase();
|
|
if (!value || value === "preserve") return fallback;
|
|
if (value === "crlf" || value === "\\r\\n") return "\r\n";
|
|
if (value === "lf" || value === "\\n") return "\n";
|
|
throw cliError("invalid_line_ending", "lineEnding must be preserve, lf, or crlf", { lineEnding: value });
|
|
}
|
|
|
|
function normalizeContentLineEndings(content: string, lineEnding: string, finalNewline: boolean) {
|
|
const normalized = String(content).replace(/\r\n?/gu, "\n");
|
|
const withFinal = finalNewline && !normalized.endsWith("\n") ? `${normalized}\n` : normalized;
|
|
return lineEnding === "\r\n" ? withFinal.replace(/\n/gu, "\r\n") : withFinal;
|
|
}
|
|
|
|
function detectLineEnding(content: string) {
|
|
const state = content.includes("\r\n") && !content.replace(/\r\n/gu, "").includes("\n") ? "\r\n" : "\n";
|
|
return state;
|
|
}
|
|
|
|
function sha256Text(content: string) {
|
|
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
}
|
|
|
|
function countOccurrences(content: string, needle: string) {
|
|
if (!needle) return 0;
|
|
let count = 0;
|
|
let index = 0;
|
|
for (;;) {
|
|
const found = content.indexOf(needle, index);
|
|
if (found < 0) return count;
|
|
count += 1;
|
|
index = found + needle.length;
|
|
}
|
|
}
|
|
|
|
function requiredRawString(value: unknown, name: string) {
|
|
const normalized = rawString(value);
|
|
if (!normalized) throw cliError("required_option_missing", `${name} is required`, { name });
|
|
return normalized;
|
|
}
|
|
|
|
function rawString(value: unknown) {
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
function findLineSequence(lines: string[], sequence: string[], start: number) {
|
|
if (sequence.length === 0) return start;
|
|
for (let index = start; index <= lines.length - sequence.length; index += 1) {
|
|
if (sequence.every((line, offset) => lines[index + offset] === line)) return index;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function findInsertAnchor(lines: string[], anchorLines: string[], allowMultiple: boolean) {
|
|
const exact = findLineSequence(lines, anchorLines, 0);
|
|
if (exact >= 0) {
|
|
const secondExact = findLineSequence(lines, anchorLines, exact + 1);
|
|
return { line: exact, secondLine: secondExact, length: anchorLines.length, kind: "line-sequence", ambiguous: secondExact >= 0 && !allowMultiple };
|
|
}
|
|
if (anchorLines.length !== 1) return { line: -1, secondLine: -1, length: anchorLines.length, kind: "line-sequence", ambiguous: false };
|
|
const needle = anchorLines[0].trim();
|
|
if (!needle) return { line: -1, secondLine: -1, length: 1, kind: "line-fragment", ambiguous: false };
|
|
const matches = lines.map((line, index) => ({ line, index })).filter((item) => item.line.includes(needle));
|
|
if (matches.length === 0) return { line: -1, secondLine: -1, length: 1, kind: "line-fragment", ambiguous: false };
|
|
return { line: matches[0].index, secondLine: matches[1]?.index ?? -1, length: 1, kind: "line-fragment", ambiguous: matches.length > 1 && !allowMultiple };
|
|
}
|
|
|
|
function resolvePatchFile(root: string, relativePath: string) {
|
|
if (!relativePath || path.isAbsolute(relativePath)) throw cliError("invalid_patch_path", "patch file paths must be relative", { path: relativePath });
|
|
return resolveWorkspacePath({ workspacePath: root }, relativePath);
|
|
}
|
|
|
|
async function cmdRun(args: any) {
|
|
const command = requiredText(args.command, "command");
|
|
const commandBinding = objectValue(args.commandBinding);
|
|
const argv = nativeHelperArgv(Array.isArray(args.argv) ? args.argv.map(String) : [], commandBinding);
|
|
const cwd = workspaceRoot(args);
|
|
const output = await spawnOutput([command, ...argv], { cwd, timeoutMs: numberValue(args.timeoutMs) ?? 30000 });
|
|
const observation = commandObservation(commandBinding, output);
|
|
return { cwd, command: [command, ...argv], ...output, ...(observation ? { observation } : {}) };
|
|
}
|
|
|
|
function nativeHelperArgv(argv: string[], commandBinding: Record<string, unknown>) {
|
|
if (text(commandBinding.nativeHelper) !== "io-probe-observation") return argv;
|
|
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../hwpod-io-probe-observation.mjs");
|
|
return [helperPath, ...argv.slice(1)];
|
|
}
|
|
|
|
function commandObservation(commandBinding: Record<string, unknown>, output: any) {
|
|
if (!["io-probe", "board-comm"].includes(text(commandBinding.kind))) return undefined;
|
|
const observation = parseJsonMaybe(output.stdout);
|
|
return observation && typeof observation === "object" && !Array.isArray(observation) ? observation : undefined;
|
|
}
|
|
|
|
async function uartRead(args: any) {
|
|
const diagnostics = uartBindingDiagnostics("io.uart.read", args);
|
|
const serialMonitorDir = text(args.serialMonitorDir) || text(process.env.HWPOD_SERIAL_MONITOR_DIR) || path.join(os.homedir(), ".agents", "skills", "serial-monitor");
|
|
const commandBase = serialMonitorCommandBase(args);
|
|
const timeoutMs = numberValue(args.timeoutMs) ?? 10000;
|
|
const status = await runSerialMonitor(commandBase, ["monitor", "status"], { cwd: serialMonitorDir, timeoutMs })
|
|
.catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [...commandBase, "monitor", "status"] }));
|
|
let monitorStatus = status;
|
|
let statusBody = parseJsonMaybe(status.stdout);
|
|
let statusData = objectValue(statusBody?.data);
|
|
const targetPort = text(diagnostics.resolvedPort) || text(diagnostics.requestedPort);
|
|
const targetBaudRate = numberValue(diagnostics.baudRate);
|
|
const activePort = text(statusData.port);
|
|
const activeBaudRate = numberValue(statusData.baudRate);
|
|
let monitorStart: any = null;
|
|
const monitoringMatches = status.ok && statusBody?.success === true && statusData.isMonitoring === true
|
|
&& (!targetPort || activePort.toLowerCase() === targetPort.toLowerCase())
|
|
&& (!targetBaudRate || activeBaudRate === targetBaudRate);
|
|
if (!monitoringMatches) {
|
|
const startArgs = ["monitor", "start", ...(targetPort ? ["-p", targetPort] : []), ...(targetBaudRate ? ["-b", String(targetBaudRate)] : [])];
|
|
const start = await runSerialMonitor(commandBase, startArgs, { cwd: serialMonitorDir, timeoutMs }).catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [...commandBase, ...startArgs] }));
|
|
const startBody = parseJsonMaybe(start.stdout);
|
|
monitorStart = startBody ?? { parseOk: false, stdout: start.stdout, stderr: start.stderr, exitCode: start.exitCode };
|
|
if (!start.ok || startBody?.success !== true) {
|
|
const ports = await runSerialMonitor(commandBase, ["ports"], { cwd: serialMonitorDir, timeoutMs }).catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [] }));
|
|
return {
|
|
ok: false,
|
|
blockerCode: "hwpod_uart_monitor_start_failed",
|
|
summary: `serial-monitor could not start on ${targetPort || "the requested UART"}${targetBaudRate ? `/${targetBaudRate}` : ""}`,
|
|
details: { ...diagnostics, serialMonitor: { dir: serialMonitorDir, command: commandBase, monitorStatus: statusBody ?? status, start: startBody ?? start, ports: parseJsonMaybe(ports.stdout) ?? ports } }
|
|
};
|
|
}
|
|
const confirmedStatus = await runSerialMonitor(commandBase, ["monitor", "status"], { cwd: serialMonitorDir, timeoutMs })
|
|
.catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [...commandBase, "monitor", "status"] }));
|
|
const confirmedBody = parseJsonMaybe(confirmedStatus.stdout);
|
|
const confirmedData = objectValue(confirmedBody?.data);
|
|
const confirmedPort = text(confirmedData.port);
|
|
const confirmedBaudRate = numberValue(confirmedData.baudRate);
|
|
const confirmedMatches = confirmedStatus.ok && confirmedBody?.success === true && confirmedData.isMonitoring === true
|
|
&& (!targetPort || confirmedPort.toLowerCase() === targetPort.toLowerCase())
|
|
&& (!targetBaudRate || confirmedBaudRate === targetBaudRate);
|
|
if (!confirmedMatches) {
|
|
return {
|
|
ok: false,
|
|
blockerCode: "hwpod_uart_monitor_binding_mismatch",
|
|
summary: "serial-monitor did not confirm the requested UART binding after start",
|
|
details: { ...diagnostics, serialMonitor: { dir: serialMonitorDir, command: commandBase, monitorStatus: statusBody ?? status, start: monitorStart, confirmedStatus: confirmedBody ?? confirmedStatus } }
|
|
};
|
|
}
|
|
monitorStatus = confirmedStatus;
|
|
statusBody = confirmedBody;
|
|
statusData = confirmedData;
|
|
}
|
|
const limit = Math.max(1, Math.min(numberValue(args.limit) ?? Math.ceil((numberValue(args.maxBytes) ?? 4096) / 80), 200));
|
|
const maxBytes = numberValue(args.maxBytes) ?? 16384;
|
|
const fetchArgs = ["fetch", "-l", String(limit), "--session-only", ...(text(args.since) ? ["-s", text(args.since)] : [])];
|
|
const fetch = await runSerialMonitor(commandBase, fetchArgs, { cwd: serialMonitorDir, timeoutMs });
|
|
const fetchBody = parseJsonMaybe(fetch.stdout);
|
|
if (!fetch.ok || fetchBody?.success !== true) {
|
|
return {
|
|
ok: false,
|
|
blockerCode: "hwpod_uart_read_failed",
|
|
summary: "serial-monitor fetch failed for io.uart.read",
|
|
details: { ...diagnostics, serialMonitor: { dir: serialMonitorDir, command: commandBase, fetch: fetchBody ?? { parseOk: false, stdout: fetch.stdout, stderr: fetch.stderr, exitCode: fetch.exitCode } } }
|
|
};
|
|
}
|
|
const rows = Array.isArray(fetchBody.data) ? fetchBody.data : [];
|
|
const joined = rows.map((row: any) => text(row?.data)).filter(Boolean).join("\n");
|
|
const truncatedText = joined.length > maxBytes ? joined.slice(0, maxBytes) : joined;
|
|
const resolvedPort = text(statusData.port) || targetPort;
|
|
const baudRate = numberValue(statusData.baudRate) || targetBaudRate || activeBaudRate || null;
|
|
const sourceFile = text(fetchBody.sourceFile);
|
|
const observation = {
|
|
observationId: `uart:${resolvedPort || "unknown"}:${sourceFile || "session"}`,
|
|
port: resolvedPort,
|
|
baudRate,
|
|
data: rows,
|
|
text: truncatedText,
|
|
truncated: joined.length > maxBytes || fetchBody.truncated === true,
|
|
...(sourceFile ? { rawArtifactRef: sourceFile } : {})
|
|
};
|
|
return {
|
|
ok: true,
|
|
workspacePath: workspaceRoot(args),
|
|
bindingSource: "serial-monitor-cli",
|
|
serialMonitorDir,
|
|
requestedPort: diagnostics.requestedPort,
|
|
resolvedPort,
|
|
baudRate,
|
|
command: [...commandBase, ...fetchArgs],
|
|
data: rows,
|
|
count: fetchBody.count ?? rows.length,
|
|
totalCount: fetchBody.totalCount ?? null,
|
|
hasMore: fetchBody.hasMore ?? false,
|
|
text: truncatedText,
|
|
truncated: joined.length > maxBytes || fetchBody.truncated === true,
|
|
monitorStatus: statusBody ?? monitorStatus,
|
|
monitorStarted: !monitoringMatches,
|
|
monitorStart,
|
|
sourceFile: sourceFile || null,
|
|
observation
|
|
};
|
|
}
|
|
|
|
async function runSerialMonitor(commandBase: string[], argv: string[], { cwd, timeoutMs }: any) {
|
|
const output = await spawnOutput([...commandBase, ...argv], { cwd, timeoutMs });
|
|
return { command: [...commandBase, ...argv], ...output };
|
|
}
|
|
|
|
function serialMonitorCommandBase(args: any) {
|
|
if (Array.isArray(args.serialMonitorCommand) && args.serialMonitorCommand.length > 0) return args.serialMonitorCommand.map(String);
|
|
const configured = text(process.env.HWPOD_SERIAL_MONITOR_COMMAND);
|
|
if (configured) return configured.split(/\s+/u).filter(Boolean);
|
|
return ["bun", "scripts/serial-monitor-cli.ts"];
|
|
}
|
|
|
|
async function spawnShellOutput(command: string, { cwd, timeoutMs }: any) {
|
|
const shellCommand = process.platform === "win32" ? ["cmd.exe", "/d", "/s", "/c", command] : ["sh", "-lc", command];
|
|
const output = await spawnOutput(shellCommand, { cwd, timeoutMs });
|
|
return { shell: shellCommand.slice(0, -1), exitCode: output.exitCode, stdout: output.stdout, stderr: output.stderr, ok: output.ok };
|
|
}
|
|
|
|
async function spawnOutput(command: string[], { cwd, stdinText = "", timeoutMs }: any) {
|
|
const resolution = await resolveHwpodNodeCommand(command[0]);
|
|
const resolvedCommand = [resolution.command, ...command.slice(1)];
|
|
let proc;
|
|
try {
|
|
proc = Bun.spawn(resolvedCommand, { cwd, stdin: stdinText ? "pipe" : "ignore", stdout: "pipe", stderr: "pipe" });
|
|
} catch (error) {
|
|
return {
|
|
exitCode: null,
|
|
stdout: "",
|
|
stderr: error instanceof Error ? error.message : String(error),
|
|
ok: false,
|
|
blockerCode: "hwpod_node_command_spawn_failed",
|
|
commandResolution: resolution,
|
|
spawnDiagnostics: commandSpawnDiagnostics(command, resolvedCommand, cwd, resolution, error)
|
|
};
|
|
}
|
|
if (stdinText && proc.stdin) {
|
|
proc.stdin.write(stdinText);
|
|
proc.stdin.end();
|
|
}
|
|
const timeout = setTimeout(() => proc.kill(), timeoutMs);
|
|
try {
|
|
const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
|
return { exitCode, stdout, stderr, ok: exitCode === 0, commandResolution: resolution };
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function commandSpawnDiagnostics(command: string[], resolvedCommand: string[], cwd: string, resolution: any, error: unknown) {
|
|
return {
|
|
requestedCommand: command,
|
|
resolvedCommand,
|
|
commandResolution: resolution,
|
|
cwd,
|
|
platform: process.platform,
|
|
hostname: os.hostname(),
|
|
nodeVersion: NODE_VERSION,
|
|
pathPreview: pathPreview(process.env.PATH),
|
|
error: error instanceof Error ? { name: error.name, message: error.message, code: (error as any).code ?? null } : { message: String(error) }
|
|
};
|
|
}
|
|
|
|
function pathPreview(value: unknown) {
|
|
return text(value).split(path.delimiter).filter(Boolean).slice(0, 12);
|
|
}
|
|
|
|
export async function resolveHwpodNodeCommand(command: string, options: any = {}) {
|
|
const requested = requiredText(command, "command");
|
|
const platform = text(options.platform) || process.platform;
|
|
if (isExplicitExecutablePath(requested)) {
|
|
return { requested, command: requested, resolved: false, source: "explicit-path" };
|
|
}
|
|
if (platform !== "win32") {
|
|
return { requested, command: requested, resolved: false, source: "process-path" };
|
|
}
|
|
const candidates = windowsWellKnownCommandCandidates(requested, options.env ?? process.env);
|
|
const exists = options.fileExists ?? fileExists;
|
|
for (const candidate of candidates) {
|
|
if (await exists(candidate)) {
|
|
return { requested, command: candidate, resolved: true, source: "windows-well-known-tool", candidates };
|
|
}
|
|
}
|
|
return { requested, command: requested, resolved: false, source: "process-path", candidates };
|
|
}
|
|
|
|
function isExplicitExecutablePath(command: string) {
|
|
return command.includes("/") || command.includes("\\") || path.isAbsolute(command) || isWindowsAbsolutePath(command);
|
|
}
|
|
|
|
function windowsWellKnownCommandCandidates(command: string, env: Record<string, unknown>) {
|
|
const normalized = command.toLowerCase().replace(/\.exe$/u, "");
|
|
if (normalized !== "git") return [];
|
|
return [
|
|
text(env.HWPOD_NODE_GIT_EXE),
|
|
"C:\\Program Files\\Git\\cmd\\git.exe",
|
|
"C:\\Program Files\\Git\\bin\\git.exe",
|
|
"C:\\Program Files (x86)\\Git\\cmd\\git.exe",
|
|
"C:\\Program Files (x86)\\Git\\bin\\git.exe"
|
|
].filter(Boolean);
|
|
}
|
|
|
|
async function fileExists(candidate: string) {
|
|
try {
|
|
const info = await stat(candidate);
|
|
return info.isFile() || !info.isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function workspaceRoot(args: any) {
|
|
const workspacePath = text(args.workspacePath) || process.env.HWPOD_WORKSPACE_PATH || process.cwd();
|
|
return isWindowsAbsolutePath(workspacePath) ? normalizeWindowsPath(workspacePath) : path.resolve(workspacePath);
|
|
}
|
|
|
|
function resolveWorkspacePath(args: any, relativePath: string) {
|
|
const root = workspaceRoot(args);
|
|
const target = resolveWorkspaceChild(root, relativePath);
|
|
const separator = windowsPathKind(root) ? "\\" : path.sep;
|
|
if (target !== root && !target.startsWith(`${root}${separator}`)) {
|
|
throw cliError("workspace_path_outside_root", "workspace path must stay inside workspacePath", { workspacePath: root, path: relativePath });
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function resolveWorkspaceChild(root: string, relativePath: string) {
|
|
const normalizedRelative = text(relativePath) || ".";
|
|
if (!windowsPathKind(root)) return path.resolve(root, normalizedRelative);
|
|
if (normalizedRelative === ".") return root;
|
|
if (isWindowsAbsolutePath(normalizedRelative) || normalizedRelative.startsWith("/")) return normalizeWindowsPath(normalizedRelative);
|
|
return `${root.replace(/[\\/]+$/u, "")}\\${normalizedRelative.replace(/[\\/]+/gu, "\\").replace(/^[\\/]+/u, "")}`;
|
|
}
|
|
|
|
function windowsPathKind(value: string) {
|
|
return /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("\\\\");
|
|
}
|
|
|
|
function isWindowsAbsolutePath(value: string) {
|
|
return windowsPathKind(String(value ?? "").trim());
|
|
}
|
|
|
|
function normalizeWindowsPath(value: string) {
|
|
return String(value).replace(/\//gu, "\\").replace(/[\\]+$/u, "");
|
|
}
|
|
|
|
function planFromParsed(parsed: ParsedArgs, stdinText?: string) {
|
|
if (parsed.planJson) return JSON.parse(String(parsed.planJson));
|
|
if (parsed.plan) return JSON.parse(String(parsed.plan));
|
|
if (stdinText) return JSON.parse(stdinText);
|
|
throw cliError("hwpod_node_plan_required", "pass --plan-json or pipe a hwpod-node-ops plan to hwpod-node run");
|
|
}
|
|
|
|
function help() {
|
|
return {
|
|
ok: true,
|
|
action: "hwpod-node.help",
|
|
status: "succeeded",
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
version: NODE_VERSION,
|
|
role: "Single host-side executor for stable hwpod-node-ops; hwpod-spec, hwpod-cli, hwpod-ctl, and hwpod-compiler stay in the Code Agent workspace.",
|
|
usage: [
|
|
"bun tools/hwpod-node.ts run --plan-json '{...}'",
|
|
"bun tools/hwpod-node.ts serve --host 127.0.0.1 --port 19678",
|
|
"bun tools/hwpod-node.ts connect --cloud-url http://74.48.78.17:19667 --node-id node-d601-f103-v2"
|
|
]
|
|
};
|
|
}
|
|
|
|
function result(exitCode: number, payload: any, now: () => string) {
|
|
return { exitCode, payload: { ...payload, observedAt: payload?.observedAt ?? now() } };
|
|
}
|
|
|
|
function opOk(opId: string, op: string, output: any) {
|
|
return { opId, op, ok: true, status: "completed", output };
|
|
}
|
|
|
|
function opBlocked(opId: string, op: string, code: string, summary: string, details?: Record<string, unknown>) {
|
|
return { opId, op, ok: false, status: "blocked", blocker: { code, layer: "hwpod-node", retryable: true, summary, details: details ?? undefined } };
|
|
}
|
|
|
|
function opBlockedFromError(opId: string, op: string, error: any) {
|
|
return { opId, op, ok: false, status: "blocked", blocker: { code: error?.code || "hwpod_node_op_failed", layer: "hwpod-node", retryable: true, summary: error?.message || String(error), details: error?.details || undefined } };
|
|
}
|
|
|
|
function opFailed(opId: string, op: string, output: any, code: string, summary: string) {
|
|
return { opId, op, ok: false, status: "failed", output, blocker: { code, layer: "hwpod-node", retryable: false, summary } };
|
|
}
|
|
|
|
function failure(action: string, error: any) {
|
|
return { ok: false, action, status: "failed", error: { code: error?.code || "hwpod_node_error", message: error?.message || String(error), details: error?.details || undefined } };
|
|
}
|
|
|
|
function uartBindingDiagnostics(op: string, args: any) {
|
|
const ioProbe = objectValue(args.ioProbe);
|
|
const uart = objectValue(ioProbe.uart);
|
|
const requestedPort = text(args.port) || text(uart.id) || "uart1";
|
|
const devicePort = text(uart.port) || text(uart.path) || text(ioProbe.port);
|
|
const baudRate = numberValue(args.baudRate ?? args.baudrate ?? uart.baudRate ?? uart.baudrate ?? ioProbe.baudRate ?? ioProbe.baudrate);
|
|
return {
|
|
op,
|
|
requestedPort,
|
|
resolvedPort: devicePort || requestedPort,
|
|
baudRate: baudRate ?? null,
|
|
workspacePath: text(args.workspacePath) || null,
|
|
ioProbe,
|
|
platform: process.platform,
|
|
nodeVersion: NODE_VERSION,
|
|
requiredBinding: {
|
|
kind: "uart-tool",
|
|
acceptedSpecPaths: ["spec.ioProbe.uart.port", "spec.ioProbe.port"],
|
|
nodeEnvHints: ["HWPOD_SERIAL_MONITOR_DIR", "HWPOD_SERIAL_MONITOR_COMMAND", "HWPOD_UART_PORT"]
|
|
},
|
|
supportedFallback: false
|
|
};
|
|
}
|
|
|
|
function parseJsonMaybe(value: string) {
|
|
try {
|
|
return JSON.parse(String(value ?? "").trim());
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseOptions(argv: string[]): ParsedArgs {
|
|
const parsed: ParsedArgs = { _: [] };
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const token = argv[index];
|
|
if (token === "--") {
|
|
parsed._.push(...argv.slice(index + 1));
|
|
break;
|
|
}
|
|
if (!token.startsWith("-") || token === "-") {
|
|
parsed._.push(token);
|
|
continue;
|
|
}
|
|
if (token === "-h") {
|
|
parsed.h = true;
|
|
parsed.help = true;
|
|
continue;
|
|
}
|
|
const [rawKey, rawInline] = token.slice(2).split(/=(.*)/su, 2);
|
|
const key = rawKey.replace(/-([a-z0-9])/giu, (_match, char) => char.toUpperCase());
|
|
if (BOOLEAN_OPTIONS.has(key)) {
|
|
parsed[key] = rawInline === undefined || rawInline === "" ? true : /^(?:1|true|yes|on)$/iu.test(rawInline);
|
|
continue;
|
|
}
|
|
if (rawInline !== undefined && rawInline !== "") {
|
|
parsed[key] = rawInline;
|
|
continue;
|
|
}
|
|
const next = argv[index + 1];
|
|
if (next === undefined || next.startsWith("--")) {
|
|
parsed[key] = true;
|
|
} else {
|
|
parsed[key] = next;
|
|
index += 1;
|
|
}
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function readBody(request: any, limitBytes = DEFAULT_BODY_LIMIT_BYTES) {
|
|
const limit = Number.isInteger(limitBytes) && limitBytes > 0 ? limitBytes : DEFAULT_BODY_LIMIT_BYTES;
|
|
return new Promise<string>((resolve, reject) => {
|
|
let body = "";
|
|
request.setEncoding("utf8");
|
|
request.on("data", (chunk: string) => {
|
|
body += chunk;
|
|
if (Buffer.byteLength(body, "utf8") > limit) request.destroy(new Error(`request body exceeds ${limit} bytes`));
|
|
});
|
|
request.on("end", () => resolve(body));
|
|
request.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function sendJson(response: any, statusCode: number, body: any) {
|
|
response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
response.end(`${JSON.stringify(body)}\n`);
|
|
}
|
|
|
|
function cliError(code: string, message: string, details: Record<string, unknown> = {}) {
|
|
const error = new Error(message) as Error & { code?: string; details?: Record<string, unknown> };
|
|
error.code = code;
|
|
error.details = details;
|
|
return error;
|
|
}
|
|
|
|
function requiredText(value: unknown, name: string) {
|
|
const normalized = text(value);
|
|
if (!normalized) throw cliError("required_option_missing", `${name} is required`, { name });
|
|
return normalized;
|
|
}
|
|
|
|
function text(value: unknown) {
|
|
return typeof value === "string" && value.trim() ? value.trim() : "";
|
|
}
|
|
|
|
function objectValue(value: any) {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
}
|
|
|
|
function numberValue(value: unknown) {
|
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
return Number.isInteger(parsed) ? parsed : undefined;
|
|
}
|