feat: sync g14 harness and device-pod cli
This commit is contained in:
+249
-15
@@ -61,7 +61,11 @@ function help() {
|
||||
"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:/ put User/new.c --reason TEXT --lease-token TOKEN < file",
|
||||
"device-pod-cli device-pod-71-freq:workspace:/ rmdir User/empty --reason TEXT --lease-token TOKEN",
|
||||
"device-pod-cli device-pod-71-freq:workspace:/ keil add-source User/new.c --group User --reason TEXT --lease-token TOKEN",
|
||||
"device-pod-cli device-pod-71-freq:workspace:/ build start --reason TEXT --lease-token TOKEN",
|
||||
"device-pod-cli device-pod-71-freq:io-probe:/uart/1 jsonrpc gpio.read --params-json '{\"pin\":\"PB5\'} --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"
|
||||
]
|
||||
@@ -207,23 +211,117 @@ async function buildJob(selector: any, operation: string, rest: string[], parsed
|
||||
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"])); }
|
||||
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 === "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";
|
||||
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"; Object.assign(args, passthroughOptions(parsed, ["target", "timeoutMs", "captureUart", "captureDurationMs", "durationMs", "port", "baudRate"])); }
|
||||
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 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), leaseToken: text(parsed.leaseToken) });
|
||||
@@ -259,18 +357,31 @@ 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) { out[key] = item.slice(eq + 1); continue; }
|
||||
if (eq >= 0) { setOption(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;
|
||||
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;
|
||||
@@ -286,14 +397,59 @@ function passthroughOptions(parsed: ParsedArgs, keys: string[]) {
|
||||
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"]);
|
||||
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"; }
|
||||
function apiBaseUrl(parsed: ParsedArgs, env: EnvLike) { const value = text(parsed.apiBaseUrl ?? parsed.apiUrl ?? env.HWLAB_DEVICE_POD_API_URL ?? env.HWLAB_CLOUD_API_URL); 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"); 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 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); }
|
||||
@@ -301,6 +457,7 @@ function requiredText(value: unknown, field: string) { const valueText = text(va
|
||||
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 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");
|
||||
@@ -336,6 +493,83 @@ function normalizePath(value: string) { return String(value || ".").trim().repla
|
||||
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 }; }
|
||||
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 }; }
|
||||
|
||||
Reference in New Issue
Block a user