752 lines
37 KiB
TypeScript
752 lines
37 KiB
TypeScript
import { request as httpRequest } from "node:http";
|
|
import { request as httpsRequest } from "node:https";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import { resolveRuntimeEndpoint, runtimeEndpointVisibility, sameRuntimeEndpointScope } from "./runtime-endpoint-resolver.ts";
|
|
|
|
const VERSION = "0.2.0-rest";
|
|
const CLI_NAME = "device-pod-cli";
|
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
const BOOLEAN_OPTIONS = new Set(["dryRun", "full", "help", "h"]);
|
|
|
|
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;
|
|
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 === "setup") return setupCommand(context);
|
|
if (target === "admin") return adminCommand(context);
|
|
if (target === "login") return login(context);
|
|
if (target === "doctor") return doctor(context);
|
|
if (target === "health") return health(context);
|
|
if (target === "bootsharp") return bootsharpCommand(context);
|
|
if (target === "profile") return profileCommand(context);
|
|
if (target === "lease" || target === "leases") return removedStandaloneLeaseCommand();
|
|
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: "auto-located from assembled HWLAB_RUNTIME_* / HWLAB_DEVICE_POD_* env; manual endpoint arguments are rejected when HWLAB_RUNTIME_ENDPOINT_LOCKED=1",
|
|
auth: "assembled HWLAB_DEVICE_POD_API_KEY for AgentRun; browser cookie or explicit user session for local debug"
|
|
},
|
|
usage: [
|
|
"hwpod profile list",
|
|
"hwpod profile show --pod-id device-pod-71-freq",
|
|
"hwpod bootsharp --pod-id D601-F103-V2",
|
|
"hwpod D601-F103-V2:workspace:/ bootsharp",
|
|
"hwpod device-pod-71-freq:workspace:/ ls",
|
|
"hwpod device-pod-71-freq:workspace:/ put User/new.c --reason TEXT < file",
|
|
"hwpod device-pod-71-freq:workspace:/ rmdir User/empty --reason TEXT",
|
|
"hwpod device-pod-71-freq:workspace:/ keil add-source User/new.c --group User --reason TEXT",
|
|
"hwpod device-pod-71-freq:workspace:/ build start --reason TEXT",
|
|
"hwpod device-pod-71-freq:io-probe:/uart/1 jsonrpc gpio.read --params-json '{\"pin\":\"PB5\"}' --reason TEXT",
|
|
"hwpod job output --pod-id device-pod-71-freq <jobId>"
|
|
],
|
|
startupProbe: "Run bootsharp first after selecting a Device Pod or resuming context; it returns workspace tree and AGENTS.md hints through the formal REST job path."
|
|
});
|
|
}
|
|
|
|
async function setupCommand({ parsed, rest, env, fetchImpl }: any) {
|
|
const subcommand = rest[0] || "first-admin";
|
|
if (subcommand !== "first-admin") throw cliError("unsupported_setup_command", `unsupported setup command: ${subcommand}`);
|
|
const devicePod = devicePodSeedFromOptions(parsed, { required: false });
|
|
const body = clean({
|
|
username: requiredOption(parsed, env, "username", "HWLAB_USERNAME"),
|
|
password: requiredOption(parsed, env, "password", "HWLAB_PASSWORD"),
|
|
displayName: text(parsed.displayName),
|
|
...(devicePod ? { devicePod } : {})
|
|
});
|
|
const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: "/v1/setup/first-admin", body, auth: false });
|
|
return responsePayload("setup.first-admin", response, { setCookie: response.headers.get("set-cookie") ?? null, devicePodSeedProvided: Boolean(devicePod), localProfileAuthority: false });
|
|
}
|
|
|
|
async function adminCommand(context: any) {
|
|
const subcommand = context.rest[0] || "help";
|
|
if (subcommand === "device-pod" || subcommand === "profile") return adminDevicePodCommand({ ...context, rest: context.rest.slice(1) });
|
|
if (subcommand === "grant" || subcommand === "device-pod-grant") return adminGrantCommand({ ...context, rest: context.rest.slice(1) });
|
|
throw cliError("unsupported_admin_command", `unsupported admin command: ${subcommand}`);
|
|
}
|
|
|
|
async function adminDevicePodCommand({ parsed, rest, env, fetchImpl }: any) {
|
|
const subcommand = rest[0] || "upsert";
|
|
if (!["create", "upsert", "update", "put"].includes(subcommand)) throw cliError("unsupported_admin_device_pod_command", `unsupported admin device-pod command: ${subcommand}`);
|
|
const devicePod = devicePodSeedFromOptions(parsed, { required: true });
|
|
const method = subcommand === "update" || subcommand === "put" ? "PUT" : "POST";
|
|
const path = method === "PUT" ? `/v1/admin/device-pods/${encodeURIComponent(devicePod.devicePodId)}` : "/v1/admin/device-pods";
|
|
const response = await requestJson({ parsed, env, fetchImpl, method, path, body: devicePod });
|
|
return responsePayload(`admin.device-pod.${subcommand}`, response, { route: { method, path }, devicePodId: devicePod.devicePodId, localProfileAuthority: false });
|
|
}
|
|
|
|
async function adminGrantCommand({ parsed, rest, env, fetchImpl }: any) {
|
|
const podId = requiredText(parsed.podId ?? parsed.devicePodId ?? rest[0], "podId");
|
|
const userId = requiredText(parsed.userId ?? rest[1], "userId");
|
|
const body = { devicePodId: podId, userId };
|
|
const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: "/v1/admin/device-pod-grants", body });
|
|
return responsePayload("admin.device-pod-grant", response, { devicePodId: podId, userId });
|
|
}
|
|
|
|
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 bootsharpCommand(context: any) {
|
|
const podId = requiredText(context.parsed.podId ?? context.rest[0], "podId");
|
|
const path = text(context.parsed.path ?? context.rest[1] ?? "/") || "/";
|
|
const selector = { podId, surface: "workspace", path };
|
|
return invokeSelector(selector, { ...context, rest: ["bootsharp"] });
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
|
|
function removedStandaloneLeaseCommand() {
|
|
throw cliError(
|
|
"standalone_lease_command_removed",
|
|
"standalone hwpod lease commands are not part of the v0.2 standard path; send named Device Pod jobs through cloud-api with --reason and, only when cloud-api requires it, --lease-token",
|
|
{
|
|
next: [
|
|
"Run hwpod bootsharp --pod-id <devicePodId> before operating a pod.",
|
|
"Run the actual Device Pod job, for example hwpod <devicePodId>:workspace:/ build start --reason TEXT.",
|
|
"Do not call hwpod lease acquire/current/release or switch to a generic gateway shell."
|
|
]
|
|
}
|
|
);
|
|
}
|
|
|
|
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, compactKind: subcommand === "output" ? "device-job-output" : null }, parsed);
|
|
}
|
|
|
|
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 === "bootsharp") {
|
|
intent = "workspace.bootsharp";
|
|
args.path = joinPath(basePath, rest[1] || "");
|
|
Object.assign(args, passthroughOptions(parsed, ["depth", "limit", "agentsLimit"]));
|
|
}
|
|
else 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 = workspaceRgPattern(parsed, rest[1]);
|
|
args.path = joinPath(basePath, text(parsed.path) || rest[2] || "");
|
|
Object.assign(args, passthroughOptions(parsed, [
|
|
"glob",
|
|
"g",
|
|
"filesWithMatches",
|
|
"l",
|
|
"ignoreCase",
|
|
"i",
|
|
"maxCount"
|
|
]));
|
|
}
|
|
else if (operation === "put") {
|
|
intent = "workspace.put";
|
|
args.path = joinPath(basePath, requiredText(rest[1] ?? parsed.path, "path"));
|
|
Object.assign(
|
|
args,
|
|
await contentPayload(parsed, stdinText),
|
|
passthroughOptions(parsed, ["encoding", "charset", "createOnly", "createDirs", "updateOnly"])
|
|
);
|
|
}
|
|
else if (operation === "rm") {
|
|
intent = "workspace.rm";
|
|
args.path = joinPath(basePath, requiredText(rest[1] ?? parsed.path, "path"));
|
|
Object.assign(args, passthroughOptions(parsed, ["missingOk"]));
|
|
}
|
|
else if (operation === "rmdir") {
|
|
intent = "workspace.rmdir";
|
|
args.path = joinPath(basePath, requiredText(rest[1] ?? parsed.path, "path"));
|
|
}
|
|
else if (operation === "keil") {
|
|
intent = "workspace.keil";
|
|
args.action = requiredText(rest[1], "keil action");
|
|
args.base = basePath;
|
|
args.path = text(rest[2] ?? parsed.path ?? parsed.source ?? parsed.file ?? parsed.src);
|
|
Object.assign(args, passthroughOptions(parsed, ["group", "groupName", "target", "timeoutMs"]));
|
|
}
|
|
else if (operation === "apply-patch") {
|
|
intent = "workspace.apply-patch";
|
|
args.base = joinPath(basePath, rest[1] || "");
|
|
args.patch = await patchText(parsed, stdinText);
|
|
Object.assign(args, passthroughOptions(parsed, ["encoding", "charset", "createDirs"]));
|
|
}
|
|
else if (operation === "build") {
|
|
intent = "workspace.build";
|
|
args.action = rest[1] || "start";
|
|
if (["status", "wait"].includes(String(args.action))) args.jobId = text(rest[2] ?? parsed.jobId);
|
|
Object.assign(args, passthroughOptions(parsed, ["target", "timeoutMs", "clean", "dryRun"]));
|
|
}
|
|
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";
|
|
if (["status", "wait"].includes(String(args.action))) args.jobId = text(rest[2] ?? parsed.jobId);
|
|
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 if (operation === "jsonrpc") {
|
|
intent = "io.uart.jsonrpc";
|
|
args.uartId = uartId;
|
|
args.method = text(rest[1] ?? parsed.method);
|
|
Object.assign(args, jsonRpcArgs(parsed), passthroughOptions(parsed, uartJsonRpcOptionKeys));
|
|
}
|
|
else throw cliError("unsupported_io_operation", `unsupported io-probe operation: ${operation}`);
|
|
}
|
|
return clean({ intent, args: clean(args), reason: text(parsed.reason) });
|
|
}
|
|
|
|
function jobRouteFromSelector(selector: any, operation: string, rest: string[], parsed: ParsedArgs) {
|
|
if (selector.surface === "workspace" && (operation === "status" || operation === "output" || operation === "cancel")) return [operation, requiredText(rest[1] ?? parsed.jobId, "jobId")];
|
|
if (selector.surface === "workspace" && operation === "build" && isSelectorCloudJobAlias(rest[1], rest[2] ?? parsed.jobId)) return [String(rest[1]), requiredText(rest[2] ?? parsed.jobId, "jobId")];
|
|
if (selector.surface === "debug-probe" && operation === "download" && isSelectorCloudJobAlias(rest[1], rest[2] ?? parsed.jobId)) return [String(rest[1]), requiredText(rest[2] ?? parsed.jobId, "jobId")];
|
|
return null;
|
|
}
|
|
|
|
function isSelectorCloudJobAlias(subcommand: unknown, candidateJobId: unknown) {
|
|
const subcommandText = String(subcommand ?? "");
|
|
if (!["status", "output", "cancel"].includes(subcommandText)) return false;
|
|
if (subcommandText === "output" || subcommandText === "cancel") return true;
|
|
return /^job(?:_|-)/u.test(text(candidateJobId));
|
|
}
|
|
|
|
function workspaceRgPattern(parsed: ParsedArgs, positional: unknown) {
|
|
const encoded = text(parsed.patternB64);
|
|
if (encoded) return Buffer.from(encoded, "base64").toString("utf8");
|
|
return requiredText(parsed.pattern ?? parsed.query ?? positional, "pattern");
|
|
}
|
|
|
|
async function requestJson({ parsed, env, fetchImpl, method, path, body, auth = true, extraHeaders = {} }: any) {
|
|
const endpoint = resolveRuntimeEndpoint({ kind: "api", parsed, env });
|
|
const baseUrl = endpoint.baseUrl;
|
|
const url = `${baseUrl}${path}`;
|
|
const authHeaderValues = auth ? await authHeaders(parsed, env, endpoint) : {};
|
|
const requestBody = body ? JSON.stringify(body) : undefined;
|
|
const headers = clean({
|
|
accept: "application/json",
|
|
...(requestBody ? { "content-type": "application/json", "content-length": Buffer.byteLength(requestBody) } : {}),
|
|
...authHeaderValues,
|
|
...extraHeaders
|
|
});
|
|
const timeoutMs = numberOption(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS;
|
|
if (fetchImpl) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
method,
|
|
headers,
|
|
body: requestBody,
|
|
signal: controller.signal
|
|
});
|
|
const textBody = await response.text();
|
|
return { status: response.status, headers: response.headers, body: parseJson(textBody), url, method, path, runtimeEndpoint: endpoint };
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
return requestJsonWithNodeHttp({ url, method, path, headers, body: requestBody, timeoutMs, runtimeEndpoint: endpoint });
|
|
}
|
|
|
|
function requestJsonWithNodeHttp({ url, method, path, headers, body, timeoutMs, runtimeEndpoint }: any): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
const target = new URL(url);
|
|
const requestImpl = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
const request = requestImpl(target, { method, headers: nodeHttpHeaders(headers) }, (response) => {
|
|
const chunks: Buffer[] = [];
|
|
response.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
response.on("end", () => {
|
|
const textBody = Buffer.concat(chunks).toString("utf8");
|
|
resolve({
|
|
status: response.statusCode ?? 0,
|
|
headers: nodeResponseHeaders(response.headers),
|
|
body: parseJson(textBody),
|
|
url,
|
|
method,
|
|
path,
|
|
runtimeEndpoint
|
|
});
|
|
});
|
|
});
|
|
request.setTimeout(timeoutMs, () => request.destroy(cliError("request_timeout", `request timed out after ${timeoutMs}ms`)));
|
|
request.on("error", reject);
|
|
if (body) request.write(body);
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
function nodeHttpHeaders(headers: Record<string, unknown>) {
|
|
const out: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (value === undefined || value === null) continue;
|
|
out[key] = String(value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function nodeResponseHeaders(headers: Record<string, unknown>) {
|
|
return {
|
|
get(name: string) {
|
|
const value = headers[String(name || "").toLowerCase()];
|
|
if (Array.isArray(value)) return value.join("; ");
|
|
return value === undefined || value === null ? null : String(value);
|
|
}
|
|
};
|
|
}
|
|
|
|
function parseOptions(argv: string[]): ParsedArgs {
|
|
const out: ParsedArgs = { _: [] };
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const item = argv[i] ?? "";
|
|
if (/^-[A-Za-z]$/u.test(item)) {
|
|
const key = item.slice(1);
|
|
const next = argv[i + 1];
|
|
if (next && !next.startsWith("-")) { setOption(out, key, next); i += 1; }
|
|
else setOption(out, key, true);
|
|
continue;
|
|
}
|
|
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) { setOption(out, key, item.slice(eq + 1)); continue; }
|
|
if (BOOLEAN_OPTIONS.has(key)) { setOption(out, key, true); continue; }
|
|
const next = argv[i + 1];
|
|
if (next && !next.startsWith("--")) { setOption(out, key, next); i += 1; }
|
|
else setOption(out, key, true);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function setOption(out: ParsedArgs, key: string, value: unknown) {
|
|
if (out[key] === undefined) out[key] = value;
|
|
else if (Array.isArray(out[key])) (out[key] as unknown[]).push(value);
|
|
else out[key] = [out[key], value];
|
|
}
|
|
|
|
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",
|
|
"workspace.put",
|
|
"workspace.rm",
|
|
"workspace.rmdir",
|
|
"workspace.keil",
|
|
"debug.download",
|
|
"debug.reset",
|
|
"io.uart.read-after-launch-flash",
|
|
"io.uart.write",
|
|
"io.uart.jsonrpc"
|
|
]);
|
|
const uartJsonRpcOptionKeys = [
|
|
"id",
|
|
"request",
|
|
"requestB64",
|
|
"params",
|
|
"paramsB64",
|
|
"responseTimeoutMs",
|
|
"durationMs",
|
|
"retry",
|
|
"retries",
|
|
"retryDelayMs",
|
|
"lineEnding",
|
|
"noNewline",
|
|
"lineDelimited",
|
|
"discardBefore",
|
|
"requireResponse",
|
|
"requireJson",
|
|
"requireJsonrpc",
|
|
"requireJsonrpcResult",
|
|
"allowIdMismatch",
|
|
"port",
|
|
"baudRate"
|
|
];
|
|
function defaultOperation(surface: string) { return surface === "workspace" ? "ls" : surface === "debug-probe" ? "status" : "read"; }
|
|
async function authHeaders(parsed: ParsedArgs, env: EnvLike, endpoint: any) {
|
|
const apiKey = text(parsed.devicePodApiKey ?? env.HWLAB_DEVICE_POD_API_KEY);
|
|
const explicitCookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE);
|
|
const sessionToken = text(parsed.sessionToken ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN);
|
|
const bearer = text(parsed.bearerToken ?? env.HWLAB_BEARER_TOKEN);
|
|
const stateCookie = !apiKey && !explicitCookie && !sessionToken && !bearer ? await savedHwlabCliCookie(parsed, env, endpoint) : "";
|
|
const cookie = explicitCookie || stateCookie;
|
|
return clean({
|
|
...(apiKey ? { "x-hwlab-device-pod-api-key": apiKey } : {}),
|
|
...(cookie ? { cookie: cookie.includes("=") ? cookie : `hwlab_session=${encodeURIComponent(cookie)}` } : {}),
|
|
...(sessionToken ? { "x-hwlab-session-token": sessionToken } : {}),
|
|
...(bearer ? { authorization: `Bearer ${bearer}` } : {})
|
|
});
|
|
}
|
|
|
|
async function savedHwlabCliCookie(parsed: ParsedArgs, env: EnvLike, endpoint: any) {
|
|
const file = hwlabCliSessionFile(parsed, env);
|
|
let stored: any;
|
|
try {
|
|
stored = JSON.parse(await readFile(file, "utf8"));
|
|
} catch {
|
|
return "";
|
|
}
|
|
const cookie = text(stored?.cookie);
|
|
const storedBaseUrl = text(stored?.baseUrl);
|
|
if (!cookie || !storedBaseUrl) return "";
|
|
return storedBaseUrl === endpoint?.baseUrl || sameRuntimeEndpointScope(storedBaseUrl, endpoint?.baseUrl) ? cookie : "";
|
|
}
|
|
|
|
function hwlabCliSessionFile(parsed: ParsedArgs, env: EnvLike) {
|
|
const explicit = text(parsed.stateFile ?? env.HWLAB_CLI_STATE_FILE ?? env.HWLAB_SESSION_STATE_FILE);
|
|
return path.resolve(process.cwd(), explicit || ".state/hwlab-cli/session.json");
|
|
}
|
|
function responsePayload(action: string, response: any, extra: Record<string, unknown> = {}, parsed: ParsedArgs = { _: [] }) {
|
|
const success = response.status >= 200 && response.status < 300 && response.body?.ok !== false;
|
|
const full = parsed.full === true;
|
|
const compactKind = text(extra.compactKind);
|
|
const cleanExtra = { ...extra };
|
|
delete cleanExtra.compactKind;
|
|
const body = !full && compactKind === "device-job-output" ? compactJobOutputBody(response.body) : response.body;
|
|
return {
|
|
ok: success,
|
|
action,
|
|
status: success ? "succeeded" : "failed",
|
|
apiBaseUrl: response.runtimeEndpoint?.baseUrl,
|
|
runtimeEndpoint: runtimeEndpointVisibility(response.runtimeEndpoint),
|
|
httpStatus: response.status,
|
|
request: pruneUndefined({ method: response.method, path: response.path, url: response.url }),
|
|
...cleanExtra,
|
|
body,
|
|
...(full ? { full: true } : {})
|
|
};
|
|
}
|
|
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) {
|
|
const summary = errorSummary(error);
|
|
return {
|
|
ok: false,
|
|
action,
|
|
status: "failed",
|
|
error: summary,
|
|
...patchHintForText(summary.message),
|
|
...(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 pruneUndefined<T extends Record<string, unknown>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T; }
|
|
function arrayOption(value: unknown) { return (Array.isArray(value) ? value : value === undefined ? [] : [value]).map((item) => text(item)).filter(Boolean); }
|
|
function devicePodSeedFromOptions(parsed: ParsedArgs, { required = false } = {}) {
|
|
const devicePodText = text(parsed.devicePodJson ?? parsed.devicePod);
|
|
if (devicePodText) return normalizeDevicePodSeed(parseJsonObjectOption(devicePodText, "devicePodJson"), "devicePodJson");
|
|
const profileText = text(parsed.profileJson ?? parsed.profile);
|
|
if (!profileText) {
|
|
if (required) throw cliError("device_pod_profile_required", "--profile-json or --device-pod-json is required", { field: "profileJson" });
|
|
return null;
|
|
}
|
|
return normalizeDevicePodSeed({
|
|
devicePodId: parsed.podId ?? parsed.devicePodId,
|
|
name: parsed.name,
|
|
status: parsed.status,
|
|
profile: parseJsonObjectOption(profileText, "profileJson")
|
|
}, "devicePod");
|
|
}
|
|
function normalizeDevicePodSeed(value: any, field: string) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw cliError("invalid_device_pod_seed", `${field} must be a JSON object`, { field });
|
|
const devicePodId = requiredText(value.devicePodId ?? value.id, `${field}.devicePodId`);
|
|
const profile = value.profile ?? value.profileJson;
|
|
if (!profile || typeof profile !== "object" || Array.isArray(profile)) throw cliError("invalid_device_pod_profile", `${field}.profile must be a JSON object`, { field: `${field}.profile` });
|
|
return clean({ devicePodId, name: text(value.name), status: text(value.status), profile });
|
|
}
|
|
function parseJsonObjectOption(value: string, field: string) {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
|
|
return parsed;
|
|
} catch (error: any) {
|
|
throw cliError("invalid_json_option", `${field} must be a JSON object`, { field, reason: error?.message ?? String(error) });
|
|
}
|
|
}
|
|
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; if (left === ".") 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; }
|
|
async function contentPayload(parsed: ParsedArgs, stdinText?: string) {
|
|
if (typeof parsed.contentB64 === "string") return { contentB64: parsed.contentB64 };
|
|
if (typeof parsed.textB64 === "string") return { textB64: parsed.textB64 };
|
|
if (typeof parsed.text === "string" || typeof parsed.content === "string") {
|
|
return { text: String(parsed.text ?? parsed.content) };
|
|
}
|
|
const content = stdinText !== undefined ? stdinText : await readStdinText();
|
|
if (!content) {
|
|
throw cliError(
|
|
"content_required",
|
|
"workspace put requires stdin text or --content-b64/--text/--text-b64"
|
|
);
|
|
}
|
|
return { contentB64: Buffer.from(content, "utf8").toString("base64") };
|
|
}
|
|
async function readStdinText() { const chunks = []; for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); return Buffer.concat(chunks).toString("utf8"); }
|
|
function jsonRpcArgs(parsed: ParsedArgs) {
|
|
return clean({
|
|
request: parsed.requestJson,
|
|
params: parsed.paramsJson ?? parsed.params,
|
|
expectResultField: arrayOption(parsed.expectResultField ?? parsed.resultField)
|
|
});
|
|
}
|
|
function redactJobRequest(job: any) { return { ...job, args: redactArgs(job.args) }; }
|
|
function redactArgs(args: any = {}) {
|
|
if (!args || typeof args !== "object") return args;
|
|
const next = { ...args };
|
|
if (typeof next.patch === "string") {
|
|
next.patch = undefined;
|
|
next.patchBytes = Buffer.byteLength(args.patch, "utf8");
|
|
}
|
|
if (typeof next.contentB64 === "string") {
|
|
next.contentB64 = undefined;
|
|
next.contentBytes = Buffer.from(args.contentB64, "base64").length;
|
|
}
|
|
if (typeof next.text === "string") {
|
|
next.text = undefined;
|
|
next.textBytes = Buffer.byteLength(args.text, "utf8");
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function patchHintForText(message: unknown) {
|
|
const textValue = String(message ?? "");
|
|
const patchLike = /apply-patch|patch|hunk|\*\*\* Begin Patch|\*\*\* End Patch|Update File|Add File|Delete File/iu;
|
|
if (!patchLike.test(textValue)) return {};
|
|
return {
|
|
patchHint: {
|
|
standardForm: [
|
|
"*** Begin Patch",
|
|
"*** Update File: path/to/file.c",
|
|
"@@",
|
|
" exact unchanged context line",
|
|
"-old line",
|
|
"+new line",
|
|
"*** End Patch"
|
|
],
|
|
next: patchHintNext(textValue)
|
|
}
|
|
};
|
|
}
|
|
|
|
function patchHintNext(message: string) {
|
|
const next = [
|
|
"Re-read the current target file with workspace cat/rg, then retry a smaller exact-context hunk patch."
|
|
];
|
|
if (/end with \*\*\* End Patch/iu.test(message)) {
|
|
next.unshift("Add a final exact `*** End Patch` line.");
|
|
} else if (/start with \*\*\* Begin Patch/iu.test(message)) {
|
|
next.unshift("Start the patch with an exact `*** Begin Patch` line.");
|
|
} else if (/hunk did not match|must start with @@|invalid update line prefix|ellipsis/iu.test(message)) {
|
|
next.unshift("Use exact current context; do not use ellipsis or line-number-only hunks.");
|
|
} else if (/unsupported patch header|capitalization|New File/iu.test(message)) {
|
|
next.unshift("Use exactly `*** Update File:`, `*** Add File:`, or `*** Delete File:` headers.");
|
|
}
|
|
next.push("Do not switch to workspace put for existing source unless a smaller hunk cannot safely express the edit.");
|
|
return next;
|
|
}
|
|
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 }; }
|
|
function compactJobOutputBody(body: any) {
|
|
if (!body || typeof body !== "object") return body;
|
|
const output = body.output && typeof body.output === "object" ? body.output : {};
|
|
const textValue = typeof body.text === "string" ? body.text : typeof output.text === "string" ? output.text : "";
|
|
const maxTextBytes = 4000;
|
|
const textBuffer = Buffer.from(textValue, "utf8");
|
|
const clippedText = textBuffer.length > maxTextBytes ? textBuffer.subarray(0, maxTextBytes).toString("utf8") : textValue;
|
|
return clean({
|
|
serviceId: body.serviceId,
|
|
contractVersion: body.contractVersion,
|
|
accepted: body.accepted,
|
|
status: body.status,
|
|
devicePodId: body.devicePodId,
|
|
targetId: body.targetId,
|
|
profileHash: body.profileHash,
|
|
traceId: body.traceId,
|
|
operationId: body.operationId,
|
|
job: body.job,
|
|
blocker: body.blocker,
|
|
freshness: body.freshness,
|
|
outputUrl: body.outputUrl,
|
|
cancelUrl: body.cancelUrl,
|
|
text: clippedText,
|
|
bytes: body.bytes ?? output.bytes,
|
|
truncation: body.truncation ?? output.truncation,
|
|
outputSummary: output.summary,
|
|
gateway: output.gateway,
|
|
evidenceId: output.evidenceId,
|
|
httpStatus: output.httpStatus,
|
|
compacted: true,
|
|
compactNote: "Use --full to print the complete device job output payload.",
|
|
textTruncation: {
|
|
maxBytes: maxTextBytes,
|
|
originalBytes: textBuffer.length,
|
|
truncated: textBuffer.length > maxTextBytes
|
|
}
|
|
});
|
|
}
|