1341 lines
67 KiB
TypeScript
1341 lines
67 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 { 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", "ignoreCase", "json", "noAuth", "sessionOnly", "wait"]);
|
|
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.patchContent === 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; fetchImpl?: FetchLike; 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, compilerHelp(), now);
|
|
if (command !== "compile") throw cliError("unsupported_compiler_command", `unsupported hwpod-compiler-cli command: ${command}`);
|
|
const intent = requiredText(parsed.intent ?? parsed._[1], "intent");
|
|
const args = parseJsonObject(parsed.argsJson ?? parsed.args ?? "{}", "args");
|
|
const resolved = await resolveHwpodDocument({ parsed, env, fetchImpl: options.fetchImpl });
|
|
const plan = compileHwpodNodeOpsPlan({ document: resolved.document, specPath: resolved.specPath, specAuthority: resolved.authority, intent, args, now });
|
|
return result(0, ok("hwpod-compiler.compile", { specPath: resolved.specPath, hwpodId: resolved.hwpodId, specAuthority: resolved.authority, 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; fetchImpl?: FetchLike; now?: () => string } = {}) {
|
|
const env = options.env ?? process.env;
|
|
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, env, options.fetchImpl), now);
|
|
if (subcommand === "show") return result(0, await showSpec(parsed, env, options.fetchImpl), 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 (parsed.help === true || parsed.h === true) return result(0, hwpodCliCommandHelp(command, parsed), now);
|
|
if (command === "closeout") return result(0, closeout(parsed), now);
|
|
|
|
const { intent, args } = commandToIntent(parsed, options.stdinText);
|
|
const compiled = await compilePlanWithCompilerCli({ parsed, env, fetchImpl: options.fetchImpl, intent, args });
|
|
const plan = compiled.plan;
|
|
if (parsed.dryRun === true) {
|
|
return result(0, ok("hwpod-cli.plan", { specPath: compiled.specPath, hwpodId: compiled.hwpodId, specAuthority: compiled.specAuthority, 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;
|
|
const diagnostic = hwpodNodeOpsCliDiagnostic(response);
|
|
const payload = ok("hwpod-cli.invoke", {
|
|
specPath: compiled.specPath,
|
|
hwpodId: compiled.hwpodId,
|
|
specAuthority: compiled.specAuthority,
|
|
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.otelTraceId ? { otelTraceId: response.otelTraceId } : {}),
|
|
...(diagnostic ? { diagnostic } : {})
|
|
}, response.body?.status ?? (exitCode === 0 ? "succeeded" : "failed"));
|
|
if (exitCode !== 0) payload.ok = false;
|
|
return result(exitCode, payload, 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, specAuthority = "code-agent-workspace", 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
|
|
},
|
|
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 --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --intent workspace.ls --args '{\"path\":\".\"}'",
|
|
"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 a runtime-resolved hwpodId plus high-level intent into hwpod-node-ops. Local --spec is an explicit debug/import path."
|
|
});
|
|
}
|
|
|
|
function ctlHelp() {
|
|
return ok("hwpod-ctl.help", {
|
|
contractVersion: "hwpod-ctl-v1",
|
|
usage: [
|
|
"bun tools/hwpod-ctl.ts spec validate --hwpod-id d601-f103-v2 --workspace-path <run-worktree>",
|
|
"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 --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --dry-run",
|
|
"bun tools/hwpod-cli.ts workspace ls . --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --dry-run",
|
|
"bun tools/hwpod-cli.ts workspace cat projects/01_baseline/User/main.c --hwpod-id d601-f103-v2 --workspace-path <run-worktree>",
|
|
"bun tools/hwpod-cli.ts workspace read projects/01_baseline/User/main.c --hwpod-id d601-f103-v2 --workspace-path <run-worktree>",
|
|
"bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --hwpod-id d601-f103-v2 --workspace-path <run-worktree>",
|
|
"cat patch.txt | bun tools/hwpod-cli.ts workspace apply-patch --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --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 --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --dry-run",
|
|
"bun tools/hwpod-cli.ts download --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --dry-run",
|
|
"bun tools/hwpod-cli.ts job status <jobId> --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --dry-run",
|
|
"bun tools/hwpod-cli.ts uart read --port uart1 --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --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. `read` is a CLI alias for `cat`.",
|
|
applyPatch: "Apply a Codex/UniDesk apply_patch v2 envelope through hwpod-node. Patch content may come from stdin, --patch/--patchText/--patch-content, or --patchBase64. Raw unified diff is rejected with a format hint.",
|
|
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."
|
|
},
|
|
debug: {
|
|
build: "Keil build starts an async job by default; pass --wait only for bounded manual debugging.",
|
|
download: "Keil download compiles to a flash job through cmd.run, so build+program stays inside the Keil job.",
|
|
jobStatus: "Query a Keil async job with `hwpod job status <jobId>`; this compiles to cmd.run rather than a new node op."
|
|
},
|
|
uart: {
|
|
read: "Use the configured serial-monitor CLI through cmd.run. The hwpod-node contract stays at cmd.run plus file ops."
|
|
},
|
|
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"
|
|
});
|
|
}
|
|
|
|
function hwpodCliCommandHelp(command: string, parsed: ParsedArgs) {
|
|
if (command === "workspace") return hwpodWorkspaceHelp(text(parsed._[1]));
|
|
if (command === "job") return hwpodJobHelp(text(parsed._[1]));
|
|
if (command === "build") return hwpodBuildHelp();
|
|
if (command === "download") return hwpodDownloadHelp();
|
|
if (command === "uart") return hwpodUartHelp(text(parsed._[1]));
|
|
if (command === "cmd") return hwpodCmdHelp();
|
|
return cliHelp();
|
|
}
|
|
|
|
function hwpodWorkspaceHelp(subcommand = "") {
|
|
return ok("hwpod-cli.workspace.help", {
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
command: "workspace",
|
|
subcommand: subcommand || null,
|
|
usage: [
|
|
"hwpod workspace ls [path] --hwpod-id d601-f103-v2 --workspace-path <run-worktree>",
|
|
"hwpod workspace cat <path> --hwpod-id d601-f103-v2 --workspace-path <run-worktree>",
|
|
"hwpod workspace read <path> --hwpod-id d601-f103-v2 --workspace-path <run-worktree>",
|
|
"hwpod workspace rg <pattern> [path] --context 3 --max-matches 20 --hwpod-id d601-f103-v2 --workspace-path <run-worktree>",
|
|
"hwpod workspace write --path <path> --content <text> --expected-sha <sha>",
|
|
"hwpod workspace replace --path <path> --find <text> --replace <text> --expected-sha <sha>",
|
|
"hwpod workspace insert-after --path <path> --anchor <text> --line <text> --expected-sha <sha>",
|
|
"cat patch.txt | hwpod workspace apply-patch --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --reason <reason>",
|
|
"hwpod workspace apply-patch --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --patch-content '<apply_patch v2 envelope>'"
|
|
],
|
|
aliases: {
|
|
read: "workspace.cat",
|
|
grep: "workspace.rg",
|
|
search: "workspace.rg",
|
|
file: "--path",
|
|
remotePath: "--path",
|
|
patchContent: "--patch"
|
|
},
|
|
applyPatchEngine: "codex-apply-patch-v2-compatible",
|
|
applyPatchBoundary: "workspace apply-patch accepts a Codex/UniDesk v2 envelope. Put target paths in *** Update/Add/Delete File headers; --remote-path is only a path alias for file-oriented workspace commands, not a substitute for envelope headers.",
|
|
boundary: "Workspace file operations are the standard editing path; do not use shell quoting for subject text edits unless debugging cmd.run itself."
|
|
});
|
|
}
|
|
|
|
function hwpodJobHelp(subcommand = "") {
|
|
return ok("hwpod-cli.job.help", {
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
command: "job",
|
|
subcommand: subcommand || null,
|
|
usage: ["hwpod job status <jobId> --hwpod-id d601-f103-v2 --workspace-path <run-worktree>"],
|
|
boundary: "Job status compiles to cmd.run against the configured Keil CLI; it is not a separate hwpod-node op."
|
|
});
|
|
}
|
|
|
|
function hwpodBuildHelp() {
|
|
return ok("hwpod-cli.build.help", {
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
usage: ["hwpod build --hwpod-id d601-f103-v2 --workspace-path <run-worktree>", "hwpod build --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --dry-run"],
|
|
boundary: "Build is a short HWPOD CLI invocation that normally starts an async Keil job through cmd.run."
|
|
});
|
|
}
|
|
|
|
function hwpodDownloadHelp() {
|
|
return ok("hwpod-cli.download.help", {
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
usage: ["hwpod download --hwpod-id d601-f103-v2 --workspace-path <run-worktree>", "hwpod download --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --dry-run"],
|
|
boundary: "Download compiles to a Keil flash cmd.run plan; do not add a download-specific hwpod-node op."
|
|
});
|
|
}
|
|
|
|
function hwpodUartHelp(subcommand = "") {
|
|
return ok("hwpod-cli.uart.help", {
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
command: "uart",
|
|
subcommand: subcommand || null,
|
|
usage: ["hwpod uart read --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --port uart1"],
|
|
boundary: "UART read is compiled to cmd.run against the configured serial-monitor CLI."
|
|
});
|
|
}
|
|
|
|
function hwpodCmdHelp() {
|
|
return ok("hwpod-cli.cmd.help", {
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
usage: ["hwpod cmd <command> [...argv] --hwpod-id d601-f103-v2 --workspace-path <run-worktree>"],
|
|
boundary: "cmd.run is the full-capability host execution op; add compiler/CLI combinations before considering new 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, env: EnvLike = process.env, fetchImpl?: FetchLike) {
|
|
const resolved = await resolveHwpodDocument({ parsed, env, fetchImpl });
|
|
const document = resolved.document;
|
|
return ok("hwpod-ctl.spec.validate", { specPath: resolved.specPath, hwpodId: resolved.hwpodId, specAuthority: resolved.authority, document, fourElements: ["targetDevice", "workspace", "debugProbe", "ioProbe"], nodeId: document.spec.nodeBinding.nodeId });
|
|
}
|
|
|
|
async function showSpec(parsed: ParsedArgs, env: EnvLike = process.env, fetchImpl?: FetchLike) {
|
|
const resolved = await resolveHwpodDocument({ parsed, env, fetchImpl });
|
|
return ok("hwpod-ctl.spec.show", { specPath: resolved.specPath, hwpodId: resolved.hwpodId, specAuthority: resolved.authority, document: resolved.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({ parsed, env, fetchImpl, intent, args }: { parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike; intent: string; args: Record<string, unknown> }) {
|
|
const resolved = await resolveHwpodDocument({ parsed, env, fetchImpl });
|
|
const plan = compileHwpodNodeOpsPlan({ document: resolved.document, specPath: resolved.specPath, specAuthority: resolved.authority, intent, args });
|
|
return {
|
|
plan,
|
|
specPath: resolved.specPath,
|
|
hwpodId: resolved.hwpodId,
|
|
specAuthority: resolved.authority,
|
|
compilerInvocation: clean({
|
|
compiler: COMPILER_NAME,
|
|
mode: "in-process",
|
|
entrypoint: "tools/src/hwpod-harness-lib.ts",
|
|
argv: ["compile", resolved.specPath.startsWith("hwpod://") ? "--hwpod-id" : "--spec", resolved.specPath.startsWith("hwpod://") ? resolved.hwpodId : resolved.specPath, "--intent", intent, "--args", "<json>"],
|
|
exitCode: 0,
|
|
action: "hwpod-compiler.compile",
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
planId: plan.planId,
|
|
source: plan.source
|
|
})
|
|
};
|
|
}
|
|
|
|
async function resolveHwpodDocument({ parsed, env, fetchImpl }: { parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike }) {
|
|
const explicitSpecPath = text(parsed.spec ?? parsed.specPath);
|
|
if (explicitSpecPath) {
|
|
const document = applyHwpodRuntimeOverrides(await readHwpodSpec(explicitSpecPath), parsed, env, explicitSpecPath);
|
|
return { document, specPath: explicitSpecPath, hwpodId: document.metadata.name || document.metadata.uid, authority: "code-agent-workspace" };
|
|
}
|
|
|
|
const hwpodId = requiredText(parsed.hwpodId ?? parsed.hwpod ?? env.HWPOD_ID ?? env.HWLAB_HWPOD_ID, "hwpodId");
|
|
const spec = await fetchHwpodSpecById({ hwpodId, parsed, env, fetchImpl });
|
|
const document = applyHwpodRuntimeOverrides(spec.document, parsed, env, `hwpod://${hwpodId}`);
|
|
return {
|
|
document,
|
|
specPath: `hwpod://${hwpodId}`,
|
|
hwpodId: document.metadata.name || document.metadata.uid || hwpodId,
|
|
authority: text(spec.authority) || text(spec.source?.kind) || "runtime-hwpod-registry"
|
|
};
|
|
}
|
|
|
|
async function fetchHwpodSpecById({ hwpodId, parsed, env, fetchImpl }: { hwpodId: string; parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike }) {
|
|
const endpoint = resolveRuntimeEndpoint({ kind: "api", parsed, env });
|
|
const route = { method: "GET", path: `/v1/hwpod/specs?hwpodId=${encodeURIComponent(hwpodId)}` };
|
|
const url = `${endpoint.baseUrl}${route.path}`;
|
|
const headers = authHeaders(parsed, env);
|
|
const response = fetchImpl
|
|
? await fetchImpl(url, { method: route.method, headers })
|
|
: await requestJsonNative(url, { method: route.method, headers, timeoutMs: numberValue(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS });
|
|
const body = await response.json().catch(() => null);
|
|
if (!body || body.ok === false) throw cliError("hwpod_registry_request_failed", "failed to query HWPOD registry", { hwpodId, httpStatus: response.status, body: compactObject(body), runtimeEndpoint: runtimeEndpointVisibility(endpoint) });
|
|
const specs = Array.isArray(body.specs) ? body.specs : [];
|
|
const match = specs.find((item: any) => text(item.hwpodId) === hwpodId || text(item.name) === hwpodId || text(item.uid) === hwpodId);
|
|
if (!match) throw cliError("hwpod_id_not_found", `HWPOD id not found in runtime registry: ${hwpodId}`, { hwpodId, count: specs.length, runtimeEndpoint: runtimeEndpointVisibility(endpoint) });
|
|
const document = objectValue(match.document);
|
|
if (!isPlainObject(document)) throw cliError("hwpod_registry_document_missing", `HWPOD registry item has no document: ${hwpodId}`, { hwpodId, item: compactObject(match) });
|
|
return { ...match, document };
|
|
}
|
|
|
|
function applyHwpodRuntimeOverrides(document: any, parsed: ParsedArgs, env: EnvLike, sourcePath: string) {
|
|
const copy = JSON.parse(JSON.stringify(document));
|
|
const workspacePath = text(parsed.workspacePath ?? parsed.subjectWorktreePath ?? env.HWPOD_WORKSPACE_PATH ?? env.HWLAB_HWPOD_WORKSPACE_PATH);
|
|
if (workspacePath) {
|
|
copy.spec = objectValue(copy.spec);
|
|
copy.spec.workspace = { ...objectValue(copy.spec.workspace), path: workspacePath };
|
|
}
|
|
return normalizeHwpodSpec(copy, sourcePath);
|
|
}
|
|
|
|
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" || subcommand === "read") return { intent: "workspace.cat", args: { path: requiredWorkspacePath(parsed, 2) } };
|
|
if (subcommand === "rg" || subcommand === "grep" || subcommand === "search") return { intent: "workspace.rg", args: workspaceSearchArgs(parsed) };
|
|
if (subcommand === "apply-patch") return { intent: "workspace.apply-patch", args: { patch: patchText(parsed.patch ?? parsed.patchText ?? parsed.patchContent ?? stdinText), patchBase64: text(parsed.patchBase64), reason: text(parsed.reason) } };
|
|
if (subcommand === "write") return { intent: "workspace.write", args: clean({ path: requiredWorkspacePath(parsed, 2), 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: requiredWorkspacePath(parsed, 2), 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: requiredWorkspacePath(parsed, 2), anchor: requiredRawString(parsed.anchor ?? parsed.marker ?? parsed._[3], "anchor"), line: requiredRawString(parsed.line ?? parsed.content ?? 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), wait: parsed.wait === true, timeoutMs: numberValue(parsed.timeoutMs), 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), wait: parsed.wait === true, timeoutMs: numberValue(parsed.timeoutMs), reason: text(parsed.reason) }) };
|
|
if (command === "job") {
|
|
const subcommand = parsed._[1] || "status";
|
|
if (subcommand === "status") return { intent: "debug.job-status", args: clean({ jobId: requiredText(parsed.jobId ?? parsed._[2], "jobId"), timeoutMs: numberValue(parsed.timeoutMs), reason: text(parsed.reason) }) };
|
|
throw cliError("unsupported_job_command", `unsupported job command: ${subcommand}`);
|
|
}
|
|
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), limit: numberValue(parsed.limit), since: text(parsed.since), sessionOnly: parsed.sessionOnly === true, timeoutMs: numberValue(parsed.timeoutMs), serialMonitorDir: text(parsed.serialMonitorDir), serialMonitorCommand: text(parsed.serialMonitorCommand), reason: text(parsed.reason) }) };
|
|
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 workspaceSearchArgs(parsed: ParsedArgs) {
|
|
return clean({
|
|
pattern: requiredText(parsed.pattern ?? parsed._[2], "pattern"),
|
|
path: text(parsed.path ?? parsed._[3] ?? "."),
|
|
ignoreCase: parsed.ignoreCase === true,
|
|
beforeContext: numberValue(parsed.beforeContext),
|
|
afterContext: numberValue(parsed.afterContext),
|
|
context: numberValue(parsed.context),
|
|
maxMatches: numberValue(parsed.maxMatches),
|
|
maxFiles: numberValue(parsed.maxFiles),
|
|
maxBytesPerFile: numberValue(parsed.maxBytesPerFile),
|
|
maxLineBytes: numberValue(parsed.maxLineBytes)
|
|
});
|
|
}
|
|
|
|
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: clean({ ...common, pattern: requiredText(args.pattern, "pattern"), path: text(args.path) || ".", ignoreCase: args.ignoreCase === true, beforeContext: numberValue(args.beforeContext), afterContext: numberValue(args.afterContext), context: numberValue(args.context), maxMatches: numberValue(args.maxMatches), maxFiles: numberValue(args.maxFiles), maxBytesPerFile: numberValue(args.maxBytesPerFile), maxLineBytes: numberValue(args.maxLineBytes) }) }];
|
|
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 debugDownloadOps(common, args, document);
|
|
if (intent === "debug.job-status") return debugJobStatusOps(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 uartReadOps(common, args, document);
|
|
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: "debug.build", args: clean({ ...opArgs, commandRun: generated.commandRun, command: 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,
|
|
commandRuns: generated?.commandRuns,
|
|
commandBinding: generated?.binding,
|
|
timeoutMs: numberValue(args.timeoutMs) ?? generated?.timeoutMs,
|
|
reason: text(args.reason)
|
|
});
|
|
}
|
|
|
|
function debugDownloadOps(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);
|
|
const opArgs = clean({
|
|
...common,
|
|
artifact: text(args.artifact),
|
|
target: text(args.target) || generated?.target,
|
|
commandBinding: generated?.binding,
|
|
timeoutMs: numberValue(args.timeoutMs) ?? generated?.timeoutMs,
|
|
reason: text(args.reason)
|
|
});
|
|
if (generated?.commandRuns?.length) {
|
|
return generated.commandRuns.map((entry: any) => ({
|
|
op: "cmd.run",
|
|
args: clean({
|
|
...opArgs,
|
|
commandBinding: entry.commandBinding ?? opArgs.commandBinding,
|
|
command: entry.commandRun.command,
|
|
argv: entry.commandRun.argv,
|
|
commandLine: entry.command,
|
|
step: entry.step
|
|
})
|
|
}));
|
|
}
|
|
return [{ op: "debug.download", args: debugDownloadArgs(common, args, document) }];
|
|
}
|
|
|
|
function debugJobStatusOps(common: any, args: any, document: any) {
|
|
const generated = keilJobStatusCommand(args, document);
|
|
return [{
|
|
op: "cmd.run",
|
|
args: clean({
|
|
...common,
|
|
workspacePath: dirnameForCommandPath(generated.keilCliPath),
|
|
command: generated.commandRun.command,
|
|
argv: generated.commandRun.argv,
|
|
commandLine: generated.command,
|
|
step: "keil-job-status",
|
|
commandBinding: generated.binding,
|
|
timeoutMs: numberValue(args.timeoutMs) ?? generated.timeoutMs,
|
|
reason: text(args.reason)
|
|
})
|
|
}];
|
|
}
|
|
|
|
function uartReadOps(common: any, args: any, document: any) {
|
|
const generated = serialMonitorReadCommand(args, document);
|
|
return [{
|
|
op: "io.uart.read",
|
|
args: clean({
|
|
...common,
|
|
port: generated.physicalPort,
|
|
baudRate: generated.baudRate,
|
|
maxBytes: numberValue(args.maxBytes),
|
|
limit: numberValue(args.limit),
|
|
since: text(args.since),
|
|
sessionOnly: args.sessionOnly === true,
|
|
serialMonitorDir: generated.serialMonitorDir,
|
|
serialMonitorCommand: generated.commandBase,
|
|
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), ...(args.wait === true ? ["--wait"] : [])];
|
|
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, wait: args.wait === true })
|
|
};
|
|
}
|
|
|
|
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 === true || debugProbe.autoBindProbe === true;
|
|
const sidecarDiagnostics = keilSidecarDiagnosticRun(project);
|
|
const bindTokens = autoBindUvoptx && probeUid
|
|
? [...commonTokens, "project", "probe-binding", "set", "-p", project, ...projectTargetOption(target), "--probe-uid", probeUid, ...probeNameOption(probeName)]
|
|
: [];
|
|
const flashTokens = [
|
|
...commonTokens,
|
|
"flash",
|
|
"-p",
|
|
project,
|
|
"-m",
|
|
programmer,
|
|
"--program-backend",
|
|
programBackend,
|
|
...probeOption(probeUid),
|
|
...targetOption(target),
|
|
...(args.wait === true ? ["--wait"] : [])
|
|
];
|
|
const commandRuns = [
|
|
sidecarDiagnostics,
|
|
...(bindTokens.length > 0 ? [{ step: "keil-probe-binding", command: shellCommand(bindTokens), commandRun: commandRun(bindTokens) }] : []),
|
|
{ step: "keil-flash", command: shellCommand(flashTokens), commandRun: commandRun(flashTokens) }
|
|
];
|
|
return {
|
|
target,
|
|
command: commandRuns.map((entry) => entry.command).join(" && "),
|
|
commandRuns,
|
|
timeoutMs,
|
|
binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "download", keilCommand: "flash", project, target, keilCliPath, pythonCommand, programmer, programBackend, probeUid, probeName, autoBindUvoptx: bindTokens.length > 0, wait: args.wait === true, keilSidecars: sidecarDiagnostics.commandBinding })
|
|
};
|
|
}
|
|
|
|
function keilSidecarDiagnosticRun(project: string) {
|
|
const base = project.replace(/\.uvprojx$/iu, "");
|
|
const primary = `${base}.uvoptx`;
|
|
const alternate = `${base}.uvopt`;
|
|
const script = [
|
|
"const fs=require('fs');",
|
|
"const input=JSON.parse(process.argv[1]||'{}');",
|
|
"const primary=String(input.primary||'');",
|
|
"const alternate=String(input.alternate||'');",
|
|
"let result;",
|
|
"if(fs.existsSync(primary)){result={status:'present',primary};}",
|
|
"else if(fs.existsSync(alternate)){result={status:'present',alternate};}",
|
|
"else{result={status:'missing',primary,alternate,reason:'keil_uvoptx_binding_absent'};}",
|
|
"console.log('hwpodKeilSidecar '+JSON.stringify(result));"
|
|
].join("");
|
|
const run = { command: "node", argv: ["-e", script, JSON.stringify({ primary, alternate })] };
|
|
return { step: "keil-sidecar-diagnostics", command: shellCommand([run.command, ...run.argv]), commandRun: run, commandBinding: { primary, alternate } };
|
|
}
|
|
|
|
function keilJobStatusCommand(args: any, document: any) {
|
|
const workspace = objectValue(document.spec.workspace);
|
|
const debugProbe = objectValue(document.spec.debugProbe);
|
|
const tooling = objectValue(document.spec.tooling);
|
|
const keilCliPath = text(args.keilCliPath) || text(workspace.keilCliPath) || text(debugProbe.keilCliPath) || text(tooling.keilCliPath) || "keil-cli.py";
|
|
const pythonCommand = text(args.pythonCommand) || text(workspace.pythonCommand) || text(debugProbe.pythonCommand) || text(tooling.pythonCommand) || "py -3";
|
|
const timeoutMs = numberValue(args.timeoutMs) ?? numberValue(workspace.keilCommandTimeoutMs) ?? numberValue(debugProbe.keilCommandTimeoutMs) ?? DEFAULT_KEIL_COMMAND_TIMEOUT_MS;
|
|
const jobId = requiredText(args.jobId, "jobId");
|
|
const commandTokens = [...splitCommandWords(pythonCommand), keilCliPath, "job-status", jobId];
|
|
return {
|
|
keilCliPath,
|
|
command: shellCommand(commandTokens),
|
|
commandRun: commandRun(commandTokens),
|
|
timeoutMs,
|
|
binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "job-status", jobId, keilCliPath, pythonCommand })
|
|
};
|
|
}
|
|
|
|
function serialMonitorReadCommand(args: any, document: any) {
|
|
const ioProbe = objectValue(document.spec.ioProbe);
|
|
const uart = objectValue(ioProbe.uart);
|
|
const tooling = objectValue(document.spec.tooling);
|
|
const requestedPort = text(args.port) || text(uart.id) || "uart1";
|
|
const physicalPort = text(args.physicalPort) || text(uart.port) || text(uart.path) || text(ioProbe.port) || requestedPort;
|
|
const baudRate = numberValue(args.baudRate ?? args.baudrate ?? uart.baudRate ?? uart.baudrate ?? ioProbe.baudRate ?? ioProbe.baudrate) ?? 115200;
|
|
const serialMonitorDir = text(args.serialMonitorDir) || text(tooling.serialMonitorDir) || text(ioProbe.serialMonitorDir) || "C:\\Users\\liang\\.agents\\skills\\serial-monitor";
|
|
const commandBase = serialMonitorCommandBaseForCompiler(args, tooling, ioProbe);
|
|
const timeoutMs = numberValue(args.timeoutMs) ?? 30000;
|
|
const limit = Math.max(1, Math.min(numberValue(args.limit) ?? Math.ceil((numberValue(args.maxBytes) ?? 4096) / 80), 200));
|
|
const fetchArgs = ["fetch", "-l", String(limit), ...(args.sessionOnly === true ? ["--session-only"] : []), ...(text(args.since) ? ["-s", text(args.since)] : [])];
|
|
const sequenceRun = jsonCliSequenceCommandRun([
|
|
[...commandBase, "monitor", "start", "-p", physicalPort, "-b", String(baudRate)],
|
|
[...commandBase, ...fetchArgs]
|
|
]);
|
|
return {
|
|
physicalPort,
|
|
baudRate,
|
|
serialMonitorDir,
|
|
commandBase,
|
|
command: sequenceRun.commandLine,
|
|
commandRun: sequenceRun.commandRun,
|
|
timeoutMs,
|
|
binding: clean({ kind: "serial-monitor-cli", source: "hwpod-compiler.serial-monitor", action: "uart-read", requestedPort, physicalPort, baudRate, serialMonitorDir, sessionOnly: args.sessionOnly === true })
|
|
};
|
|
}
|
|
|
|
function serialMonitorCommandBaseForCompiler(args: any, tooling: any, ioProbe: any) {
|
|
const configured = text(args.serialMonitorCommand) || text(tooling.serialMonitorCommand) || text(ioProbe.serialMonitorCommand);
|
|
if (configured) return splitCommandWords(configured);
|
|
return ["bun", "scripts/serial-monitor-cli.ts"];
|
|
}
|
|
|
|
function jsonCliSequenceCommandRun(sequence: string[][]) {
|
|
const script = [
|
|
"const {spawnSync}=require('child_process');",
|
|
"const seq=JSON.parse(process.argv[1]);",
|
|
"for (const argv of seq) {",
|
|
"const r=spawnSync(argv[0],argv.slice(1),{encoding:'utf8'});",
|
|
"if(r.stdout) process.stdout.write(r.stdout);",
|
|
"if(r.stderr) process.stderr.write(r.stderr);",
|
|
"let ok=r.status===0;",
|
|
"try{const body=JSON.parse((r.stdout||'').trim()); if(body && body.success===false) ok=false;}catch{}",
|
|
"if(!ok) process.exit(r.status||1);",
|
|
"}",
|
|
"process.exit(0);"
|
|
].join(" ");
|
|
return {
|
|
commandLine: `node -e ${shellArg(script)} ${shellArg(JSON.stringify(sequence))}`,
|
|
commandRun: { command: "node", argv: ["-e", script, JSON.stringify(sequence)] }
|
|
};
|
|
}
|
|
|
|
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 dirnameForCommandPath(candidate: string) {
|
|
const value = text(candidate);
|
|
if (!value) return ".";
|
|
if (/^[A-Za-z]:[\\/]/u.test(value) || value.includes("\\")) {
|
|
const normalized = value.replace(/[\\/]+$/u, "");
|
|
const parent = normalized.replace(/[\\/][^\\/]*$/u, "");
|
|
return parent && parent !== normalized ? parent : ".";
|
|
}
|
|
return path.dirname(value);
|
|
}
|
|
|
|
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[]) {
|
|
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);
|
|
const otelTraceId = responseHeader(response, "x-hwlab-otel-trace-id") || hwpodNodeOpsOtelTraceId(body);
|
|
const traceparent = responseHeader(response, "traceparent") || text(body?.requestMeta?.traceparent);
|
|
return { status: response.status, body: enrichHwpodNodeOpsBody(body, { otelTraceId, traceparent }), route, runtimeEndpoint: runtimeEndpointVisibility(endpoint), otelTraceId, traceparent };
|
|
}
|
|
|
|
function hwpodNodeOpsCliDiagnostic(response: any) {
|
|
const body = response?.body;
|
|
if (!body || typeof body !== "object" || body.ok !== false) return null;
|
|
const blocker = body.blocker ?? body.results?.find?.((result: any) => result?.blocker)?.blocker ?? body.error ?? {};
|
|
const otelTraceId = text(response?.otelTraceId ?? blocker?.otelTraceId ?? blocker?.diagnostic?.otelTraceId ?? body?.otelTraceId ?? body?.requestMeta?.otelTraceId);
|
|
return {
|
|
code: text(blocker?.code) || "hwpod_node_ops_failed",
|
|
summary: text(blocker?.summary ?? blocker?.message) || "hwpod-node-ops request failed",
|
|
otelTraceId: otelTraceId || null,
|
|
traceLine: otelTraceId ? `OTel traceId: ${otelTraceId}` : null,
|
|
route: response?.route,
|
|
httpStatus: response?.status,
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
function enrichHwpodNodeOpsBody(body: any, meta: { otelTraceId?: string; traceparent?: string } = {}) {
|
|
if (!body || typeof body !== "object") return body;
|
|
const otelTraceId = text(meta.otelTraceId) || hwpodNodeOpsOtelTraceId(body);
|
|
if (!otelTraceId) return body;
|
|
const enrichBlocker = (blocker: any) => {
|
|
if (!blocker || typeof blocker !== "object") return blocker;
|
|
const diagnostic = {
|
|
...(blocker.diagnostic && typeof blocker.diagnostic === "object" ? blocker.diagnostic : {}),
|
|
code: text(blocker.code) || text(blocker.diagnostic?.code) || "hwpod_node_ops_failed",
|
|
summary: text(blocker.summary ?? blocker.message ?? blocker.diagnostic?.summary) || "hwpod-node-ops request failed",
|
|
otelTraceId,
|
|
traceLine: `OTel traceId: ${otelTraceId}`,
|
|
valuesPrinted: false
|
|
};
|
|
return { ...blocker, otelTraceId, diagnostic };
|
|
};
|
|
const blocker = enrichBlocker(body.blocker);
|
|
const results = Array.isArray(body.results)
|
|
? body.results.map((result: any) => result?.blocker ? { ...result, blocker: enrichBlocker(result.blocker) } : result)
|
|
: body.results;
|
|
return {
|
|
...body,
|
|
blocker,
|
|
results,
|
|
otelTraceId,
|
|
diagnostic: body.diagnostic ?? blocker?.diagnostic ?? results?.find?.((result: any) => result?.blocker?.diagnostic)?.blocker?.diagnostic,
|
|
requestMeta: body.requestMeta && typeof body.requestMeta === "object"
|
|
? { ...body.requestMeta, otelTraceId, ...(meta.traceparent ? { traceparent: meta.traceparent } : {}) }
|
|
: body.requestMeta
|
|
};
|
|
}
|
|
|
|
function hwpodNodeOpsOtelTraceId(body: any) {
|
|
return text(body?.otelTraceId ?? body?.diagnostic?.otelTraceId ?? body?.blocker?.otelTraceId ?? body?.blocker?.diagnostic?.otelTraceId ?? body?.requestMeta?.otelTraceId ?? body?.results?.find?.((result: any) => result?.blocker?.otelTraceId)?.blocker?.otelTraceId);
|
|
}
|
|
|
|
function responseHeader(response: any, name: string) {
|
|
const headers = response?.headers;
|
|
if (!headers) return "";
|
|
if (typeof headers.get === "function") return text(headers.get(name));
|
|
const raw = headers[String(name).toLowerCase()] ?? headers[name];
|
|
return text(Array.isArray(raw) ? raw[0] : raw);
|
|
}
|
|
|
|
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 requestJsonNative(urlValue: string, { method, headers, timeoutMs }: { method: string; headers: Record<string, 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, 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,
|
|
headers: response.headers,
|
|
json: async () => textValue ? JSON.parse(textValue) : null
|
|
});
|
|
});
|
|
});
|
|
request.on("timeout", () => request.destroy(cliError("hwpod_registry_timeout", `HWPOD registry request timed out after ${timeoutMs}ms`)));
|
|
request.on("error", reject);
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
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,
|
|
headers: response.headers,
|
|
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;
|
|
}
|
|
if (["-i", "-A", "-B", "-C"].includes(token) || /^-[ABC]\d+$/u.test(token)) {
|
|
applyShortSearchOption(parsed, token, argv[index + 1]);
|
|
if (["-A", "-B", "-C"].includes(token)) index += 1;
|
|
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 applyShortSearchOption(parsed: ParsedArgs, token: string, next: string | undefined) {
|
|
if (token === "-i") {
|
|
parsed.ignoreCase = true;
|
|
return;
|
|
}
|
|
const flag = token[1];
|
|
const inlineValue = token.length > 2 ? token.slice(2) : next;
|
|
const value = numberValue(inlineValue);
|
|
if (flag === "A") parsed.afterContext = value;
|
|
if (flag === "B") parsed.beforeContext = value;
|
|
if (flag === "C") parsed.context = value;
|
|
}
|
|
|
|
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 requiredWorkspacePath(parsed: ParsedArgs, positionalIndex: number) {
|
|
return requiredText(parsed.path ?? parsed.file ?? parsed.remotePath ?? parsed.remote ?? parsed._[positionalIndex], "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 compactObject(value: any) {
|
|
if (value === null || value === undefined) return null;
|
|
if (!isPlainObject(value) && !Array.isArray(value)) return value;
|
|
try {
|
|
return JSON.parse(JSON.stringify(value));
|
|
} catch {
|
|
return { value: String(value) };
|
|
}
|
|
}
|
|
|
|
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 ?? ""));
|
|
}
|