diff --git a/tools/hwlab-cli/hwpod.test.ts b/tools/hwlab-cli/hwpod.test.ts new file mode 100644 index 00000000..aa80b6f9 --- /dev/null +++ b/tools/hwlab-cli/hwpod.test.ts @@ -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"; +} diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index 80412b95..d51914c9 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -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 { + 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;