hwpod: support stdin workspace patches

This commit is contained in:
Codex Agent
2026-06-06 15:45:58 +08:00
parent 2be05a55cf
commit 64b0b3e630
2 changed files with 82 additions and 3 deletions
+43
View File
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { test } from "bun:test";
import { runHwpodCli } from "../src/hwpod-harness-lib.ts";
test("hwpod-cli help exposes workspace cat and apply-patch stdin usage", async () => {
const result = await runHwpodCli(["help"], { now: () => "2026-06-06T00:00:00.000Z" });
assert.equal(result.exitCode, 0);
assert.equal(result.payload.action, "hwpod-cli.help");
assert.equal(result.payload.usage.some((item: string) => /workspace cat/u.test(item)), true);
assert.equal(result.payload.usage.some((item: string) => /workspace apply-patch/u.test(item)), true);
assert.match(result.payload.applyPatchExample, /\*\*\* Begin Patch/u);
});
test("hwpod-cli workspace apply-patch passes stdin patch to hwpod-node plan", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwpod-cli-test-"));
const specPath = path.join(root, "hwpod-spec.yaml");
const patch = "*** Begin Patch\n*** Update File: app.c\n@@\n old\n+new\n*** End Patch\n";
try {
await writeFile(specPath, hwpodSpecText(), "utf8");
const result = await runHwpodCli(["workspace", "apply-patch", "--spec", specPath, "--reason", "test", "--dry-run"], {
stdinText: patch,
now: () => "2026-06-06T00:00:00.000Z"
});
assert.equal(result.exitCode, 0);
assert.equal(result.payload.action, "hwpod-cli.plan");
assert.equal(result.payload.intent, "workspace.apply-patch");
assert.equal(result.payload.plan.ops[0].op, "workspace.apply-patch");
assert.equal(result.payload.plan.ops[0].args.patch, patch);
assert.equal(result.payload.plan.ops[0].args.reason, "test");
} finally {
await rm(root, { recursive: true, force: true });
}
});
function hwpodSpecText() {
return "kind: Hwpod\nmetadata:\n name: test-hwpod\nspec:\n nodeBinding:\n nodeId: test-node\n workspace:\n path: F:\\\\Work\\\\HWLAB-CASE-F103\n targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n";
}
+39 -3
View File
@@ -33,11 +33,29 @@ export async function mainHwpodCtl(argv = process.argv.slice(2), options: any =
}
export async function mainHwpodCli(argv = process.argv.slice(2), options: any = {}) {
const result = await runHwpodCli(argv, options);
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" && subcommand === "apply-patch" && parsed.patch === undefined && parsed.patchText === undefined && parsed.patchBase64 === 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 {
@@ -165,10 +183,24 @@ function cliHelp() {
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 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."
},
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"
});
}
@@ -291,7 +323,7 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) {
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: text(parsed.patch ?? parsed.patchText ?? stdinText), patchBase64: text(parsed.patchBase64), reason: text(parsed.reason) } };
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) } };
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) }) };
@@ -321,7 +353,7 @@ function opsForIntent(intent: string, args: any, document: any) {
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: text(args.patch), patchBase64: text(args.patchBase64), reason: text(args.reason) }) }];
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 === "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) }) }];
@@ -794,6 +826,10 @@ function text(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : "";
}
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;