Merge remote-tracking branch 'origin/G14' into feat/device-pod-sidebar-fake-service
# Conflicts: # package.json
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -32,7 +32,7 @@ MVP surface 固定为:
|
||||
- `debug-probe`:下载、复位、探针状态和芯片 ID。
|
||||
- `io-probe`:UART、GPIO、AI/AO/DI/DO、状态采样和日志读取。
|
||||
|
||||
workspace 操作采用 busybox 风格白名单,不提供 `cmd` 子命令。第一版建议只开放 `ls`、`cat`、`stat`、`rg`、`find`、`head`、`tail`、`wc`、`apply-patch`、`upload`、`download`、`build`、`clean` 和 `artifact list`。所有文件路径必须限制在 profile 声明的 workspace root 内。
|
||||
workspace 操作采用 busybox 风格白名单,不提供 `cmd` 子命令。已跑通的 CLI MVP 先开放 `ls`、`cat`、`rg`、`apply-patch` 和 `build`;`stat`、`find`、`head`、`tail`、`wc`、`upload`、`download`、`clean` 和 `artifact list` 作为后续扩展进入同一白名单模型。所有文件路径必须限制在 profile 声明的 workspace root 内。
|
||||
|
||||
示例:
|
||||
|
||||
@@ -61,7 +61,7 @@ CLI 默认输出 JSON。成功和失败都必须包含 `devicePodId`、`targetId
|
||||
1. 先实现 profile schema、loader、locator parser 和 JSON 输出合同,用 fake profile 在本地单元测试覆盖成功、缺 profile、坏 profile、路径越界和未知 surface。
|
||||
2. 实现 workspace busybox 操作和 fake gateway adapter,先在不接硬件时证明路径裁剪、输出截断和错误结构稳定。
|
||||
3. 实现 debug-probe 与 io-probe 的 adapter 接口,先用 fake `device-host-cli` 返回 chip ID、UART 样例和下载 job 状态。
|
||||
4. 在 D518 Windows 上开发 `device-host-cli`,通过 `D518:win` 做真实 DAPLink、串口或下载器 smoke;D518 侧只承载硬件上位机逻辑,不改变 G14 source truth。
|
||||
4. 在 D518 Windows 上开发自包含 `device-host-cli`,通过 `D518:win` 做真实 DAPLink、串口或下载器 smoke;Keil、串口监控、mklink 和文件编辑 skill 只能作为实现参考,不作为运行时依赖。D518 侧只承载硬件上位机逻辑,不改变 G14 source truth。
|
||||
5. 在 G14 code agent pod 中开发和验证 `device-pod-cli`,code agent pod 不放完整 HWLAB 源码和 Git key;CLI 通过 skill 分发到预装位置,并在 skill 说明中写清仅适用于 HWLAB 内部 code agent。
|
||||
6. fake 闭环通过后再做 G14 -> gateway -> D518 -> 硬件的最小 live smoke,然后把修复固化到源码、测试和长期参考。
|
||||
|
||||
@@ -78,7 +78,7 @@ CLI 默认输出 JSON。成功和失败都必须包含 `devicePodId`、`targetId
|
||||
MVP 通过至少需要满足:
|
||||
|
||||
- `.device-pod/<devicePodId>.json` 能被读取、校验、hash 并体现在每次 CLI JSON 输出中。
|
||||
- workspace `ls/cat/rg/apply-patch/build/artifact list` 在受控 root 内可用,路径越界、过大输出和未知命令有结构化失败。
|
||||
- workspace `ls/cat/rg/apply-patch/build` 在受控 root 内可用,路径越界、过大输出和未知命令有结构化失败;`artifact list` 进入后续扩展。
|
||||
- `debug-probe chip-id/download/reset` 能通过 fake adapter 稳定返回 job/evidence;live smoke 至少证明一次真实 debug probe 路径可达。
|
||||
- `io-probe:/uart/1 read` 与 `io-probe:/inner/... read` 在输出中明确标注外部/内部来源,不互相冒充。
|
||||
- gateway 仍只是 cmd transport;CLI 不暴露任意 shell、不接收泛化 `cmd` 子命令、不绕过 profile 访问硬件。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Device Pod 设备目标能力模型
|
||||
|
||||
本文是 HWLAB `device-pod` 的长期参考口径。`device-pod` 是一个以单副本 k8s Pod 运行的设备目标代理单元,用于把一个可开发、可下载、可复位、可观测 I/O 的设备目标封装成统一 REST/job API。对外稳定身份由 Service/Deployment 承载,不依赖实际 Pod name。
|
||||
本文是 HWLAB `device-pod` 的长期参考口径。`device-pod` 是一个逻辑实验台抽象,用于把一个可开发、可编译、可下载、可复位、可观测 I/O 的设备目标封装成统一能力单元。进入 server 阶段后,该能力单元可以由单副本 k8s Pod 承载,并对外提供统一 REST/job API;对外稳定身份由 Service/Deployment 承载,不依赖实际 Pod name。
|
||||
|
||||
`device-pod` 不等同于裸物理设备,也不是泛化远程 shell。它是以下四个要素的运行时组合:
|
||||
|
||||
@@ -12,6 +12,31 @@ device-pod
|
||||
+ ioInterface
|
||||
```
|
||||
|
||||
## 设计抽象
|
||||
|
||||
`device-pod` 可以理解成云端可拥有和调度的一张嵌入式实验台。云端拥有的是 `device-pod` 这个逻辑能力单元,不是直接拥有某一块裸板、某一根探针或某一台用户 PC。只要 profile 能把 `deviceTarget`、`debugInterface`、`projectWorkspace` 和 `ioInterface` 四要素绑定清楚,code agent 就可以围绕同一个 `devicePodId` 完成源码修改、构建、下载、复位、状态读取和 I/O 观察,从而覆盖嵌入式开发的完整闭环。
|
||||
|
||||
四要素的物理落点可以不同,也可以复用同一个硬件实体的不同能力:
|
||||
|
||||
- `deviceTarget` 是被调试和被验证的目标设备。
|
||||
- `debugInterface` 提供下载、烧录、复位、debug probe 状态和芯片识别等调试能力。
|
||||
- `projectWorkspace` 承载源码、工程、Keil/GCC 等工具链和编译产物。
|
||||
- `ioInterface` 提供 AI、AO、DI、DO、UART、截图、日志和其他实时或近实时观测/控制能力。
|
||||
|
||||
`device-pod` 因此是逻辑打包关系,而不是一对一物理设备关系。一个 DAPLink/MKLink 类探针可以同时拆出 `debugInterface` 的 SWD/CMSIS-DAP 能力和 `ioInterface` 的 UART 能力;同一个 `device-pod` 也可以把该探针与调试 box、USB 摄像头、串口工具或厂商上位机组合起来,共同形成对同一个 `deviceTarget` 的完整访问能力。
|
||||
|
||||
## 平台化服务模式
|
||||
|
||||
`device-pod` 把设备目标和访问能力封装成统一逻辑单元后,HWLAB 可以在同一技术模型上承载多种交付模式。不同模式的差异主要在于 `deviceTarget`、probe、workspace、云平台和运维责任分别由谁提供,但对 code agent 和用户界面暴露的仍应是稳定的 `devicePodId`、能力边界、锁、job、trace 和 evidence。
|
||||
|
||||
- **Device Pod 租赁**:HWLAB 运营常用开发板、调试探针、I/O 工具和 workspace 的设备机房,用户无需自备硬件即可在云端基于真实原型开发;软件验证后可以自行找硬件工程师,或委托平台侧硬件工程师继续绘制 PCB。
|
||||
- **用户 PCB 托管**:用户已经完成 PCB 或样机,委托 HWLAB 托管该 `deviceTarget`,平台提供或代管 probe、I/O 工具、workspace 和 `device-pod` 运行面,用户通过云端继续开发、测试和迭代。
|
||||
- **用户自有设备接入**:用户保留自己的 `deviceTarget`、debug probe 和 I/O probe,只接入 HWLAB 云;平台把这些资源通过 profile/gateway/device-host-cli 抽象为 `device-pod`,供用户自己远程开发。
|
||||
- **委托开发与运维**:在用户自有设备接入的基础上,用户可以授权平台内其他开发者、维护人员或自动化 code agent 围绕同一个 `device-pod` 进行软件开发、调试、故障复现、在线维护和验收。
|
||||
- **私有 HWLAB 云部署**:用户公司内部部署自有 HWLAB 云和自有设备池,平台提供安装、升级、运维和最佳实践支持;设备、数据和权限留在用户私有环境内,`device-pod` 模型保持一致。
|
||||
|
||||
这些模式都不能绕过 `device-pod` 的安全边界:profile 仍不得携带长期 secret;mutating operation 仍需 approval、reason、锁和 evidence;平台不得把缓存、dry-run、前端状态或本地模拟误报为真实 DEV-LIVE 设备证据。
|
||||
|
||||
## 实施计划
|
||||
|
||||
长期模型以本文为准;分阶段实现计划单独维护,避免把一次性开发步骤写进长期参考:
|
||||
@@ -27,6 +52,27 @@ 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/<devicePodId>.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` 验证。
|
||||
|
||||
`device-host-cli` 不是一次性预装脚本,而是 HWLAB 内部 code agent 可继续热开发的 host 侧工具。DeepSeek、Codex runner 或其他 HWLAB 内部 code agent 在真实硬件闭环中遇到 Keil、debug probe、串口、文件工作区或硬件启动路径不顺手时,优先在目标 Windows workspace 的 `tools\device-host-cli.mjs` 上新增或修复具名设备能力,并通过 `device-pod-cli -> cloud-api -> gateway -> device-host-cli` 的真实链路热验证;验证通过后再把同一实现回填到 `/app/skills/device-pod-cli/assets/device-host-cli.mjs` 对应的 HWLAB repo 资产,进入下一次 CI/CD 预装。不得因为 host CLI 暂时不顺手而把 `device-pod-cli` 退化为泛化 cmd/shell 入口。
|
||||
|
||||
目标 Windows workspace 的最小布局为:
|
||||
|
||||
```text
|
||||
<windows-workspace>\tools\device-host-cli.mjs
|
||||
<windows-workspace>\.device-pod\<devicePodId>.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`
|
||||
@@ -40,11 +86,13 @@ profile 必须只描述 target、debugInterface、projectWorkspace、ioInterface
|
||||
第一版路径固定为:
|
||||
|
||||
```text
|
||||
device-pod -> cloud-api -> gateway -> cmd -> skill/downloader -> debug probe -> target
|
||||
device-pod -> cloud-api -> gateway -> cmd -> device-host-cli -> downloader/debug probe -> target
|
||||
```
|
||||
|
||||
`debugInterface` 只暴露设备语义能力,例如 `debug.probe`、`debug.download`、`debug.reset`。不得把它退化成任意 shell 执行入口。
|
||||
|
||||
`device-host-cli` 必须是用户 PC 侧自包含组件;Keil、串口、DAPLink、J-Link 或其他 skill 代码只能作为实现参考,不能成为运行时依赖。
|
||||
|
||||
`debugInterface` 不承载源码编译、工程发现或工具链定义;这些属于
|
||||
`projectWorkspace`。`debugInterface` 可以消费 `projectWorkspace` 产生的
|
||||
artifact,例如 `.hex`、`.bin` 或 `.elf`,并负责把该 artifact 下载、校验、
|
||||
@@ -78,7 +126,7 @@ projectWorkspace.build
|
||||
|
||||
### `ioInterface`
|
||||
|
||||
`ioInterface` 是设备的实时或近实时 I/O 观测与控制接口,代表 UART、AI、AO、DI、DO、状态采样、日志抓取等能力。典型实现包括 DAPLink 自带串口、独立串口工具、USB/WiFi/厂商协议上位机或 `device-host-cli`。
|
||||
`ioInterface` 是设备的实时或近实时 I/O 观测与控制接口,代表 UART、AI、AO、DI、DO、状态采样、日志抓取、截图等能力。典型实现包括 DAPLink/MKLink 自带串口、独立串口工具、调试 box、USB 摄像头、USB/WiFi/厂商协议上位机或 `device-host-cli`。
|
||||
|
||||
MVP 推荐路径为:
|
||||
|
||||
@@ -160,4 +208,6 @@ projectWorkspace: STM32F103 源码、Keil 工程和编译工具链
|
||||
ioInterface: DAPLink UART 串口
|
||||
```
|
||||
|
||||
最小真实闭环也可以表述为:STM32F103 最小系统板作为 `deviceTarget`,一只 MKLink/DAPLink 类探针同时提供 `debugInterface` 和第一版 `ioInterface`,其中 debug 侧负责下载、复位和芯片识别,I/O 侧先只开放 UART;`projectWorkspace` 映射到用户 PC 上的源码目录、Keil 工程和工具链,再通过 gateway/device-host-cli 暴露受控构建和 artifact 能力。这样四要素齐备后,云端 code agent 就能围绕同一个 `device-pod` 完成改源码、编译、下载、复位、读串口的最小嵌入式开发闭环。
|
||||
|
||||
该模型后续可以扩展到更多 target 或更复杂 I/O probe,但每个 `device-pod` 仍只代表一个明确的设备目标能力单元。
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,147 @@
|
||||
---
|
||||
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/<devicePodId>.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.
|
||||
- `device-host-cli` is not just a copied preinstall script. It is the hot
|
||||
development tool that HWLAB internal code agents, including DeepSeek-backed
|
||||
runners, may keep improving on the target Windows workspace when hardware,
|
||||
Keil, probe, UART, or filesystem operations are not smooth enough. Hot fixes
|
||||
are expected whenever host-side behavior is awkward or missing; validate them
|
||||
through the real device-pod/gateway route first, then copy the result back into
|
||||
the HWLAB repo asset for the next CI/CD preinstall.
|
||||
|
||||
## 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
|
||||
<windows-workspace>\tools\device-host-cli.mjs
|
||||
<windows-workspace>\.device-pod\<devicePodId>.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. In HWLAB k8s runner pods, `tran.mjs` auto-selects the in-cluster
|
||||
`hwlab-cloud-api.<namespace>.svc.cluster.local:6667` API endpoint; outside k8s it
|
||||
falls back to local `127.0.0.1:6667`, and non-standard deployments may still pass
|
||||
`--api-base-url`. `tran.mjs upload` uses bounded base64 chunks tuned for the
|
||||
Windows cmd passthrough limit; do not raise the chunk size above the documented
|
||||
default to hide slow transfers. If upload/download becomes awkward again, fix
|
||||
`tran.mjs` or the gateway transport first.
|
||||
|
||||
```text
|
||||
node /app/tools/tran.mjs <gatewaySessionId>:/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 <gatewaySessionId>:/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.
|
||||
|
||||
## Host CLI Hot Development
|
||||
|
||||
When a profiled operation is awkward or missing, prefer improving the Windows
|
||||
workspace copy of `tools\device-host-cli.mjs` instead of bypassing the device-pod
|
||||
model with arbitrary cmd snippets. The intended loop is:
|
||||
|
||||
```text
|
||||
edit tools\device-host-cli.mjs in the Windows device-pod workspace
|
||||
node tools\device-host-cli.mjs health
|
||||
node tools\device-host-cli.mjs <new named operation>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs <devicePodId>:... <same operation>
|
||||
copy the validated change back to skills/device-pod-cli/assets/device-host-cli.mjs
|
||||
```
|
||||
|
||||
The host CLI should stay self-contained and named-operation oriented: add
|
||||
`debug-probe launch-flash` or `io-probe uart/1 read-after-launch-flash` style
|
||||
device semantics, not a generic shell escape. If gateway/cmd transfer itself is
|
||||
the friction, fix `tran.mjs` or the gateway transport separately instead of
|
||||
embedding transport workarounds into `device-host-cli`.
|
||||
|
||||
## 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 <jobId>
|
||||
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 <jobId>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe launch-flash --approved --reason "manual flash-vector launch"
|
||||
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 --duration-ms 5000
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read-after-launch-flash --approved --reason "boot log capture"
|
||||
```
|
||||
|
||||
## Safety
|
||||
|
||||
- Mutating operations such as workspace patching, download, reset,
|
||||
`launch-flash`, `read-after-launch-flash`, and I/O write require
|
||||
`--approved --reason <text>` 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.
|
||||
@@ -0,0 +1,833 @@
|
||||
#!/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 pyocdCommanderArgs(profile, commands, options = {}) {
|
||||
const tools = await toolInfo(profile);
|
||||
if (!tools.pyocd.found) throw new Error('pyOCD executable not found');
|
||||
const debug = profile.debugInterface || {};
|
||||
const args = ['commander'];
|
||||
if (debug.probeUid) args.push('-u', debug.probeUid);
|
||||
if (debug.pyocdTarget) args.push('-t', debug.pyocdTarget);
|
||||
const connectMode = options['connect-mode'] || options.connectMode || debug.launchConnectMode || 'attach';
|
||||
if (connectMode) args.push('-M', connectMode);
|
||||
if (debug.frequency) args.push('-f', debug.frequency);
|
||||
args.push('-W');
|
||||
for (const command of commands) args.push('-c', command);
|
||||
return { pyocd: tools.pyocd.path, args };
|
||||
}
|
||||
|
||||
function parseFlashVectors(text, flashBase) {
|
||||
const normalizedBase = String(flashBase || '').toLowerCase().replace(/^0x/u, '');
|
||||
const escapedBase = normalizedBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = String(text || '').toLowerCase().match(new RegExp(`${escapedBase}\\s*:\\s*([0-9a-f]{8})\\s+([0-9a-f]{8})`, 'u'));
|
||||
if (!match) throw new Error(`unable to parse flash vectors at ${flashBase}`);
|
||||
return { initialSp: `0x${match[1]}`, resetHandler: `0x${match[2]}` };
|
||||
}
|
||||
|
||||
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 launchFlash(options = {}) {
|
||||
const profile = loadProfile();
|
||||
const flashBase = String(options['flash-base'] || options.flashBase || profile.debugInterface?.flashBase || profile.target?.flashBase || '0x08000000');
|
||||
const skipHardwareReset = Boolean(options['skip-hardware-reset'] || options.skipHardwareReset);
|
||||
const prepareCommand = skipHardwareReset ? 'halt' : 'reset halt HARDWARE';
|
||||
const timeoutMs = Number(options['timeout-ms'] || options.timeoutMs || 60000);
|
||||
const read = await pyocdCommanderArgs(profile, [prepareCommand, `read32 ${flashBase} 8`], options);
|
||||
const vectorResult = await runCapture(read.pyocd, read.args, { timeoutMs });
|
||||
if (vectorResult.exitCode !== 0) throw new Error(`pyOCD vector read failed: ${shortTail(vectorResult.stdout || vectorResult.stderr)}`);
|
||||
const vectorText = `${vectorResult.stdout}\n${vectorResult.stderr}`;
|
||||
const vectors = parseFlashVectors(vectorText, flashBase);
|
||||
const launchCommands = [
|
||||
prepareCommand,
|
||||
`write32 0xE000ED08 ${flashBase}`,
|
||||
`wreg sp ${vectors.initialSp}`,
|
||||
`wreg pc ${vectors.resetHandler}`,
|
||||
'wreg xpsr 0x01000000',
|
||||
'wreg primask 0',
|
||||
'wreg faultmask 0',
|
||||
'wreg basepri 0',
|
||||
'go',
|
||||
];
|
||||
const launch = await pyocdCommanderArgs(profile, launchCommands, options);
|
||||
const launchResult = await runCapture(launch.pyocd, launch.args, { timeoutMs });
|
||||
ok('debug-probe.launch-flash', {
|
||||
success: launchResult.exitCode === 0,
|
||||
exitCode: launchResult.exitCode,
|
||||
flashBase,
|
||||
vectorTableRegister: '0xE000ED08',
|
||||
prepareCommand,
|
||||
skipHardwareReset,
|
||||
initialSp: vectors.initialSp,
|
||||
resetHandler: vectors.resetHandler,
|
||||
stdout: launchResult.stdout,
|
||||
stderr: launchResult.stderr,
|
||||
vectorRead: { exitCode: vectorResult.exitCode, stdout: vectorResult.stdout, stderr: vectorResult.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 serialReadAfterLaunchFlash(uartId, options) {
|
||||
const profile = loadProfile();
|
||||
const uart = resolveUart(profile, uartId);
|
||||
const tools = await toolInfo(profile);
|
||||
if (!tools.pyocd.found) throw new Error('pyOCD executable not found');
|
||||
const debug = profile.debugInterface || {};
|
||||
const durationMs = Number(options['duration-ms'] || options.durationMs || 5000);
|
||||
const flashBase = String(options['flash-base'] || options.flashBase || debug.flashBase || profile.target?.flashBase || '0x08000000');
|
||||
const skipHardwareReset = Boolean(options['skip-hardware-reset'] || options.skipHardwareReset);
|
||||
const prepareCommand = skipHardwareReset ? 'halt' : 'reset halt HARDWARE';
|
||||
const connectMode = options['connect-mode'] || options.connectMode || debug.launchConnectMode || 'attach';
|
||||
const script = [
|
||||
'$ErrorActionPreference = \'Stop\';',
|
||||
`$portName = ${psQuote(uart.port)};`,
|
||||
`$baud = ${Number(uart.baudRate || 115200)};`,
|
||||
`$durationMs = ${durationMs};`,
|
||||
`$pyocd = ${psQuote(tools.pyocd.path)};`,
|
||||
`$uid = ${psQuote(debug.probeUid || '')};`,
|
||||
`$target = ${psQuote(debug.pyocdTarget || '')};`,
|
||||
`$frequency = ${psQuote(debug.frequency || '')};`,
|
||||
`$connectMode = ${psQuote(connectMode || '')};`,
|
||||
`$flashBase = ${psQuote(flashBase)};`,
|
||||
`$prepareCommand = ${psQuote(prepareCommand)};`,
|
||||
'$common = @(\'commander\');',
|
||||
'if ($uid) { $common += @(\'-u\', $uid) }',
|
||||
'if ($target) { $common += @(\'-t\', $target) }',
|
||||
'if ($connectMode) { $common += @(\'-M\', $connectMode) }',
|
||||
'if ($frequency) { $common += @(\'-f\', $frequency) }',
|
||||
'$common += @(\'-W\');',
|
||||
'$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();',
|
||||
' $port.DiscardInBuffer();',
|
||||
' $port.Close();',
|
||||
' $vectorOutput = & $pyocd @common -c $prepareCommand -c "read32 $flashBase 8" 2>&1;',
|
||||
' $vectorExitCode = $LASTEXITCODE;',
|
||||
' $vectorText = ($vectorOutput | Out-String);',
|
||||
' if ($vectorExitCode -ne 0) { throw "pyOCD vector read failed: $vectorText" }',
|
||||
' $escapedBase = [regex]::Escape($flashBase.ToLower().Replace("0x", ""));',
|
||||
' $match = [regex]::Match($vectorText.ToLower(), "$escapedBase\\s*:\\s*([0-9a-f]{8})\\s+([0-9a-f]{8})");',
|
||||
' if (-not $match.Success) { throw "Unable to parse flash vectors from pyOCD output: $vectorText" }',
|
||||
' $initialSp = "0x" + $match.Groups[1].Value;',
|
||||
' $resetHandler = "0x" + $match.Groups[2].Value;',
|
||||
' $launchOutput = & $pyocd @common -c $prepareCommand -c "write32 0xE000ED08 $flashBase" -c "wreg sp $initialSp" -c "wreg pc $resetHandler" -c "wreg xpsr 0x01000000" -c "wreg primask 0" -c "wreg faultmask 0" -c "wreg basepri 0" -c "go" 2>&1;',
|
||||
' $launchExitCode = $LASTEXITCODE;',
|
||||
' $launchText = ($launchOutput | Out-String);',
|
||||
' if ($launchExitCode -ne 0) { throw "pyOCD flash-vector launch failed: $launchText" }',
|
||||
' $port.Open();',
|
||||
' Start-Sleep -Milliseconds $durationMs;',
|
||||
' $text = $port.ReadExisting();',
|
||||
' $bytes = [System.Text.Encoding]::UTF8.GetBytes($text);',
|
||||
' [pscustomobject]@{ contentB64 = [Convert]::ToBase64String($bytes); flashBase = $flashBase; vectorTableRegister = "0xE000ED08"; prepareCommand = $prepareCommand; initialSp = $initialSp; resetHandler = $resetHandler; vectorExitCode = $vectorExitCode; launchExitCode = $launchExitCode; vectorStdout = $vectorText; launchStdout = $launchText } | ConvertTo-Json -Depth 5 -Compress | Write-Output;',
|
||||
'} finally { if ($port.IsOpen) { $port.Close() }; $port.Dispose() }',
|
||||
].join(' ');
|
||||
const result = await runPowerShell(script, Math.max(15000, durationMs + 20000));
|
||||
if (result.exitCode !== 0) throw new Error(`uart read-after-launch-flash failed: ${shortTail(result.stdout || result.stderr)}`);
|
||||
const payload = JSON.parse(result.stdout.trim());
|
||||
const content = payload.contentB64 ? Buffer.from(payload.contentB64, '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-after-launch-flash', uartId: uart.id, port: uart.port, baudRate: uart.baudRate, content })}\n`, 'utf8');
|
||||
ok('io-probe.uart.read-after-launch-flash', { uart, durationMs, exitCode: result.exitCode, content, bytes: Buffer.byteLength(content), logFile, stderr: result.stderr, launch: { flashBase: payload.flashBase, vectorTableRegister: payload.vectorTableRegister, prepareCommand: payload.prepareCommand, initialSp: payload.initialSp, resetHandler: payload.resetHandler, vectorExitCode: payload.vectorExitCode, launchExitCode: payload.launchExitCode, vectorStdout: payload.vectorStdout, launchStdout: payload.launchStdout } });
|
||||
}
|
||||
|
||||
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 <path> [--limit N]',
|
||||
'node tools/device-host-cli.mjs workspace rg <pattern> [path]',
|
||||
'node tools/device-host-cli.mjs workspace apply-patch [base] --patch-b64 <base64>',
|
||||
'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|launch-flash',
|
||||
'node tools/device-host-cli.mjs io-probe uart/1 ports|read|read-after-launch-flash|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 (command === 'launch-flash') return await launchFlash(parseArgs(rest));
|
||||
}
|
||||
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 === 'read-after-launch-flash') return await serialReadAfterLaunchFlash(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));
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import '../../../tools/device-pod-cli.mjs';
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
|
||||
const VERSION = '0.1.0-mvp';
|
||||
const PROFILE_DIR = process.env.DEVICE_POD_PROFILE_DIR || path.resolve(process.cwd(), '.device-pod');
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
|
||||
function nowIso() { return new Date().toISOString(); }
|
||||
function stripSlash(value) { return String(value || '').replace(/\/+$/u, ''); }
|
||||
function sha256(text) { return createHash('sha256').update(text).digest('hex'); }
|
||||
function print(body, exitCode = 0) {
|
||||
console.log(JSON.stringify({ generatedAt: nowIso(), cli: 'device-pod-cli', version: VERSION, ...body }, null, 2));
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
function fail(action, error, details = {}) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
print({ ok: false, action, status: 'failed', blocker: classifyBlocker(message), error: message, details }, 1);
|
||||
}
|
||||
function classifyBlocker(message) {
|
||||
if (/profile not found/u.test(message)) return 'profile-missing';
|
||||
if (/profile invalid|requires/u.test(message)) return 'profile-invalid';
|
||||
if (/approval/u.test(message)) return 'approval-required';
|
||||
if (/unsupported|invalid target selector/u.test(message)) return 'invalid-request';
|
||||
return 'operation-failed';
|
||||
}
|
||||
|
||||
function parseTarget(raw) {
|
||||
if (!raw || raw === '--help' || raw === 'help') return { kind: 'help' };
|
||||
if (raw === 'health') return { kind: 'health' };
|
||||
const match = String(raw).match(/^([^:]+):(workspace|debug-probe|io-probe)(?::(.*))?$/u);
|
||||
if (!match) throw new Error(`invalid target selector: ${raw}`);
|
||||
return { kind: 'invoke', podId: match[1], surface: match[2], path: match[3] || '' };
|
||||
}
|
||||
|
||||
function parseOptions(argv) {
|
||||
const out = { _: [] };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const item = argv[i];
|
||||
if (!item.startsWith('--')) { out._.push(item); continue; }
|
||||
const eq = item.indexOf('=');
|
||||
const rawKey = eq >= 0 ? item.slice(2, eq) : item.slice(2);
|
||||
const key = rawKey.replace(/-([a-z])/gu, (_, c) => c.toUpperCase());
|
||||
if (eq >= 0) { out[key] = item.slice(eq + 1); continue; }
|
||||
const next = argv[i + 1];
|
||||
if (next && !next.startsWith('--')) { out[key] = next; i += 1; }
|
||||
else out[key] = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolvePodId(requested) {
|
||||
if (requested) return requested;
|
||||
if (process.env.DEVICE_POD_ID) return process.env.DEVICE_POD_ID;
|
||||
const names = fs.existsSync(PROFILE_DIR)
|
||||
? fs.readdirSync(PROFILE_DIR).filter((name) => name.endsWith('.json')).map((name) => path.basename(name, '.json'))
|
||||
: [];
|
||||
if (names.length === 1) return names[0];
|
||||
throw new Error('profile invalid: pass <devicePodId>:<surface> or set DEVICE_POD_ID when multiple/no profiles exist');
|
||||
}
|
||||
|
||||
function readProfile(podId) {
|
||||
const requestedPodId = podId || process.env.DEVICE_POD_ID || null;
|
||||
const explicitFile = process.env.DEVICE_POD_PROFILE || null;
|
||||
const devicePodId = explicitFile ? requestedPodId : resolvePodId(requestedPodId);
|
||||
const file = explicitFile || path.join(PROFILE_DIR, `${devicePodId}.json`);
|
||||
if (!fs.existsSync(file)) throw new Error(`profile not found: ${file}`);
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
const profile = JSON.parse(raw);
|
||||
const resolvedPodId = profile.devicePodId || profile.podId || devicePodId || path.basename(file, '.json');
|
||||
const targetId = profile.targetId || profile.target?.id || profile.deviceTarget?.targetId || profile.deviceTarget?.id;
|
||||
const required = ['cloudApiUrl', 'gatewaySessionId', 'resourceId', 'capabilityId', 'hostWorkspaceRoot', 'hostCli'];
|
||||
const missing = required.filter((key) => !profile[key]);
|
||||
if (missing.length) throw new Error(`profile invalid: missing ${missing.join(', ')}`);
|
||||
if (devicePodId && resolvedPodId !== devicePodId) throw new Error(`profile invalid: podId mismatch selector=${devicePodId} profile=${resolvedPodId}`);
|
||||
if (!targetId) throw new Error('profile invalid: missing target.id');
|
||||
return { ...profile, podId: resolvedPodId, targetId, __profilePath: file, __profileHash: sha256(raw) };
|
||||
}
|
||||
|
||||
function profileSummary(profile) {
|
||||
return { profilePath: profile.__profilePath, profileHash: profile.__profileHash, devicePodId: profile.podId, targetId: profile.targetId };
|
||||
}
|
||||
function routeSummary(profile) {
|
||||
return { cloudApiUrl: profile.cloudApiUrl, gatewaySessionId: profile.gatewaySessionId, resourceId: profile.resourceId, capabilityId: profile.capabilityId, hostWorkspaceRoot: profile.hostWorkspaceRoot };
|
||||
}
|
||||
|
||||
function cmdQuote(value) {
|
||||
const text = String(value);
|
||||
if (/^[A-Za-z0-9_./:\\-]+$/u.test(text)) return text;
|
||||
return `"${text.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
const CONTROL_OPTION_KEYS = new Set([
|
||||
'approved',
|
||||
'reason',
|
||||
'dryRun',
|
||||
'full',
|
||||
'timeoutMs',
|
||||
'traceId',
|
||||
'podId',
|
||||
'patch',
|
||||
'patchB64',
|
||||
]);
|
||||
|
||||
function camelToKebab(value) {
|
||||
return String(value).replace(/[A-Z]/gu, (match) => `-${match.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function hostOptionArgs(parsed) {
|
||||
const out = [];
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (key === '_' || CONTROL_OPTION_KEYS.has(key) || value === undefined || value === false) continue;
|
||||
const name = `--${camelToKebab(key)}`;
|
||||
if (value === true) out.push(name);
|
||||
else out.push(name, String(value));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function buildHostCommand(selector, parsed) {
|
||||
const args = [];
|
||||
const options = hostOptionArgs(parsed);
|
||||
const operation = parsed._[0] || (selector.surface === 'workspace' ? 'ls' : selector.surface === 'debug-probe' ? 'status' : 'read');
|
||||
const mutating = isMutating(selector.surface, operation);
|
||||
if (mutating) requireApproval(parsed, `${selector.surface}.${operation}`);
|
||||
|
||||
if (selector.surface === 'workspace') {
|
||||
const workspacePath = selector.path.replace(/^\/+/, '') || '.';
|
||||
if (operation === 'ls' || operation === 'cat') args.push('workspace', operation, parsed._[1] ? path.posix.join(workspacePath, parsed._[1]) : workspacePath, ...parsed._.slice(2), ...options);
|
||||
else if (operation === 'rg') args.push('workspace', 'rg', parsed._[1] || '', workspacePath, ...parsed._.slice(2), ...options);
|
||||
else if (operation === 'apply-patch') args.push('workspace', 'apply-patch', workspacePath, '--patch-b64', Buffer.from(await resolvePatchText(parsed), 'utf8').toString('base64'));
|
||||
else if (operation === 'build') args.push('workspace', 'build', ...(parsed._.slice(1).length ? parsed._.slice(1) : ['start']), ...options);
|
||||
else throw new Error(`unsupported workspace command: ${operation}`);
|
||||
} else if (selector.surface === 'debug-probe') {
|
||||
args.push('debug-probe', operation, ...parsed._.slice(1), ...options);
|
||||
} else if (selector.surface === 'io-probe') {
|
||||
const probePath = selector.path.replace(/^\/+/, '') || 'uart/1';
|
||||
if (probePath.startsWith('inner/')) throw new Error(`unsupported io-probe path in MVP: ${probePath}`);
|
||||
args.push('io-probe', probePath, operation, ...parsed._.slice(1), ...options);
|
||||
} else {
|
||||
throw new Error(`unsupported surface: ${selector.surface}`);
|
||||
}
|
||||
return { operation, mutating, commandArgs: args };
|
||||
}
|
||||
|
||||
function isMutating(surface, operation) {
|
||||
return (surface === 'workspace' && operation === 'apply-patch')
|
||||
|| (surface === 'debug-probe' && (operation === 'download' || operation === 'reset' || operation === 'launch-flash'))
|
||||
|| (surface === 'io-probe' && (operation === 'write' || operation === 'read-after-launch-flash'));
|
||||
}
|
||||
function requireApproval(parsed, operation) {
|
||||
if (parsed.approved === true && typeof parsed.reason === 'string' && parsed.reason.trim()) return;
|
||||
throw new Error(`approval required for ${operation}: pass --approved --reason <text>`);
|
||||
}
|
||||
|
||||
async function resolvePatchText(parsed) {
|
||||
if (typeof parsed.patch === 'string') return parsed.patch;
|
||||
if (typeof parsed.patchB64 === 'string') return Buffer.from(parsed.patchB64, 'base64').toString('utf8');
|
||||
const text = await readAllStdin();
|
||||
if (!text.trim()) throw new Error('workspace apply-patch requires patch text on stdin or --patch-b64');
|
||||
return text;
|
||||
}
|
||||
async function readAllStdin() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
async function invoke(profile, command, parsed) {
|
||||
const timeoutMs = Number(parsed.timeoutMs || DEFAULT_TIMEOUT_MS);
|
||||
const requestId = `req_device_pod_cli_${randomUUID()}`;
|
||||
const operationId = `op_device_pod_cli_${randomUUID()}`;
|
||||
const traceId = parsed.traceId || `trc_device_pod_cli_${Date.now()}`;
|
||||
const response = await requestJson(`${stripSlash(profile.cloudApiUrl)}/v1/rpc/hardware.invoke.shell`, {
|
||||
timeoutMs: timeoutMs + 10000,
|
||||
headers: { 'x-trace-id': traceId, 'x-request-id': requestId, 'x-actor-id': 'usr_device_pod_cli', 'x-source-service-id': 'hwlab-cloud-api' },
|
||||
body: { projectId: profile.projectId || 'prj_mvp_topology', operationId, gatewaySessionId: profile.gatewaySessionId, resourceId: profile.resourceId, capabilityId: profile.capabilityId, input: { command, cwd: profile.hostWorkspaceRoot, timeoutMs } }
|
||||
});
|
||||
const dispatch = response.body?.result?.dispatch || response.body?.result || {};
|
||||
const hostJson = parseMaybeJson(dispatch.stdout);
|
||||
const status = response.body?.error || dispatch.exitCode !== 0 || dispatch.status === 'failed' ? 'failed' : 'succeeded';
|
||||
return { requestId, operationId, traceId, httpStatus: response.status, status, dispatch, hostJson, gatewayBody: response.body };
|
||||
}
|
||||
|
||||
function requestJson(url, options = {}) {
|
||||
const body = JSON.stringify(options.body || {});
|
||||
const target = new URL(url);
|
||||
const client = target.protocol === 'https:' ? https : http;
|
||||
const headers = { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), ...(options.headers || {}) };
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = client.request(target, { method: 'POST', headers }, (res) => {
|
||||
let text = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => { text += chunk; });
|
||||
res.on('end', () => {
|
||||
try { resolve({ status: res.statusCode || 0, body: text ? JSON.parse(text) : null }); }
|
||||
catch (error) { reject(new Error(`invalid JSON response from gateway API: ${error.message}; body=${text.slice(0, 500)}`)); }
|
||||
});
|
||||
});
|
||||
const timer = setTimeout(() => req.destroy(new Error(`request timeout after ${options.timeoutMs || DEFAULT_TIMEOUT_MS}ms`)), options.timeoutMs || DEFAULT_TIMEOUT_MS);
|
||||
req.on('error', reject);
|
||||
req.on('close', () => clearTimeout(timer));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function parseMaybeJson(text) {
|
||||
if (!text) return null;
|
||||
try { return JSON.parse(text); } catch { return null; }
|
||||
}
|
||||
function compactDispatch(dispatch) {
|
||||
return { status: dispatch.status || dispatch.dispatchStatus, exitCode: dispatch.exitCode, timedOut: dispatch.timedOut, durationMs: dispatch.durationMs, stdoutTruncated: dispatch.stdoutTruncated, stderrTruncated: dispatch.stderrTruncated, stderr: dispatch.stderr || '', auditId: dispatch.auditId, evidenceId: dispatch.evidenceId };
|
||||
}
|
||||
|
||||
function help() {
|
||||
print({ ok: true, action: 'help', scope: 'HWLAB internal code agents only; not for external UniDesk workspaces.', usage: [
|
||||
'node tools/device-pod-cli.mjs health',
|
||||
'node tools/device-pod-cli.mjs device-pod-71-freq:workspace:/ ls',
|
||||
'node tools/device-pod-cli.mjs device-pod-71-freq:workspace:/firmware cat main.c',
|
||||
'node tools/device-pod-cli.mjs device-pod-71-freq:workspace:/ apply-patch --approved --reason "DEV edit" < patch.diff',
|
||||
'node tools/device-pod-cli.mjs device-pod-71-freq:workspace:/ build start|status <jobId>',
|
||||
'node tools/device-pod-cli.mjs device-pod-71-freq:debug-probe chip-id|status|download|reset|launch-flash --approved --reason "DEV smoke"',
|
||||
'node tools/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 ports|read|read-after-launch-flash|write --approved --reason "DEV io"'
|
||||
] });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [rawTarget, ...argv] = process.argv.slice(2);
|
||||
const selector = parseTarget(rawTarget);
|
||||
if (selector.kind === 'help') return help();
|
||||
const parsed = parseOptions(argv);
|
||||
const profile = readProfile(selector.podId || parsed.podId);
|
||||
if (selector.kind === 'health') return print({ ok: true, action: 'health', status: 'succeeded', ...profileSummary(profile), route: routeSummary(profile) });
|
||||
const built = await buildHostCommand(selector, parsed);
|
||||
const command = `${profile.hostCli} ${built.commandArgs.map(cmdQuote).join(' ')}`;
|
||||
if (parsed.dryRun) return print({ ok: true, action: 'device-pod.plan', status: 'planned', selector, operation: built.operation, mutating: built.mutating, command, ...profileSummary(profile), route: routeSummary(profile) });
|
||||
const result = await invoke(profile, command, parsed);
|
||||
const ok = result.status === 'succeeded';
|
||||
print({ ok, action: 'device-pod.invoke', status: result.status, selector, operation: built.operation, mutating: built.mutating, command, ...profileSummary(profile), route: routeSummary(profile), traceId: result.traceId, operationId: result.operationId, requestId: result.requestId, httpStatus: result.httpStatus, dispatch: parsed.full ? result.dispatch : compactDispatch(result.dispatch), hostJson: result.hostJson, evidence: { auditId: result.dispatch.auditId, evidenceId: result.dispatch.evidenceId }, gatewayBody: parsed.full ? result.gatewayBody : undefined }, ok ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((error) => fail('device-pod-cli', error));
|
||||
@@ -11,14 +11,15 @@ import {
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_API_BASE_URL = "http://127.0.0.1:6667";
|
||||
const LOCAL_API_BASE_URL = "http://127.0.0.1:6667";
|
||||
const DEFAULT_API_PORT = "6667";
|
||||
const DEFAULT_PROJECT_ID = "prj_mvp_topology";
|
||||
const DEFAULT_RESOURCE_ID = "res_windows_host";
|
||||
const DEFAULT_CAPABILITY_ID = "cap_windows_cmd_exec";
|
||||
const DEFAULT_TIMEOUT_MS = 120000;
|
||||
const MAX_TIMEOUT_MS = 600000;
|
||||
const DEFAULT_CHUNK_SIZE = 900;
|
||||
const MAX_CHUNK_SIZE = 3000;
|
||||
const DEFAULT_CHUNK_SIZE = 2000;
|
||||
const MAX_CHUNK_SIZE = 2000;
|
||||
const REQUEST_TIMEOUT_GRACE_MS = 10000;
|
||||
const POWERSHELL_PROLOGUE = [
|
||||
"$ErrorActionPreference = 'Stop';",
|
||||
@@ -38,7 +39,7 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiBaseUrl = stripTrailingSlash(cli.options.apiBaseUrl || process.env.HWLAB_GATEWAY_TRAN_API_BASE_URL || process.env.HWLAB_GATEWAY_SHELL_API_BASE_URL || DEFAULT_API_BASE_URL);
|
||||
const apiBaseUrl = stripTrailingSlash(cli.options.apiBaseUrl || process.env.HWLAB_GATEWAY_TRAN_API_BASE_URL || process.env.HWLAB_GATEWAY_SHELL_API_BASE_URL || defaultApiBaseUrl());
|
||||
const timeoutMs = boundedPositiveInteger(cli.options.timeoutMs ?? process.env.HWLAB_GATEWAY_TRAN_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, { min: 1000, max: MAX_TIMEOUT_MS });
|
||||
const requestTimeoutMs = boundedPositiveInteger(cli.options.requestTimeoutMs, timeoutMs + REQUEST_TIMEOUT_GRACE_MS, {
|
||||
min: timeoutMs,
|
||||
@@ -61,6 +62,24 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultApiBaseUrl() {
|
||||
const namespace = readKubernetesNamespace();
|
||||
if (process.env.KUBERNETES_SERVICE_HOST && namespace) {
|
||||
return `http://hwlab-cloud-api.${namespace}.svc.cluster.local:${DEFAULT_API_PORT}`;
|
||||
}
|
||||
return LOCAL_API_BASE_URL;
|
||||
}
|
||||
|
||||
function readKubernetesNamespace() {
|
||||
try {
|
||||
const value = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "utf8").trim();
|
||||
if (/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(value)) return value;
|
||||
} catch {
|
||||
// Non-Kubernetes hosts keep the local cloud-api default.
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseCli(argv) {
|
||||
if (argv.length === 0) return { help: true, exitCode: 2 };
|
||||
if (argv[0] === "--help" || argv[0] === "-h") return { help: true, exitCode: 0 };
|
||||
@@ -441,10 +460,10 @@ function printHelp(exitCode = 0) {
|
||||
" download <remote> [local] Download a remote file using verified base64 chunks.",
|
||||
"",
|
||||
"Options:",
|
||||
" --api-base-url URL Default http://127.0.0.1:6667 or HWLAB_GATEWAY_TRAN_API_BASE_URL.",
|
||||
" --api-base-url URL Default env override, in-cluster hwlab-cloud-api service, or http://127.0.0.1:6667.",
|
||||
" --timeout-ms N Gateway command timeout, default 120000ms.",
|
||||
" --request-timeout-ms N HTTP wait timeout; defaults to command timeout plus grace.",
|
||||
" --chunk-size N Upload/download base64 chunk size, default 900, max 3000.",
|
||||
" --chunk-size N Upload/download base64 chunk size, default 2000, max 2000.",
|
||||
" --json Print the full JSON response for cmd/ps or transfer summary.",
|
||||
"",
|
||||
"Examples:",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import './hwlab-gateway-tran.mjs';
|
||||
Reference in New Issue
Block a user