diff --git a/tools/hwlab-cli/bin/hwlab-cli.ts b/tools/hwlab-cli/bin/hwlab-cli.ts index d61c2ec9..2f19be96 100644 --- a/tools/hwlab-cli/bin/hwlab-cli.ts +++ b/tools/hwlab-cli/bin/hwlab-cli.ts @@ -36,6 +36,9 @@ if (argv[0] === "tasktree") { console.log(JSON.stringify({ ok: false, operation: "hwpod", error: { code: error?.code ?? "hwpod_cli_error", message: error?.message ?? String(error) } }, null, 2)); process.exitCode = 1; } +} else if (argv[0] === "hwpod") { + const { mainHwpodCli } = await import("../../src/hwpod-harness-lib.ts"); + await mainHwpodCli(argv.slice(1)); } else if (argv[0] === "workbench") { const { runWorkbenchCli } = await import("../../src/workbench-cli.ts"); try { diff --git a/tools/hwlab-cli/hwpod.test.ts b/tools/hwlab-cli/hwpod.test.ts index 20ba1563..b4e62948 100644 --- a/tools/hwlab-cli/hwpod.test.ts +++ b/tools/hwlab-cli/hwpod.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { test } from "bun:test"; @@ -56,6 +56,69 @@ test("hwpod-cli workspace apply-patch accepts --patch-content alias", async () = } }); -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"; +test("hwlab-cli hwpod L0 executes workspace edits and a bounded build helper", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwpod-cli-l0-")); + const specPath = path.join(root, "hwpod-spec.yaml"); + const sourcePath = path.join(root, "app.c"); + const artifactPath = path.join(root, "firmware.hex"); + const patch = "*** Begin Patch\n*** Update File: app.c\n@@\n-old\n+new\n*** End Patch\n"; + try { + await writeFile(specPath, hwpodSpecText(root, "node build.mjs"), "utf8"); + await writeFile(sourcePath, "old\n", "utf8"); + await writeFile(path.join(root, "build.mjs"), "import { writeFileSync } from 'node:fs'; writeFileSync('firmware.hex', ':00000001FF\\n'); console.log('build completed');\n", "utf8"); + + const edited = await runHwpodCli(["workspace", "apply-patch", "--local", "--spec", specPath, "--patch-content", patch]); + assert.equal(edited.exitCode, 0); + assert.equal(edited.payload.action, "hwpod-cli.local"); + assert.equal(edited.payload.mode, "l0-native-function"); + assert.equal(edited.payload.transport, "native-function"); + assert.equal(await readFile(sourcePath, "utf8"), "new\n"); + + const listed = await runHwpodCli(["workspace", "ls", ".", "--local", "--spec", specPath]); + assert.equal(listed.payload.body.results[0].output.entries.some((item: any) => item.name === "app.c"), true); + + const searched = await runHwpodCli(["workspace", "rg", "new", ".", "--local", "--spec", specPath]); + assert.equal(searched.payload.body.results[0].output.matchCount, 1); + + const written = await runHwpodCli(["workspace", "write", "generated.txt", "--content", "alpha\n", "--local", "--spec", specPath]); + assert.equal(written.exitCode, 0); + assert.equal(await readFile(path.join(root, "generated.txt"), "utf8"), "alpha\n"); + + const replaced = await runHwpodCli(["workspace", "replace", "app.c", "--find", "new", "--replace", "newer", "--local", "--spec", specPath]); + assert.equal(replaced.exitCode, 0); + const inserted = await runHwpodCli(["workspace", "insert-after", "app.c", "--anchor", "newer", "--line", "marker", "--local", "--spec", specPath]); + assert.equal(inserted.exitCode, 0); + + const read = await runHwpodCli(["workspace", "cat", "app.c", "--local", "--spec", specPath]); + assert.equal(read.payload.body.results[0].output.content, "newer\nmarker\n"); + + const child = Bun.spawn([ + process.execPath, + path.join(process.cwd(), "tools/hwlab-cli/bin/hwlab-cli.ts"), + "hwpod", + "build", + "--local", + "--spec", + specPath, + ], { cwd: process.cwd(), stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + assert.equal(exitCode, 0, stderr); + const built = JSON.parse(stdout); + assert.equal(built.action, "hwpod-cli.local"); + assert.equal(built.mode, "l0-native-function"); + assert.match(built.body.results[0].output.stdout, /build completed/u); + assert.equal(await readFile(artifactPath, "utf8"), ":00000001FF\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("HWPOD L0 local mode fails closed without an explicit spec", async () => { + const result = await runHwpodCli(["workspace", "ls", ".", "--local"]); + assert.equal(result.exitCode, 1); + assert.equal(result.payload.error.code, "hwpod_l0_spec_required"); +}); + +function hwpodSpecText(workspacePath = "F:\\Work\\HWLAB-CASE-F103", buildCommand = "") { + return `kind: Hwpod\nmetadata:\n name: test-hwpod\nspec:\n nodeBinding:\n nodeId: test-node\n workspace:\n path: ${JSON.stringify(workspacePath)}\n${buildCommand ? ` buildCommand: ${JSON.stringify(buildCommand)}\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 411fd1ac..49ec2366 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -5,6 +5,7 @@ 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 { executeHwpodNodeOpsPlan } from "./hwpod-node-lib.ts"; import { resolveRuntimeEndpoint, runtimeEndpointVisibility } from "./runtime-endpoint-resolver.ts"; export const DEFAULT_HWPOD_SPEC_PATH = ".hwlab/hwpod-spec.yaml"; @@ -13,7 +14,7 @@ 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 BOOLEAN_OPTIONS = new Set(["all", "allowMultiple", "dryRun", "finalNewline", "force", "full", "help", "h", "ignoreCase", "json", "local", "noAuth", "sessionOnly", "wait"]); const DEFAULT_KEIL_COMMAND_TIMEOUT_MS = 30000; type EnvLike = Record; type ParsedArgs = Record & { _: string[] }; @@ -107,11 +108,35 @@ export async function runHwpodCli(argv: string[], options: { env?: EnvLike; fetc if (command === "closeout") return result(0, closeout(parsed), now); const { intent, args } = commandToIntent(parsed, options.stdinText); + if (parsed.local === true && !text(parsed.spec ?? parsed.specPath)) { + throw cliError("hwpod_l0_spec_required", "HWPOD L0 local mode requires an explicit --spec path", { + mode: "l0-native-function", + next: "pass --local --spec " + }); + } 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); } + if (parsed.local === true) { + const body = await executeHwpodNodeOpsPlan(plan, { now }); + const exitCode = body.ok === false ? 1 : 0; + const payload = ok("hwpod-cli.local", { + mode: "l0-native-function", + transport: "native-function", + serviceRuntime: false, + specPath: compiled.specPath, + hwpodId: compiled.hwpodId, + specAuthority: compiled.specAuthority, + intent: plan.intent, + contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, + compilerInvocation: compiled.compilerInvocation, + body + }, body.status ?? (exitCode === 0 ? "completed" : "failed")); + if (exitCode !== 0) payload.ok = false; + return result(exitCode, payload, 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); @@ -203,6 +228,8 @@ function cliHelp() { usage: [ "bun tools/hwpod-cli.ts inspect --hwpod-id d601-f103-v2 --workspace-path --dry-run", "bun tools/hwpod-cli.ts workspace ls . --hwpod-id d601-f103-v2 --workspace-path --dry-run", + "hwlab-cli hwpod workspace ls . --local --spec .hwlab/hwpod-spec.yaml", + "hwlab-cli hwpod build --local --spec .hwlab/hwpod-spec.yaml", "bun tools/hwpod-cli.ts workspace cat projects/01_baseline/User/main.c --hwpod-id d601-f103-v2 --workspace-path ", "bun tools/hwpod-cli.ts workspace read projects/01_baseline/User/main.c --hwpod-id d601-f103-v2 --workspace-path ", "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 ", @@ -238,7 +265,8 @@ function cliHelp() { "+new line", "*** End Patch" ].join("\n"), - route: "POST /v1/hwpod-node-ops" + route: "POST /v1/hwpod-node-ops", + localMode: "Pass --local --spec to compile and execute the same plan in-process without API, worker, Temporal, Web, or Kubernetes." }); }