889 lines
43 KiB
TypeScript
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 ?? ""));
|
|
}
|