568 lines
28 KiB
JavaScript
568 lines
28 KiB
JavaScript
#!/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);
|
|
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 (/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') {
|
|
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}`
|
|
];
|
|
}
|
|
return [`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId}`];
|
|
}
|
|
|
|
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 = fs.existsSync(PROFILE_DIR)
|
|
? fs.readdirSync(PROFILE_DIR).filter((name) => name.endsWith('.json')).map((name) => path.basename(name, '.json'))
|
|
: [];
|
|
if (names.length === 1) return names[0];
|
|
throw new Error('profile invalid: pass <devicePodId>:<surface> or set DEVICE_POD_ID when multiple/no profiles exist');
|
|
}
|
|
|
|
function readProfile(podId) {
|
|
const requestedPodId = podId || process.env.DEVICE_POD_ID || null;
|
|
const explicitFile = process.env.DEVICE_POD_PROFILE || null;
|
|
const devicePodId = explicitFile ? requestedPodId : resolvePodId(requestedPodId);
|
|
const file = explicitFile || path.join(PROFILE_DIR, `${devicePodId}.json`);
|
|
if (!fs.existsSync(file)) throw new Error(`profile not found: ${file}`);
|
|
const raw = fs.readFileSync(file, 'utf8');
|
|
const profile = JSON.parse(raw);
|
|
const resolvedPodId = profile.devicePodId || profile.podId || devicePodId || path.basename(file, '.json');
|
|
const targetId = profile.targetId || profile.target?.id || profile.deviceTarget?.targetId || profile.deviceTarget?.id;
|
|
const required = ['cloudApiUrl', 'gatewaySessionId', 'resourceId', 'capabilityId', 'hostWorkspaceRoot', 'hostCli'];
|
|
const missing = required.filter((key) => !profile[key]);
|
|
if (missing.length) throw new Error(`profile invalid: missing ${missing.join(', ')}`);
|
|
if (devicePodId && resolvedPodId !== devicePodId) throw new Error(`profile invalid: podId mismatch selector=${devicePodId} profile=${resolvedPodId}`);
|
|
if (!targetId) throw new Error('profile invalid: missing target.id');
|
|
return { ...profile, podId: resolvedPodId, targetId, __profilePath: file, __profileHash: sha256(raw) };
|
|
}
|
|
|
|
function profileSummary(profile) {
|
|
return { profilePath: profile.__profilePath, profileHash: profile.__profileHash, devicePodId: profile.podId, targetId: profile.targetId };
|
|
}
|
|
function routeSummary(profile) {
|
|
return { cloudApiUrl: profile.cloudApiUrl, gatewaySessionId: profile.gatewaySessionId, resourceId: profile.resourceId, capabilityId: profile.capabilityId, hostWorkspaceRoot: profile.hostWorkspaceRoot };
|
|
}
|
|
|
|
function cmdQuote(value) {
|
|
const text = String(value);
|
|
if (/^[A-Za-z0-9_./:\\-]+$/u.test(text)) return text;
|
|
return `"${text.replace(/"/g, '\\"')}"`;
|
|
}
|
|
|
|
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_DESKTOP-1MHOD9I',
|
|
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 {
|
|
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
|
|
};
|
|
}
|
|
|
|
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 uploadRoot = `${profile.gatewaySessionId}:${String(profile.hostWorkspaceRoot || '').replace(/^[A-Za-z]:/u, (drive) => `/${drive[0].toLowerCase()}`).replace(/\\/gu, '/')}`;
|
|
print({
|
|
ok: true,
|
|
action: 'doctor',
|
|
status: '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`,
|
|
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`
|
|
},
|
|
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;
|
|
}
|
|
fail('doctor', error, { podId });
|
|
}
|
|
}
|
|
|
|
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 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 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);
|
|
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';
|
|
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, ...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));
|