Merge pull request #553 from pikasTech/fix/v02-device-pod-rest-cli
feat: migrate v0.2 device-pod CLI to REST authority
This commit is contained in:
@@ -173,6 +173,61 @@ test("device pod executor dispatches internal jobs through cloud-api gateway ada
|
||||
}
|
||||
});
|
||||
|
||||
test("device-pod executor maps v0.1 CLI options to device-host-cli argv", async () => {
|
||||
let dispatchedBody = null;
|
||||
const cloudApi = createServer(async (request, response) => {
|
||||
dispatchedBody = await requestJson(request);
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({
|
||||
ok: true,
|
||||
result: {
|
||||
status: "completed",
|
||||
dispatch: { shellExecuted: true, dispatchStatus: "succeeded", stdout: "ok", exitCode: 0 },
|
||||
auditId: "aud_options",
|
||||
evidenceId: "ev_options"
|
||||
}
|
||||
}));
|
||||
});
|
||||
await new Promise((resolve) => cloudApi.listen(0, "127.0.0.1", resolve));
|
||||
const cloudPort = cloudApi.address().port;
|
||||
const service = await startDevicePod("device-pod-test", {
|
||||
HWLAB_CLOUD_API_INTERNAL_URL: `http://127.0.0.1:${cloudPort}`
|
||||
});
|
||||
|
||||
try {
|
||||
const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-hwlab-internal-service": "hwlab-cloud-api" },
|
||||
body: JSON.stringify({
|
||||
jobId: "job_options_test",
|
||||
intent: "debug.download",
|
||||
args: { action: "start", captureUart: "uart/1", captureDurationMs: 8000, port: "COM4", baudRate: 921600 },
|
||||
profileHash: "sha256:profile",
|
||||
profile: {
|
||||
devicePodId: "device-pod-test",
|
||||
target: { id: "target-test" },
|
||||
route: { gatewaySessionId: "gws_test", resourceId: "res_test", capabilityId: "cap_test", hostCli: "node tools/device-host-cli.mjs" }
|
||||
}
|
||||
})
|
||||
});
|
||||
assert.equal(jobResponse.status, 202);
|
||||
await waitForJobStatus(service.port, "device-pod-test", "job_options_test", "completed");
|
||||
const command = dispatchedBody.params.input.command;
|
||||
const encoded = command.match(/"([A-Za-z0-9+/=]+)"$/u)[1];
|
||||
const decoded = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
|
||||
assert.deepEqual(decoded.args, [
|
||||
"debug-probe", "download", "start",
|
||||
"--capture-uart", "uart/1",
|
||||
"--capture-duration-ms", "8000",
|
||||
"--port", "COM4",
|
||||
"--baud-rate", "921600"
|
||||
]);
|
||||
} finally {
|
||||
await service.stop();
|
||||
await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
async function startDevicePod(devicePodId, extraEnv = {}) {
|
||||
const port = await freePort();
|
||||
const child = spawn(bunCommand, ["run", "cmd/hwlab-device-pod/main.ts"], {
|
||||
|
||||
@@ -399,20 +399,38 @@ function deviceHostArgs(intent, args = {}) {
|
||||
}
|
||||
if (intent === "workspace.rg") {
|
||||
const pattern = textOr(args.pattern ?? args.query, "");
|
||||
return pattern ? ["workspace", "rg", pattern, textOr(args.path, ".")] : null;
|
||||
return pattern ? ["workspace", "rg", pattern, textOr(args.path, "."), ...hostOptionArgs(args, ["glob", "g", "filesWithMatches", "l", "ignoreCase", "i", "maxCount"])] : null;
|
||||
}
|
||||
if (intent === "workspace.apply-patch") return ["workspace", "apply-patch", textOr(args.base, "."), "--patch-b64", patchBase64(args)];
|
||||
if (intent === "workspace.build") return ["workspace", "build", textOr(args.action, "start")];
|
||||
if (intent === "workspace.build") return ["workspace", "build", textOr(args.action, "start"), ...hostOptionArgs(args, ["target", "timeoutMs"]), ...jobIdArgs(args)];
|
||||
if (intent === "debug.status") return ["debug-probe", "status"];
|
||||
if (intent === "debug.chip-id") return ["debug-probe", "chip-id"];
|
||||
if (intent === "debug.download") return ["debug-probe", "download", textOr(args.action, "start")];
|
||||
if (intent === "debug.download") return ["debug-probe", "download", textOr(args.action, "start"), ...hostOptionArgs(args, ["target", "timeoutMs", "captureUart", "captureDurationMs", "durationMs", "port", "baudRate"]), ...jobIdArgs(args)];
|
||||
if (intent === "debug.reset") return ["debug-probe", "reset"];
|
||||
if (intent === "io.ports") return ["io-probe", textOr(args.uartId, "uart/1"), "ports"];
|
||||
if (intent === "io.uart.read") return ["io-probe", textOr(args.uartId, "uart/1"), "read", "--duration-ms", String(numberOr(args.durationMs ?? args["duration-ms"], 1000))];
|
||||
if (intent === "io.uart.write") return ["io-probe", textOr(args.uartId, "uart/1"), "write", ...(args.hex ? ["--hex"] : []), textOr(args.message ?? args.text ?? args.data, "")];
|
||||
if (intent === "io.uart.read") return ["io-probe", textOr(args.uartId, "uart/1"), "read", "--duration-ms", String(numberOr(args.durationMs ?? args["duration-ms"], 1000)), ...hostOptionArgs(args, ["port", "baudRate"])] ;
|
||||
if (intent === "io.uart.read-after-launch-flash") return ["io-probe", textOr(args.uartId, "uart/1"), "read-after-launch-flash", ...hostOptionArgs(args, ["durationMs", "port", "baudRate", "flashBase", "skipHardwareReset", "connectMode", "timeoutMs"])] ;
|
||||
if (intent === "io.uart.write") return ["io-probe", textOr(args.uartId, "uart/1"), "write", ...(args.hex ? ["--hex"] : []), ...hostOptionArgs(args, ["port", "baudRate"]), textOr(args.message ?? args.text ?? args.data, "")];
|
||||
return null;
|
||||
}
|
||||
|
||||
function jobIdArgs(args = {}) {
|
||||
const jobId = textOr(args.jobId, "");
|
||||
return jobId ? [jobId] : [];
|
||||
}
|
||||
|
||||
function hostOptionArgs(args = {}, keys = []) {
|
||||
const out = [];
|
||||
for (const key of keys) {
|
||||
const value = args[key];
|
||||
if (value === undefined || value === null || value === "" || value === false) continue;
|
||||
const name = `--${key.replace(/[A-Z]/gu, (match) => `-${match.toLowerCase()}`)}`;
|
||||
if (value === true) out.push(name);
|
||||
else out.push(name, String(value));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function patchBase64(args = {}) {
|
||||
if (typeof args.patchB64 === "string") return args.patchB64;
|
||||
if (typeof args["patch-b64"] === "string") return args["patch-b64"];
|
||||
|
||||
@@ -26,7 +26,7 @@ device-pod-cli or cloud-web
|
||||
- 用最少组件把 `device-pod-cli` 从“本地 profile + RPC/gateway 调用”迁到“1:1 REST 请求”。
|
||||
- `cloud-api` 是用户身份、device grant、profile authority 和 lease 判断入口。
|
||||
- `hwlab-device-pod` 承接设备业务:profile 校验后的运行、job 生命周期、freshness、blocker、bounded output 和 gateway 调用。
|
||||
- `device-pod-cli` 只做 selector 解析、REST 请求和 JSON 输出,不再保存或上传权威 profile。
|
||||
- `device-pod-cli` 只做 selector 解析、cloud-api REST 请求和 JSON 输出;默认正式模式不读取 `.device-pod/*.json`,不保存、不上传、不修改权威 profile。
|
||||
- 第一阶段只部署一个 `hwlab-device-pod` Deployment/Service,管理多个逻辑 `devicePodId`,避免为每台设备创建独立 k8s Service/Deployment。
|
||||
- 普通用户和 code agent session 不获得 Kubernetes 用户、Service 直连权限、gateway route 或 host workspace route。
|
||||
|
||||
@@ -200,6 +200,7 @@ DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId}
|
||||
- `debug.reset`
|
||||
- `io.ports`
|
||||
- `io.uart.read`
|
||||
- `io.uart.read-after-launch-flash`
|
||||
- `io.uart.write`
|
||||
|
||||
所有响应必须包含 `devicePodId`、`targetId`、`profileHash`、`traceId`、`operationId`、`status`、`freshness`、`blocker` 和 bounded output metadata。真实硬件响应不得把 fake、dry-run、SOURCE、LOCAL 或过期缓存标为 `DEV-LIVE`。
|
||||
@@ -210,7 +211,7 @@ DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId}
|
||||
| --- | --- |
|
||||
| `hwlab-cloud-api` | 用户身份、admin/user、device grant、lease、profile authority、用户态 REST API、转发到内部 device-pod。 |
|
||||
| `hwlab-device-pod` | 多 `devicePodId` 运行 registry、profile runtime validation、job store、freshness、bounded output、gateway/device-host-cli adapter。 |
|
||||
| `device-pod-cli` | 把 `devicePodId:surface:path operation args` 1:1 转成 cloud-api REST;不保存权威 profile、不直连 gateway。 |
|
||||
| `device-pod-cli` | 把 `devicePodId:surface:path operation args` 1:1 转成 cloud-api REST;不保存权威 profile、不读取本地 profile 作为默认 authority、不直连 gateway。 |
|
||||
| `device-host-cli` | Windows host 侧自包含业务工具,负责 Keil、pyOCD、UART、workspace 文件操作。 |
|
||||
| `hwlab-gateway` | 只做受控 transport,不理解用户权限和 device-pod 授权。 |
|
||||
| `hwlab-cloud-web` | 展示用户可见 device pod、admin 管理 profile/grant、显示 job/status/freshness。 |
|
||||
@@ -248,6 +249,15 @@ manages: many devicePodId
|
||||
- `POST /v1/device-pods/{devicePodId}/leases` 只对已授权 actor 创建或刷新互斥租约,响应只返回一次性 `leaseToken`;后续强副作用 job 必须通过 `leaseToken` 或 `x-hwlab-device-lease-token` 证明持有租约。
|
||||
- 撤销 device pod grant 必须释放该用户对同一 `devicePodId` 的活动 lease。
|
||||
|
||||
## CLI 实现口径
|
||||
|
||||
`tools/device-pod-cli.ts` 是 v0.2 正式 CLI 实现,稳定 runner 入口仍是 `/app/skills/device-pod-cli/scripts/device-pod-cli.mjs`。该 `.mjs` 只作为兼容启动器,通过 Bun 运行 TypeScript CLI。正式 CLI 的默认行为是:
|
||||
|
||||
- `profile list/show` 调用 cloud-api `/v1/device-pods` 和 `/status`,只显示服务端脱敏 profile 摘要和 `profileHash`。
|
||||
- `devicePodId:workspace|debug-probe|io-probe...` selector 转换为 `POST /v1/device-pods/{devicePodId}/jobs` 或 job status/output/cancel REST,不直接调用 `/v1/rpc/hardware.invoke.shell`。
|
||||
- mutating job 继续由 cloud-api 侧强制 lease/reason;CLI 只转发 `reason` 和 `leaseToken`,不在本地绕过。
|
||||
- `profile create` 这类本地 profile bootstrap 在正式默认路径中返回 `legacy_profile_create_removed`;管理员应使用 cloud-api admin API 管理服务端 profile/grant。
|
||||
|
||||
## 测试规格
|
||||
|
||||
## T1
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- `tools/hwlab-cli/bin/hwlab-cli.mjs` 是唯一 CLI bin 入口。
|
||||
- `tools/hwlab-cli/lib/cli.mjs` 负责参数解析、JSON 输出、legacy cicd 拒绝、M3 Skill CLI 转发和 MVP e2e dry-run plan。
|
||||
- `hwlab-cli-template` 是 `suspend: true` 的 Kubernetes Job template,运行时通过 env 注入 endpoint、commit 和 image metadata。
|
||||
- Device Pod 专用 CLI 是 `tools/device-pod-cli.ts`,稳定 skill wrapper 为 `skills/device-pod-cli/scripts/device-pod-cli.mjs`;它属于 device-pod 受控 REST 客户端,不是 `hwlab-cli` 的 CICD/运维入口。
|
||||
|
||||
## API 接口说明
|
||||
|
||||
@@ -44,4 +45,5 @@
|
||||
| legacy cicd 删除 | 已实现 | `cicd` 子命令返回明确错误。 |
|
||||
| v02 Job template | 已实现 | `hwlab-cli-template` 存在且 `suspend: true`。 |
|
||||
| live e2e 执行 | 未完全实现 | 当前 live path 在 blocker 或未实现时拒绝执行。 |
|
||||
| Device Pod REST CLI | 已实现 | `device-pod-cli` 已迁到 Bun+TS 默认 REST authority;旧本地 profile/gateway RPC 路径不再作为正式默认入口。 |
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
| `POST /v1/device-pods/{devicePodId}/jobs` | 只接受 `hwlab-cloud-api` 内部调用;创建内部 executor job 并返回 job、freshness、output/cancel URL;有可用 profile route 和 `HWLAB_CLOUD_API_INTERNAL_URL` 时通过 cloud-api gateway dispatch 下发到 device-host-cli,否则返回 `gateway_dispatch_unavailable`。 |
|
||||
| `GET /v1/device-pods/{devicePodId}/jobs/{jobId}`、`GET /output`、`POST /cancel` | 只接受 `hwlab-cloud-api` 内部调用,用于查询内部 job、bounded output 和取消非终态 job;普通用户仍必须走 cloud-api 用户态 API。 |
|
||||
|
||||
用户态 `POST /jobs`、job output/cancel 和 admin profile/grant API 由 `hwlab-cloud-api` 实现,应以 [spec-device-pod.md](spec-device-pod.md) 为目标。
|
||||
用户态 `POST /jobs`、job output/cancel、admin profile/grant API 和正式 `device-pod-cli` REST 调用由 `hwlab-cloud-api` 实现,应以 [spec-device-pod.md](spec-device-pod.md) 为目标。`hwlab-device-pod` 不接受 CLI、浏览器或 Code Agent 直接上传 profile snapshot。
|
||||
|
||||
## 测试规格
|
||||
|
||||
@@ -48,4 +48,5 @@
|
||||
| 正式 profile authority | 已在 cloud-api 实现 | `hwlab-device-pod` 不读取或覆盖 `device_pods.profile_json`。 |
|
||||
| job lifecycle | 部分实现 | 用户态 job 由 cloud-api 鉴权、授权、lease 校验和持久化;executor 侧已提供内部 job create/get/output/cancel lifecycle,并能把 job dispatch 结果写回 bounded output。 |
|
||||
| gateway/device-host-cli adapter | 部分实现 | executor 已通过 cloud-api internal dispatch 接入 gateway poll/result 和 device-host-cli 命令映射;真实执行仍依赖 profile route、在线 gateway 和 host CLI。 |
|
||||
| device-pod-cli REST authority | 已实现 | `tools/device-pod-cli.ts` 默认只走 cloud-api REST,不读取 `.device-pod/*.json` 作为 profile authority;旧 `.mjs` 入口只负责启动 TypeScript CLI。 |
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const DEVICE_JOB_INTENTS = new Set([
|
||||
"debug.reset",
|
||||
"io.ports",
|
||||
"io.uart.read",
|
||||
"io.uart.read-after-launch-flash",
|
||||
"io.uart.write"
|
||||
]);
|
||||
const ACCESS_SCHEMA_STATEMENTS = Object.freeze([
|
||||
@@ -111,6 +112,7 @@ const MUTATING_INTENTS = new Set([
|
||||
"workspace.build",
|
||||
"debug.download",
|
||||
"debug.reset",
|
||||
"io.uart.read-after-launch-flash",
|
||||
"io.uart.write"
|
||||
]);
|
||||
const DEFAULT_DEVICE_LEASE_TTL_SECONDS = 15 * 60;
|
||||
|
||||
@@ -77,8 +77,10 @@ export const checkProfiles = Object.freeze({
|
||||
{ id: "check-058-tools-hwlab-gateway-shell", group: "tools", command: ["node","--check","tools/hwlab-gateway-shell.mjs"] },
|
||||
{ id: "check-059-tools-hwlab-gateway-tran", group: "tools", command: ["node","--check","tools/hwlab-gateway-tran.mjs"] },
|
||||
{ id: "check-060-tools-tran", group: "tools", command: ["node","--check","tools/tran.mjs"] },
|
||||
{ id: "check-061-tools-device-pod-cli", group: "tools", command: ["node","--check","tools/device-pod-cli.mjs"] },
|
||||
{ id: "check-062-tools-device-pod-cli", group: "tools", command: ["node","--check","skills/device-pod-cli/scripts/device-pod-cli.mjs"] },
|
||||
{ id: "check-061-tools-device-pod-cli", group: "tools", command: ["node","scripts/run-bun.mjs","build","tools/device-pod-cli.ts","tools/device-pod-cli.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
|
||||
{ id: "check-061a-tools-device-pod-cli-test", group: "tools", command: ["node","scripts/run-bun.mjs","test","tools/device-pod-cli.test.ts"] },
|
||||
{ id: "check-062-tools-device-pod-cli", group: "tools", command: ["node","--check","tools/device-pod-cli.mjs"] },
|
||||
{ id: "check-062a-tools-device-pod-cli-skill-wrapper", group: "tools", command: ["node","--check","skills/device-pod-cli/scripts/device-pod-cli.mjs"] },
|
||||
{ id: "check-063-tools-device-host-cli", group: "tools", command: ["node","--check","skills/device-pod-cli/assets/device-host-cli.mjs"] },
|
||||
{ id: "check-064-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","scripts/cloud-api-runtime-smoke.mjs","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
|
||||
{ id: "check-065-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","scripts/code-agent-chat-smoke.mjs","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
|
||||
|
||||
+121
-406
@@ -1,447 +1,162 @@
|
||||
---
|
||||
name: device-pod-cli
|
||||
description: Use the HWLAB internal device-pod CLI from a HWLAB code agent runner to create/register device-pod profiles and operate a profiled target workspace, debug probe, and I/O probe through hwlab-gateway and device-host-cli.
|
||||
version: 0.1.0-mvp
|
||||
description: Use the HWLAB v0.2 internal device-pod CLI from a HWLAB code agent runner to operate authorized Device Pods through cloud-api REST, leases, hwlab-device-pod, gateway, and device-host-cli.
|
||||
version: 0.2.0-rest
|
||||
---
|
||||
|
||||
# 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.
|
||||
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, PROD, or v0.2 images. It does not apply to external UniDesk developer workspaces or third-party machines.
|
||||
|
||||
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.
|
||||
Use this skill when the task mentions `device-pod-cli`, `device-pod`, `device-host-cli`, Keil build/download through a Device Pod, debug probe control, workspace operations, or I/O probe reads such as UART boot logs.
|
||||
|
||||
Canonical skill location: `/app/skills/device-pod-cli/SKILL.md`. Do not look
|
||||
for or create duplicate skill copies under `/root/.agents/skills`,
|
||||
`/home/ubuntu/.agents/skills`, or `/workspace/hwlab/skills`; those locations are
|
||||
not the HWLAB runner contract and cause stale instructions.
|
||||
|
||||
## DeepSeek Operation Friction Rules
|
||||
|
||||
- For build, download, UART, and workspace actions, use `device-pod-cli` first.
|
||||
Do not start by calling `/app/tools/hwlab-gateway-tran.mjs` directly, do not
|
||||
hunt for Windows-side Keil skills, and do not call `keil-cli.py` directly when
|
||||
a matching device-pod profile exists.
|
||||
- When a profile, selector, host route, or upload step is ambiguous, run
|
||||
`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id <devicePodId>`
|
||||
before trying lower-level commands. Treat the `doctor` JSON as the source of
|
||||
truth for profile path, profile hash, route, host profile path, selectors, and
|
||||
the exact upload/host-health commands.
|
||||
- The profile already carries `hostWorkspaceRoot`, `projectWorkspace`,
|
||||
`debugInterface.uv4Path`, probe UID, UART port, and gateway route. Let
|
||||
`device-pod-cli -> device-host-cli` apply those values; do not rediscover the
|
||||
Windows project path with `dir`, `Get-ChildItem`, or ad hoc `cmd` probes.
|
||||
- If a command returns `profile-missing`, run profile bootstrap. If it returns a
|
||||
device-host-cli JSON failure, fix the named device-pod operation or host CLI;
|
||||
do not bypass the profile with generic gateway shell.
|
||||
- If a command returns a JSON `next` array or object, follow those CLI-owned next
|
||||
commands in order. Do not discard the failure context, slash the route into an
|
||||
ad hoc command, or replace `device-pod-cli` with direct gateway shell unless
|
||||
the `next` field explicitly instructs that bootstrap/repair action.
|
||||
- Before rerouting, gateway repair, or host asset repair, run
|
||||
`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id <devicePodId> --probe`.
|
||||
If it reports `gateway-session-mismatch`, `gateway-session-unavailable`, or
|
||||
`host-cmd-unavailable`, report that blocker and its `next` field; do not keep
|
||||
retrying `/app/tools/hwlab-gateway-tran.mjs` or raw gateway shell commands.
|
||||
- Workspace search follows a compact ripgrep-like shape:
|
||||
`device-pod-71-freq:workspace:/ rg "printf|main" projects/01_baseline -g "*.c" -g "*.h" -l`.
|
||||
The optional path after the pattern is honored as the search root; use it to
|
||||
avoid scanning vendor PDFs or generated text outside the project under test.
|
||||
- For debug probe jobs, `download start` is mutating and requires
|
||||
`--approved --reason`, but `download status <jobId>` is read-only and must be
|
||||
queried without approval friction. If `download status` includes UART capture,
|
||||
read the top-level `hostUartCapture` field first before falling back to job
|
||||
files or direct `tran`.
|
||||
- I/O probe selectors are strict path tokens. Always write
|
||||
`device-pod-71-freq:io-probe:/uart/1 read` as one selector token; do not write
|
||||
`io-probe:/uart / 1 read`, `io-probe:/uart/ 1 read`, or other spaced path
|
||||
variants. The CLI should fail these forms with an `invalid-request` hint
|
||||
instead of normalizing the path or forwarding the malformed command to the
|
||||
Windows host.
|
||||
|
||||
Fast build smoke for `device-pod-71-freq`:
|
||||
|
||||
```sh
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile show --pod-id device-pod-71-freq
|
||||
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>
|
||||
```
|
||||
|
||||
The build status JSON must be treated as the evidence. Report
|
||||
`hostJson.status`, `hostJson.result.success`, `hostJson.result.exitCode`,
|
||||
`hostJson.result.project`, `hostJson.result.target`, and the output artifact
|
||||
paths from the profile; do not describe Windows Keil skill config as the source
|
||||
of truth for a device-pod build.
|
||||
|
||||
## DeepSeek Profile-Recreate Task
|
||||
|
||||
When a HWLAB runner asks DeepSeek or another code agent to delete and recreate a
|
||||
profile, this SKILL.md is the operating procedure. Do not stop after listing the
|
||||
workspace or describing files. Execute the profile bootstrap and validation
|
||||
commands, then report the resulting JSON evidence.
|
||||
|
||||
For `device-pod-71-00075-11`, the required order is:
|
||||
|
||||
1. Prove the profile is missing with
|
||||
`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-00075-11`.
|
||||
2. Create `/workspace/hwlab/.device-pod/device-pod-71-00075-11.json` with
|
||||
`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile create --pod-id device-pod-71-00075-11 --force`.
|
||||
3. Run `doctor --pod-id device-pod-71-00075-11 --probe` and follow its
|
||||
`next.uploadHostCli`, `next.uploadProfile`, and `next.hostHealth` commands;
|
||||
do not hard-code a gateway session id from old examples.
|
||||
4. Verify Windows host health through `node tools\device-host-cli.mjs --profile
|
||||
.device-pod\device-pod-71-00075-11.json --pod-id device-pod-71-00075-11
|
||||
health`.
|
||||
5. Run `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list`
|
||||
and report the profile `count` plus the hash of each profile.
|
||||
6. Verify runner health, selector dry-runs, workspace `ls`, build start/status,
|
||||
download start/status, and UART read/write/read through `device-pod-cli`.
|
||||
|
||||
Any failure must be reported as `FAIL` with the exact command and the JSON
|
||||
`blocker` or stderr/stdout summary. A successful bootstrap report must include
|
||||
runner profile path, profile SHA-256, `profile list` count, host health or the
|
||||
observed gateway blocker, runner health, and both selector dry-run results.
|
||||
Full hardware validation reports must additionally include build job id,
|
||||
download job id, download `Verify OK` evidence, and UART byte count/summary.
|
||||
|
||||
For the first bootstrap turn, run this block exactly. It intentionally performs
|
||||
only missing-state proof, runner profile creation, host asset/profile upload,
|
||||
host health, runner health, and selector dry-run checks; run build/download/UART
|
||||
in a second turn after this block succeeds.
|
||||
|
||||
Preferred one-line flow for DeepSeek runners: use the CLI-owned profile template
|
||||
instead of hand-copying JSON. This avoids heredoc/quoting drift in model-run
|
||||
shells and still creates the same explicit profile under the persistent profile
|
||||
directory.
|
||||
|
||||
```sh
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile create --pod-id device-pod-71-00075-11 --force
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id device-pod-71-00075-11 --probe
|
||||
# Follow the JSON next.uploadHostCli / next.uploadProfile / next.hostHealth commands from doctor.
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-00075-11
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-00075-11:workspace:/ ls --dry-run
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ ls --dry-run
|
||||
```
|
||||
|
||||
The lower-level JSON block below is a reference for the CLI template and for
|
||||
manual recovery only.
|
||||
|
||||
```sh
|
||||
set -eu
|
||||
skill_file=/app/skills/device-pod-cli/SKILL.md
|
||||
grep -n "DeepSeek Profile-Recreate Task" "$skill_file"
|
||||
|
||||
set +e
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-00075-11
|
||||
missing_exit=$?
|
||||
set -e
|
||||
if [ "$missing_exit" -eq 0 ]; then
|
||||
echo "FAIL: expected profile-missing before bootstrap" >&2
|
||||
exit 10
|
||||
fi
|
||||
|
||||
profile_dir="${DEVICE_POD_PROFILE_DIR:-/workspace/hwlab/.device-pod}"
|
||||
profile_file="$profile_dir/device-pod-71-00075-11.json"
|
||||
mkdir -p "$profile_dir"
|
||||
cat > "$profile_file" <<'JSON'
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"podId": "device-pod-71-00075-11",
|
||||
"devicePodId": "device-pod-71-00075-11",
|
||||
"workspaceRoot": "F:\\Work\\ConStart\\projects\\71-00075-11",
|
||||
"target": {
|
||||
"id": "constart-71-00075-11",
|
||||
"mcu": "STM32H723ZGTx",
|
||||
"flashBase": "0x08000000"
|
||||
},
|
||||
"projectWorkspace": {
|
||||
"projectPath": "FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx",
|
||||
"targetName": "FREQ_Controller_FW",
|
||||
"hexPath": "FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.hex",
|
||||
"mapPath": "FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.map"
|
||||
},
|
||||
"debugInterface": {
|
||||
"id": "debug-probe",
|
||||
"type": "cmsis-dap",
|
||||
"probeUid": "3FD750C63E342E24",
|
||||
"uv4Path": "C:\\Keil_v5\\UV4\\UV4.exe",
|
||||
"programBackend": "keil-headless",
|
||||
"autoBindUvoptx": true
|
||||
},
|
||||
"ioInterface": {
|
||||
"uart": [
|
||||
{
|
||||
"id": "uart/1",
|
||||
"scope": "external",
|
||||
"port": "COM4",
|
||||
"baudRate": 921600,
|
||||
"encoding": "utf8",
|
||||
"description": "ConStart debug USART1 via CMSIS-DAP CDC UART (verified COM4)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cloudApiUrl": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
||||
"gatewaySessionId": "gws_YUAN",
|
||||
"resourceId": "res_windows_host",
|
||||
"capabilityId": "cap_windows_cmd_exec",
|
||||
"hostWorkspaceRoot": "F:\\Work\\ConStart\\projects\\71-00075-11",
|
||||
"hostCli": "node tools\\device-host-cli.mjs"
|
||||
}
|
||||
JSON
|
||||
sha256sum "$profile_file"
|
||||
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id device-pod-71-00075-11 --probe
|
||||
# Follow the JSON next.uploadHostCli / next.uploadProfile / next.hostHealth commands from doctor.
|
||||
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-00075-11
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-00075-11:workspace:/ ls --dry-run
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ ls --dry-run
|
||||
```
|
||||
Canonical skill location: `/app/skills/device-pod-cli/SKILL.md`. Do not create duplicate skill copies under `/root/.agents/skills`, `/home/ubuntu/.agents/skills`, or `/workspace/hwlab/skills`; those locations cause stale instructions.
|
||||
|
||||
## 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_PROFILE_DIR/<devicePodId>.json` when that
|
||||
directory is set, otherwise from `.device-pod/<devicePodId>.json` in the
|
||||
current code agent workspace; `DEVICE_POD_PROFILE` may point to one explicit
|
||||
profile file. G14 device-agent runners should keep shared profiles in the
|
||||
persistent `/workspace/hwlab/.device-pod` directory rather than only in
|
||||
ephemeral `/app/.device-pod`.
|
||||
- Multiple profiles may coexist in the same runner workspace. Use the selector
|
||||
prefix, such as `device-pod-71-freq:...` or `device-pod-71-00075-11:...`, as
|
||||
the source of truth for the active pod. `device-pod-cli` automatically passes
|
||||
that selected profile to Windows `device-host-cli` as `--profile
|
||||
.device-pod\\<devicePodId>.json --pod-id <devicePodId>`, so host-side commands
|
||||
must not fall back to the default `device-pod-71-freq` profile when another
|
||||
pod is selected.
|
||||
- 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.
|
||||
- The stable runner entrypoint is `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs`.
|
||||
- The canonical implementation is `/app/tools/device-pod-cli.ts`; `/app/tools/device-pod-cli.mjs` and the skill script are compatibility wrappers that launch the Bun+TypeScript CLI.
|
||||
- All commands emit JSON. A command with no output is a failure.
|
||||
- Formal v0.2 mode is cloud-api authority: the CLI calls `/v1/device-pods...` REST APIs and must not read `.device-pod/*.json` as the source of truth for gateway session, workspace root, probe UID, UART port, or host CLI.
|
||||
- Server-side profile, grant, lease, job authority, and profile hash are owned by `hwlab-cloud-api`; `hwlab-device-pod` and `device-host-cli` execute only after cloud-api authorization.
|
||||
|
||||
## Profile Bootstrap
|
||||
## Configuration
|
||||
|
||||
A missing profile is a normal bootstrap state. Do not switch to another profile
|
||||
and do not guess defaults. Create the selected device-pod profile explicitly in
|
||||
both places below, then verify it before running build, download, or UART I/O.
|
||||
|
||||
- Runner profile: `/workspace/hwlab/.device-pod/<devicePodId>.json`. This is
|
||||
the persistent HWLAB code agent workspace profile used by `device-pod-cli`.
|
||||
- Windows host profile: `<hostWorkspaceRoot>\.device-pod\<devicePodId>.json`.
|
||||
This is the matching profile loaded by `device-host-cli` after the gateway
|
||||
command reaches the Windows hardware PC.
|
||||
- Windows host CLI: `<hostWorkspaceRoot>\tools\device-host-cli.mjs`. Upload the
|
||||
packaged `/app/skills/device-pod-cli/assets/device-host-cli.mjs` asset when a
|
||||
new profile is bootstrapped or after the host CLI has been hot-fixed.
|
||||
|
||||
Minimum profile fields are `schemaVersion`, `podId` or `devicePodId`,
|
||||
`target.id`, `cloudApiUrl`, `gatewaySessionId`, `resourceId`, `capabilityId`,
|
||||
`hostWorkspaceRoot`, and `hostCli`. Keep the project, debug probe, and I/O probe
|
||||
sections explicit so later agents do not depend on hidden CLI overrides.
|
||||
|
||||
For G14 DEV ConStart `device-pod-71-00075-11`, use this known hardware profile.
|
||||
This example is intentionally complete so a DeepSeek runner can recreate the
|
||||
profile after `/workspace/hwlab/.device-pod/device-pod-71-00075-11.json` and the
|
||||
matching Windows profile have been deleted.
|
||||
Pass the API base URL with one of these sources:
|
||||
|
||||
```sh
|
||||
profile_dir="${DEVICE_POD_PROFILE_DIR:-/workspace/hwlab/.device-pod}"
|
||||
profile_file="$profile_dir/device-pod-71-00075-11.json"
|
||||
mkdir -p "$profile_dir"
|
||||
cat > "$profile_file" <<'JSON'
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"podId": "device-pod-71-00075-11",
|
||||
"devicePodId": "device-pod-71-00075-11",
|
||||
"workspaceRoot": "F:\\Work\\ConStart\\projects\\71-00075-11",
|
||||
"target": {
|
||||
"id": "constart-71-00075-11",
|
||||
"mcu": "STM32H723ZGTx",
|
||||
"flashBase": "0x08000000"
|
||||
},
|
||||
"projectWorkspace": {
|
||||
"projectPath": "FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx",
|
||||
"targetName": "FREQ_Controller_FW",
|
||||
"hexPath": "FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.hex",
|
||||
"mapPath": "FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.map"
|
||||
},
|
||||
"debugInterface": {
|
||||
"id": "debug-probe",
|
||||
"type": "cmsis-dap",
|
||||
"probeUid": "3FD750C63E342E24",
|
||||
"uv4Path": "C:\\Keil_v5\\UV4\\UV4.exe",
|
||||
"programBackend": "keil-headless",
|
||||
"autoBindUvoptx": true
|
||||
},
|
||||
"ioInterface": {
|
||||
"uart": [
|
||||
{
|
||||
"id": "uart/1",
|
||||
"scope": "external",
|
||||
"port": "COM4",
|
||||
"baudRate": 921600,
|
||||
"encoding": "utf8",
|
||||
"description": "ConStart debug USART1 via CMSIS-DAP CDC UART (verified COM4)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cloudApiUrl": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
||||
"gatewaySessionId": "gws_YUAN",
|
||||
"resourceId": "res_windows_host",
|
||||
"capabilityId": "cap_windows_cmd_exec",
|
||||
"hostWorkspaceRoot": "F:\\Work\\ConStart\\projects\\71-00075-11",
|
||||
"hostCli": "node tools\\device-host-cli.mjs"
|
||||
}
|
||||
JSON
|
||||
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-00075-11
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id device-pod-71-00075-11 --probe
|
||||
# Follow the JSON next.uploadHostCli / next.uploadProfile / next.hostHealth commands from doctor.
|
||||
--api-base-url http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667
|
||||
HWLAB_DEVICE_POD_API_URL=http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667
|
||||
HWLAB_CLOUD_API_URL=http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667
|
||||
HWLAB_CLI_ENDPOINT=http://74.48.78.17:19667
|
||||
```
|
||||
|
||||
After bootstrap, validate selector isolation before touching hardware:
|
||||
Pass authentication with one of these sources:
|
||||
|
||||
```text
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-00075-11:workspace:/ ls --dry-run
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ ls --dry-run
|
||||
```sh
|
||||
--session-token <token>
|
||||
--cookie 'hwlab_session=<token>'
|
||||
--bearer-token <token>
|
||||
HWLAB_DEVICE_POD_SESSION_TOKEN=<token>
|
||||
HWLAB_CLOUD_API_SESSION_TOKEN=<token>
|
||||
HWLAB_SESSION_TOKEN=<token>
|
||||
HWLAB_SESSION_COOKIE='hwlab_session=<token>'
|
||||
```
|
||||
|
||||
The two dry-run outputs must reference different `--profile
|
||||
.device-pod\<devicePodId>.json` host arguments and different profile hashes.
|
||||
Use `login` only when a username/password credential is explicitly available:
|
||||
|
||||
## 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
|
||||
```sh
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs login --api-base-url <url> --username <user> --password <password>
|
||||
```
|
||||
|
||||
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.
|
||||
The `setCookie` value in the JSON output can be passed back as `--cookie` or `HWLAB_SESSION_COOKIE`.
|
||||
|
||||
```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 --profile .device-pod\\<devicePodId>.json --pod-id <devicePodId> health
|
||||
## Primary Commands
|
||||
|
||||
List visible Device Pods through cloud-api authority:
|
||||
|
||||
```sh
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list --api-base-url <url> --session-token <token>
|
||||
```
|
||||
|
||||
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:
|
||||
Show one visible Device Pod status and redacted server profile summary:
|
||||
|
||||
```text
|
||||
node tools\device-host-cli.mjs --profile .device-pod\<devicePodId>.json --pod-id <devicePodId> health
|
||||
```sh
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile show --pod-id device-pod-71-freq --api-base-url <url> --session-token <token>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-freq --api-base-url <url> --session-token <token>
|
||||
```
|
||||
|
||||
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.
|
||||
Diagnose auth, grant, lease, and gateway blockers without touching local profiles:
|
||||
|
||||
## 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
|
||||
```sh
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id device-pod-71-freq --api-base-url <url> --session-token <token>
|
||||
```
|
||||
|
||||
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`.
|
||||
Acquire and inspect a lease for mutating jobs:
|
||||
|
||||
## 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 download start --capture-uart uart/1 --capture-duration-ms 8000 --approved --reason "DEV boot log capture"
|
||||
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 --port COM4 --baud-rate 921600 --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"
|
||||
```sh
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs lease acquire --pod-id device-pod-71-freq --reason "build smoke" --api-base-url <url> --session-token <token>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs lease current --pod-id device-pod-71-freq --api-base-url <url> --session-token <token>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs lease release --pod-id device-pod-71-freq --lease-token <lease-token> --api-base-url <url> --session-token <token>
|
||||
```
|
||||
|
||||
Use `--port COMx` and `--baud-rate N` only as a short-lived UART discovery
|
||||
override. After the real port is proven, write the stable value back into the
|
||||
device-pod profile so later agents do not depend on a hidden command override.
|
||||
Run read-only workspace, debug, and I/O jobs:
|
||||
|
||||
For boot logs that only appear during reset/download, prefer
|
||||
`debug-probe download start --capture-uart uart/1 --capture-duration-ms N` so
|
||||
the host opens the UART before Keil starts flashing and stores the captured
|
||||
bytes in the download job status under `result.uartCapture`.
|
||||
```sh
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ ls --api-base-url <url> --session-token <token>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/src rg main --api-base-url <url> --session-token <token>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe chip-id --api-base-url <url> --session-token <token>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read --duration-ms 5000 --api-base-url <url> --session-token <token>
|
||||
```
|
||||
|
||||
Run mutating jobs only with reason and lease token when required by cloud-api:
|
||||
|
||||
```sh
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ apply-patch --reason "DEV edit" --lease-token <lease-token> --api-base-url <url> --session-token <token> < patch.diff
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ build start --reason "build smoke" --lease-token <lease-token> --api-base-url <url> --session-token <token>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download start --reason "DEV smoke" --lease-token <lease-token> --api-base-url <url> --session-token <token>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe reset --reason "reset smoke" --lease-token <lease-token> --api-base-url <url> --session-token <token>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 write "AT" --reason "UART write smoke" --lease-token <lease-token> --api-base-url <url> --session-token <token>
|
||||
```
|
||||
|
||||
Inspect or cancel jobs through cloud-api:
|
||||
|
||||
```sh
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs job status --pod-id device-pod-71-freq <jobId> --api-base-url <url> --session-token <token>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs job output --pod-id device-pod-71-freq <jobId> --api-base-url <url> --session-token <token>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs job cancel --pod-id device-pod-71-freq <jobId> --api-base-url <url> --session-token <token>
|
||||
```
|
||||
|
||||
Selector aliases also support job status/output/cancel after build/download:
|
||||
|
||||
```sh
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ build status <jobId> --api-base-url <url> --session-token <token>
|
||||
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download output <jobId> --api-base-url <url> --session-token <token>
|
||||
```
|
||||
|
||||
## Removed Legacy Path
|
||||
|
||||
`profile create` is removed from formal v0.2 mode. If a profile is missing or wrong, an admin must update cloud-api with the admin Device Pod APIs and then grant users. The CLI returns `legacy_profile_create_removed` instead of creating `.device-pod/<devicePodId>.json`.
|
||||
|
||||
Do not treat local files such as `.device-pod/device-pod-71-freq.json` or `DEVICE_POD_PROFILE_DIR` as authority. They are ignored by the formal CLI and may only exist as obsolete caches or hardware-host bootstrap artifacts.
|
||||
|
||||
## Operation Rules
|
||||
|
||||
- Use `device-pod-cli` first for workspace, build, download, reset, UART, and debug-probe tasks.
|
||||
- Do not start by calling `/app/tools/hwlab-gateway-tran.mjs`, Windows-side Keil skills, serial-monitor skills, `keil-cli.py`, or generic shell when the Device Pod REST path is available.
|
||||
- If the CLI returns an auth, grant, lease, executor, or gateway blocker, fix that named blocker in the cloud-api/device-pod path; do not bypass the profile with generic gateway shell.
|
||||
- The server-side profile already carries `hostWorkspaceRoot`, `projectWorkspace`, `debugInterface.uv4Path`, probe UID, UART port, and gateway route. Let `cloud-api -> hwlab-device-pod -> device-host-cli` apply those values.
|
||||
- Keep I/O selectors as strict path tokens, for example `device-pod-71-freq:io-probe:/uart/1 read`; do not split `/uart/1` across argv.
|
||||
- Use `--dry-run` to inspect the REST job request without dispatching it.
|
||||
|
||||
## Host Asset Bootstrap
|
||||
|
||||
The packaged Windows host CLI asset remains `/app/skills/device-pod-cli/assets/device-host-cli.mjs`. Upload it only when cloud-api/gateway dispatch reaches a Windows host and reports that `tools\device-host-cli.mjs` is missing or broken.
|
||||
|
||||
Do not upload or recreate runner-side `.device-pod/*.json` as the formal profile source. The profile must come from cloud-api. Host-side profile snapshots, when needed, are written by `hwlab-device-pod` from the cloud-api-provided server profile before invoking `device-host-cli`.
|
||||
|
||||
## 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.
|
||||
- Mutating operations require a human/business reason and may require an active lease. Cloud-api is the enforcement point; the CLI only forwards `reason` and `leaseToken`.
|
||||
- `hwlab-gateway` is transport only. Do not add a generic `cmd` operation to `device-pod-cli`; add a named Device Pod operation instead.
|
||||
- `SOURCE`, `LOCAL`, `DRY-RUN`, fixture data, and local profile files cannot be reported as `DEV-LIVE` hardware evidence.
|
||||
|
||||
## Manual Tests
|
||||
|
||||
## T1
|
||||
|
||||
阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:在未设置 API URL 时运行 `profile list`,确认输出 JSON 且错误码为 `api_base_url_required`。
|
||||
|
||||
## T2
|
||||
|
||||
阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:设置 `HWLAB_DEVICE_POD_API_URL` 和有效 session 后运行 `profile list`、`profile show --pod-id <devicePodId>`、`doctor --pod-id <devicePodId>`,确认输出来自 cloud-api authority,并且没有泄露 `gatewaySessionId`、`hostWorkspaceRoot`、probe UID 或 UART port。
|
||||
|
||||
## T3
|
||||
|
||||
阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:创建本地 `.device-pod/<devicePodId>.json`,写入假的 `gatewaySessionId`,再运行 `profile list` 和 selector `--dry-run`;确认 JSON 输出标记 `localProfileAuthority=false` 且没有读取本地 fake route。
|
||||
|
||||
## T4
|
||||
|
||||
阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:运行 `profile create --pod-id <devicePodId>`,确认返回 `legacy_profile_create_removed`,并提示使用 cloud-api admin API 管理服务端 profile/grant。
|
||||
|
||||
+35
-677
@@ -1,686 +1,44 @@
|
||||
#!/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';
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const VERSION = '0.1.0-mvp';
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const runBun = path.join(root, "scripts", "run-bun.mjs");
|
||||
const cli = path.join(root, "tools", "device-pod-cli.ts");
|
||||
|
||||
function defaultProfileDir() {
|
||||
if (process.env.DEVICE_POD_PROFILE_DIR) return process.env.DEVICE_POD_PROFILE_DIR;
|
||||
const candidates = ['/workspace/hwlab/.device-pod', path.resolve(process.cwd(), '.device-pod'), '/app/.device-pod'];
|
||||
return candidates.find((dir) => fs.existsSync(dir)) || path.resolve(process.cwd(), '.device-pod');
|
||||
}
|
||||
|
||||
const PROFILE_DIR = defaultProfileDir();
|
||||
|
||||
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);
|
||||
const blocker = classifyBlocker(message);
|
||||
print({ ok: false, action, status: 'failed', blocker, error: message, details, next: nextForBlocker(blocker, { ...details, message }) }, 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 (/resource is not registered under the requested gateway session|resourceGatewaySessionId/u.test(message)) return 'gateway-session-mismatch';
|
||||
if (/gateway session .*not found|gateway session .*not registered|gateway.*not.*registered/u.test(message)) return 'gateway-session-unavailable';
|
||||
if (/spawn C:\\Windows\\system32\\cmd\.exe ENOENT|cmd\.exe ENOENT/u.test(message)) return 'host-cmd-unavailable';
|
||||
if (/unsupported|invalid target selector|invalid io-probe (?:path|selector)/u.test(message)) return 'invalid-request';
|
||||
return 'operation-failed';
|
||||
}
|
||||
|
||||
function nextForBlocker(blocker, details = {}) {
|
||||
const podId = details.podId || podIdFromMessage(details.message) || process.env.DEVICE_POD_ID || '<devicePodId>';
|
||||
if (blocker === 'profile-missing') {
|
||||
return [
|
||||
`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId}`,
|
||||
`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile create --pod-id ${podId} --force`,
|
||||
'upload the generated profile and device-host-cli asset using the doctor next.upload commands'
|
||||
];
|
||||
}
|
||||
if (blocker === 'profile-invalid') {
|
||||
const names = availableProfileNames();
|
||||
if (names.length > 1) return profileChoiceNext(names);
|
||||
return [
|
||||
`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId}`,
|
||||
`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile show --pod-id ${podId}`
|
||||
];
|
||||
}
|
||||
if (blocker === 'approval-required') {
|
||||
return ['rerun the mutating operation with --approved --reason <text> after the user explicitly requested it'];
|
||||
}
|
||||
if (blocker === 'invalid-request') {
|
||||
return [
|
||||
'keep the device selector as one token, for example device-pod-71-00075-11:io-probe:/uart/1 read',
|
||||
`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId}`
|
||||
];
|
||||
}
|
||||
if (blocker === 'gateway-session-mismatch') {
|
||||
return [
|
||||
`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId} --probe --full`,
|
||||
'update the profile gatewaySessionId to the resourceGatewaySessionId reported by cloud-api, then rerun doctor --probe',
|
||||
'do not bypass device-pod-cli with generic gateway shell'
|
||||
];
|
||||
}
|
||||
if (blocker === 'gateway-session-unavailable') {
|
||||
return [
|
||||
`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId} --probe --full`,
|
||||
'restart or reconnect the target hwlab-gateway session, then rerun the same device-pod-cli command',
|
||||
'do not guess another gateway session without updating the profile'
|
||||
];
|
||||
}
|
||||
if (blocker === 'host-cmd-unavailable') {
|
||||
return [
|
||||
`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId} --probe --full`,
|
||||
'the gateway route was reached but cannot spawn Windows cmd.exe; repair the Windows gateway host or use a profile bound to a Windows cmd-capable gateway',
|
||||
'stop retrying lower-level gateway shell commands until doctor --probe is green'
|
||||
];
|
||||
}
|
||||
return [`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId}`];
|
||||
}
|
||||
|
||||
function profileChoiceNext(names) {
|
||||
const script = 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs';
|
||||
return [
|
||||
`${script} profile list`,
|
||||
...names.map((name) => `${script} doctor --pod-id ${name}`),
|
||||
...names.map((name) => `${script} health --pod-id ${name}`)
|
||||
];
|
||||
}
|
||||
|
||||
function podIdFromMessage(message) {
|
||||
const match = String(message || '').match(/([A-Za-z0-9_.-]+)\.json\b/u);
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
function parseTarget(raw) {
|
||||
if (!raw || raw === '--help' || raw === 'help') return { kind: 'help' };
|
||||
if (raw === 'doctor') return { kind: 'doctor' };
|
||||
if (raw === 'health') return { kind: 'health' };
|
||||
if (raw === 'profile') return { kind: 'profile' };
|
||||
const match = String(raw).trim().match(/^([^:]+):(workspace|debug-probe|io-probe)(?::(.*))?$/u);
|
||||
if (!match) throw new Error(`invalid target selector: ${raw}`);
|
||||
const surface = match[2];
|
||||
const selectorPath = surface === 'io-probe' ? normalizeProbePath(match[3] || '') : normalizeDevicePath(match[3] || '');
|
||||
return { kind: 'invoke', podId: match[1], surface, path: selectorPath };
|
||||
}
|
||||
|
||||
function normalizeDevicePath(value) {
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.replace(/\\/gu, '/')
|
||||
.replace(/\s*\/\s*/gu, '/')
|
||||
.replace(/\/+/gu, '/')
|
||||
.replace(/^\/+/u, '')
|
||||
.replace(/\/+$/u, '');
|
||||
return normalized || '.';
|
||||
}
|
||||
|
||||
function joinDevicePath(base, child) {
|
||||
const left = normalizeDevicePath(base);
|
||||
const right = normalizeDevicePath(child);
|
||||
if (!right || right === '.') return left;
|
||||
if (String(child || '').trim().startsWith('/')) return right;
|
||||
if (!left || left === '.') return right;
|
||||
return normalizeDevicePath(`${left}/${right}`);
|
||||
}
|
||||
|
||||
function normalizeProbePath(value) {
|
||||
const raw = String(value || '').trim().replace(/\\/gu, '/');
|
||||
if (!raw) return '';
|
||||
if (/\s/u.test(raw)) {
|
||||
throw new Error(`invalid io-probe path: "${raw}". Use io-probe:/uart/1 with no spaces; do not write io-probe:/uart / 1`);
|
||||
}
|
||||
return raw.replace(/^\/+/u, '').replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
const WORKSPACE_COMMANDS = new Set(['ls', 'cat', 'rg', 'apply-patch', 'build']);
|
||||
const IO_PROBE_COMMANDS = new Set(['ports', 'read', 'write', 'read-after-launch-flash']);
|
||||
|
||||
function normalizeWorkspaceSelector(selector, parsed) {
|
||||
let workspacePath = normalizeDevicePath(selector.path || '.');
|
||||
if (parsed._.length >= 2 && WORKSPACE_COMMANDS.has(parsed._[1])) {
|
||||
const maybePath = String(parsed._[0] || '').trim();
|
||||
if (maybePath === '/' || maybePath === '.' || maybePath.startsWith('/') || maybePath.includes('/')) {
|
||||
workspacePath = joinDevicePath(workspacePath, maybePath);
|
||||
parsed._ = parsed._.slice(1);
|
||||
}
|
||||
}
|
||||
return workspacePath;
|
||||
}
|
||||
|
||||
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 = availableProfileNames();
|
||||
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; available profiles: ${names.join(', ') || '(none)'}`);
|
||||
}
|
||||
|
||||
function availableProfileNames() {
|
||||
return fs.existsSync(PROFILE_DIR)
|
||||
? fs.readdirSync(PROFILE_DIR).filter((name) => name.endsWith('.json')).map((name) => path.basename(name, '.json')).sort()
|
||||
: [];
|
||||
}
|
||||
|
||||
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, '\\"')}"`;
|
||||
}
|
||||
|
||||
function hostProfilePath(profile) {
|
||||
return profile.hostProfilePath || `.device-pod\\${profile.podId}.json`;
|
||||
}
|
||||
|
||||
function writeJsonFile(file, value) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function conStart710007511Profile(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
podId: 'device-pod-71-00075-11',
|
||||
devicePodId: 'device-pod-71-00075-11',
|
||||
workspaceRoot: 'F:\\Work\\ConStart\\projects\\71-00075-11',
|
||||
target: {
|
||||
id: 'constart-71-00075-11',
|
||||
mcu: 'STM32H723ZGTx',
|
||||
flashBase: '0x08000000'
|
||||
},
|
||||
projectWorkspace: {
|
||||
projectPath: 'FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx',
|
||||
targetName: 'FREQ_Controller_FW',
|
||||
hexPath: 'FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.hex',
|
||||
mapPath: 'FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.map'
|
||||
},
|
||||
debugInterface: {
|
||||
id: 'debug-probe',
|
||||
type: 'cmsis-dap',
|
||||
probeUid: '3FD750C63E342E24',
|
||||
uv4Path: 'C:\\Keil_v5\\UV4\\UV4.exe',
|
||||
programBackend: 'keil-headless',
|
||||
autoBindUvoptx: true
|
||||
},
|
||||
ioInterface: {
|
||||
uart: [
|
||||
{
|
||||
id: 'uart/1',
|
||||
scope: 'external',
|
||||
port: 'COM4',
|
||||
baudRate: 921600,
|
||||
encoding: 'utf8',
|
||||
description: 'ConStart debug USART1 via CMSIS-DAP CDC UART (verified COM4)'
|
||||
}
|
||||
]
|
||||
},
|
||||
cloudApiUrl: 'http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667',
|
||||
gatewaySessionId: 'gws_YUAN',
|
||||
resourceId: 'res_windows_host',
|
||||
capabilityId: 'cap_windows_cmd_exec',
|
||||
hostWorkspaceRoot: 'F:\\Work\\ConStart\\projects\\71-00075-11',
|
||||
hostCli: 'node tools\\device-host-cli.mjs',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function profileTemplate(podId) {
|
||||
if (podId === 'device-pod-71-00075-11') return conStart710007511Profile();
|
||||
throw new Error(`unsupported profile template: ${podId}`);
|
||||
}
|
||||
|
||||
function createProfile(parsed) {
|
||||
const subcommand = parsed._[0] || '';
|
||||
if (subcommand !== 'create') throw new Error(`unsupported profile command: ${subcommand || '(empty)'}`);
|
||||
const podId = parsed.podId || parsed._[1];
|
||||
if (!podId) throw new Error('profile create requires --pod-id <devicePodId>');
|
||||
const profile = profileTemplate(podId);
|
||||
const file = parsed.output || path.join(PROFILE_DIR, `${profile.devicePodId}.json`);
|
||||
if (fs.existsSync(file) && parsed.force !== true) throw new Error(`profile already exists: ${file}; pass --force to overwrite`);
|
||||
writeJsonFile(file, profile);
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
print({ ok: true, action: 'profile.create', status: 'succeeded', profilePath: file, profileHash: sha256(raw), devicePodId: profile.devicePodId, targetId: profile.target.id, route: routeSummary(profile), next: [
|
||||
`node /app/tools/tran.mjs ${profile.gatewaySessionId}:/f/Work/ConStart/projects/71-00075-11 upload /app/skills/device-pod-cli/assets/device-host-cli.mjs tools/device-host-cli.mjs`,
|
||||
`node /app/tools/tran.mjs ${profile.gatewaySessionId}:/f/Work/ConStart/projects/71-00075-11 upload ${file} .device-pod/${profile.devicePodId}.json`,
|
||||
`node /app/tools/tran.mjs ${profile.gatewaySessionId}:/f/Work/ConStart/projects/71-00075-11 cmd -- node tools\\device-host-cli.mjs --profile .device-pod\\${profile.devicePodId}.json --pod-id ${profile.devicePodId} health`
|
||||
] });
|
||||
}
|
||||
|
||||
function listProfiles() {
|
||||
const entries = fs.existsSync(PROFILE_DIR)
|
||||
? fs.readdirSync(PROFILE_DIR).filter((name) => name.endsWith('.json')).sort()
|
||||
: [];
|
||||
const items = entries.map((name) => {
|
||||
const file = path.join(PROFILE_DIR, name);
|
||||
try {
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
const profile = JSON.parse(raw);
|
||||
const devicePodId = profile.devicePodId || profile.podId || path.basename(name, '.json');
|
||||
const targetId = profile.targetId || profile.target?.id || profile.deviceTarget?.targetId || profile.deviceTarget?.id || null;
|
||||
return { ok: true, devicePodId, targetId, profilePath: file, profileHash: sha256(raw), gatewaySessionId: profile.gatewaySessionId || null, hostWorkspaceRoot: profile.hostWorkspaceRoot || profile.workspaceRoot || null };
|
||||
} catch (error) {
|
||||
return { ok: false, devicePodId: path.basename(name, '.json'), profilePath: file, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
});
|
||||
print({ ok: true, action: 'profile.list', status: 'succeeded', profileDir: PROFILE_DIR, count: items.length, items });
|
||||
}
|
||||
|
||||
function showProfile(parsed) {
|
||||
const profile = readProfile(parsed.podId || parsed._[1]);
|
||||
print({ ok: true, action: 'profile.show', status: 'succeeded', ...profileSummary(profile), route: routeSummary(profile), projectWorkspace: profile.projectWorkspace || null, debugInterface: profile.debugInterface || null, ioInterface: profile.ioInterface || null, hostProfilePath: hostProfilePath(profile) });
|
||||
}
|
||||
|
||||
function profileMissingDoctor(podId, file) {
|
||||
const templateAvailable = Boolean(podId);
|
||||
return {
|
||||
if (!existsSync(runBun) || !existsSync(cli)) {
|
||||
console.log(JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
cli: "device-pod-cli",
|
||||
ok: false,
|
||||
action: 'doctor',
|
||||
status: 'failed',
|
||||
blocker: 'profile-missing',
|
||||
profileDir: PROFILE_DIR,
|
||||
devicePodId: podId || null,
|
||||
expectedProfilePath: file || (podId ? path.join(PROFILE_DIR, `${podId}.json`) : null),
|
||||
next: {
|
||||
create: templateAvailable ? `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile create --pod-id ${podId} --force` : 'pass --pod-id <devicePodId> or set DEVICE_POD_ID',
|
||||
list: 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list'
|
||||
},
|
||||
valuesRedacted: true,
|
||||
secretMaterialStored: false
|
||||
};
|
||||
}
|
||||
|
||||
async function doctor(parsed) {
|
||||
const podId = parsed.podId || parsed._[0] || process.env.DEVICE_POD_ID || null;
|
||||
try {
|
||||
const profile = readProfile(podId);
|
||||
const script = 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs';
|
||||
const selector = `${profile.podId}:workspace:/`;
|
||||
const hostProfile = hostProfilePath(profile);
|
||||
const hostHealthCommand = buildHostHealthCommand(profile);
|
||||
const probe = parsed.probe === true
|
||||
? await probeHostHealth(profile, hostHealthCommand, parsed)
|
||||
: undefined;
|
||||
const uploadRoot = `${profile.gatewaySessionId}:${String(profile.hostWorkspaceRoot || '').replace(/^[A-Za-z]:/u, (drive) => `/${drive[0].toLowerCase()}`).replace(/\\/gu, '/')}`;
|
||||
print({
|
||||
ok: true,
|
||||
action: 'doctor',
|
||||
status: probe?.ok === false ? 'degraded' : 'succeeded',
|
||||
...profileSummary(profile),
|
||||
profileDir: PROFILE_DIR,
|
||||
route: routeSummary(profile),
|
||||
hostProfilePath: hostProfile,
|
||||
selectors: {
|
||||
workspace: `${profile.podId}:workspace:/`,
|
||||
debugProbe: `${profile.podId}:debug-probe`,
|
||||
ioProbe: `${profile.podId}:io-probe:/uart/1`
|
||||
},
|
||||
next: {
|
||||
show: `${script} profile show --pod-id ${profile.podId}`,
|
||||
health: `${script} health --pod-id ${profile.podId}`,
|
||||
plan: `${script} ${selector} ls --dry-run`,
|
||||
probe: `${script} doctor --pod-id ${profile.podId} --probe`,
|
||||
uploadHostCli: `node /app/tools/tran.mjs ${uploadRoot} upload /app/skills/device-pod-cli/assets/device-host-cli.mjs tools/device-host-cli.mjs`,
|
||||
uploadProfile: `node /app/tools/tran.mjs ${uploadRoot} upload ${profile.__profilePath} .device-pod/${profile.podId}.json`,
|
||||
hostHealth: `node /app/tools/tran.mjs ${uploadRoot} cmd -- node tools\\device-host-cli.mjs --profile ${hostProfile} --pod-id ${profile.podId} health`
|
||||
},
|
||||
probe,
|
||||
valuesRedacted: true,
|
||||
secretMaterialStored: false
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const missingMatch = message.match(/profile not found: (.+)$/u);
|
||||
if (classifyBlocker(message) === 'profile-missing') {
|
||||
print(profileMissingDoctor(podId, missingMatch?.[1] || null), 1);
|
||||
return;
|
||||
status: "failed",
|
||||
error: {
|
||||
code: "device_pod_cli_entrypoint_missing",
|
||||
message: "device-pod-cli TypeScript entrypoint or Bun runner is missing",
|
||||
runBun,
|
||||
cli
|
||||
}
|
||||
fail('doctor', error, { podId });
|
||||
}
|
||||
}
|
||||
|
||||
function buildHostHealthCommand(profile) {
|
||||
const hostArgs = ['--profile', hostProfilePath(profile), '--pod-id', profile.podId, 'health'];
|
||||
return `${profile.hostCli} ${hostArgs.map(cmdQuote).join(' ')}`;
|
||||
}
|
||||
|
||||
async function probeHostHealth(profile, command, parsed) {
|
||||
const result = await invoke(profile, command, {
|
||||
...parsed,
|
||||
timeoutMs: Number(parsed.timeoutMs || 10000),
|
||||
traceId: parsed.traceId || `trc_device_pod_doctor_${Date.now()}`
|
||||
}, null, 2));
|
||||
process.exitCode = 127;
|
||||
} else {
|
||||
const cliArgs = process.argv.slice(2).map((arg) => (arg === "--help" || arg === "-h" ? "help" : arg));
|
||||
const result = spawnSync(process.execPath, [runBun, cli, ...cliArgs], {
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
stdio: "inherit"
|
||||
});
|
||||
const diagnosis = diagnoseInvokeFailure(profile, result, { operation: 'doctor.host-health' });
|
||||
return {
|
||||
ok: result.status === 'succeeded',
|
||||
status: result.status,
|
||||
command,
|
||||
traceId: result.traceId,
|
||||
operationId: result.operationId,
|
||||
requestId: result.requestId,
|
||||
httpStatus: result.httpStatus,
|
||||
blocker: diagnosis.blocker,
|
||||
next: diagnosis.next,
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
function handleProfileCommand(parsed) {
|
||||
const subcommand = parsed._[0] || 'list';
|
||||
if (subcommand === 'list') return listProfiles();
|
||||
if (subcommand === 'show') return showProfile(parsed);
|
||||
if (subcommand === 'create') return createProfile(parsed);
|
||||
throw new Error(`unsupported profile command: ${subcommand}`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function ioProbeHint(selector, probePath, parsed) {
|
||||
const parts = parsed._ || [];
|
||||
if ((parts[0] === '/' || parts[0] === '\\') && parts[1]) {
|
||||
return `${selector.podId}:io-probe:/${normalizeProbePath(`${probePath}/${parts[1]}`)} ${parts[2] || 'read'}`;
|
||||
}
|
||||
if (parts[0] && !IO_PROBE_COMMANDS.has(parts[0]) && parts[1] && IO_PROBE_COMMANDS.has(parts[1])) {
|
||||
return `${selector.podId}:io-probe:/${normalizeProbePath(`${probePath}/${parts[0]}`)} ${parts[1]}`;
|
||||
}
|
||||
return `${selector.podId}:io-probe:/${probePath || 'uart/1'} ${IO_PROBE_COMMANDS.has(parts[0]) ? parts[0] : 'read'}`;
|
||||
}
|
||||
|
||||
function validateIoProbeCommand(selector, probePath, operation, parsed) {
|
||||
if ((parsed._[0] === '/' || parsed._[0] === '\\') || (parsed._[0] && !IO_PROBE_COMMANDS.has(parsed._[0]) && parsed._[1] && IO_PROBE_COMMANDS.has(parsed._[1]))) {
|
||||
throw new Error(`invalid io-probe selector path: path appears split across argv near /${probePath}. Use ${ioProbeHint(selector, probePath, parsed)}; do not put spaces around '/' in selector paths`);
|
||||
}
|
||||
if (probePath === 'uart') {
|
||||
throw new Error(`invalid io-probe path: /uart is not a concrete endpoint. Use ${selector.podId}:io-probe:/uart/1 ${operation || 'read'}`);
|
||||
}
|
||||
if (!IO_PROBE_COMMANDS.has(operation)) {
|
||||
throw new Error(`unsupported io-probe operation: ${operation || '(empty)'}. Use ${ioProbeHint(selector, probePath, parsed)}; paths must be a single selector token with no spaces`);
|
||||
}
|
||||
}
|
||||
|
||||
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, parsed);
|
||||
if (mutating) requireApproval(parsed, `${selector.surface}.${operation}`);
|
||||
|
||||
if (selector.surface === 'workspace') {
|
||||
const workspacePath = normalizeWorkspaceSelector(selector, parsed);
|
||||
if (operation === 'ls' || operation === 'cat') args.push('workspace', operation, joinDevicePath(workspacePath, parsed._[1] || ''), ...parsed._.slice(2), ...options);
|
||||
else if (operation === 'rg') {
|
||||
const pattern = parsed._[1] || '';
|
||||
const rootArg = parsed._[2] && !String(parsed._[2]).startsWith('-') ? parsed._[2] : '';
|
||||
const restStart = rootArg ? 3 : 2;
|
||||
args.push('workspace', 'rg', pattern, joinDevicePath(workspacePath, rootArg), ...parsed._.slice(restStart), ...options);
|
||||
} else if (operation === 'apply-patch') {
|
||||
const baseArg = parsed._[1] && !String(parsed._[1]).startsWith('-') ? parsed._[1] : '';
|
||||
args.push('workspace', 'apply-patch', joinDevicePath(workspacePath, baseArg), '--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 = normalizeProbePath(selector.path) || 'uart/1';
|
||||
validateIoProbeCommand(selector, probePath, operation, parsed);
|
||||
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, parsed = { _: [] }) {
|
||||
return (surface === 'workspace' && operation === 'apply-patch')
|
||||
|| (surface === 'debug-probe' && (operation === 'reset' || operation === 'launch-flash' || (operation === 'download' && !['status', 'wait'].includes(String(parsed._[1] || 'start')))))
|
||||
|| (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 hostFailed = hostJson && hostJson.ok === false;
|
||||
const status = response.body?.error || dispatch.exitCode !== 0 || dispatch.status === 'failed' || hostFailed ? 'failed' : 'succeeded';
|
||||
return { requestId, operationId, traceId, httpStatus: response.status, status, dispatch, hostJson, gatewayBody: response.body };
|
||||
}
|
||||
|
||||
function diagnoseInvokeFailure(profile, result, context = {}) {
|
||||
if (result.status === 'succeeded') {
|
||||
return { ok: true, blocker: null, next: [] };
|
||||
}
|
||||
const text = JSON.stringify({
|
||||
error: result.gatewayBody?.error,
|
||||
dispatch: result.dispatch,
|
||||
hostJson: result.hostJson
|
||||
});
|
||||
const blocker = classifyBlocker(text);
|
||||
const next = nextForBlocker(blocker, { podId: profile.podId, message: text });
|
||||
return {
|
||||
ok: false,
|
||||
blocker,
|
||||
next,
|
||||
operation: context.operation || null,
|
||||
summary: summarizeFailureText(text),
|
||||
route: routeSummary(profile),
|
||||
valuesRedacted: true,
|
||||
secretMaterialStored: false
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeFailureText(text) {
|
||||
return String(text || '')
|
||||
.replace(/\s+/gu, ' ')
|
||||
.slice(0, 500);
|
||||
}
|
||||
|
||||
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 hostJsonSummary(hostJson) {
|
||||
if (!hostJson || typeof hostJson !== 'object') return {};
|
||||
const data = hostJson.data && typeof hostJson.data === 'object' ? hostJson.data : {};
|
||||
const result = hostJson.result && typeof hostJson.result === 'object'
|
||||
? hostJson.result
|
||||
: data.result && typeof data.result === 'object'
|
||||
? data.result
|
||||
: {};
|
||||
const jobId = data.jobId || hostJson.jobId || hostJson.job_id || result.jobId || result.job_id || null;
|
||||
const status = data.status || hostJson.status || result.status || null;
|
||||
const uartCapture = result.uartCapture || data.uartCapture || hostJson.uartCapture || undefined;
|
||||
return {
|
||||
hostAction: hostJson.action || null,
|
||||
hostJobId: jobId,
|
||||
hostStatus: status,
|
||||
hostSuccess: typeof hostJson.ok === 'boolean' ? hostJson.ok : result.success,
|
||||
hostError: hostJson.error || data.error || result.error || undefined,
|
||||
hostUartCapture: uartCapture,
|
||||
hostResult: result && Object.keys(result).length > 0 ? result : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function help() {
|
||||
print({ ok: true, action: 'help', scope: 'HWLAB internal code agents only; not for external UniDesk workspaces.', usage: [
|
||||
'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id device-pod-71-freq',
|
||||
'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id device-pod-71-freq --probe',
|
||||
'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list',
|
||||
'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile show --pod-id device-pod-71-freq',
|
||||
'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile create --pod-id device-pod-71-00075-11 --force',
|
||||
'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-freq',
|
||||
'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:/ 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 download start --approved --reason "DEV smoke"',
|
||||
'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read --duration-ms 5000'
|
||||
] });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [rawTarget, ...argv] = process.argv.slice(2);
|
||||
const selector = parseTarget(rawTarget);
|
||||
if (selector.kind === 'help') return help();
|
||||
const parsed = parseOptions(argv);
|
||||
if (selector.kind === 'doctor') return doctor(parsed);
|
||||
if (selector.kind === 'profile') return handleProfileCommand(parsed);
|
||||
if (selector.kind === 'health' && !parsed.podId && !process.env.DEVICE_POD_ID) {
|
||||
const names = availableProfileNames();
|
||||
if (names.length !== 1) return print({
|
||||
if (result.error) {
|
||||
console.log(JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
cli: "device-pod-cli",
|
||||
ok: false,
|
||||
action: 'health',
|
||||
status: 'failed',
|
||||
blocker: 'profile-invalid',
|
||||
profileDir: PROFILE_DIR,
|
||||
availableProfiles: names,
|
||||
next: profileChoiceNext(names),
|
||||
valuesRedacted: true,
|
||||
secretMaterialStored: false
|
||||
}, 1);
|
||||
status: "failed",
|
||||
error: { code: "device_pod_cli_spawn_failed", message: result.error.message }
|
||||
}, null, 2));
|
||||
process.exitCode = 127;
|
||||
} else {
|
||||
process.exitCode = result.status ?? 0;
|
||||
}
|
||||
const profile = readProfile(selector.podId || parsed.podId);
|
||||
if (selector.kind === 'health') return print({ ok: true, action: 'health', status: 'succeeded', ...profileSummary(profile), route: routeSummary(profile), next: [`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${profile.podId}`] });
|
||||
const built = await buildHostCommand(selector, parsed);
|
||||
const hostArgs = ['--profile', hostProfilePath(profile), '--pod-id', profile.podId, ...built.commandArgs];
|
||||
const command = `${profile.hostCli} ${hostArgs.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';
|
||||
const diagnosis = ok ? null : diagnoseInvokeFailure(profile, result, { operation: built.operation });
|
||||
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, blocker: diagnosis?.blocker, next: diagnosis?.next, diagnosis, ...hostJsonSummary(result.hostJson), 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));
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { runDevicePodCli } from "./src/device-pod-cli-lib.ts";
|
||||
|
||||
test("device-pod-cli lists cloud-api authority and ignores local profile routes", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-device-pod-cli-"));
|
||||
const profileDir = path.join(root, ".device-pod");
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
await writeFile(path.join(profileDir, "device-pod-71-freq.json"), JSON.stringify({ gatewaySessionId: "gws_local_should_not_be_read", hostWorkspaceRoot: "F:\\legacy" }), "utf8");
|
||||
const seen: any[] = [];
|
||||
const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "profile", "list"], {
|
||||
env: { DEVICE_POD_PROFILE_DIR: profileDir },
|
||||
fetchImpl: async (url, init) => {
|
||||
seen.push({ url: String(url), init });
|
||||
return jsonResponse(200, { ok: true, contractVersion: "device-pod-authority-v1", devicePods: [{ devicePodId: "device-pod-71-freq", profileHash: "sha256:abc", profile: { route: { gatewaySessionId: "redacted" } } }] });
|
||||
},
|
||||
now: () => "2026-05-29T00:00:00.000Z"
|
||||
});
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.payload.localProfileAuthority, false);
|
||||
assert.equal(result.payload.localProfileRead, false);
|
||||
assert.equal(seen[0].url, "http://cloud.test/v1/device-pods");
|
||||
assert.equal(seen[0].init.headers["x-hwlab-session-token"], "session-a");
|
||||
assert.equal(JSON.stringify(result.payload).includes("gws_local_should_not_be_read"), false);
|
||||
});
|
||||
|
||||
test("device-pod-cli selector creates REST job instead of gateway RPC", async () => {
|
||||
const seen: any[] = [];
|
||||
const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "device-pod-71-freq:workspace:/src", "ls"], {
|
||||
fetchImpl: async (url, init) => {
|
||||
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
|
||||
return jsonResponse(202, { ok: true, status: "running", job: { id: "job_1", status: "running" }, profileHash: "sha256:abc" });
|
||||
},
|
||||
now: () => "2026-05-29T00:00:00.000Z"
|
||||
});
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(seen[0].url, "http://cloud.test/v1/device-pods/device-pod-71-freq/jobs");
|
||||
assert.deepEqual(seen[0].body, { intent: "workspace.ls", args: { path: "src" } });
|
||||
assert.equal(JSON.stringify(seen[0].body).includes("gatewaySessionId"), false);
|
||||
});
|
||||
|
||||
test("device-pod-cli sends reason and lease token for mutating jobs", async () => {
|
||||
const seen: any[] = [];
|
||||
const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "--lease-token", "lease-a", "--reason", "reset smoke", "device-pod-71-freq:debug-probe", "reset"], {
|
||||
fetchImpl: async (url, init) => {
|
||||
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
|
||||
return jsonResponse(202, { ok: true, status: "running", job: { id: "job_reset", status: "running" } });
|
||||
},
|
||||
now: () => "2026-05-29T00:00:00.000Z"
|
||||
});
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.deepEqual(seen[0].body, { intent: "debug.reset", args: {}, reason: "reset smoke", leaseToken: "lease-a" });
|
||||
});
|
||||
|
||||
test("device-pod-cli preserves common v0.1 hardware options in REST job args", async () => {
|
||||
const seen: any[] = [];
|
||||
const result = await runDevicePodCli([
|
||||
"--api-base-url", "http://cloud.test",
|
||||
"--session-token", "session-a",
|
||||
"--reason", "boot log",
|
||||
"--lease-token", "lease-a",
|
||||
"--capture-uart", "uart/1",
|
||||
"--capture-duration-ms", "8000",
|
||||
"--port", "COM4",
|
||||
"--baud-rate", "921600",
|
||||
"device-pod-71-freq:debug-probe",
|
||||
"download",
|
||||
"start"
|
||||
], {
|
||||
fetchImpl: async (url, init) => {
|
||||
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
|
||||
return jsonResponse(202, { ok: true, status: "running", job: { id: "job_download", status: "running" } });
|
||||
},
|
||||
now: () => "2026-05-29T00:00:00.000Z"
|
||||
});
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.deepEqual(seen[0].body, {
|
||||
intent: "debug.download",
|
||||
args: { action: "start", captureUart: "uart/1", captureDurationMs: 8000, port: "COM4", baudRate: 921600 },
|
||||
reason: "boot log",
|
||||
leaseToken: "lease-a"
|
||||
});
|
||||
});
|
||||
|
||||
test("device-pod-cli rejects legacy local profile create in formal mode", async () => {
|
||||
const result = await runDevicePodCli(["profile", "create", "--pod-id", "device-pod-71-freq"], { now: () => "2026-05-29T00:00:00.000Z" });
|
||||
assert.equal(result.exitCode, 1);
|
||||
assert.equal(result.payload.error.code, "legacy_profile_create_removed");
|
||||
});
|
||||
|
||||
function jsonResponse(status: number, body: unknown) {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bun
|
||||
import { main } from "./src/device-pod-cli-lib.ts";
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,266 @@
|
||||
const VERSION = "0.2.0-rest";
|
||||
const CLI_NAME = "device-pod-cli";
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
type EnvLike = Record<string, string | undefined>;
|
||||
type ParsedArgs = Record<string, unknown> & { _: string[] };
|
||||
|
||||
export async function main(argv = process.argv.slice(2), options: { env?: EnvLike; fetchImpl?: FetchLike; stdinText?: string; now?: () => string } = {}) {
|
||||
const result = await runDevicePodCli(argv, options);
|
||||
console.log(JSON.stringify(result.payload, null, 2));
|
||||
process.exitCode = result.exitCode;
|
||||
}
|
||||
|
||||
export async function runDevicePodCli(argv: string[], options: { env?: EnvLike; fetchImpl?: FetchLike; stdinText?: string; now?: () => string } = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const now = options.now ?? (() => new Date().toISOString());
|
||||
try {
|
||||
const parsed = parseOptions(argv);
|
||||
const target = parsed._[0] || "help";
|
||||
const rest = parsed._.slice(1);
|
||||
const context = { parsed, rest, env, fetchImpl, stdinText: options.stdinText, now };
|
||||
const payload = await dispatch(target, context);
|
||||
return { exitCode: payload.ok === false ? 1 : 0, payload: withMeta(payload, now) };
|
||||
} catch (error) {
|
||||
return { exitCode: 1, payload: withMeta(failure("device-pod-cli", error), now) };
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatch(target: string, context: any) {
|
||||
if (["help", "--help", "-h"].includes(target)) return help();
|
||||
if (target === "login") return login(context);
|
||||
if (target === "doctor") return doctor(context);
|
||||
if (target === "health") return health(context);
|
||||
if (target === "profile") return profileCommand(context);
|
||||
if (target === "lease" || target === "leases") return leaseCommand(context);
|
||||
if (target === "job" || target === "jobs") return jobCommand(context);
|
||||
const selector = parseSelector(target);
|
||||
if (!selector) throw cliError("invalid_target_selector", `invalid target selector: ${target}`);
|
||||
return invokeSelector(selector, context);
|
||||
}
|
||||
|
||||
function help() {
|
||||
return ok("help", {
|
||||
version: VERSION,
|
||||
contractVersion: "device-pod-rest-cli-v1",
|
||||
profileAuthority: "hwlab-cloud-api",
|
||||
localProfileAuthority: false,
|
||||
configuration: {
|
||||
apiBaseUrl: "--api-base-url or HWLAB_DEVICE_POD_API_URL/HWLAB_CLOUD_API_URL/HWLAB_CLI_ENDPOINT",
|
||||
auth: "--session-token, --cookie, --bearer-token, or matching HWLAB_* env"
|
||||
},
|
||||
usage: [
|
||||
"device-pod-cli login --api-base-url URL --username USER --password PASS",
|
||||
"device-pod-cli profile list --api-base-url URL --session-token TOKEN",
|
||||
"device-pod-cli profile show --pod-id device-pod-71-freq --api-base-url URL --session-token TOKEN",
|
||||
"device-pod-cli device-pod-71-freq:workspace:/ ls --api-base-url URL --session-token TOKEN",
|
||||
"device-pod-cli device-pod-71-freq:workspace:/ build start --reason TEXT --lease-token TOKEN",
|
||||
"device-pod-cli job output --pod-id device-pod-71-freq <jobId> --api-base-url URL --session-token TOKEN",
|
||||
"device-pod-cli lease acquire --pod-id device-pod-71-freq --reason TEXT --api-base-url URL --session-token TOKEN"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
async function login({ parsed, env, fetchImpl }: any) {
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: "/auth/login", body: { username: requiredOption(parsed, env, "username", "HWLAB_USERNAME"), password: requiredOption(parsed, env, "password", "HWLAB_PASSWORD") }, auth: false });
|
||||
return responsePayload("login", response, { setCookie: response.headers.get("set-cookie") ?? null });
|
||||
}
|
||||
|
||||
async function doctor({ parsed, env, fetchImpl }: any) {
|
||||
const podId = text(parsed.podId ?? parsed._[1]);
|
||||
const probes = [];
|
||||
for (const path of ["/v1/access/status", "/auth/session", "/v1/device-pods", podId ? `/v1/device-pods/${encodeURIComponent(podId)}/status` : ""].filter(Boolean)) {
|
||||
try {
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path });
|
||||
probes.push({ path, httpStatus: response.status, ok: response.status < 400, body: compactBody(response.body) });
|
||||
} catch (error) {
|
||||
probes.push({ path, ok: false, blocker: errorSummary(error) });
|
||||
}
|
||||
}
|
||||
const hardFailed = probes.some((probe) => probe.ok === false && Number(probe.httpStatus ?? 0) >= 500);
|
||||
return ok("doctor", { status: hardFailed ? "degraded" : "succeeded", profileAuthority: "hwlab-cloud-api", localProfileAuthority: false, localProfileRead: false, devicePodId: podId || null, probes }, hardFailed ? "degraded" : "succeeded");
|
||||
}
|
||||
|
||||
async function health({ parsed, rest, env, fetchImpl }: any) {
|
||||
const podId = text(parsed.podId ?? rest[0]);
|
||||
const path = podId ? `/v1/device-pods/${encodeURIComponent(podId)}/status` : "/health/live";
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path, auth: Boolean(podId) });
|
||||
return responsePayload("health", response, { route: { method: "GET", path }, devicePodId: podId || null, localProfileAuthority: false });
|
||||
}
|
||||
|
||||
async function profileCommand({ parsed, rest, env, fetchImpl }: any) {
|
||||
const subcommand = rest[0] || "list";
|
||||
if (subcommand === "create") {
|
||||
throw cliError("legacy_profile_create_removed", "local device-pod profile creation is removed from v0.2 formal mode; use cloud-api admin device-pod APIs", { next: ["POST /v1/admin/device-pods", "POST /v1/admin/device-pod-grants"] });
|
||||
}
|
||||
if (subcommand === "list") {
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path: "/v1/device-pods" });
|
||||
return responsePayload("profile.list", response, { localProfileAuthority: false, localProfileRead: false });
|
||||
}
|
||||
if (subcommand === "show") {
|
||||
const podId = requiredText(parsed.podId ?? rest[1], "podId");
|
||||
const path = `/v1/device-pods/${encodeURIComponent(podId)}/status`;
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path });
|
||||
return responsePayload("profile.show", response, { route: { method: "GET", path }, devicePodId: podId, localProfileAuthority: false, localProfileRead: false });
|
||||
}
|
||||
throw cliError("unsupported_profile_command", `unsupported profile command: ${subcommand}`);
|
||||
}
|
||||
|
||||
async function leaseCommand({ parsed, rest, env, fetchImpl }: any) {
|
||||
const subcommand = rest[0] || "current";
|
||||
const podId = requiredText(parsed.podId ?? rest[1], "podId");
|
||||
const basePath = `/v1/device-pods/${encodeURIComponent(podId)}/leases`;
|
||||
if (subcommand === "acquire" || subcommand === "create") {
|
||||
const body = clean({ reason: text(parsed.reason), ttlSeconds: numberOption(parsed.ttlSeconds), agentSessionId: text(parsed.agentSessionId), projectId: text(parsed.projectId) });
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: basePath, body });
|
||||
return responsePayload("lease.acquire", response, { devicePodId: podId });
|
||||
}
|
||||
if (subcommand === "current" || subcommand === "show") {
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path: `${basePath}/current` });
|
||||
return responsePayload("lease.current", response, { devicePodId: podId });
|
||||
}
|
||||
if (subcommand === "release" || subcommand === "delete") {
|
||||
const leaseToken = text(parsed.leaseToken ?? env.HWLAB_DEVICE_LEASE_TOKEN);
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method: "DELETE", path: `${basePath}/current`, extraHeaders: leaseToken ? { "x-hwlab-device-lease-token": leaseToken } : {} });
|
||||
return responsePayload("lease.release", response, { devicePodId: podId, leaseTokenProvided: Boolean(leaseToken) });
|
||||
}
|
||||
throw cliError("unsupported_lease_command", `unsupported lease command: ${subcommand}`);
|
||||
}
|
||||
|
||||
async function jobCommand({ parsed, rest, env, fetchImpl }: any) {
|
||||
const subcommand = rest[0] || "status";
|
||||
const podId = requiredText(parsed.podId, "podId");
|
||||
const jobId = requiredText(rest[1] ?? parsed.jobId, "jobId");
|
||||
const suffix = subcommand === "output" ? "/output" : subcommand === "cancel" ? "/cancel" : "";
|
||||
const method = subcommand === "cancel" ? "POST" : "GET";
|
||||
const path = `/v1/device-pods/${encodeURIComponent(podId)}/jobs/${encodeURIComponent(jobId)}${suffix}`;
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method, path });
|
||||
return responsePayload(`job.${subcommand}`, response, { route: { method, path }, devicePodId: podId, jobId });
|
||||
}
|
||||
|
||||
async function invokeSelector(selector: any, context: any) {
|
||||
const { parsed, rest } = context;
|
||||
const operation = rest[0] || defaultOperation(selector.surface);
|
||||
const jobRoute = jobRouteFromSelector(selector, operation, rest, parsed);
|
||||
if (jobRoute) return jobCommand({ ...context, parsed: { ...parsed, podId: selector.podId }, rest: jobRoute });
|
||||
if (selector.surface === "debug-probe" && operation === "status") return probeGet("debug.status", selector, "/status", context);
|
||||
const job = await buildJob(selector, operation, rest, parsed, context.stdinText);
|
||||
if (parsed.dryRun === true) return ok("device-pod.plan", { route: { method: "POST", path: `/v1/device-pods/${encodeURIComponent(selector.podId)}/jobs` }, request: redactJobRequest(job), selector, localProfileAuthority: false, localProfileRead: false });
|
||||
const response = await requestJson({ parsed, env: context.env, fetchImpl: context.fetchImpl, method: "POST", path: `/v1/device-pods/${encodeURIComponent(selector.podId)}/jobs`, body: job });
|
||||
return responsePayload("device-pod.invoke", response, { selector, intent: job.intent, mutating: MUTATING_INTENTS.has(job.intent), localProfileAuthority: false, localProfileRead: false });
|
||||
}
|
||||
|
||||
async function probeGet(action: string, selector: any, suffix: string, { parsed, env, fetchImpl }: any) {
|
||||
const path = `/v1/device-pods/${encodeURIComponent(selector.podId)}${suffix}`;
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path });
|
||||
return responsePayload(action, response, { route: { method: "GET", path }, selector, localProfileAuthority: false });
|
||||
}
|
||||
|
||||
async function buildJob(selector: any, operation: string, rest: string[], parsed: ParsedArgs, stdinText?: string) {
|
||||
const args = clean({});
|
||||
let intent = "";
|
||||
if (selector.surface === "workspace") {
|
||||
const basePath = selector.path || ".";
|
||||
if (operation === "ls") { intent = "workspace.ls"; args.path = joinPath(basePath, rest[1] || ""); }
|
||||
else if (operation === "cat") { intent = "workspace.cat"; args.path = joinPath(basePath, rest[1] || ""); args.maxBytes = numberOption(parsed.maxBytes ?? parsed.limit); }
|
||||
else if (operation === "rg") { intent = "workspace.rg"; args.pattern = requiredText(rest[1], "pattern"); args.path = joinPath(basePath, rest[2] || ""); Object.assign(args, passthroughOptions(parsed, ["glob", "g", "filesWithMatches", "l", "ignoreCase", "i", "maxCount"])); }
|
||||
else if (operation === "apply-patch") { intent = "workspace.apply-patch"; args.path = joinPath(basePath, rest[1] || ""); args.patch = await patchText(parsed, stdinText); }
|
||||
else if (operation === "build") { intent = "workspace.build"; args.action = rest[1] || "start"; Object.assign(args, passthroughOptions(parsed, ["target", "timeoutMs"])); }
|
||||
else throw cliError("unsupported_workspace_operation", `unsupported workspace operation: ${operation}`);
|
||||
} else if (selector.surface === "debug-probe") {
|
||||
if (operation === "chip-id") intent = "debug.chip-id";
|
||||
else if (operation === "download") { intent = "debug.download"; args.action = rest[1] || "start"; Object.assign(args, passthroughOptions(parsed, ["target", "timeoutMs", "captureUart", "captureDurationMs", "durationMs", "port", "baudRate"])); }
|
||||
else if (operation === "reset") intent = "debug.reset";
|
||||
else throw cliError("unsupported_debug_operation", `unsupported debug-probe operation: ${operation}`);
|
||||
} else if (selector.surface === "io-probe") {
|
||||
const uartId = normalizeProbePath(selector.path || "uart/1");
|
||||
if (operation === "ports") { intent = "io.ports"; args.uartId = uartId; }
|
||||
else if (operation === "read") { intent = "io.uart.read"; args.uartId = uartId; Object.assign(args, passthroughOptions(parsed, ["durationMs", "port", "baudRate"])); }
|
||||
else if (operation === "read-after-launch-flash") { intent = "io.uart.read-after-launch-flash"; args.uartId = uartId; Object.assign(args, passthroughOptions(parsed, ["durationMs", "port", "baudRate", "flashBase", "skipHardwareReset", "connectMode", "timeoutMs"])); }
|
||||
else if (operation === "write") { intent = "io.uart.write"; args.uartId = uartId; args.message = text(rest[1] ?? parsed.message ?? parsed.text ?? parsed.data); args.hex = parsed.hex === true; Object.assign(args, passthroughOptions(parsed, ["port", "baudRate"])); }
|
||||
else throw cliError("unsupported_io_operation", `unsupported io-probe operation: ${operation}`);
|
||||
}
|
||||
return clean({ intent, args: clean(args), reason: text(parsed.reason), leaseToken: text(parsed.leaseToken) });
|
||||
}
|
||||
|
||||
function jobRouteFromSelector(selector: any, operation: string, rest: string[], parsed: ParsedArgs) {
|
||||
if (operation === "status" || operation === "output" || operation === "cancel") return [operation, requiredText(rest[1] ?? parsed.jobId, "jobId")];
|
||||
if ((selector.surface === "workspace" && operation === "build") || (selector.surface === "debug-probe" && operation === "download")) {
|
||||
if (["status", "output", "cancel"].includes(rest[1])) return [rest[1], requiredText(rest[2] ?? parsed.jobId, "jobId")];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function requestJson({ parsed, env, fetchImpl, method, path, body, auth = true, extraHeaders = {} }: any) {
|
||||
const baseUrl = apiBaseUrl(parsed, env);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), numberOption(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetchImpl(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: clean({ accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), ...(auth ? authHeaders(parsed, env) : {}), ...extraHeaders }),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal
|
||||
});
|
||||
const textBody = await response.text();
|
||||
return { status: response.status, headers: response.headers, body: parseJson(textBody) };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptions(argv: string[]): ParsedArgs {
|
||||
const out: ParsedArgs = { _: [] };
|
||||
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) => String(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 parseSelector(raw: string) {
|
||||
const match = raw.match(/^([^:]+):(workspace|debug-probe|io-probe)(?::(.*))?$/u);
|
||||
return match ? { podId: match[1], surface: match[2], path: match[2] === "io-probe" ? normalizeProbePath(match[3] || "") : normalizePath(match[3] || ".") } : null;
|
||||
}
|
||||
|
||||
function passthroughOptions(parsed: ParsedArgs, keys: string[]) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
const value = parsed[key];
|
||||
if (value === undefined || value === false || value === "") continue;
|
||||
out[key] = typeof value === "string" && /^\d+$/u.test(value) ? Number.parseInt(value, 10) : value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const MUTATING_INTENTS = new Set(["workspace.apply-patch", "workspace.build", "debug.download", "debug.reset", "io.uart.read-after-launch-flash", "io.uart.write"]);
|
||||
function defaultOperation(surface: string) { return surface === "workspace" ? "ls" : surface === "debug-probe" ? "status" : "read"; }
|
||||
function apiBaseUrl(parsed: ParsedArgs, env: EnvLike) { const value = text(parsed.apiBaseUrl ?? parsed.apiUrl ?? env.HWLAB_DEVICE_POD_API_URL ?? env.HWLAB_CLOUD_API_URL ?? env.HWLAB_CLI_ENDPOINT); if (!value) throw cliError("api_base_url_required", "device-pod-cli requires --api-base-url or HWLAB_DEVICE_POD_API_URL/HWLAB_CLOUD_API_URL/HWLAB_CLI_ENDPOINT"); return value.replace(/\/+$/u, ""); }
|
||||
function authHeaders(parsed: ParsedArgs, env: EnvLike) { const cookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE); const sessionToken = text(parsed.sessionToken ?? env.HWLAB_DEVICE_POD_SESSION_TOKEN ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN); const bearer = text(parsed.bearerToken ?? env.HWLAB_BEARER_TOKEN); return clean({ ...(cookie ? { cookie: cookie.includes("=") ? cookie : `hwlab_session=${encodeURIComponent(cookie)}` } : {}), ...(sessionToken ? { "x-hwlab-session-token": sessionToken } : {}), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }); }
|
||||
function responsePayload(action: string, response: any, extra: Record<string, unknown> = {}) { const success = response.status >= 200 && response.status < 300 && response.body?.ok !== false; return { ok: success, action, status: success ? "succeeded" : "failed", httpStatus: response.status, ...extra, body: response.body }; }
|
||||
function withMeta(payload: any, now: () => string) { return { generatedAt: now(), cli: CLI_NAME, version: VERSION, ...payload }; }
|
||||
function ok(action: string, data: Record<string, unknown> = {}, status = "succeeded") { return { ok: true, action, status, ...data }; }
|
||||
function failure(action: string, error: any) { return { ok: false, action, status: "failed", error: errorSummary(error), ...(error?.details ? { details: error.details } : {}) }; }
|
||||
function cliError(code: string, message: string, details: Record<string, unknown> = {}) { return Object.assign(new Error(message), { code, details }); }
|
||||
function errorSummary(error: any) { return { code: error?.code ?? "device_pod_cli_error", message: error?.message ?? String(error), ...(error?.details ? { details: error.details } : {}) }; }
|
||||
function requiredOption(parsed: ParsedArgs, env: EnvLike, key: string, envKey: string) { return requiredText(parsed[key] ?? env[envKey], key); }
|
||||
function requiredText(value: unknown, field: string) { const valueText = text(value); if (!valueText) throw cliError("missing_required_value", `${field} is required`, { field }); return valueText; }
|
||||
function text(value: unknown) { return String(value ?? "").trim(); }
|
||||
function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; }
|
||||
function clean<T extends Record<string, any>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== false)) as T; }
|
||||
function normalizePath(value: string) { return String(value || ".").trim().replace(/\\/gu, "/").replace(/\s*\/\s*/gu, "/").replace(/\/+/gu, "/").replace(/^\/+|\/+$/gu, "") || "."; }
|
||||
function normalizeProbePath(value: string) { const normalized = normalizePath(value); if (normalized === ".") return "uart/1"; if (normalized === "uart") throw cliError("invalid_io_probe_path", "io-probe path must be concrete, for example /uart/1"); return normalized; }
|
||||
function joinPath(base: string, child: string) { const left = normalizePath(base); const right = normalizePath(child || "."); if (!right || right === ".") return left; if (child.startsWith("/")) return right; return normalizePath(`${left}/${right}`); }
|
||||
async function patchText(parsed: ParsedArgs, stdinText?: string) { if (typeof parsed.patch === "string") return parsed.patch; if (typeof parsed.patchB64 === "string") return Buffer.from(parsed.patchB64, "base64").toString("utf8"); if (stdinText !== undefined) return stdinText; const chunks = []; for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); const textValue = Buffer.concat(chunks).toString("utf8"); if (!textValue.trim()) throw cliError("patch_text_required", "workspace apply-patch requires patch text on stdin or --patch/--patch-b64"); return textValue; }
|
||||
function redactJobRequest(job: any) { return { ...job, args: job.args?.patch ? { ...job.args, patch: undefined, patchBytes: Buffer.byteLength(job.args.patch, "utf8") } : job.args }; }
|
||||
function parseJson(value: string) { if (!value) return null; try { return JSON.parse(value); } catch { return { rawText: value.slice(0, 2000), parseError: true }; } }
|
||||
function compactBody(body: any) { if (!body || typeof body !== "object") return body; return { ok: body.ok, status: body.status, authenticated: body.authenticated, actor: body.actor, contractVersion: body.contractVersion, devicePodCount: Array.isArray(body.devicePods) ? body.devicePods.length : undefined, selectedDevicePodId: body.selectedDevicePodId, error: body.error, summary: body.summary }; }
|
||||
Reference in New Issue
Block a user