#!/usr/bin/env node import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; import https from 'node:https'; import path from 'node:path'; const VERSION = '0.1.0-mvp'; const DEFAULT_TIMEOUT_MS = 30000; 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); print({ ok: false, action, status: 'failed', blocker: classifyBlocker(message), error: message, details }, 1); } function classifyBlocker(message) { if (/profile not found/u.test(message)) return 'profile-missing'; if (/profile invalid|requires/u.test(message)) return 'profile-invalid'; if (/approval/u.test(message)) return 'approval-required'; if (/unsupported|invalid target selector/u.test(message)) return 'invalid-request'; return 'operation-failed'; } function parseTarget(raw) { if (!raw || raw === '--help' || raw === 'help') return { kind: 'help' }; if (raw === 'health') return { kind: 'health' }; const match = String(raw).match(/^([^:]+):(workspace|debug-probe|io-probe)(?::(.*))?$/u); if (!match) throw new Error(`invalid target selector: ${raw}`); return { kind: 'invoke', podId: match[1], surface: match[2], path: match[3] || '' }; } function parseOptions(argv) { const out = { _: [] }; for (let i = 0; i < argv.length; i += 1) { const item = argv[i]; if (!item.startsWith('--')) { out._.push(item); continue; } const eq = item.indexOf('='); const rawKey = eq >= 0 ? item.slice(2, eq) : item.slice(2); const key = rawKey.replace(/-([a-z])/gu, (_, c) => c.toUpperCase()); if (eq >= 0) { out[key] = item.slice(eq + 1); continue; } const next = argv[i + 1]; if (next && !next.startsWith('--')) { out[key] = next; i += 1; } else out[key] = true; } return out; } function resolvePodId(requested) { if (requested) return requested; if (process.env.DEVICE_POD_ID) return process.env.DEVICE_POD_ID; const names = fs.existsSync(PROFILE_DIR) ? fs.readdirSync(PROFILE_DIR).filter((name) => name.endsWith('.json')).map((name) => path.basename(name, '.json')) : []; if (names.length === 1) return names[0]; throw new Error('profile invalid: pass : or set DEVICE_POD_ID when multiple/no profiles exist'); } function readProfile(podId) { const requestedPodId = podId || process.env.DEVICE_POD_ID || null; const explicitFile = process.env.DEVICE_POD_PROFILE || null; const devicePodId = explicitFile ? requestedPodId : resolvePodId(requestedPodId); const file = explicitFile || path.join(PROFILE_DIR, `${devicePodId}.json`); if (!fs.existsSync(file)) throw new Error(`profile not found: ${file}`); const raw = fs.readFileSync(file, 'utf8'); const profile = JSON.parse(raw); const resolvedPodId = profile.devicePodId || profile.podId || devicePodId || path.basename(file, '.json'); const targetId = profile.targetId || profile.target?.id || profile.deviceTarget?.targetId || profile.deviceTarget?.id; const required = ['cloudApiUrl', 'gatewaySessionId', 'resourceId', 'capabilityId', 'hostWorkspaceRoot', 'hostCli']; const missing = required.filter((key) => !profile[key]); if (missing.length) throw new Error(`profile invalid: missing ${missing.join(', ')}`); if (devicePodId && resolvedPodId !== devicePodId) throw new Error(`profile invalid: podId mismatch selector=${devicePodId} profile=${resolvedPodId}`); if (!targetId) throw new Error('profile invalid: missing target.id'); return { ...profile, podId: resolvedPodId, targetId, __profilePath: file, __profileHash: sha256(raw) }; } function profileSummary(profile) { return { profilePath: profile.__profilePath, profileHash: profile.__profileHash, devicePodId: profile.podId, targetId: profile.targetId }; } function routeSummary(profile) { return { cloudApiUrl: profile.cloudApiUrl, gatewaySessionId: profile.gatewaySessionId, resourceId: profile.resourceId, capabilityId: profile.capabilityId, hostWorkspaceRoot: profile.hostWorkspaceRoot }; } function cmdQuote(value) { const text = String(value); if (/^[A-Za-z0-9_./:\\-]+$/u.test(text)) return text; return `"${text.replace(/"/g, '\\"')}"`; } function hostProfilePath(profile) { return profile.hostProfilePath || `.device-pod\\${profile.podId}.json`; } const CONTROL_OPTION_KEYS = new Set([ 'approved', 'reason', 'dryRun', 'full', 'timeoutMs', 'traceId', 'podId', 'patch', 'patchB64', ]); function camelToKebab(value) { return String(value).replace(/[A-Z]/gu, (match) => `-${match.toLowerCase()}`); } function hostOptionArgs(parsed) { const out = []; for (const [key, value] of Object.entries(parsed)) { if (key === '_' || CONTROL_OPTION_KEYS.has(key) || value === undefined || value === false) continue; const name = `--${camelToKebab(key)}`; if (value === true) out.push(name); else out.push(name, String(value)); } return out; } async function buildHostCommand(selector, parsed) { const args = []; const options = hostOptionArgs(parsed); const operation = parsed._[0] || (selector.surface === 'workspace' ? 'ls' : selector.surface === 'debug-probe' ? 'status' : 'read'); const mutating = isMutating(selector.surface, operation); if (mutating) requireApproval(parsed, `${selector.surface}.${operation}`); if (selector.surface === 'workspace') { const workspacePath = selector.path.replace(/^\/+/, '') || '.'; if (operation === 'ls' || operation === 'cat') args.push('workspace', operation, parsed._[1] ? path.posix.join(workspacePath, parsed._[1]) : workspacePath, ...parsed._.slice(2), ...options); else if (operation === 'rg') args.push('workspace', 'rg', parsed._[1] || '', workspacePath, ...parsed._.slice(2), ...options); else if (operation === 'apply-patch') args.push('workspace', 'apply-patch', workspacePath, '--patch-b64', Buffer.from(await resolvePatchText(parsed), 'utf8').toString('base64')); else if (operation === 'build') args.push('workspace', 'build', ...(parsed._.slice(1).length ? parsed._.slice(1) : ['start']), ...options); else throw new Error(`unsupported workspace command: ${operation}`); } else if (selector.surface === 'debug-probe') { args.push('debug-probe', operation, ...parsed._.slice(1), ...options); } else if (selector.surface === 'io-probe') { const probePath = selector.path.replace(/^\/+/, '') || 'uart/1'; if (probePath.startsWith('inner/')) throw new Error(`unsupported io-probe path in MVP: ${probePath}`); args.push('io-probe', probePath, operation, ...parsed._.slice(1), ...options); } else { throw new Error(`unsupported surface: ${selector.surface}`); } return { operation, mutating, commandArgs: args }; } function isMutating(surface, operation) { return (surface === 'workspace' && operation === 'apply-patch') || (surface === 'debug-probe' && (operation === 'download' || operation === 'reset' || operation === 'launch-flash')) || (surface === 'io-probe' && (operation === 'write' || operation === 'read-after-launch-flash')); } function requireApproval(parsed, operation) { if (parsed.approved === true && typeof parsed.reason === 'string' && parsed.reason.trim()) return; throw new Error(`approval required for ${operation}: pass --approved --reason `); } 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 requestJson(url, options = {}) { const body = JSON.stringify(options.body || {}); const target = new URL(url); const client = target.protocol === 'https:' ? https : http; const headers = { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), ...(options.headers || {}) }; return new Promise((resolve, reject) => { const req = client.request(target, { method: 'POST', headers }, (res) => { let text = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { text += chunk; }); res.on('end', () => { try { resolve({ status: res.statusCode || 0, body: text ? JSON.parse(text) : null }); } catch (error) { reject(new Error(`invalid JSON response from gateway API: ${error.message}; body=${text.slice(0, 500)}`)); } }); }); const timer = setTimeout(() => req.destroy(new Error(`request timeout after ${options.timeoutMs || DEFAULT_TIMEOUT_MS}ms`)), options.timeoutMs || DEFAULT_TIMEOUT_MS); req.on('error', reject); req.on('close', () => clearTimeout(timer)); req.write(body); req.end(); }); } function parseMaybeJson(text) { if (!text) return null; try { return JSON.parse(text); } catch { return null; } } function compactDispatch(dispatch) { return { status: dispatch.status || dispatch.dispatchStatus, exitCode: dispatch.exitCode, timedOut: dispatch.timedOut, durationMs: dispatch.durationMs, stdoutTruncated: dispatch.stdoutTruncated, stderrTruncated: dispatch.stderrTruncated, stderr: dispatch.stderr || '', auditId: dispatch.auditId, evidenceId: dispatch.evidenceId }; } function help() { print({ ok: true, action: 'help', scope: 'HWLAB internal code agents only; not for external UniDesk workspaces.', usage: [ 'node tools/device-pod-cli.mjs health', 'node tools/device-pod-cli.mjs device-pod-71-freq:workspace:/ ls', 'node tools/device-pod-cli.mjs device-pod-71-freq:workspace:/firmware cat main.c', 'node tools/device-pod-cli.mjs device-pod-71-freq:workspace:/ apply-patch --approved --reason "DEV edit" < patch.diff', 'node tools/device-pod-cli.mjs device-pod-71-freq:workspace:/ build start|status ', 'node tools/device-pod-cli.mjs device-pod-71-freq:debug-probe chip-id|status|download|reset|launch-flash --approved --reason "DEV smoke"', 'node tools/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 ports|read|read-after-launch-flash|write --approved --reason "DEV io"' ] }); } async function main() { const [rawTarget, ...argv] = process.argv.slice(2); const selector = parseTarget(rawTarget); if (selector.kind === 'help') return help(); const parsed = parseOptions(argv); const profile = readProfile(selector.podId || parsed.podId); if (selector.kind === 'health') return print({ ok: true, action: 'health', status: 'succeeded', ...profileSummary(profile), route: routeSummary(profile) }); const built = await buildHostCommand(selector, parsed); const 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'; print({ ok, action: 'device-pod.invoke', status: result.status, selector, operation: built.operation, mutating: built.mutating, command, ...profileSummary(profile), route: routeSummary(profile), traceId: result.traceId, operationId: result.operationId, requestId: result.requestId, httpStatus: result.httpStatus, dispatch: parsed.full ? result.dispatch : compactDispatch(result.dispatch), hostJson: result.hostJson, evidence: { auditId: result.dispatch.auditId, evidenceId: result.dispatch.evidenceId }, gatewayBody: parsed.full ? result.gatewayBody : undefined }, ok ? 0 : 1); } main().catch((error) => fail('device-pod-cli', error));