Files
pikasTech-HWLAB/tools/src/hwpod-harness-lib.ts
T
2026-06-06 17:28:06 +08:00

889 lines
43 KiB
TypeScript

import { randomUUID } from "node:crypto";
import http from "node:http";
import https from "node:https";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "./hwpod-node-ops-contract.ts";
import { resolveRuntimeEndpoint, runtimeEndpointVisibility } from "./runtime-endpoint-resolver.ts";
export const DEFAULT_HWPOD_SPEC_PATH = ".hwlab/hwpod-spec.yaml";
const COMPILER_NAME = "hwpod-compiler-cli";
const CTL_NAME = "hwpod-ctl";
const CLI_NAME = "hwpod-cli";
const DEFAULT_TIMEOUT_MS = 30000;
const BOOLEAN_OPTIONS = new Set(["all", "allowMultiple", "dryRun", "finalNewline", "force", "full", "help", "h", "json", "noAuth"]);
const DEFAULT_KEIL_COMMAND_TIMEOUT_MS = 30000;
type EnvLike = Record<string, string | undefined>;
type ParsedArgs = Record<string, unknown> & { _: string[] };
type FetchLike = typeof fetch;
export async function mainHwpodCompilerCli(argv = process.argv.slice(2), options: any = {}) {
const result = await runHwpodCompilerCli(argv, options);
console.log(JSON.stringify(result.payload, null, 2));
process.exitCode = result.exitCode;
}
export async function mainHwpodCtl(argv = process.argv.slice(2), options: any = {}) {
const result = await runHwpodCtl(argv, options);
console.log(JSON.stringify(result.payload, null, 2));
process.exitCode = result.exitCode;
}
export async function mainHwpodCli(argv = process.argv.slice(2), options: any = {}) {
const stdinText = options.stdinText ?? await readCliStdinForCommand(argv);
const result = await runHwpodCli(argv, { ...options, stdinText });
console.log(JSON.stringify(result.payload, null, 2));
process.exitCode = result.exitCode;
}
async function readCliStdinForCommand(argv: string[]): Promise<string | undefined> {
const parsed = parseOptions(argv);
const command = parsed._[0] || "help";
const subcommand = parsed._[1] || "";
if (command === "workspace" && ["apply-patch", "write"].includes(subcommand) && parsed.patch === undefined && parsed.patchText === undefined && parsed.patchBase64 === undefined && parsed.content === undefined && parsed.contentText === undefined && parsed.contentBase64 === undefined && !process.stdin.isTTY) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString("utf8");
}
if (command === "uart" && subcommand === "write" && parsed.data === undefined && parsed._[3] === undefined && !process.stdin.isTTY) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString("utf8");
}
return undefined;
}
export async function runHwpodCompilerCli(argv: string[], options: { env?: EnvLike; 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, compilerHelp(), now);
if (command !== "compile") throw cliError("unsupported_compiler_command", `unsupported hwpod-compiler-cli command: ${command}`);
const specPath = specPathFrom(parsed);
const intent = requiredText(parsed.intent ?? parsed._[1], "intent");
const args = parseJsonObject(parsed.argsJson ?? parsed.args ?? "{}", "args");
const document = await readHwpodSpec(specPath);
const plan = compileHwpodNodeOpsPlan({ document, specPath, intent, args, now });
return result(0, ok("hwpod-compiler.compile", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, plan }), now);
} catch (error) {
return result(1, failure(COMPILER_NAME, error), now);
}
}
export async function runHwpodCtl(argv: string[], options: { env?: EnvLike; now?: () => string } = {}) {
const now = options.now ?? (() => new Date().toISOString());
try {
const parsed = parseOptions(argv);
const group = parsed._[0] || "help";
if (["help", "--help", "-h"].includes(group)) return result(0, ctlHelp(), now);
if (group === "bind") return result(0, await bindSpec(parsed, now), now);
if (group !== "spec") throw cliError("unsupported_ctl_command", `unsupported hwpod-ctl command: ${group}`);
const subcommand = parsed._[1] || "validate";
if (subcommand === "init") return result(0, await initSpec(parsed, now), now);
if (subcommand === "validate") return result(0, await validateSpec(parsed), now);
if (subcommand === "show") return result(0, await showSpec(parsed), now);
if (subcommand === "set") return result(0, await setSpec(parsed, now), now);
if (subcommand === "bind") return result(0, await bindSpec(parsed, now), now);
throw cliError("unsupported_spec_command", `unsupported hwpod-ctl spec command: ${subcommand}`);
} catch (error) {
return result(1, failure(CTL_NAME, error), now);
}
}
export async function runHwpodCli(argv: string[], options: { env?: EnvLike; fetchImpl?: FetchLike; stdinText?: string; now?: () => string } = {}) {
const env = options.env ?? process.env;
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, cliHelp(), now);
if (command === "closeout") return result(0, closeout(parsed), now);
const specPath = specPathFrom(parsed);
const { intent, args } = commandToIntent(parsed, options.stdinText);
const compiled = await compilePlanWithCompilerCli({ specPath, intent, args });
const plan = compiled.plan;
if (parsed.dryRun === true) {
return result(0, ok("hwpod-cli.plan", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, compilerInvocation: compiled.compilerInvocation, plan, dryRun: true }), now);
}
const response = await submitHwpodNodeOpsPlan({ parsed, env, fetchImpl: options.fetchImpl, plan });
const exitCode = response.body?.ok === false || response.status >= 400 ? 1 : 0;
return result(exitCode, ok("hwpod-cli.invoke", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, compilerInvocation: compiled.compilerInvocation, route: response.route, runtimeEndpoint: response.runtimeEndpoint, body: response.body, httpStatus: response.status }, response.body?.status ?? (exitCode === 0 ? "succeeded" : "failed")), now);
} catch (error) {
return result(1, failure(CLI_NAME, error), now);
}
}
export async function readHwpodSpec(specPath = DEFAULT_HWPOD_SPEC_PATH) {
const text = await readFile(specPath, "utf8");
return normalizeHwpodSpec(parseSimpleYaml(text, specPath), specPath);
}
export function compileHwpodNodeOpsPlan({ document, specPath = DEFAULT_HWPOD_SPEC_PATH, intent, args = {}, now = () => new Date().toISOString() }: any) {
const normalizedIntent = normalizeIntent(intent);
const nodeId = document.spec.nodeBinding.nodeId;
const hwpodId = document.metadata.name || document.metadata.uid;
const ops = opsForIntent(normalizedIntent, args, document).map((operation: any, index: number) => ({
opId: operation.opId ?? `op_${String(index + 1).padStart(2, "0")}_${operation.op.replace(/[^a-z0-9]+/giu, "_")}`,
...operation
}));
return {
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
planId: `hwpod_plan_${randomUUID()}`,
hwpodId,
nodeId,
intent: normalizedIntent,
source: {
compiler: COMPILER_NAME,
specPath,
specAuthority: "code-agent-workspace"
},
resourceHints: {
workspacePath: document.spec.workspace.path,
targetDevice: document.spec.targetDevice,
debugProbe: document.spec.debugProbe,
ioProbe: document.spec.ioProbe
},
ops,
createdAt: now()
};
}
function compilerHelp() {
return ok("hwpod-compiler.help", {
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
usage: [
"bun tools/hwpod-compiler-cli.ts compile --spec .hwlab/hwpod-spec.yaml --intent workspace.ls --args '{\"path\":\".\"}'",
"bun tools/hwpod-compiler-cli.ts compile --intent inspect"
],
role: "Translate workspace-local hwpod-spec plus high-level intent into hwpod-node-ops."
});
}
function ctlHelp() {
return ok("hwpod-ctl.help", {
contractVersion: "hwpod-ctl-v1",
usage: [
"bun tools/hwpod-ctl.ts spec init --spec .hwlab/hwpod-spec.yaml --node local-node",
"bun tools/hwpod-ctl.ts spec validate --spec .hwlab/hwpod-spec.yaml",
"bun tools/hwpod-ctl.ts spec set spec.workspace.path /workspace/firmware",
"bun tools/hwpod-ctl.ts bind --node pc-host-1"
],
defaultSpecPath: DEFAULT_HWPOD_SPEC_PATH
});
}
function cliHelp() {
return ok("hwpod-cli.help", {
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
usage: [
"bun tools/hwpod-cli.ts inspect --dry-run",
"bun tools/hwpod-cli.ts workspace ls . --dry-run",
"bun tools/hwpod-cli.ts workspace cat projects/01_baseline/User/main.c --spec .hwlab/hwpod-spec.yaml",
"cat patch.txt | bun tools/hwpod-cli.ts workspace apply-patch --spec .hwlab/hwpod-spec.yaml --reason \"edit subject workspace through hwpod-node\"",
"bun tools/hwpod-cli.ts workspace replace --path projects/01_baseline/User/main.c --find \"old text\" --replace \"new text\" --expected-sha <sha>",
"bun tools/hwpod-cli.ts workspace insert-after --path projects/01_baseline/User/main.c --anchor \"while (1)\" --line \" /* marker */\"",
"bun tools/hwpod-cli.ts build --dry-run",
"bun tools/hwpod-cli.ts uart read --port uart1 --dry-run",
"bun tools/hwpod-cli.ts --api-base-url http://74.48.78.17:19667 inspect"
],
workspace: {
cat: "Read a subject workspace file through hwpod-node; path is relative to spec.workspace.path.",
applyPatch: "Apply a Codex apply_patch envelope through hwpod-node. Patch content may come from stdin, --patch/--patchText, or --patchBase64.",
write: "Write a whole file with optional --expected-sha, --line-ending preserve|lf|crlf and dry-run diff output.",
replace: "Replace exact text in a subject file with SHA/diff diagnostics; pass --all for global replacement.",
insertAfter: "Insert one or more lines after an exact anchor without shell quoting."
},
applyPatchExample: [
"*** Begin Patch",
"*** Update File: projects/01_baseline/User/main.c",
"@@",
" existing line",
"+new line",
"*** End Patch"
].join("\n"),
route: "POST /v1/hwpod-node-ops"
});
}
async function initSpec(parsed: ParsedArgs, now: () => string) {
const specPath = specPathFrom(parsed);
const document = defaultSpecDocument(parsed, now);
await mkdir(path.dirname(specPath), { recursive: true });
if (parsed.force !== true) {
try {
await readFile(specPath, "utf8");
throw cliError("hwpod_spec_exists", `hwpod spec already exists: ${specPath}`, { specPath, next: "pass --force to replace it" });
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
await writeFile(specPath, stringifySimpleYaml(document), "utf8");
return ok("hwpod-ctl.spec.init", { specPath, document: normalizeHwpodSpec(document, specPath) });
}
async function validateSpec(parsed: ParsedArgs) {
const specPath = specPathFrom(parsed);
const document = await readHwpodSpec(specPath);
return ok("hwpod-ctl.spec.validate", { specPath, document, fourElements: ["targetDevice", "workspace", "debugProbe", "ioProbe"], nodeId: document.spec.nodeBinding.nodeId });
}
async function showSpec(parsed: ParsedArgs) {
const specPath = specPathFrom(parsed);
const document = await readHwpodSpec(specPath);
return ok("hwpod-ctl.spec.show", { specPath, document });
}
async function setSpec(parsed: ParsedArgs, now: () => string) {
const specPath = specPathFrom(parsed);
const dotPath = requiredText(parsed._[2] ?? parsed.path, "path");
const value = scalarFromSetValue(requiredText(parsed._[3] ?? parsed.value, "value"));
const document = parseSimpleYaml(await readFile(specPath, "utf8"), specPath);
setDotPath(document, dotPath, value);
document.metadata = objectValue(document.metadata);
document.metadata.updatedAt = now();
const normalized = normalizeHwpodSpec(document, specPath);
await writeFile(specPath, stringifySimpleYaml(document), "utf8");
return ok("hwpod-ctl.spec.set", { specPath, path: dotPath, value, document: normalized });
}
async function bindSpec(parsed: ParsedArgs, now: () => string) {
const nodeId = requiredText(parsed.node ?? parsed.nodeId ?? parsed._[1] ?? parsed._[2], "node");
const specPath = specPathFrom(parsed);
const document = parseSimpleYaml(await readFile(specPath, "utf8"), specPath);
setDotPath(document, "spec.nodeBinding.nodeId", nodeId);
document.metadata = objectValue(document.metadata);
document.metadata.updatedAt = now();
const normalized = normalizeHwpodSpec(document, specPath);
await writeFile(specPath, stringifySimpleYaml(document), "utf8");
return ok("hwpod-ctl.bind", { specPath, nodeId, document: normalized });
}
function closeout(parsed: ParsedArgs) {
return ok("hwpod-cli.closeout", {
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
specPath: specPathFrom(parsed),
note: "Closeout is a reporting step; use hwpod-cli operations and hwlab-cli runtime evidence before closing the issue."
});
}
async function compilePlanWithCompilerCli({ specPath, intent, args }: { specPath: string; intent: string; args: Record<string, unknown> }) {
const compilerPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../hwpod-compiler-cli.ts");
const argsJson = JSON.stringify(args ?? {});
const command = [process.execPath, compilerPath, "compile", "--spec", specPath, "--intent", intent, "--args", argsJson];
const proc = Bun.spawn(command, { cwd: process.cwd(), env: process.env, stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited
]);
const compilerInvocation = clean({
compiler: COMPILER_NAME,
mode: "subprocess",
entrypoint: "tools/hwpod-compiler-cli.ts",
argv: ["compile", "--spec", specPath, "--intent", intent, "--args", "<json>"],
exitCode,
stderr: clipText(stderr)
});
if (exitCode !== 0) {
throw cliError("hwpod_compiler_failed", "hwpod-compiler-cli failed to compile hwpod-node-ops plan", {
compilerInvocation,
stdout: clipText(stdout),
stderr: clipText(stderr)
});
}
const payload = parseJsonObject(stdout, "compiler_stdout");
const plan = isPlainObject(payload.plan) ? payload.plan : null;
if (payload.ok === false || !plan) {
throw cliError("hwpod_compiler_invalid_output", "hwpod-compiler-cli did not return a valid hwpod-node-ops plan", {
compilerInvocation,
payload: clean({ ok: payload.ok, action: payload.action, status: payload.status, error: payload.error })
});
}
return {
plan,
compilerInvocation: clean({
...compilerInvocation,
action: payload.action,
contractVersion: payload.contractVersion,
planId: plan.planId,
source: plan.source
})
};
}
function commandToIntent(parsed: ParsedArgs, stdinText?: string) {
const command = parsed._[0];
if (command === "inspect") return { intent: "inspect", args: {} };
if (command === "node") {
const subcommand = parsed._[1] || "health";
return { intent: `node.${subcommand}`, args: {} };
}
if (command === "workspace") {
const subcommand = parsed._[1] || "ls";
if (subcommand === "ls") return { intent: "workspace.ls", args: { path: text(parsed.path ?? parsed._[2] ?? ".") } };
if (subcommand === "cat") return { intent: "workspace.cat", args: { path: requiredText(parsed.path ?? parsed._[2], "path") } };
if (subcommand === "rg") return { intent: "workspace.rg", args: { pattern: requiredText(parsed.pattern ?? parsed._[2], "pattern"), path: text(parsed.path ?? parsed._[3] ?? "."), ignoreCase: parsed.ignoreCase === true } };
if (subcommand === "apply-patch") return { intent: "workspace.apply-patch", args: { patch: patchText(parsed.patch ?? parsed.patchText ?? stdinText), patchBase64: text(parsed.patchBase64), reason: text(parsed.reason) } };
if (subcommand === "write") return { intent: "workspace.write", args: clean({ path: requiredText(parsed.path ?? parsed._[2], "path"), content: patchText(parsed.content ?? parsed.contentText ?? stdinText), contentBase64: text(parsed.contentBase64), expectedSha: text(parsed.expectedSha), lineEnding: text(parsed.lineEnding), finalNewline: parsed.finalNewline === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) };
if (subcommand === "replace") return { intent: "workspace.replace", args: cleanTextEditArgs({ path: requiredText(parsed.path ?? parsed._[2], "path"), find: requiredRawString(parsed.find ?? parsed._[3], "find"), replace: rawString(parsed.replace ?? parsed.replacement ?? parsed._[4]), expectedSha: text(parsed.expectedSha), all: parsed.all === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) };
if (subcommand === "insert-after") return { intent: "workspace.insert-after", args: cleanTextEditArgs({ path: requiredText(parsed.path ?? parsed._[2], "path"), anchor: requiredRawString(parsed.anchor ?? parsed._[3], "anchor"), line: requiredRawString(parsed.line ?? parsed.insert ?? parsed.text ?? parsed._[4], "line"), expectedSha: text(parsed.expectedSha), allowMultiple: parsed.allowMultiple === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) };
throw cliError("unsupported_workspace_command", `unsupported workspace command: ${subcommand}`);
}
if (command === "build") return { intent: "debug.build", args: clean({ target: text(parsed.target), command: text(parsed.command ?? parsed.commandLine), reason: text(parsed.reason) }) };
if (command === "download") return { intent: "debug.download", args: clean({ artifact: text(parsed.artifact ?? parsed._[1]), target: text(parsed.target), command: text(parsed.command ?? parsed.commandLine), reason: text(parsed.reason) }) };
if (command === "reset") return { intent: "debug.reset", args: clean({ mode: text(parsed.mode ?? parsed._[1]), command: text(parsed.command ?? parsed.commandLine), reason: text(parsed.reason) }) };
if (command === "uart") {
const subcommand = parsed._[1] || "read";
if (subcommand === "read") return { intent: "io.uart.read", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), maxBytes: numberValue(parsed.maxBytes), since: text(parsed.since) }) };
if (subcommand === "write") return { intent: "io.uart.write", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), data: requiredText(parsed.data ?? parsed._[3] ?? stdinText, "data") }) };
throw cliError("unsupported_uart_command", `unsupported uart command: ${subcommand}`);
}
if (command === "jsonrpc") {
const method = requiredText(parsed.method ?? parsed._[2] ?? parsed._[1], "method");
return { intent: "io.uart.jsonrpc", args: clean({ port: text(parsed.port ?? "uart1"), method, params: parseJsonObject(parsed.paramsJson ?? parsed.params ?? "{}", "params") }) };
}
if (command === "cmd") return { intent: "cmd.run", args: { command: requiredText(parsed._[1] ?? parsed.command, "command"), argv: parsed._.slice(2) } };
throw cliError("unsupported_hwpod_command", `unsupported hwpod-cli command: ${command}`);
}
function opsForIntent(intent: string, args: any, document: any) {
const common = commonOpArgs(document);
if (intent === "inspect") return [
{ op: "node.health", args: common },
{ op: "node.inventory", args: common }
];
if (intent === "node.health" || intent === "node.version" || intent === "node.inventory") return [{ op: intent, args: common }];
if (intent === "workspace.ls") return [{ op: "workspace.ls", args: { ...common, path: text(args.path) || "." } }];
if (intent === "workspace.cat") return [{ op: "workspace.cat", args: { ...common, path: requiredText(args.path, "path") } }];
if (intent === "workspace.rg") return [{ op: "workspace.rg", args: { ...common, pattern: requiredText(args.pattern, "pattern"), path: text(args.path) || ".", ignoreCase: args.ignoreCase === true } }];
if (intent === "workspace.apply-patch") return [{ op: "workspace.apply-patch", args: clean({ ...common, patch: patchText(args.patch), patchBase64: text(args.patchBase64), reason: text(args.reason) }) }];
if (intent === "workspace.write") return [{ op: "workspace.write", args: clean({ ...common, path: requiredText(args.path, "path"), content: patchText(args.content), contentBase64: text(args.contentBase64), expectedSha: text(args.expectedSha), lineEnding: text(args.lineEnding), finalNewline: args.finalNewline === true, dryRun: args.dryRun === true, reason: text(args.reason) }) }];
if (intent === "workspace.replace") return [{ op: "workspace.replace", args: cleanTextEditArgs({ ...common, path: requiredText(args.path, "path"), find: requiredRawString(args.find, "find"), replace: rawString(args.replace), expectedSha: text(args.expectedSha), all: args.all === true, dryRun: args.dryRun === true, reason: text(args.reason) }) }];
if (intent === "workspace.insert-after") return [{ op: "workspace.insert-after", args: cleanTextEditArgs({ ...common, path: requiredText(args.path, "path"), anchor: requiredRawString(args.anchor, "anchor"), line: requiredRawString(args.line, "line"), expectedSha: text(args.expectedSha), allowMultiple: args.allowMultiple === true, dryRun: args.dryRun === true, reason: text(args.reason) }) }];
if (intent === "debug.build") return debugBuildOps(common, args, document);
if (intent === "debug.download") return [{ op: "debug.download", args: debugDownloadArgs(common, args, document) }];
if (intent === "debug.reset") return [{ op: "debug.reset", args: clean({ ...common, mode: text(args.mode), command: text(args.command) || text(document.spec.debugProbe.resetCommand), reason: text(args.reason) }) }];
if (intent === "io.uart.read") return [{ op: "io.uart.read", args: clean({ ...common, port: text(args.port) || "uart1", maxBytes: numberValue(args.maxBytes), since: text(args.since) }) }];
if (intent === "io.uart.write") return [{ op: "io.uart.write", args: clean({ ...common, port: text(args.port) || "uart1", data: requiredText(args.data, "data") }) }];
if (intent === "io.uart.jsonrpc") return [{ op: "io.uart.jsonrpc", args: clean({ ...common, port: text(args.port) || "uart1", method: requiredText(args.method, "method"), params: objectValue(args.params) }) }];
if (intent === "cmd.run") return [{ op: "cmd.run", args: clean({ ...common, command: requiredText(args.command, "command"), argv: Array.isArray(args.argv) ? args.argv : [] }) }];
throw cliError("unsupported_hwpod_intent", `unsupported hwpod intent: ${intent}`, { supportedIntents: Array.from(HWPOD_NODE_OPS).concat(["inspect"]) });
}
function commonOpArgs(document: any) {
return {
hwpodId: document.metadata.name || document.metadata.uid,
workspacePath: document.spec.workspace.path,
targetDevice: document.spec.targetDevice,
debugProbe: document.spec.debugProbe,
ioProbe: document.spec.ioProbe
};
}
function debugBuildOps(common: any, args: any, document: any) {
const explicitCommand = text(args.command) || text(document.spec.workspace.buildCommand);
const generated = explicitCommand ? null : keilCommandForIntent("debug.build", args, document);
const opArgs = clean({
...common,
target: text(args.target) || generated?.target,
commandBinding: generated?.binding,
timeoutMs: numberValue(args.timeoutMs) ?? generated?.timeoutMs,
reason: text(args.reason)
});
if (generated?.commandRun) {
return [{ op: "cmd.run", args: clean({ ...opArgs, command: generated.commandRun.command, argv: generated.commandRun.argv, commandLine: generated.command }) }];
}
return [{ op: "debug.build", args: clean({ ...opArgs, command: explicitCommand || generated?.command }) }];
}
function debugDownloadArgs(common: any, args: any, document: any) {
const explicitCommand = text(args.command) || text(document.spec.debugProbe.downloadCommand);
const generated = explicitCommand ? null : keilCommandForIntent("debug.download", args, document);
return clean({
...common,
artifact: text(args.artifact),
target: text(args.target) || generated?.target,
command: explicitCommand || generated?.command,
commandBinding: generated?.binding,
timeoutMs: numberValue(args.timeoutMs) ?? generated?.timeoutMs,
reason: text(args.reason)
});
}
function keilCommandForIntent(intent: "debug.build" | "debug.download", args: any, document: any) {
const workspace = objectValue(document.spec.workspace);
const debugProbe = objectValue(document.spec.debugProbe);
const toolchain = text(workspace.toolchain).toLowerCase();
if (!toolchain || !["keil", "keil-mdk", "mdk", "uv4"].includes(toolchain)) return null;
const project = hwpodKeilProject(args, document);
const target = hwpodKeilTarget(args, document);
const keilCliPath = text(args.keilCliPath) || text(workspace.keilCliPath) || text(debugProbe.keilCliPath) || text(objectValue(document.spec.tooling).keilCliPath) || "keil-cli.py";
const pythonCommand = text(args.pythonCommand) || text(workspace.pythonCommand) || text(debugProbe.pythonCommand) || text(objectValue(document.spec.tooling).pythonCommand) || "py -3";
const timeoutMs = numberValue(args.timeoutMs) ?? numberValue(workspace.keilCommandTimeoutMs) ?? numberValue(debugProbe.keilCommandTimeoutMs) ?? DEFAULT_KEIL_COMMAND_TIMEOUT_MS;
const commonTokens = [...splitCommandWords(pythonCommand), keilCliPath];
if (intent === "debug.build") {
const commandTokens = [...commonTokens, "build", "-p", project, ...targetOption(target)];
return {
target,
command: shellCommand(commandTokens),
commandRun: commandRun(commandTokens),
timeoutMs,
binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "build", project, target, keilCliPath, pythonCommand })
};
}
const probeUid = text(args.probeUid ?? args.probe) || text(debugProbe.probeUid ?? debugProbe.uid);
const programmer = text(args.programmer) || text(debugProbe.programmer) || (text(debugProbe.type).toLowerCase() === "daplink" ? "daplink" : "daplink");
const programBackend = text(args.programBackend ?? args.backend) || text(debugProbe.programBackend) || (text(debugProbe.adapter).toLowerCase() === "keil" ? "keil" : "keil");
const probeName = text(args.probeName) || text(debugProbe.probeName) || text(debugProbe.name);
const autoBindUvoptx = debugProbe.autoBindUvoptx !== false && debugProbe.autoBindProbe !== false;
const bindCommand = autoBindUvoptx && probeUid
? shellCommand([...commonTokens, "project", "probe-binding", "set", "-p", project, ...projectTargetOption(target), "--probe-uid", probeUid, ...probeNameOption(probeName)])
: "";
const programCommand = shellCommand([
...commonTokens,
"program",
"-p",
project,
"-m",
programmer,
"--program-backend",
programBackend,
...probeOption(probeUid),
...targetOption(target)
]);
return {
target,
command: bindCommand ? `${bindCommand} && ${programCommand}` : programCommand,
timeoutMs,
binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "download", project, target, keilCliPath, pythonCommand, programmer, programBackend, probeUid, probeName, autoBindUvoptx: Boolean(bindCommand) })
};
}
function hwpodKeilProject(args: any, document: any) {
const workspace = objectValue(document.spec.workspace);
const projectWorkspace = objectValue(document.spec.projectWorkspace);
const rawProject = text(args.project ?? args.projectPath) || text(workspace.keilProject) || text(workspace.projectPath) || text(workspace.project) || text(projectWorkspace.projectPath);
if (!rawProject) throw cliError("hwpod_keil_project_required", "keil-mdk hwpod-spec requires spec.workspace.keilProject or spec.workspace.projectPath");
return resolveWorkspaceRelativePath(text(workspace.path), rawProject);
}
function hwpodKeilTarget(args: any, document: any) {
const workspace = objectValue(document.spec.workspace);
const projectWorkspace = objectValue(document.spec.projectWorkspace);
return text(args.target) || text(workspace.keilTarget) || text(workspace.targetName) || text(workspace.target) || text(projectWorkspace.targetName);
}
function resolveWorkspaceRelativePath(workspacePath: string, candidate: string) {
if (!workspacePath || isAbsoluteLikePath(candidate)) return candidate;
const separator = /^[A-Za-z]:[\\/]/u.test(workspacePath) || workspacePath.includes("\\") ? "\\" : "/";
const base = workspacePath.replace(/[\\/]+$/u, "");
const child = separator === "\\" ? candidate.replace(/\//gu, "\\") : candidate.replace(/\\/gu, "/");
return `${base}${separator}${child.replace(/^[\\/]+/u, "")}`;
}
function isAbsoluteLikePath(value: string) {
return /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("/") || value.startsWith("\\\\");
}
function targetOption(target: string) {
return target ? ["-t", target] : [];
}
function projectTargetOption(target: string) {
return target ? ["--target", target] : [];
}
function probeOption(probeUid: string) {
return probeUid ? ["-u", probeUid] : [];
}
function probeNameOption(probeName: string) {
return probeName ? ["--probe-name", probeName] : [];
}
function splitCommandWords(command: string) {
const words = command.trim().split(/\s+/u).filter(Boolean);
if (words.length === 0) throw cliError("invalid_keil_python_command", "pythonCommand must not be empty");
return words;
}
function shellCommand(tokens: string[]) {
return tokens.map(shellArg).join(" ");
}
function commandRun(tokens: string[]) {
if (tokens.some(isAbsoluteLikePath)) return { command: "cmd.exe", argv: ["/d", "/s", "/c", shellCommand(tokens)] };
const [command, ...argv] = tokens;
return { command, argv };
}
function shellArg(value: string) {
const raw = String(value);
if (/^[A-Za-z0-9_./:@\\-]+$/u.test(raw)) return raw;
return `"${raw.replace(/(["^&|<>])/gu, "^$1").replace(/%/gu, "%%")}"`;
}
function normalizeIntent(value: unknown) {
const intent = requiredText(value, "intent");
if (intent === "build") return "debug.build";
if (intent === "download") return "debug.download";
if (intent === "reset") return "debug.reset";
if (intent === "uart.read") return "io.uart.read";
if (intent === "uart.write") return "io.uart.write";
if (intent === "jsonrpc.call") return "io.uart.jsonrpc";
return intent;
}
function defaultSpecDocument(parsed: ParsedArgs, now: () => string) {
const name = text(parsed.name) || "local-hwpod";
return {
apiVersion: "hwlab.pikastech.com/v1alpha1",
kind: "Hwpod",
metadata: {
name,
uid: text(parsed.uid) || `hwpod-${name}`,
createdAt: now()
},
spec: {
nodeBinding: {
nodeId: text(parsed.node ?? parsed.nodeId) || "local-node"
},
targetDevice: {
id: text(parsed.targetDevice ?? parsed.targetDeviceId) || "target-device-local"
},
workspace: {
path: text(parsed.workspace ?? parsed.workspacePath) || "."
},
debugProbe: {
id: text(parsed.debugProbe ?? parsed.debugProbeId) || "debug-probe-local",
kind: text(parsed.debugProbeKind) || "generic"
},
ioProbe: {
id: text(parsed.ioProbe ?? parsed.ioProbeId) || "io-probe-local",
kind: text(parsed.ioProbeKind) || "generic"
}
}
};
}
function normalizeHwpodSpec(document: any, specPath: string) {
const root = objectValue(document);
if (root.kind !== "Hwpod") throw cliError("invalid_hwpod_spec_kind", "hwpod-spec kind must be Hwpod", { specPath, kind: root.kind });
const spec = objectValue(root.spec);
const metadata = objectValue(root.metadata);
for (const key of ["targetDevice", "workspace", "debugProbe", "ioProbe"]) {
if (!isPlainObject(spec[key])) throw cliError("invalid_hwpod_spec_element", `hwpod-spec missing ${key}`, { specPath, element: key });
}
const workspacePath = requiredText(spec.workspace.path, "spec.workspace.path");
const nodeBinding = objectValue(spec.nodeBinding);
const nodeId = requiredText(nodeBinding.nodeId, "spec.nodeBinding.nodeId");
const name = requiredText(metadata.name ?? metadata.uid, "metadata.name");
return {
apiVersion: text(root.apiVersion) || "hwlab.pikastech.com/v1alpha1",
kind: "Hwpod",
metadata: { ...metadata, name },
spec: {
...spec,
nodeBinding: { ...nodeBinding, nodeId },
workspace: { ...spec.workspace, path: workspacePath },
targetDevice: spec.targetDevice,
debugProbe: spec.debugProbe,
ioProbe: spec.ioProbe
}
};
}
async function submitHwpodNodeOpsPlan({ parsed, env, fetchImpl, plan }: { parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike; plan: any }) {
const endpoint = resolveRuntimeEndpoint({ kind: "api", parsed, env });
const route = { method: "POST", path: "/v1/hwpod-node-ops" };
const url = `${endpoint.baseUrl}${route.path}`;
const headers = authHeaders(parsed, env);
const requestBody = JSON.stringify(plan);
const response = fetchImpl
? await fetchImpl(url, { method: route.method, headers, body: requestBody })
: await postJsonNative(url, { method: route.method, headers, body: requestBody, timeoutMs: numberValue(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS });
const body = await response.json().catch(() => null);
return { status: response.status, body, route, runtimeEndpoint: runtimeEndpointVisibility(endpoint) };
}
function authHeaders(parsed: ParsedArgs, env: EnvLike) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (parsed.noAuth === true) return headers;
const apiKey = hwpodApiKey(parsed, env);
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
const sessionToken = text(parsed.sessionToken ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN);
if (sessionToken) headers["x-hwlab-session-token"] = sessionToken;
const cookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE);
if (cookie) headers.cookie = cookie;
return headers;
}
function hwpodApiKey(parsed: ParsedArgs, env: EnvLike) {
const unsupported = [
text(parsed.apiKey) ? "--api-key" : "",
text(env.HWLAB_BEARER_TOKEN) ? "HWLAB_BEARER_TOKEN" : ""
].filter(Boolean);
if (unsupported.length > 0) {
throw cliError("unsupported_api_key_source", "HWPOD auth uses only HWLAB_API_KEY; remove API key aliases and export HWLAB_API_KEY instead.", {
allowed: "HWLAB_API_KEY",
unsupported
});
}
return text(env.HWLAB_API_KEY);
}
function postJsonNative(urlValue: string, { method, headers, body, timeoutMs }: { method: string; headers: Record<string, string>; body: string; timeoutMs: number }) {
return new Promise<any>((resolve, reject) => {
const url = new URL(urlValue);
const client = url.protocol === "https:" ? https : http;
const request = client.request(url, {
method,
headers: {
...headers,
"content-length": String(Buffer.byteLength(body))
},
timeout: timeoutMs
}, (response) => {
const chunks: Buffer[] = [];
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
response.on("end", () => {
const textValue = Buffer.concat(chunks).toString("utf8");
resolve({
status: response.statusCode ?? 0,
json: async () => textValue ? JSON.parse(textValue) : null
});
});
});
request.on("timeout", () => request.destroy(cliError("hwpod_node_ops_timeout", `hwpod-node-ops request timed out after ${timeoutMs}ms`)));
request.on("error", reject);
request.end(body);
});
}
function parseSimpleYaml(textValue: string, sourcePath: string) {
const root: any = {};
const stack = [{ indent: -1, value: root }];
const lines = textValue.replace(/\r\n?/gu, "\n").split("\n");
for (let index = 0; index < lines.length; index += 1) {
const rawLine = stripYamlComment(lines[index]);
if (!rawLine.trim()) continue;
const indent = rawLine.match(/^ */u)?.[0].length ?? 0;
if (indent % 2 !== 0) throw cliError("invalid_hwpod_spec_yaml", `invalid indentation at ${sourcePath}:${index + 1}`);
const line = rawLine.slice(indent);
const match = line.match(/^([A-Za-z0-9_.-]+):(?:\s*(.*))?$/u);
if (!match) throw cliError("invalid_hwpod_spec_yaml", `unsupported YAML line at ${sourcePath}:${index + 1}`, { line });
while (stack[stack.length - 1].indent >= indent) stack.pop();
const parent = stack[stack.length - 1].value;
const key = match[1];
const rawValue = match[2] ?? "";
if (rawValue.trim() === "") {
const next: any = {};
parent[key] = next;
stack.push({ indent, value: next });
} else {
parent[key] = parseScalar(rawValue.trim());
}
}
return root;
}
function stringifySimpleYaml(value: any, indent = 0): string {
return `${Object.entries(value).map(([key, child]) => {
if (isPlainObject(child)) return `${" ".repeat(indent)}${key}:\n${stringifySimpleYaml(child, indent + 2)}`;
return `${" ".repeat(indent)}${key}: ${formatScalar(child)}`;
}).join("\n")}\n`;
}
function stripYamlComment(line: string) {
let quoted: string | null = null;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if ((char === '"' || char === "'") && line[index - 1] !== "\\") quoted = quoted === char ? null : quoted || char;
if (char === "#" && !quoted && (index === 0 || /\s/u.test(line[index - 1]))) return line.slice(0, index).trimEnd();
}
return line;
}
function parseScalar(value: string) {
if (value === "true") return true;
if (value === "false") return false;
if (value === "null") return null;
if (/^-?\d+(?:\.\d+)?$/u.test(value)) return Number(value);
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
return value.startsWith('"') ? JSON.parse(value) : value.slice(1, -1).replace(/''/gu, "'");
}
return value;
}
function formatScalar(value: any) {
if (value === null || value === undefined) return "null";
if (typeof value === "boolean" || typeof value === "number") return String(value);
const stringValue = String(value);
return /^[A-Za-z0-9_./:@-]+$/u.test(stringValue) ? stringValue : JSON.stringify(stringValue);
}
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 = camelCase(rawKey);
if (BOOLEAN_OPTIONS.has(key)) {
parsed[key] = rawInline === undefined || rawInline === "" ? true : truthy(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 parseJsonObject(value: unknown, name: string) {
if (value === undefined || value === null || value === "") return {};
if (isPlainObject(value)) return value;
try {
const parsed = JSON.parse(String(value));
if (!isPlainObject(parsed)) throw new Error(`${name} must be a JSON object`);
return parsed;
} catch (error) {
throw cliError(`invalid_${name}_json`, `${name} must be a JSON object`, { reason: error.message });
}
}
function setDotPath(target: any, dotPath: string, value: any) {
const parts = dotPath.split(".").filter(Boolean);
if (parts.length === 0) throw cliError("invalid_hwpod_spec_path", "spec path is empty");
let cursor = target;
for (const part of parts.slice(0, -1)) {
if (!isPlainObject(cursor[part])) cursor[part] = {};
cursor = cursor[part];
}
cursor[parts[parts.length - 1]] = value;
}
function scalarFromSetValue(value: string) {
if (value === "true" || value === "false" || value === "null" || /^-?\d+(?:\.\d+)?$/u.test(value)) return parseScalar(value);
return value;
}
function specPathFrom(parsed: ParsedArgs) {
return text(parsed.spec ?? parsed.specPath) || DEFAULT_HWPOD_SPEC_PATH;
}
function result(exitCode: number, payload: any, now: () => string) {
return { exitCode, payload: { ...payload, observedAt: now() } };
}
function ok(action: string, extra: Record<string, unknown> = {}, status = "succeeded") {
return { ok: true, action, status, ...extra };
}
function failure(action: string, error: any) {
return {
ok: false,
action,
status: "failed",
error: {
code: error?.code || "hwpod_cli_error",
message: error?.message || String(error),
details: error?.details || undefined
}
};
}
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 requiredRawString(value: unknown, name: string) {
const normalized = rawString(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 rawString(value: unknown) {
return typeof value === "string" ? value : "";
}
function patchText(value: unknown) {
return typeof value === "string" && value.length > 0 ? value : "";
}
function numberValue(value: unknown) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) ? parsed : undefined;
}
function clipText(value: unknown, maxBytes = 4000) {
const textValue = typeof value === "string" ? value : String(value ?? "");
const buffer = Buffer.from(textValue, "utf8");
if (buffer.length <= maxBytes) return textValue;
return `${buffer.subarray(0, maxBytes).toString("utf8").replace(/\uFFFD$/u, "")}\n... clipped ...`;
}
function clean<T extends Record<string, unknown>>(value: T): T {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T;
}
function cleanTextEditArgs<T extends Record<string, unknown>>(value: T): T {
return Object.fromEntries(Object.entries(value).filter(([key, item]) => item !== undefined && item !== null && (item !== "" || ["replace", "line"].includes(key)))) as T;
}
function objectValue(value: any) {
return isPlainObject(value) ? value : {};
}
function isPlainObject(value: any) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function camelCase(value: string) {
return value.replace(/-([a-z0-9])/giu, (_match, char) => char.toUpperCase());
}
function truthy(value: unknown) {
return /^(?:1|true|yes|on)$/iu.test(String(value ?? ""));
}