From f53f9d1850c331451b37a76ffe4164c4fa481140 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 27 May 2026 22:06:37 +0800 Subject: [PATCH] feat: preinstall device pod cli skill --- AGENTS.md | 2 +- docs/reference/device-pod.md | 19 + package.json | 2 +- skills/device-pod-cli/SKILL.md | 111 +++ .../device-pod-cli/assets/device-host-cli.mjs | 702 ++++++++++++++++++ .../device-pod-cli/scripts/device-pod-cli.mjs | 2 + tools/tran.mjs | 2 + 7 files changed, 838 insertions(+), 2 deletions(-) create mode 100644 skills/device-pod-cli/SKILL.md create mode 100644 skills/device-pod-cli/assets/device-host-cli.mjs create mode 100644 skills/device-pod-cli/scripts/device-pod-cli.mjs create mode 100644 tools/tran.mjs diff --git a/AGENTS.md b/AGENTS.md index a500df43..cb9c45ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - Code Agent 对话就绪与真实回复判定:[docs/reference/code-agent-chat-readiness.md](docs/reference/code-agent-chat-readiness.md) - DEV runtime hotfix runbook 与只读审计:[docs/reference/dev-runtime-hotfix-runbook.md](docs/reference/dev-runtime-hotfix-runbook.md) - Gateway 主动出站 demo、poll/result 和本地 smoke:[docs/reference/gateway-outbound-demo.md](docs/reference/gateway-outbound-demo.md) -- Device Pod 四要素、debug/io 接口拆分和最小 REST/job 口径:[docs/reference/device-pod.md](docs/reference/device-pod.md) +- Device Pod 四要素、profile、HWLAB coder 预装合同与 CLI/server 分阶段计划:[docs/reference/device-pod.md](docs/reference/device-pod.md) - MVP E2E 验收测试与带编号测试报告 issue 规则:[docs/reference/MVP-e2e-acceptance.md](docs/reference/MVP-e2e-acceptance.md) - 指挥官协作、PR 和 runner 交接:[docs/reference/commander-collaboration.md](docs/reference/commander-collaboration.md) - M3 闭环发布运行手册:[docs/reference/m3-loop-rollout-runbook.md](docs/reference/m3-loop-rollout-runbook.md) diff --git a/docs/reference/device-pod.md b/docs/reference/device-pod.md index 7274d584..d4a51b41 100644 --- a/docs/reference/device-pod.md +++ b/docs/reference/device-pod.md @@ -52,6 +52,25 @@ MVP 不使用刚性的中心 profile 注册表。`device-pod` profile 的灵活 profile 必须只描述 target、debugInterface、projectWorkspace、ioInterface、gateway route、host CLI 能力和受控路径边界;不得放入 Git key、云端 token、kubeconfig、数据库 URL 或其他长期 secret。profile 校验失败时应返回 `profile-invalid` 或 `profile-missing` blocker,而不是回退到默认设备或任意 shell。 +## HWLAB coder 预装合同 + +HWLAB coder / code agent runner 镜像必须通过正式 CI/CD 预装 device-pod 最小闭环所需的三类入口: + +- **cmd 透传入口**:`/app/tools/tran.mjs` 是 runner 内的稳定合同入口,负责通过已注册 gateway session 对 Windows PC 执行 `cmd`、PowerShell、upload 和 download,并承担 UTF-8、cwd、stdout/stderr、quoting 和文件传输边界。`/app/tools/hwlab-gateway-tran.mjs` 是同一能力的兼容实现入口,不是第二套 transport;若兼容入口存在但 `/app/tools/tran.mjs` 缺失,CI/CD 预装判定不通过,必须补齐 `tran.mjs` 短名,而不是让 device-pod 逻辑适配多个临时入口。 +- **device-pod-cli 入口**:runner 内必须预装 `device-pod-cli` skill,稳定入口为 `/app/skills/device-pod-cli/scripts/device-pod-cli.mjs`,其实现指向 `/app/tools/device-pod-cli.mjs`。code agent 应通过该 CLI 读取 workspace 下 `.device-pod/.json`,再走 cloud-api/gateway/device-host-cli 调用设备能力。 +- **device-host-cli 资产**:runner 内必须随 `device-pod-cli` skill 打包 Windows 侧自包含 host CLI 资产,稳定路径为 `/app/skills/device-pod-cli/assets/device-host-cli.mjs`。新 Windows 硬件 PC 接入时,不运行独立安装器;code agent 使用预装 cmd 透传入口把该资产发送到目标 workspace 的 `tools\device-host-cli.mjs`,再通过同一 cmd 透传入口执行 `node tools\device-host-cli.mjs health` 验证。 + +目标 Windows workspace 的最小布局为: + +```text +\tools\device-host-cli.mjs +\.device-pod\.json +``` + +完成上述布局后,profile 中的 `hostCli` 应指向 `node tools\device-host-cli.mjs`,`device-pod-cli` 才能稳定执行 workspace、debug-probe 和 io-probe 操作。`device-host-cli` 必须自包含,不能在运行时依赖 Windows 侧 skill 目录;Keil、serial-monitor、mklink、文件编辑等 skill 只允许作为实现参考。 + +CI/CD 判定口径是:构建产物中同时存在 `/app/tools/tran.mjs`、`/app/tools/hwlab-gateway-tran.mjs`、`/app/tools/device-pod-cli.mjs`、`/app/skills/device-pod-cli/SKILL.md`、`/app/skills/device-pod-cli/scripts/device-pod-cli.mjs` 和 `/app/skills/device-pod-cli/assets/device-host-cli.mjs`,`node /app/tools/tran.mjs --help` 能展示 cmd/ps/upload/download 透传帮助,且 code agent skill discovery 能发现 `device-pod-cli`。只有这些入口都在 runner 中可见,才能称为 HWLAB coder 已具备 device-pod 最小预装能力。 + ## 四要素 ### `deviceTarget` diff --git a/package.json b/package.json index 2e3746a7..2d5254b0 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/repo-reports-guard.mjs && node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/dev-runtime-hotfix-audit.mjs && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/src/dev-runtime-hotfix-audit.test.mjs", - "check": "node scripts/repo-reports-guard.mjs && node --check scripts/repo-reports-guard.mjs && node --check scripts/src/report-paths.mjs && node --check internal/protocol/index.mjs && node --check internal/build-metadata.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/cloud-web-routes.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.test.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/code-agent-trace-store.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-cloud-api/provision.mjs && node --check cmd/hwlab-cloud-api/migrate.mjs && node --check cmd/hwlab-deepseek-responses-bridge/main.mjs && node --check cmd/hwlab-deepseek-responses-bridge/main.test.mjs && node --test cmd/hwlab-deepseek-responses-bridge/main.test.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check tools/hwlab-gateway-shell.mjs && node --check tools/hwlab-gateway-tran.mjs && node --check tools/device-pod-cli.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.test.mjs && node --check scripts/artifact-publish.mjs && node --check scripts/g14-artifact-publish.mjs && node --check scripts/dev-runtime-base-image.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.test.mjs && node --test scripts/src/dev-runtime-hotfix-audit.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check tools/hwlab-cli/lib/cli.test.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node cmd/hwlab-cloud-api/provision.mjs --check && node cmd/hwlab-cloud-api/migrate.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node scripts/repo-reports-guard.mjs && node --check scripts/repo-reports-guard.mjs && node --check scripts/src/report-paths.mjs && node --check internal/protocol/index.mjs && node --check internal/build-metadata.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/cloud-web-routes.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.test.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/code-agent-trace-store.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-cloud-api/provision.mjs && node --check cmd/hwlab-cloud-api/migrate.mjs && node --check cmd/hwlab-deepseek-responses-bridge/main.mjs && node --check cmd/hwlab-deepseek-responses-bridge/main.test.mjs && node --test cmd/hwlab-deepseek-responses-bridge/main.test.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check tools/hwlab-gateway-shell.mjs && node --check tools/hwlab-gateway-tran.mjs && node --check tools/tran.mjs && node --check tools/device-pod-cli.mjs && node --check skills/device-pod-cli/scripts/device-pod-cli.mjs && node --check skills/device-pod-cli/assets/device-host-cli.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.test.mjs && node --check scripts/artifact-publish.mjs && node --check scripts/g14-artifact-publish.mjs && node --check scripts/dev-runtime-base-image.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.test.mjs && node --test scripts/src/dev-runtime-hotfix-audit.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check tools/hwlab-cli/lib/cli.test.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node cmd/hwlab-cloud-api/provision.mjs --check && node cmd/hwlab-cloud-api/migrate.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "dev-runtime-base:build": "node scripts/dev-runtime-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", diff --git a/skills/device-pod-cli/SKILL.md b/skills/device-pod-cli/SKILL.md new file mode 100644 index 00000000..33639392 --- /dev/null +++ b/skills/device-pod-cli/SKILL.md @@ -0,0 +1,111 @@ +--- +name: device-pod-cli +description: Use the HWLAB internal device-pod CLI from a HWLAB code agent runner to operate a profiled target workspace, debug probe, and I/O probe through hwlab-gateway and device-host-cli. +version: 0.1.0-mvp +--- + +# Device Pod CLI + +Skill(cli-spec) + +Scope: this skill is only for HWLAB-internal code agent runners inside the +HWLAB runtime, such as `/workspace/hwlab` sessions running from the G14 DEV or +PROD images. It does not apply to external UniDesk developer workspaces or +third-party machines; those environments must not treat this skill as a general +hardware control contract. + +Use this skill when the task mentions `device-pod-cli`, `device-pod`, +`device-host-cli`, HWLAB device-pod profile files, Keil build/download through a +device pod, debug probe control, or I/O probe reads such as UART boot logs. + +## Runtime Contract + +- The preinstalled CLI entrypoint is + `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs`. +- The canonical implementation is `/app/tools/device-pod-cli.mjs`; the skill + script is only a stable preinstalled wrapper for code agent runners. +- The packaged Windows host CLI asset is + `/app/skills/device-pod-cli/assets/device-host-cli.mjs`. It is bundled with + this skill so a runner can send the exact tested host-side CLI to a new + Windows hardware PC before the first device-pod operation. +- Profiles are read from `.device-pod/.json` in the current code + agent workspace, or from `DEVICE_POD_PROFILE` when a task explicitly points to + a profile file. +- The CLI calls HWLAB cloud-api `hardware.invoke.shell`, which dispatches to an + outbound `hwlab-gateway` session and then to the user PC side + `device-host-cli`. Do not call gateway, serial ports, Keil, pyOCD, DAPLink, or + Windows shell directly when the profiled device-pod path is available. +- `device-host-cli` is expected to be self-contained on the user PC. Keil, + serial-monitor, mklink, and other skills can be implementation references, but + are not runtime dependencies of this HWLAB runner skill. + +## Windows Host CLI Bootstrap + +Do not run a separate installer. To bring up a new Windows hardware PC, send the +packaged asset to the target workspace through the existing cmd passthrough path, +then start it through the same cmd passthrough path. + +Target layout: + +```text +\tools\device-host-cli.mjs +\.device-pod\.json +``` + +When the UniDesk Windows maintenance route is available, the file transfer may +use the same remote channel that backs cmd passthrough. In HWLAB coder images, +`/app/tools/tran.mjs` is the stable contract entrypoint; +`/app/tools/hwlab-gateway-tran.mjs` is only the compatible explicit wrapper for +the same transport. If the compatible wrapper exists but `/app/tools/tran.mjs` +is missing, report a CI/CD preinstall gap instead of adding a device-pod +workaround. + +```text +node /app/tools/tran.mjs :/d/Work/HWLAB-workspace upload /app/skills/device-pod-cli/assets/device-host-cli.mjs tools/device-host-cli.mjs +node /app/tools/tran.mjs :/d/Work/HWLAB-workspace cmd -- node tools\\device-host-cli.mjs health +``` + +When only HWLAB gateway `hardware.invoke.shell` is available, send the asset +bytes as bounded base64 chunks through cmd/PowerShell into +`tools\device-host-cli.mjs`, then verify with: + +```text +node tools\device-host-cli.mjs health +``` + +After that, `device-pod-cli` should use the profile's `hostCli`, normally +`node tools\device-host-cli.mjs`, and should not depend on any Windows skill +directory at runtime. + +## Commands + +All commands emit JSON and must remain short-running. Use `build start` / +`build status` and `download start` / `download status` style host jobs for +long operations instead of holding one gateway request open. + +```text +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ ls +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ rg main +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ apply-patch --approved --reason "DEV edit" < patch.diff +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ build start +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ build status +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe chip-id +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download start --approved --reason "DEV smoke" +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download status +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 ports +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read +``` + +## Safety + +- Mutating operations such as workspace patching, download, reset, and I/O write + require `--approved --reason ` before the CLI dispatches anything. +- `io-probe:/inner/...` is intentionally distinct from external physical I/O + paths such as `io-probe:/uart/1`; do not use internal target state as evidence + for external UART/GPIO observations. +- Keep the gateway as transport only. Do not add a generic `cmd` operation to + `device-pod-cli`; add a named device-pod operation instead. +- If the CLI returns `profile-missing`, `profile-invalid`, or + `approval-required`, fix that blocker first rather than guessing a default + device or falling back to arbitrary shell commands. diff --git a/skills/device-pod-cli/assets/device-host-cli.mjs b/skills/device-pod-cli/assets/device-host-cli.mjs new file mode 100644 index 00000000..6da0f489 --- /dev/null +++ b/skills/device-pod-cli/assets/device-host-cli.mjs @@ -0,0 +1,702 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const VERSION = '0.1.0-mvp'; +const DEFAULT_POD_ID = process.env.DEVICE_POD_ID || 'device-pod-71-freq'; + +function workspaceRoot() { + return path.resolve(process.env.DEVICE_POD_WORKSPACE || process.cwd()); +} + +function devicePodDir(root = workspaceRoot()) { + return path.join(root, '.device-pod'); +} + +function stateRoot(root = workspaceRoot()) { + return path.join(devicePodDir(root), '.state', 'device-host-cli'); +} + +function nowIso() { + return new Date().toISOString(); +} + +function ok(action, data = {}) { + console.log(JSON.stringify({ ok: true, action, generatedAt: nowIso(), data }, null, 2)); +} + +function fail(action, error, details = {}, exitCode = 1) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error && error.stack ? error.stack.split('\n').slice(0, 8) : undefined; + console.log(JSON.stringify({ ok: false, action, generatedAt: nowIso(), error: message, stack, details }, null, 2)); + process.exitCode = exitCode; +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +function writeJson(file, value) { + ensureDir(path.dirname(file)); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function profilePath(root = workspaceRoot(), podId = DEFAULT_POD_ID) { + return path.join(devicePodDir(root), `${podId}.json`); +} + +function loadProfile() { + const root = workspaceRoot(); + const file = process.env.DEVICE_POD_PROFILE || profilePath(root); + if (!fs.existsSync(file)) throw new Error(`device-pod profile not found: ${file}`); + const profile = readJson(file); + profile.__profilePath = file; + profile.__workspaceRoot = path.resolve(profile.workspaceRoot || root); + return profile; +} + +function resolveWorkspacePath(profile, rel = '.') { + const root = profile.__workspaceRoot; + const raw = String(rel || '.').replace(/^\/+/, ''); + if (raw.includes('\0')) throw new Error('path contains NUL'); + const resolved = path.resolve(root, raw); + const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`; + if (resolved !== root && !resolved.startsWith(rootWithSep)) throw new Error(`path escapes workspace: ${rel}`); + return resolved; +} + +function projectPath(profile) { + return resolveWorkspacePath(profile, profile.projectWorkspace?.projectPath); +} + +function uvoptxPath(projectFile) { + return projectFile.replace(/\.uvprojx$/i, '.uvoptx'); +} + +function targetName(profile) { + return profile.projectWorkspace?.targetName || ''; +} + +function hexPath(profile) { + return resolveWorkspacePath(profile, profile.projectWorkspace?.hexPath); +} + +function shortTail(text, max = 12000) { + const value = String(text || ''); + return value.length <= max ? value : value.slice(value.length - max); +} + +function runCapture(file, args = [], options = {}) { + const timeoutMs = options.timeoutMs ?? 15000; + const startedAt = Date.now(); + return new Promise((resolve) => { + const child = spawn(file, args, { + cwd: options.cwd || workspaceRoot(), + env: { ...process.env, ...(options.env || {}) }, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + try { child.kill(); } catch {} + }, timeoutMs); + child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); }); + child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); }); + child.on('error', (error) => { + clearTimeout(timer); + resolve({ exitCode: 127, stdout, stderr: `${stderr}${error.message}\n`, timedOut, elapsedMs: Date.now() - startedAt }); + }); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ exitCode: timedOut ? 124 : (code ?? 1), stdout, stderr, timedOut, elapsedMs: Date.now() - startedAt }); + }); + }); +} + +async function runPowerShell(script, timeoutMs = 15000) { + const prefix = [ + '$ErrorActionPreference = \'Stop\';', + '[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();', + '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();', + '$OutputEncoding = [System.Text.UTF8Encoding]::new();', + ].join(' '); + return await runCapture('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', `${prefix} ${script}`], { timeoutMs }); +} + +function psQuote(value) { + return `'${String(value).replace(/'/g, "''")}'`; +} + +function parseArgs(args) { + const out = { _: [] }; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg.startsWith('--')) { + out._.push(arg); + continue; + } + const eq = arg.indexOf('='); + if (eq >= 0) { + out[arg.slice(2, eq)] = arg.slice(eq + 1); + continue; + } + const key = arg.slice(2); + const next = args[i + 1]; + if (next !== undefined && !next.startsWith('--')) { + out[key] = next; + i += 1; + } else { + out[key] = true; + } + } + return out; +} + +function findFile(candidates) { + for (const candidate of candidates.filter(Boolean)) { + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +async function commandExists(command) { + const where = process.platform === 'win32' ? 'where' : 'which'; + const result = await runCapture(where, [command], { timeoutMs: 5000 }); + return result.exitCode === 0 ? result.stdout.trim().split(/\r?\n/u)[0] : null; +} + +async function toolInfo(profile) { + const uv4 = findFile([ + profile.debugInterface?.uv4Path, + process.env.UV4_EXE, + 'D:\\Keil_v5\\UV4\\UV4.exe', + 'C:\\Keil_v5\\UV4\\UV4.exe', + 'C:\\Keil\\UV4\\UV4.exe', + ]); + const pyocd = findFile([profile.debugInterface?.pyocdPath]) || await commandExists('pyocd'); + const pyocdVersion = pyocd ? await runCapture(pyocd, ['--version'], { timeoutMs: 5000 }) : null; + return { + node: { path: process.execPath, version: process.version }, + powershell: await commandExists('powershell.exe'), + uv4: { path: uv4, found: Boolean(uv4) }, + pyocd: { + path: pyocd, + found: Boolean(pyocd), + version: pyocdVersion ? pyocdVersion.stdout.trim() || pyocdVersion.stderr.trim() : null, + }, + }; +} + +async function health() { + const profile = loadProfile(); + const tools = await toolInfo(profile); + ok('health', { + version: VERSION, + host: os.hostname(), + platform: process.platform, + workspaceRoot: profile.__workspaceRoot, + profilePath: profile.__profilePath, + profileExists: fs.existsSync(profile.__profilePath), + projectExists: fs.existsSync(projectPath(profile)), + hexExists: fs.existsSync(hexPath(profile)), + stateRoot: stateRoot(profile.__workspaceRoot), + runtimeDependencies: ['node built-ins', 'Windows PowerShell/.NET', 'Keil UV4.exe', 'pyOCD executable'], + skillRuntimeDependencies: [], + tools, + }); +} + +function listWorkspace(rel) { + const profile = loadProfile(); + const dir = resolveWorkspacePath(profile, rel || '.'); + const entries = fs.readdirSync(dir, { withFileTypes: true }).map((entry) => { + const full = path.join(dir, entry.name); + const stat = fs.statSync(full); + return { name: entry.name, type: entry.isDirectory() ? 'dir' : entry.isFile() ? 'file' : 'other', bytes: stat.size, mtime: stat.mtime.toISOString() }; + }); + ok('workspace.ls', { path: path.relative(profile.__workspaceRoot, dir) || '.', entries }); +} + +function catWorkspace(rel, limitText) { + const profile = loadProfile(); + const file = resolveWorkspacePath(profile, rel); + const limit = Number(limitText || 40000); + const content = fs.readFileSync(file, 'utf8'); + ok('workspace.cat', { + path: path.relative(profile.__workspaceRoot, file), + bytes: Buffer.byteLength(content), + truncated: Buffer.byteLength(content) > limit, + content: content.length > limit ? content.slice(0, limit) : content, + }); +} + +function walkFiles(root, rel, results, limit = 5000) { + if (results.length >= limit) return; + const dir = path.join(root, rel); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '.state') continue; + const childRel = path.join(rel, entry.name); + if (entry.isDirectory()) walkFiles(root, childRel, results, limit); + else if (entry.isFile()) results.push(childRel); + if (results.length >= limit) return; + } +} + +function rgWorkspace(pattern, rel) { + const profile = loadProfile(); + const root = resolveWorkspacePath(profile, rel || '.'); + const files = []; + const stat = fs.statSync(root); + if (stat.isFile()) files.push(path.relative(profile.__workspaceRoot, root)); + else walkFiles(root, '.', files); + const re = new RegExp(pattern, 'i'); + const matches = []; + for (const fileRel of files) { + if (matches.length >= 100) break; + const full = resolveWorkspacePath(profile, fileRel); + const buffer = fs.readFileSync(full); + if (buffer.includes(0)) continue; + const lines = buffer.toString('utf8').split(/\r?\n/u); + lines.forEach((line, index) => { + if (matches.length < 100 && re.test(line)) matches.push({ path: fileRel, line: index + 1, text: line.slice(0, 500) }); + }); + } + ok('workspace.rg', { pattern, root: path.relative(profile.__workspaceRoot, root) || '.', count: matches.length, matches }); +} + +function workspaceApplyPatch(baseRel, options = {}) { + const profile = loadProfile(); + const patchText = decodePatchText(options); + const basePath = resolveWorkspacePath(profile, baseRel || '.'); + const baseDir = fs.existsSync(basePath) && fs.statSync(basePath).isFile() ? path.dirname(basePath) : basePath; + const operations = parseApplyPatch(patchText); + const changed = []; + for (const operation of operations) { + const target = resolvePatchPath(profile, baseDir, operation.path); + if (operation.kind === 'add') { + if (fs.existsSync(target)) throw new Error(`add target already exists: ${operation.path}`); + ensureDir(path.dirname(target)); + fs.writeFileSync(target, operation.content, 'utf8'); + changed.push(path.relative(profile.__workspaceRoot, target)); + continue; + } + if (operation.kind === 'delete') { + if (!fs.existsSync(target)) throw new Error(`delete target not found: ${operation.path}`); + fs.rmSync(target, { force: true }); + changed.push(path.relative(profile.__workspaceRoot, target)); + continue; + } + if (operation.kind === 'update') { + if (!fs.existsSync(target)) throw new Error(`update target not found: ${operation.path}`); + const original = fs.readFileSync(target, 'utf8'); + const updated = applyUpdateChunks(original, operation.chunks, operation.path); + const destination = operation.movePath ? resolvePatchPath(profile, baseDir, operation.movePath) : target; + ensureDir(path.dirname(destination)); + fs.writeFileSync(destination, updated, 'utf8'); + if (destination !== target) fs.rmSync(target, { force: true }); + changed.push(destination === target ? path.relative(profile.__workspaceRoot, target) : `${path.relative(profile.__workspaceRoot, target)} -> ${path.relative(profile.__workspaceRoot, destination)}`); + continue; + } + } + ok('workspace.apply-patch', { base: path.relative(profile.__workspaceRoot, baseDir) || '.', changed, operationCount: operations.length }); +} + +function decodePatchText(options) { + if (typeof options.patch === 'string') return options.patch; + if (typeof options.patchB64 === 'string') return Buffer.from(options.patchB64, 'base64').toString('utf8'); + if (typeof options['patch-b64'] === 'string') return Buffer.from(options['patch-b64'], 'base64').toString('utf8'); + throw new Error('workspace apply-patch requires --patch-b64 or --patch'); +} + +function resolvePatchPath(profile, baseDir, patchPath) { + const raw = String(patchPath || '').replace(/^\/+/, ''); + if (!raw || raw.includes('\0')) throw new Error(`invalid patch path: ${patchPath}`); + const resolved = path.resolve(baseDir, raw); + const root = profile.__workspaceRoot; + const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`; + if (resolved !== root && !resolved.startsWith(rootWithSep)) throw new Error(`patch path escapes workspace: ${patchPath}`); + return resolved; +} + +function parseApplyPatch(patchText) { + const lines = String(patchText).replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); + if (lines[lines.length - 1] === '') lines.pop(); + if (lines[0] !== '*** Begin Patch') throw new Error('patch must start with *** Begin Patch'); + if (lines[lines.length - 1] !== '*** End Patch') throw new Error('patch must end with *** End Patch'); + const operations = []; + let index = 1; + while (index < lines.length - 1) { + const header = lines[index++]; + if (header.startsWith('*** Add File: ')) { + const filePath = header.slice('*** Add File: '.length).trim(); + const content = []; + while (index < lines.length - 1 && !lines[index].startsWith('*** ')) { + const line = lines[index++]; + if (!line.startsWith('+')) throw new Error(`add file line must start with + for ${filePath}`); + content.push(line.slice(1)); + } + operations.push({ kind: 'add', path: filePath, content: `${content.join('\n')}\n` }); + continue; + } + if (header.startsWith('*** Delete File: ')) { + operations.push({ kind: 'delete', path: header.slice('*** Delete File: '.length).trim() }); + continue; + } + if (header.startsWith('*** Update File: ')) { + const filePath = header.slice('*** Update File: '.length).trim(); + const chunks = []; + let movePath = null; + while (index < lines.length - 1 && !lines[index].startsWith('*** Add File: ') && !lines[index].startsWith('*** Delete File: ') && !lines[index].startsWith('*** Update File: ')) { + const line = lines[index++]; + if (line.startsWith('*** Move to: ')) { + movePath = line.slice('*** Move to: '.length).trim(); + continue; + } + if (!line.startsWith('@@')) throw new Error(`update hunk for ${filePath} must start with @@`); + const changes = []; + while (index < lines.length - 1 && !lines[index].startsWith('@@') && !lines[index].startsWith('*** ')) { + const change = lines[index++]; + const prefix = change[0]; + if (prefix !== ' ' && prefix !== '+' && prefix !== '-') throw new Error(`invalid update line prefix in ${filePath}: ${change}`); + changes.push({ prefix, text: change.slice(1) }); + } + chunks.push(changes); + } + operations.push({ kind: 'update', path: filePath, movePath, chunks }); + continue; + } + throw new Error(`unsupported patch header: ${header}`); + } + return operations; +} + +function applyUpdateChunks(original, chunks, filePath) { + const hadFinalNewline = original.endsWith('\n'); + const current = original.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); + if (current[current.length - 1] === '') current.pop(); + let cursor = 0; + for (const chunk of chunks) { + const oldLines = chunk.filter((line) => line.prefix !== '+').map((line) => line.text); + const newLines = chunk.filter((line) => line.prefix !== '-').map((line) => line.text); + const at = oldLines.length === 0 ? cursor : findSubsequence(current, oldLines, cursor); + if (at < 0) throw new Error(`patch hunk did not match ${filePath}`); + current.splice(at, oldLines.length, ...newLines); + cursor = at + newLines.length; + } + const output = current.join('\n'); + return hadFinalNewline || chunks.length > 0 ? `${output}\n` : output; +} + +function findSubsequence(haystack, needle, start) { + if (needle.length === 0) return start; + for (let index = Math.max(0, start); index <= haystack.length - needle.length; index += 1) { + let matched = true; + for (let offset = 0; offset < needle.length; offset += 1) { + if (haystack[index + offset] !== needle[offset]) { matched = false; break; } + } + if (matched) return index; + } + return -1; +} + +function jobDir(profile) { + const dir = path.join(stateRoot(profile.__workspaceRoot), 'jobs'); + ensureDir(dir); + return dir; +} + +function jobFile(profile, jobId) { + return path.join(jobDir(profile), `${jobId}.json`); +} + +function latestJobId(profile, kindPrefix) { + const dir = jobDir(profile); + const jobs = fs.readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => { + const file = path.join(dir, name); + try { return { file, data: readJson(file) }; } catch { return null; } + }).filter(Boolean).filter((job) => !kindPrefix || String(job.data.kind || '').startsWith(kindPrefix)); + jobs.sort((a, b) => String(b.data.createdAt || '').localeCompare(String(a.data.createdAt || ''))); + return jobs[0]?.data?.jobId || null; +} + +function startJob(kind, payload) { + const profile = loadProfile(); + const id = `${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${kind}-${randomBytes(3).toString('hex')}`; + const file = jobFile(profile, id); + const job = { jobId: id, kind, status: 'queued', createdAt: nowIso(), updatedAt: nowIso(), profilePath: profile.__profilePath, workspaceRoot: profile.__workspaceRoot, payload }; + writeJson(file, job); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '__job', id], { + cwd: profile.__workspaceRoot, + detached: true, + stdio: 'ignore', + windowsHide: true, + env: { ...process.env, DEVICE_POD_PROFILE: profile.__profilePath, DEVICE_POD_WORKSPACE: profile.__workspaceRoot }, + }); + child.unref(); + ok(`${kind}.start`, { jobId: id, statusFile: file }); +} + +function readJobStatus(kindPrefix, requestedId) { + const profile = loadProfile(); + const id = requestedId || latestJobId(profile, kindPrefix); + if (!id) throw new Error(`no job found for ${kindPrefix}`); + const job = readJson(jobFile(profile, id)); + if (job.logFile && fs.existsSync(job.logFile)) job.logTail = shortTail(fs.readFileSync(job.logFile, 'utf8')); + ok(`${kindPrefix}.status`, job); +} + +async function runJob(jobId) { + const profile = loadProfile(); + const file = jobFile(profile, jobId); + const job = readJson(file); + const update = (patch) => writeJson(file, { ...readJson(file), ...patch, updatedAt: nowIso() }); + update({ status: 'running', startedAt: nowIso(), pid: process.pid }); + try { + let result; + if (job.kind === 'keil-build') result = await runKeilBuild(profile, job.payload || {}); + else if (job.kind === 'keil-download') result = await runKeilDownload(profile, job.payload || {}); + else throw new Error(`unsupported job kind: ${job.kind}`); + update({ status: result.success ? 'completed' : 'failed', finishedAt: nowIso(), result, logFile: result.logFile || null }); + process.exitCode = result.success ? 0 : 1; + } catch (error) { + update({ status: 'failed', finishedAt: nowIso(), error: error instanceof Error ? error.message : String(error) }); + process.exitCode = 1; + } +} + +function safeTarget(value) { + return String(value || 'target').replace(/[^A-Za-z0-9_-]/g, '_'); +} + +function keilLogHasErrors(logText) { + const text = String(logText || ''); + const summary = text.match(/(\d+)\s+Error\(s\)/i); + if (summary) return Number(summary[1]) > 0; + return /\b(fatal error|error:|failed)\b/i.test(text); +} + +async function runKeilBuild(profile, payload = {}) { + const tools = await toolInfo(profile); + if (!tools.uv4.found) throw new Error('Keil UV4.exe not found'); + const project = projectPath(profile); + const target = payload.target || targetName(profile); + const outDir = path.join(stateRoot(profile.__workspaceRoot), 'keil'); + ensureDir(outDir); + const logFile = path.join(outDir, `build_${safeTarget(target)}_${Date.now()}.log`); + const args = ['-b', project, '-j0', '-sg', '-o', logFile]; + if (target) args.push('-t', target); + const result = await runCapture(tools.uv4.path, args, { cwd: path.dirname(project), timeoutMs: Number(payload.timeoutMs || 300000) }); + const logText = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : ''; + const success = result.exitCode === 0 && !keilLogHasErrors(logText); + return { success, exitCode: result.exitCode, timedOut: result.timedOut, elapsedMs: result.elapsedMs, project, target, logFile, logTail: shortTail(logText), stdout: shortTail(result.stdout), stderr: shortTail(result.stderr) }; +} + +function bindUvoptxProbe(profile, requestedUid = profile.debugInterface?.probeUid) { + if (!requestedUid) throw new Error('profile.debugInterface.probeUid is required'); + const file = uvoptxPath(projectPath(profile)); + if (!fs.existsSync(file)) throw new Error(`uvoptx not found: ${file}`); + const before = fs.readFileSync(file, 'utf8'); + const replacements = []; + const after = before.replace(/(-X"[^&]+CMSIS-DAP[^&]*"\s+-U)([^\s<]+)/gi, (match, prefix, oldUid) => { + replacements.push({ oldUid, newUid: requestedUid, xmlEscaped: true }); + return `${prefix}${requestedUid}`; + }).replace(/(-X"[^"]*CMSIS-DAP[^"]*"\s+-U)([^\s<]+)/gi, (match, prefix, oldUid) => { + replacements.push({ oldUid, newUid: requestedUid, xmlEscaped: false }); + return `${prefix}${requestedUid}`; + }); + if (replacements.length === 0) throw new Error('no CMSIS-DAP -U binding found in uvoptx'); + if (after !== before) { + const backup = `${file}.device-host-cli.${Date.now()}.bak`; + fs.writeFileSync(backup, before, 'utf8'); + fs.writeFileSync(file, after, 'utf8'); + return { changed: true, uvoptxPath: file, backup, replacements }; + } + return { changed: false, uvoptxPath: file, replacements }; +} + +async function runKeilDownload(profile, payload = {}) { + const tools = await toolInfo(profile); + if (!tools.uv4.found) throw new Error('Keil UV4.exe not found'); + const project = projectPath(profile); + const target = payload.target || targetName(profile); + let binding = null; + if (profile.debugInterface?.autoBindUvoptx !== false) binding = bindUvoptxProbe(profile); + const outDir = path.join(stateRoot(profile.__workspaceRoot), 'keil'); + ensureDir(outDir); + const logFile = path.join(outDir, `download_${safeTarget(target)}_${Date.now()}.log`); + const args = ['-f', project, '-j0', '-sg', '-o', logFile]; + if (target) args.push('-t', target); + const result = await runCapture(tools.uv4.path, args, { cwd: path.dirname(project), timeoutMs: Number(payload.timeoutMs || 300000) }); + const logText = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : ''; + const success = result.exitCode === 0 && !keilLogHasErrors(logText); + return { success, exitCode: result.exitCode, timedOut: result.timedOut, elapsedMs: result.elapsedMs, project, target, binding, logFile, logTail: shortTail(logText), stdout: shortTail(result.stdout), stderr: shortTail(result.stderr) }; +} + +async function pyocdArgs(profile, commandArgs) { + const tools = await toolInfo(profile); + if (!tools.pyocd.found) throw new Error('pyOCD executable not found'); + const debug = profile.debugInterface || {}; + const args = [...commandArgs]; + if (debug.probeUid) args.push('-u', debug.probeUid); + if (debug.pyocdTarget) args.push('-t', debug.pyocdTarget); + if (debug.connectMode) args.push('-M', debug.connectMode); + if (debug.frequency) args.push('-f', debug.frequency); + return { pyocd: tools.pyocd.path, args }; +} + +async function debugStatus() { + const profile = loadProfile(); + const tools = await toolInfo(profile); + if (!tools.pyocd.found) throw new Error('pyOCD executable not found'); + const result = await runCapture(tools.pyocd.path, ['list', '--probes'], { timeoutMs: 15000 }); + ok('debug-probe.status', { configuredProbeUid: profile.debugInterface?.probeUid, pyocdPath: tools.pyocd.path, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }); +} + +async function chipId() { + const profile = loadProfile(); + const register = profile.target?.chipIdRegister || '0xE0042000'; + const { pyocd, args } = await pyocdArgs(profile, ['cmd', '-c', `read32 ${register}`, '-c', 'exit']); + const result = await runCapture(pyocd, args, { timeoutMs: 30000 }); + const text = `${result.stdout}\n${result.stderr}`; + const match = text.match(/([0-9a-fA-F]{8}):\s+([0-9a-fA-F]{8})/u); + const rawValue = match ? `0x${match[2].toLowerCase()}` : null; + const numeric = rawValue ? Number.parseInt(rawValue, 16) : null; + const mask = Number.parseInt(profile.target?.expectedChipIdMask || '0xffffffff', 16); + const expected = Number.parseInt(profile.target?.expectedChipId || '0x0', 16); + ok('debug-probe.chip-id', { exitCode: result.exitCode, register, rawValue, maskedValue: numeric === null ? null : `0x${(numeric & mask).toString(16).padStart(8, '0')}`, expected: profile.target?.expectedChipId || null, matchesExpected: numeric === null ? null : (numeric & mask) === expected, stdout: result.stdout, stderr: result.stderr }); +} + +async function resetRun() { + const profile = loadProfile(); + const { pyocd, args } = await pyocdArgs(profile, ['cmd', '-c', 'reset', '-c', 'go', '-c', 'exit']); + const result = await runCapture(pyocd, args, { timeoutMs: 30000 }); + ok('debug-probe.reset', { exitCode: result.exitCode, success: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr }); +} + +async function serialPorts() { + const script = "Get-CimInstance Win32_SerialPort | Select-Object DeviceID,Name,PNPDeviceID | ConvertTo-Json -Compress"; + const result = await runPowerShell(script, 15000); + let ports = []; + if (result.stdout.trim()) { + const parsed = JSON.parse(result.stdout.trim()); + ports = Array.isArray(parsed) ? parsed : [parsed]; + } + const profile = loadProfile(); + ok('io-probe.ports', { configured: profile.ioInterface?.uart || [], ports, stderr: result.stderr, exitCode: result.exitCode }); +} + +function resolveUart(profile, uartId) { + const normalized = String(uartId || 'uart/1').replace(/^\/+/, ''); + const item = (profile.ioInterface?.uart || []).find((entry) => entry.id === normalized); + if (!item) throw new Error(`uart profile not found: ${normalized}`); + return item; +} + +async function serialRead(uartId, options) { + const profile = loadProfile(); + const uart = resolveUart(profile, uartId); + const durationMs = Number(options['duration-ms'] || options.durationMs || 1000); + const script = [ + '$ErrorActionPreference = \'Stop\';', + `$portName = ${psQuote(uart.port)};`, + `$baud = ${Number(uart.baudRate || 115200)};`, + `$durationMs = ${durationMs};`, + '$port = [System.IO.Ports.SerialPort]::new($portName, $baud, [System.IO.Ports.Parity]::None, 8, [System.IO.Ports.StopBits]::One);', + '$port.Encoding = [System.Text.UTF8Encoding]::new($false);', + '$port.ReadTimeout = 200;', + 'try { $port.Open(); Start-Sleep -Milliseconds $durationMs; $text = $port.ReadExisting(); $bytes = [System.Text.Encoding]::UTF8.GetBytes($text); [Console]::Out.Write([Convert]::ToBase64String($bytes)) } finally { if ($port.IsOpen) { $port.Close() }; $port.Dispose() }', + ].join(' '); + const result = await runPowerShell(script, Math.max(5000, durationMs + 5000)); + const b64 = result.stdout.trim(); + const content = b64 ? Buffer.from(b64, 'base64').toString('utf8') : ''; + const logDir = path.join(stateRoot(profile.__workspaceRoot), 'serial'); + ensureDir(logDir); + const logFile = path.join(logDir, `${uart.id.replace(/\//g, '_')}.jsonl`); + fs.appendFileSync(logFile, `${JSON.stringify({ ts: nowIso(), direction: 'read', uartId: uart.id, port: uart.port, baudRate: uart.baudRate, content })}\n`, 'utf8'); + ok('io-probe.uart.read', { uart, durationMs, exitCode: result.exitCode, content, bytes: Buffer.byteLength(content), logFile, stderr: result.stderr }); +} + +async function serialWrite(uartId, options) { + const profile = loadProfile(); + const uart = resolveUart(profile, uartId); + const message = options.hex ? Buffer.from(String(options._[0] || ''), 'hex').toString('binary') : String(options._[0] || ''); + const encoded = Buffer.from(message, options.hex ? 'binary' : 'utf8').toString('base64'); + const script = [ + '$ErrorActionPreference = \'Stop\';', + `$portName = ${psQuote(uart.port)};`, + `$baud = ${Number(uart.baudRate || 115200)};`, + `$encoded = ${psQuote(encoded)};`, + '$bytes = [Convert]::FromBase64String($encoded);', + '$port = [System.IO.Ports.SerialPort]::new($portName, $baud, [System.IO.Ports.Parity]::None, 8, [System.IO.Ports.StopBits]::One);', + 'try { $port.Open(); $port.BaseStream.Write($bytes, 0, $bytes.Length); $port.BaseStream.Flush(); [Console]::Out.Write($bytes.Length) } finally { if ($port.IsOpen) { $port.Close() }; $port.Dispose() }', + ].join(' '); + const result = await runPowerShell(script, 10000); + ok('io-probe.uart.write', { uart, exitCode: result.exitCode, bytesWritten: Number(result.stdout.trim() || 0), stderr: result.stderr }); +} + +function usage() { + ok('help', { + usage: [ + 'node tools/device-host-cli.mjs health', + 'node tools/device-host-cli.mjs workspace ls [path]', + 'node tools/device-host-cli.mjs workspace cat [--limit N]', + 'node tools/device-host-cli.mjs workspace rg [path]', + 'node tools/device-host-cli.mjs workspace apply-patch [base] --patch-b64 ', + 'node tools/device-host-cli.mjs workspace build start|status [jobId]', + 'node tools/device-host-cli.mjs debug-probe status|chip-id|bind|download|reset', + 'node tools/device-host-cli.mjs io-probe uart/1 ports|read|write [args]' + ] + }); +} + +async function main() { + const [group, command, ...rest] = process.argv.slice(2); + if (!group || group === 'help' || group === '--help') return usage(); + if (group === '__job') return await runJob(command); + if (group === 'health') return await health(); + if (group === 'profile' && command === 'show') return ok('profile.show', loadProfile()); + if (group === 'workspace') { + if (command === 'ls') return listWorkspace(rest[0] || '.'); + if (command === 'cat') return catWorkspace(rest[0], parseArgs(rest.slice(1)).limit); + if (command === 'rg') return rgWorkspace(rest[0], rest[1] || '.'); + if (command === 'apply-patch') return workspaceApplyPatch(rest[0] || '.', parseArgs(rest.slice(1))); + if (command === 'build') { + const sub = rest[0] || 'start'; + if (sub === 'start') return startJob('keil-build', parseArgs(rest.slice(1))); + if (sub === 'status') return readJobStatus('keil-build', rest[1]); + if (sub === 'wait') return readJobStatus('keil-build', rest[1]); + } + } + if (group === 'debug-probe') { + if (command === 'status') return await debugStatus(); + if (command === 'chip-id') return await chipId(); + if (command === 'bind') return ok('debug-probe.bind', bindUvoptxProbe(loadProfile())); + if (command === 'download') { + const sub = rest[0] || 'start'; + if (sub === 'start') return startJob('keil-download', parseArgs(rest.slice(1))); + if (sub === 'status') return readJobStatus('keil-download', rest[1]); + } + if (command === 'reset') return await resetRun(); + } + if (group === 'io-probe') { + const uartId = command; + const sub = rest[0] || 'read'; + if (sub === 'ports') return await serialPorts(); + if (sub === 'read') return await serialRead(uartId, parseArgs(rest.slice(1))); + if (sub === 'write') return await serialWrite(uartId, parseArgs(rest.slice(1))); + } + throw new Error(`unsupported command: ${process.argv.slice(2).join(' ')}`); +} + +main().catch((error) => fail('device-host-cli', error)); diff --git a/skills/device-pod-cli/scripts/device-pod-cli.mjs b/skills/device-pod-cli/scripts/device-pod-cli.mjs new file mode 100644 index 00000000..be7bfc99 --- /dev/null +++ b/skills/device-pod-cli/scripts/device-pod-cli.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../../../tools/device-pod-cli.mjs'; diff --git a/tools/tran.mjs b/tools/tran.mjs new file mode 100644 index 00000000..8857496f --- /dev/null +++ b/tools/tran.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import './hwlab-gateway-tran.mjs';