#!/usr/bin/env node import { spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const VERSION = '0.1.0-mvp'; const DEFAULT_POD_ID = 'device-pod-71-freq'; const GLOBAL_CLI = parseGlobalCli(process.argv.slice(2)); function parseGlobalCli(argv) { const options = {}; const args = []; for (let i = 0; i < argv.length; i += 1) { const item = argv[i]; if (item === '--profile') { options.profile = cleanArg(argv[i + 1]); i += 1; } else if (item.startsWith('--profile=')) { options.profile = cleanArg(item.slice('--profile='.length)); } else if (item === '--profile-json-b64') { options.profileJsonB64 = cleanArg(argv[i + 1]); i += 1; } else if (item.startsWith('--profile-json-b64=')) { options.profileJsonB64 = cleanArg(item.slice('--profile-json-b64='.length)); } else if (item === '--pod-id') { options.podId = cleanArg(argv[i + 1]); i += 1; } else if (item.startsWith('--pod-id=')) { options.podId = cleanArg(item.slice('--pod-id='.length)); } else if (item === '--workspace') { options.workspace = cleanArg(argv[i + 1]); i += 1; } else if (item.startsWith('--workspace=')) { options.workspace = cleanArg(item.slice('--workspace='.length)); } else { args.push(cleanArg(item)); } } return { options, args }; } function cleanArg(value) { const text = String(value ?? ''); if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) return text.slice(1, -1); return text; } function selectedPodId() { return GLOBAL_CLI.options.podId || process.env.DEVICE_POD_ID || DEFAULT_POD_ID; } function workspaceRoot() { return path.resolve(GLOBAL_CLI.options.workspace || process.env.DEVICE_POD_WORKSPACE || process.cwd()); } function devicePodDir(root = workspaceRoot()) { return path.join(root, '.device-pod'); } function stateRoot(root = workspaceRoot()) { return path.join(devicePodDir(root), '.state', 'device-host-cli'); } function nowIso() { return new Date().toISOString(); } function ok(action, data = {}) { console.log(JSON.stringify({ ok: true, action, generatedAt: nowIso(), data }, null, 2)); } function fail(action, error, details = {}, exitCode = 1) { const message = error instanceof Error ? error.message : String(error); const stack = error instanceof Error && error.stack ? error.stack.split('\n').slice(0, 8) : undefined; console.log(JSON.stringify({ ok: false, action, generatedAt: nowIso(), error: message, stack, details }, null, 2)); process.exitCode = exitCode; } function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); } function writeJson(file, value) { ensureDir(path.dirname(file)); fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } function profilePath(root = workspaceRoot(), podId = selectedPodId()) { return path.join(devicePodDir(root), `${podId}.json`); } function loadProfile() { const root = workspaceRoot(); if (GLOBAL_CLI.options.profileJsonB64) return loadInlineProfile(root); const requestedProfile = GLOBAL_CLI.options.profile || process.env.DEVICE_POD_PROFILE || ''; const file = requestedProfile ? (path.isAbsolute(requestedProfile) ? requestedProfile : path.resolve(root, requestedProfile)) : profilePath(root); if (!fs.existsSync(file)) throw new Error(`device-pod profile not found: ${file}`); const profile = readJson(file); const actualPodId = profile.devicePodId || profile.podId; const requestedPodId = GLOBAL_CLI.options.podId || process.env.DEVICE_POD_ID || ''; if (requestedPodId && actualPodId && actualPodId !== requestedPodId) throw new Error(`device-pod profile mismatch: requested ${requestedPodId}, profile ${actualPodId}`); profile.__profilePath = file; profile.__workspaceRoot = path.resolve(profile.workspaceRoot || root); return profile; } function loadInlineProfile(root) { const profile = JSON.parse(Buffer.from(GLOBAL_CLI.options.profileJsonB64, 'base64').toString('utf8')); const actualPodId = profile.devicePodId || profile.podId; const requestedPodId = GLOBAL_CLI.options.podId || process.env.DEVICE_POD_ID || ''; if (requestedPodId && actualPodId && actualPodId !== requestedPodId) throw new Error(`device-pod profile mismatch: requested ${requestedPodId}, profile ${actualPodId}`); const runtimePath = path.join(devicePodDir(root), '.runtime', `${actualPodId || requestedPodId || DEFAULT_POD_ID}.json`); writeJson(runtimePath, profile); profile.__profilePath = runtimePath; profile.__workspaceRoot = path.resolve(profile.workspaceRoot || root); return profile; } function resolveWorkspacePath(profile, rel = '.') { const root = profile.__workspaceRoot; const raw = String(rel || '.').replace(/^\/+/, ''); if (raw.includes('\0')) throw new Error('path contains NUL'); const resolved = path.resolve(root, raw); const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`; if (resolved !== root && !resolved.startsWith(rootWithSep)) throw new Error(`path escapes workspace: ${rel}`); return resolved; } function projectPath(profile) { return resolveWorkspacePath(profile, profile.projectWorkspace?.projectPath); } function uvoptxPath(projectFile) { return projectFile.replace(/\.uvprojx$/i, '.uvoptx'); } function targetName(profile) { return profile.projectWorkspace?.targetName || ''; } function hexPath(profile) { return resolveWorkspacePath(profile, profile.projectWorkspace?.hexPath); } function shortTail(text, max = 12000) { const value = String(text || ''); return value.length <= max ? value : value.slice(value.length - max); } function runCapture(file, args = [], options = {}) { const timeoutMs = options.timeoutMs ?? 15000; const startedAt = Date.now(); return new Promise((resolve) => { const child = spawn(file, args, { cwd: options.cwd || workspaceRoot(), env: { ...process.env, ...(options.env || {}) }, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; let timedOut = false; const timer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, timeoutMs); child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); }); child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); }); child.on('error', (error) => { clearTimeout(timer); resolve({ exitCode: 127, stdout, stderr: `${stderr}${error.message}\n`, timedOut, elapsedMs: Date.now() - startedAt }); }); child.on('close', (code) => { clearTimeout(timer); resolve({ exitCode: timedOut ? 124 : (code ?? 1), stdout, stderr, timedOut, elapsedMs: Date.now() - startedAt }); }); }); } async function runPowerShell(script, timeoutMs = 15000) { const prefix = [ '$ErrorActionPreference = \'Stop\';', '[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();', '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();', '$OutputEncoding = [System.Text.UTF8Encoding]::new();', ].join(' '); return await runCapture('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', `${prefix} ${script}`], { timeoutMs }); } function psQuote(value) { return `'${String(value).replace(/'/g, "''")}'`; } function psArray(values) { return `@(${values.map(psQuote).join(', ')})`; } function parseArgs(args) { const out = { _: [] }; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (/^-[A-Za-z]$/u.test(arg)) { const key = arg.slice(1); const next = args[i + 1]; if (['g', 't'].includes(key) && next !== undefined && !next.startsWith('-')) { setOption(out, key, cleanArg(next)); i += 1; } else { setOption(out, key, true); } continue; } if (!arg.startsWith('--')) { out._.push(cleanArg(arg)); continue; } const eq = arg.indexOf('='); if (eq >= 0) { setOption(out, arg.slice(2, eq), cleanArg(arg.slice(eq + 1))); continue; } const key = arg.slice(2); const next = args[i + 1]; if (next !== undefined && !next.startsWith('--')) { setOption(out, key, cleanArg(next)); i += 1; } else { setOption(out, key, true); } } return out; } function setOption(out, key, value) { if (out[key] === undefined) out[key] = value; else if (Array.isArray(out[key])) out[key].push(value); else out[key] = [out[key], value]; } function findFile(candidates) { for (const candidate of candidates.filter(Boolean)) { if (fs.existsSync(candidate)) return candidate; } return null; } async function commandExists(command) { const where = process.platform === 'win32' ? 'where' : 'which'; const result = await runCapture(where, [command], { timeoutMs: 5000 }); return result.exitCode === 0 ? result.stdout.trim().split(/\r?\n/u)[0] : null; } async function toolInfo(profile) { const uv4 = findFile([ profile.debugInterface?.uv4Path, process.env.UV4_EXE, 'D:\\Keil_v5\\UV4\\UV4.exe', 'C:\\Keil_v5\\UV4\\UV4.exe', 'C:\\Keil\\UV4\\UV4.exe', ]); const pyocd = findFile([profile.debugInterface?.pyocdPath]) || await commandExists('pyocd'); const pyocdVersion = pyocd ? await runCapture(pyocd, ['--version'], { timeoutMs: 5000 }) : null; return { node: { path: process.execPath, version: process.version }, powershell: await commandExists('powershell.exe'), uv4: { path: uv4, found: Boolean(uv4) }, pyocd: { path: pyocd, found: Boolean(pyocd), version: pyocdVersion ? pyocdVersion.stdout.trim() || pyocdVersion.stderr.trim() : null, }, }; } async function health() { const profile = loadProfile(); const tools = await toolInfo(profile); ok('health', { version: VERSION, host: os.hostname(), platform: process.platform, workspaceRoot: profile.__workspaceRoot, profilePath: profile.__profilePath, profileExists: fs.existsSync(profile.__profilePath), projectExists: fs.existsSync(projectPath(profile)), hexExists: fs.existsSync(hexPath(profile)), stateRoot: stateRoot(profile.__workspaceRoot), runtimeDependencies: ['node built-ins', 'Windows PowerShell/.NET', 'Keil UV4.exe', 'pyOCD executable'], skillRuntimeDependencies: [], tools, }); } function listWorkspace(rel) { const profile = loadProfile(); const dir = resolveWorkspacePath(profile, rel || '.'); const entries = fs.readdirSync(dir, { withFileTypes: true }).map((entry) => { const full = path.join(dir, entry.name); const stat = fs.statSync(full); return { name: entry.name, type: entry.isDirectory() ? 'dir' : entry.isFile() ? 'file' : 'other', bytes: stat.size, mtime: stat.mtime.toISOString() }; }); ok('workspace.ls', { path: path.relative(profile.__workspaceRoot, dir) || '.', entries }); } function catWorkspace(rel, limitText) { const profile = loadProfile(); const file = resolveWorkspacePath(profile, rel); const limit = Number(limitText || 40000); const content = fs.readFileSync(file, 'utf8'); ok('workspace.cat', { path: path.relative(profile.__workspaceRoot, file), bytes: Buffer.byteLength(content), truncated: Buffer.byteLength(content) > limit, content: content.length > limit ? content.slice(0, limit) : content, }); } function walkFiles(root, rel, results, limit = 5000) { if (results.length >= limit) return; const dir = path.join(root, rel); for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '.state') continue; const childRel = path.join(rel, entry.name); if (entry.isDirectory()) walkFiles(root, childRel, results, limit); else if (entry.isFile()) results.push(childRel); if (results.length >= limit) return; } } function rgWorkspace(pattern, rel, options = {}) { const profile = loadProfile(); const root = resolveWorkspacePath(profile, rel || '.'); const files = []; const stat = fs.statSync(root); if (stat.isFile()) files.push(path.relative(profile.__workspaceRoot, root)); else { const nested = []; const rootRel = path.relative(profile.__workspaceRoot, root) || '.'; walkFiles(root, '.', nested); files.push(...nested.map((fileRel) => rootRel === '.' ? fileRel : path.join(rootRel, fileRel))); } const re = new RegExp(pattern, 'i'); const matches = []; const fileMatches = []; const filesWithMatches = Boolean(options.l || options.filesWithMatches || options['files-with-matches'] || options._?.includes('-l')); const globs = normalizeList(options.g).concat(normalizeList(options.glob), normalizeList(options.iglob)); const types = normalizeList(options.t).concat(normalizeList(options.type)); for (const fileRel of files) { if (!rgFileAllowed(fileRel, { globs, types })) continue; if (!filesWithMatches && matches.length >= 100) break; const full = resolveWorkspacePath(profile, fileRel); const buffer = fs.readFileSync(full); if (buffer.includes(0)) continue; const lines = buffer.toString('utf8').split(/\r?\n/u); for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!re.test(line)) continue; if (filesWithMatches) { fileMatches.push(fileRel); break; } if (matches.length < 100) matches.push({ path: fileRel, line: index + 1, text: line.slice(0, 500) }); } } ok('workspace.rg', { pattern, root: path.relative(profile.__workspaceRoot, root) || '.', count: filesWithMatches ? fileMatches.length : matches.length, files: filesWithMatches ? fileMatches : undefined, matches: filesWithMatches ? undefined : matches, options: { globs, types, filesWithMatches } }); } function normalizeList(value) { if (value === undefined || value === false) return []; return (Array.isArray(value) ? value : [value]).map((item) => String(item)).filter(Boolean); } function rgFileAllowed(fileRel, options = {}) { const rel = fileRel.replace(/\\/gu, '/'); if (options.types?.length) { const ext = path.extname(rel).toLowerCase(); const allowed = new Set(); for (const type of options.types) { if (type === 'c') ['.c', '.h'].forEach((item) => allowed.add(item)); else if (type === 'cc') ['.cc', '.cpp', '.cxx', '.hpp', '.hh', '.hxx'].forEach((item) => allowed.add(item)); } if (allowed.size && !allowed.has(ext)) return false; } if (!options.globs?.length) return true; return options.globs.some((glob) => matchSimpleGlob(rel, glob)); } function matchSimpleGlob(fileRel, glob) { const raw = String(glob || '').replace(/\\/gu, '/'); const target = raw.includes('/') ? fileRel : path.basename(fileRel); return new RegExp(`^${globToRegExp(raw)}$`, 'iu').test(target); } function globToRegExp(glob) { let out = ''; for (let i = 0; i < glob.length; i += 1) { const char = glob[i]; if (char === '*' && glob[i + 1] === '*') { out += '.*'; i += 1; } else if (char === '*') out += '[^/]*'; else if (char === '?') out += '[^/]'; else out += char.replace(/[.+^${}()|[\]\\]/gu, '\\$&'); } return out; } function workspaceApplyPatch(baseRel, options = {}) { const profile = loadProfile(); const patchText = decodePatchText(options); const basePath = resolveWorkspacePath(profile, baseRel || '.'); const baseDir = fs.existsSync(basePath) && fs.statSync(basePath).isFile() ? path.dirname(basePath) : basePath; const operations = parseApplyPatch(patchText); const changed = []; for (const operation of operations) { const target = resolvePatchPath(profile, baseDir, operation.path); if (operation.kind === 'add') { if (fs.existsSync(target)) throw new Error(`add target already exists: ${operation.path}`); ensureDir(path.dirname(target)); fs.writeFileSync(target, operation.content, 'utf8'); changed.push(path.relative(profile.__workspaceRoot, target)); continue; } if (operation.kind === 'delete') { if (!fs.existsSync(target)) throw new Error(`delete target not found: ${operation.path}`); fs.rmSync(target, { force: true }); changed.push(path.relative(profile.__workspaceRoot, target)); continue; } if (operation.kind === 'update') { if (!fs.existsSync(target)) throw new Error(`update target not found: ${operation.path}`); const original = fs.readFileSync(target, 'utf8'); const updated = applyUpdateChunks(original, operation.chunks, operation.path); const destination = operation.movePath ? resolvePatchPath(profile, baseDir, operation.movePath) : target; ensureDir(path.dirname(destination)); fs.writeFileSync(destination, updated, 'utf8'); if (destination !== target) fs.rmSync(target, { force: true }); changed.push(destination === target ? path.relative(profile.__workspaceRoot, target) : `${path.relative(profile.__workspaceRoot, target)} -> ${path.relative(profile.__workspaceRoot, destination)}`); continue; } } ok('workspace.apply-patch', { base: path.relative(profile.__workspaceRoot, baseDir) || '.', changed, operationCount: operations.length }); } function decodePatchText(options) { if (typeof options.patch === 'string') return options.patch; if (typeof options.patchB64 === 'string') return Buffer.from(options.patchB64, 'base64').toString('utf8'); if (typeof options['patch-b64'] === 'string') return Buffer.from(options['patch-b64'], 'base64').toString('utf8'); throw new Error('workspace apply-patch requires --patch-b64 or --patch'); } function resolvePatchPath(profile, baseDir, patchPath) { const raw = String(patchPath || '').replace(/^\/+/, ''); if (!raw || raw.includes('\0')) throw new Error(`invalid patch path: ${patchPath}`); const resolved = path.resolve(baseDir, raw); const root = profile.__workspaceRoot; const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`; if (resolved !== root && !resolved.startsWith(rootWithSep)) throw new Error(`patch path escapes workspace: ${patchPath}`); return resolved; } function parseApplyPatch(patchText) { const lines = String(patchText).replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); if (lines[lines.length - 1] === '') lines.pop(); if (lines[0] !== '*** Begin Patch') throw new Error('patch must start with *** Begin Patch'); if (lines[lines.length - 1] !== '*** End Patch') throw new Error('patch must end with *** End Patch'); const operations = []; let index = 1; while (index < lines.length - 1) { const header = lines[index++]; if (header.startsWith('*** Add File: ')) { const filePath = header.slice('*** Add File: '.length).trim(); const content = []; while (index < lines.length - 1 && !lines[index].startsWith('*** ')) { const line = lines[index++]; if (!line.startsWith('+')) throw new Error(`add file line must start with + for ${filePath}`); content.push(line.slice(1)); } operations.push({ kind: 'add', path: filePath, content: `${content.join('\n')}\n` }); continue; } if (header.startsWith('*** Delete File: ')) { operations.push({ kind: 'delete', path: header.slice('*** Delete File: '.length).trim() }); continue; } if (header.startsWith('*** Update File: ')) { const filePath = header.slice('*** Update File: '.length).trim(); const chunks = []; let movePath = null; while (index < lines.length - 1 && !lines[index].startsWith('*** Add File: ') && !lines[index].startsWith('*** Delete File: ') && !lines[index].startsWith('*** Update File: ')) { const line = lines[index++]; if (line.startsWith('*** Move to: ')) { movePath = line.slice('*** Move to: '.length).trim(); continue; } if (!line.startsWith('@@')) throw new Error(`update hunk for ${filePath} must start with @@`); const changes = []; while (index < lines.length - 1 && !lines[index].startsWith('@@') && !lines[index].startsWith('*** ')) { const change = lines[index++]; const prefix = change[0]; if (prefix !== ' ' && prefix !== '+' && prefix !== '-') throw new Error(`invalid update line prefix in ${filePath}: ${change}`); changes.push({ prefix, text: change.slice(1) }); } chunks.push(changes); } operations.push({ kind: 'update', path: filePath, movePath, chunks }); continue; } throw new Error(`unsupported patch header: ${header}`); } return operations; } function applyUpdateChunks(original, chunks, filePath) { const hadFinalNewline = original.endsWith('\n'); const current = original.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); if (current[current.length - 1] === '') current.pop(); let cursor = 0; for (const chunk of chunks) { const oldLines = chunk.filter((line) => line.prefix !== '+').map((line) => line.text); const newLines = chunk.filter((line) => line.prefix !== '-').map((line) => line.text); const at = oldLines.length === 0 ? cursor : findSubsequence(current, oldLines, cursor); if (at < 0) throw new Error(`patch hunk did not match ${filePath}`); current.splice(at, oldLines.length, ...newLines); cursor = at + newLines.length; } const output = current.join('\n'); return hadFinalNewline || chunks.length > 0 ? `${output}\n` : output; } function findSubsequence(haystack, needle, start) { if (needle.length === 0) return start; for (let index = Math.max(0, start); index <= haystack.length - needle.length; index += 1) { let matched = true; for (let offset = 0; offset < needle.length; offset += 1) { if (haystack[index + offset] !== needle[offset]) { matched = false; break; } } if (matched) return index; } return -1; } function jobDir(profile) { const dir = path.join(stateRoot(profile.__workspaceRoot), 'jobs'); ensureDir(dir); return dir; } function jobFile(profile, jobId) { return path.join(jobDir(profile), `${jobId}.json`); } function latestJobId(profile, kindPrefix) { const dir = jobDir(profile); const jobs = fs.readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => { const file = path.join(dir, name); try { return { file, data: readJson(file) }; } catch { return null; } }).filter(Boolean).filter((job) => !kindPrefix || String(job.data.kind || '').startsWith(kindPrefix)); jobs.sort((a, b) => String(b.data.createdAt || '').localeCompare(String(a.data.createdAt || ''))); return jobs[0]?.data?.jobId || null; } function startJob(kind, payload) { const profile = loadProfile(); const id = `${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${kind}-${randomBytes(3).toString('hex')}`; const file = jobFile(profile, id); const job = { jobId: id, kind, status: 'queued', createdAt: nowIso(), updatedAt: nowIso(), profilePath: profile.__profilePath, workspaceRoot: profile.__workspaceRoot, payload }; writeJson(file, job); const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '__job', id], { cwd: profile.__workspaceRoot, detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, DEVICE_POD_PROFILE: profile.__profilePath, DEVICE_POD_WORKSPACE: profile.__workspaceRoot }, }); child.unref(); ok(`${kind}.start`, { jobId: id, statusFile: file }); } function readJobStatus(kindPrefix, requestedId) { const profile = loadProfile(); const id = requestedId || latestJobId(profile, kindPrefix); if (!id) throw new Error(`no job found for ${kindPrefix}`); const job = readJson(jobFile(profile, id)); if (job.logFile && fs.existsSync(job.logFile)) job.logTail = shortTail(fs.readFileSync(job.logFile, 'utf8')); ok(`${kindPrefix}.status`, job); } async function runJob(jobId) { const profile = loadProfile(); const file = jobFile(profile, jobId); const job = readJson(file); const update = (patch) => writeJson(file, { ...readJson(file), ...patch, updatedAt: nowIso() }); update({ status: 'running', startedAt: nowIso(), pid: process.pid }); try { let result; if (job.kind === 'keil-build') result = await runKeilBuild(profile, job.payload || {}); else if (job.kind === 'keil-download') result = await runKeilDownload(profile, job.payload || {}); else throw new Error(`unsupported job kind: ${job.kind}`); update({ status: result.success ? 'completed' : 'failed', finishedAt: nowIso(), result, logFile: result.logFile || null }); process.exitCode = result.success ? 0 : 1; } catch (error) { update({ status: 'failed', finishedAt: nowIso(), error: error instanceof Error ? error.message : String(error) }); process.exitCode = 1; } } function safeTarget(value) { return String(value || 'target').replace(/[^A-Za-z0-9_-]/g, '_'); } function keilLogHasErrors(logText) { const text = String(logText || ''); const summary = text.match(/(\d+)\s+Error\(s\)/i); if (summary) return Number(summary[1]) > 0; return /\b(fatal error|error:|failed)\b/i.test(text); } async function runKeilBuild(profile, payload = {}) { const tools = await toolInfo(profile); if (!tools.uv4.found) throw new Error('Keil UV4.exe not found'); const project = projectPath(profile); const target = payload.target || targetName(profile); const outDir = path.join(stateRoot(profile.__workspaceRoot), 'keil'); ensureDir(outDir); const logFile = path.join(outDir, `build_${safeTarget(target)}_${Date.now()}.log`); const args = ['-b', project, '-j0', '-sg', '-o', logFile]; if (target) args.push('-t', target); const result = await runCapture(tools.uv4.path, args, { cwd: path.dirname(project), timeoutMs: Number(payload.timeoutMs || 300000) }); const logText = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : ''; const success = result.exitCode === 0 && !keilLogHasErrors(logText); return { success, exitCode: result.exitCode, timedOut: result.timedOut, elapsedMs: result.elapsedMs, project, target, logFile, logTail: shortTail(logText), stdout: shortTail(result.stdout), stderr: shortTail(result.stderr) }; } function bindUvoptxProbe(profile, requestedUid = profile.debugInterface?.probeUid) { if (!requestedUid) throw new Error('profile.debugInterface.probeUid is required'); const file = uvoptxPath(projectPath(profile)); if (!fs.existsSync(file)) throw new Error(`uvoptx not found: ${file}`); const before = fs.readFileSync(file, 'utf8'); const replacements = []; const after = before.replace(/(-X"[^&]+CMSIS-DAP[^&]*"\s+-U)([^\s<]+)/gi, (match, prefix, oldUid) => { replacements.push({ oldUid, newUid: requestedUid, xmlEscaped: true }); return `${prefix}${requestedUid}`; }).replace(/(-X"[^"]*CMSIS-DAP[^"]*"\s+-U)([^\s<]+)/gi, (match, prefix, oldUid) => { replacements.push({ oldUid, newUid: requestedUid, xmlEscaped: false }); return `${prefix}${requestedUid}`; }); if (replacements.length === 0) throw new Error('no CMSIS-DAP -U binding found in uvoptx'); if (after !== before) { const backup = `${file}.device-host-cli.${Date.now()}.bak`; fs.writeFileSync(backup, before, 'utf8'); fs.writeFileSync(file, after, 'utf8'); return { changed: true, uvoptxPath: file, backup, replacements }; } return { changed: false, uvoptxPath: file, replacements }; } async function runKeilDownload(profile, payload = {}) { const tools = await toolInfo(profile); if (!tools.uv4.found) throw new Error('Keil UV4.exe not found'); const project = projectPath(profile); const target = payload.target || targetName(profile); let binding = null; if (profile.debugInterface?.autoBindUvoptx !== false) binding = bindUvoptxProbe(profile); const outDir = path.join(stateRoot(profile.__workspaceRoot), 'keil'); ensureDir(outDir); const logFile = path.join(outDir, `download_${safeTarget(target)}_${Date.now()}.log`); const args = ['-f', project, '-j0', '-sg', '-o', logFile]; if (target) args.push('-t', target); const timeoutMs = Number(payload.timeoutMs || 300000); const result = payload['capture-uart'] || payload.captureUart ? await runKeilDownloadWithUartCapture(profile, tools.uv4.path, args, path.dirname(project), payload) : await runCapture(tools.uv4.path, args, { cwd: path.dirname(project), timeoutMs }); const logText = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : ''; const success = result.exitCode === 0 && !keilLogHasErrors(logText); return { success, exitCode: result.exitCode, timedOut: result.timedOut, elapsedMs: result.elapsedMs, project, target, binding, logFile, logTail: shortTail(logText), stdout: shortTail(result.stdout), stderr: shortTail(result.stderr), uartCapture: result.uartCapture }; } async function runKeilDownloadWithUartCapture(profile, uv4Path, uv4Args, cwd, payload = {}) { const uart = resolveUart(profile, payload['capture-uart'] || payload.captureUart, payload); const captureDurationMs = Number(payload['capture-duration-ms'] || payload.captureDurationMs || payload['duration-ms'] || payload.durationMs || 8000); const timeoutMs = Number(payload.timeoutMs || 300000); const logDir = path.join(stateRoot(profile.__workspaceRoot), 'serial'); ensureDir(logDir); const serialLogFile = path.join(logDir, `${uart.id.replace(/\//g, '_')}.jsonl`); const script = [ '$ErrorActionPreference = \'Stop\';', `$uv4 = ${psQuote(uv4Path)};`, `$uv4Args = ${psArray(uv4Args)};`, `$cwd = ${psQuote(cwd)};`, `$portName = ${psQuote(uart.port)};`, `$baud = ${Number(uart.baudRate || 115200)};`, `$captureDurationMs = ${captureDurationMs};`, '$port = [System.IO.Ports.SerialPort]::new($portName, $baud, [System.IO.Ports.Parity]::None, 8, [System.IO.Ports.StopBits]::One);', '$port.Encoding = [System.Text.UTF8Encoding]::new($false);', '$port.ReadTimeout = 200;', '$timer = [System.Diagnostics.Stopwatch]::StartNew();', 'try {', ' $port.Open();', ' $port.DiscardInBuffer();', ' Push-Location -LiteralPath $cwd;', ' try { $uv4Output = & $uv4 @uv4Args 2>&1; $uv4ExitCode = $LASTEXITCODE } finally { Pop-Location }', ' Start-Sleep -Milliseconds $captureDurationMs;', ' $text = $port.ReadExisting();', ' $bytes = [System.Text.Encoding]::UTF8.GetBytes($text);', ' $timer.Stop();', ' [pscustomobject]@{ exitCode = $uv4ExitCode; timedOut = $false; elapsedMs = [int]$timer.ElapsedMilliseconds; stdout = ($uv4Output | Out-String); stderr = ""; contentB64 = [Convert]::ToBase64String($bytes) } | ConvertTo-Json -Depth 5 -Compress | Write-Output;', '} finally { if ($port.IsOpen) { $port.Close() }; $port.Dispose() }', ].join(' '); const psResult = await runPowerShell(script, Math.max(timeoutMs + captureDurationMs + 10000, 15000)); if (psResult.exitCode !== 0) throw new Error(`keil download UART capture failed on ${uart.port}@${uart.baudRate}: ${shortTail(psResult.stderr || psResult.stdout)}`); const payloadJson = JSON.parse(psResult.stdout.trim()); const content = payloadJson.contentB64 ? Buffer.from(payloadJson.contentB64, 'base64').toString('utf8') : ''; fs.appendFileSync(serialLogFile, `${JSON.stringify({ ts: nowIso(), direction: 'download-capture-uart', uartId: uart.id, port: uart.port, baudRate: uart.baudRate, content })}\n`, 'utf8'); return { exitCode: Number(payloadJson.exitCode || 0), timedOut: Boolean(payloadJson.timedOut), elapsedMs: Number(payloadJson.elapsedMs || 0), stdout: payloadJson.stdout || '', stderr: payloadJson.stderr || '', uartCapture: { uart, durationMs: captureDurationMs, content, bytes: Buffer.byteLength(content), logFile: serialLogFile }, }; } async function pyocdArgs(profile, commandArgs) { const tools = await toolInfo(profile); if (!tools.pyocd.found) throw new Error('pyOCD executable not found'); const debug = profile.debugInterface || {}; const args = [...commandArgs]; if (debug.probeUid) args.push('-u', debug.probeUid); if (debug.pyocdTarget) args.push('-t', debug.pyocdTarget); if (debug.connectMode) args.push('-M', debug.connectMode); if (debug.frequency) args.push('-f', debug.frequency); return { pyocd: tools.pyocd.path, args }; } async function pyocdCommanderArgs(profile, commands, options = {}) { const tools = await toolInfo(profile); if (!tools.pyocd.found) throw new Error('pyOCD executable not found'); const debug = profile.debugInterface || {}; const args = ['commander']; if (debug.probeUid) args.push('-u', debug.probeUid); if (debug.pyocdTarget) args.push('-t', debug.pyocdTarget); const connectMode = options['connect-mode'] || options.connectMode || debug.launchConnectMode || 'attach'; if (connectMode) args.push('-M', connectMode); if (debug.frequency) args.push('-f', debug.frequency); args.push('-W'); for (const command of commands) args.push('-c', command); return { pyocd: tools.pyocd.path, args }; } function parseFlashVectors(text, flashBase) { const normalizedBase = String(flashBase || '').toLowerCase().replace(/^0x/u, ''); const escapedBase = normalizedBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const match = String(text || '').toLowerCase().match(new RegExp(`${escapedBase}\\s*:\\s*([0-9a-f]{8})\\s+([0-9a-f]{8})`, 'u')); if (!match) throw new Error(`unable to parse flash vectors at ${flashBase}`); return { initialSp: `0x${match[1]}`, resetHandler: `0x${match[2]}` }; } async function debugStatus() { const profile = loadProfile(); const tools = await toolInfo(profile); if (!tools.pyocd.found) throw new Error('pyOCD executable not found'); const result = await runCapture(tools.pyocd.path, ['list', '--probes'], { timeoutMs: 15000 }); ok('debug-probe.status', { configuredProbeUid: profile.debugInterface?.probeUid, pyocdPath: tools.pyocd.path, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }); } async function chipId() { const profile = loadProfile(); const register = profile.target?.chipIdRegister || '0xE0042000'; const { pyocd, args } = await pyocdArgs(profile, ['cmd', '-c', `read32 ${register}`, '-c', 'exit']); const result = await runCapture(pyocd, args, { timeoutMs: 30000 }); const text = `${result.stdout}\n${result.stderr}`; const match = text.match(/([0-9a-fA-F]{8}):\s+([0-9a-fA-F]{8})/u); const rawValue = match ? `0x${match[2].toLowerCase()}` : null; const numeric = rawValue ? Number.parseInt(rawValue, 16) : null; const mask = Number.parseInt(profile.target?.expectedChipIdMask || '0xffffffff', 16); const expected = Number.parseInt(profile.target?.expectedChipId || '0x0', 16); ok('debug-probe.chip-id', { exitCode: result.exitCode, register, rawValue, maskedValue: numeric === null ? null : `0x${(numeric & mask).toString(16).padStart(8, '0')}`, expected: profile.target?.expectedChipId || null, matchesExpected: numeric === null ? null : (numeric & mask) === expected, stdout: result.stdout, stderr: result.stderr }); } async function resetRun() { const profile = loadProfile(); const { pyocd, args } = await pyocdArgs(profile, ['cmd', '-c', 'reset', '-c', 'go', '-c', 'exit']); const result = await runCapture(pyocd, args, { timeoutMs: 30000 }); ok('debug-probe.reset', { exitCode: result.exitCode, success: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr }); } async function launchFlash(options = {}) { const profile = loadProfile(); const flashBase = String(options['flash-base'] || options.flashBase || profile.debugInterface?.flashBase || profile.target?.flashBase || '0x08000000'); const skipHardwareReset = Boolean(options['skip-hardware-reset'] || options.skipHardwareReset); const prepareCommand = skipHardwareReset ? 'halt' : 'reset halt HARDWARE'; const timeoutMs = Number(options['timeout-ms'] || options.timeoutMs || 60000); const read = await pyocdCommanderArgs(profile, [prepareCommand, `read32 ${flashBase} 8`], options); const vectorResult = await runCapture(read.pyocd, read.args, { timeoutMs }); if (vectorResult.exitCode !== 0) throw new Error(`pyOCD vector read failed: ${shortTail(vectorResult.stdout || vectorResult.stderr)}`); const vectorText = `${vectorResult.stdout}\n${vectorResult.stderr}`; const vectors = parseFlashVectors(vectorText, flashBase); const launchCommands = [ prepareCommand, `write32 0xE000ED08 ${flashBase}`, `wreg sp ${vectors.initialSp}`, `wreg pc ${vectors.resetHandler}`, 'wreg xpsr 0x01000000', 'wreg primask 0', 'wreg faultmask 0', 'wreg basepri 0', 'go', ]; const launch = await pyocdCommanderArgs(profile, launchCommands, options); const launchResult = await runCapture(launch.pyocd, launch.args, { timeoutMs }); ok('debug-probe.launch-flash', { success: launchResult.exitCode === 0, exitCode: launchResult.exitCode, flashBase, vectorTableRegister: '0xE000ED08', prepareCommand, skipHardwareReset, initialSp: vectors.initialSp, resetHandler: vectors.resetHandler, stdout: launchResult.stdout, stderr: launchResult.stderr, vectorRead: { exitCode: vectorResult.exitCode, stdout: vectorResult.stdout, stderr: vectorResult.stderr }, }); } async function serialPorts() { const script = "Get-CimInstance Win32_SerialPort | Select-Object DeviceID,Name,PNPDeviceID | ConvertTo-Json -Compress"; const result = await runPowerShell(script, 15000); let ports = []; if (result.stdout.trim()) { const parsed = JSON.parse(result.stdout.trim()); ports = Array.isArray(parsed) ? parsed : [parsed]; } const profile = loadProfile(); ok('io-probe.ports', { configured: profile.ioInterface?.uart || [], ports, stderr: result.stderr, exitCode: result.exitCode }); } function resolveUart(profile, uartId, options = {}) { const normalized = String(uartId || 'uart/1').trim().replace(/\\/gu, '/').replace(/\s*\/\s*/gu, '/').replace(/^\/+/, ''); const item = (profile.ioInterface?.uart || []).find((entry) => entry.id === normalized); if (!item) throw new Error(`uart profile not found: ${normalized}`); const resolved = { ...item }; if (options.port) resolved.port = String(options.port); const baudRate = options['baud-rate'] || options.baudRate; if (baudRate !== undefined) { const parsed = Number(baudRate); if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`invalid UART baud rate: ${baudRate}`); resolved.baudRate = parsed; } return resolved; } async function serialRead(uartId, options) { const profile = loadProfile(); const uart = resolveUart(profile, uartId, options); const durationMs = Number(options['duration-ms'] || options.durationMs || 1000); const script = [ '$ErrorActionPreference = \'Stop\';', `$portName = ${psQuote(uart.port)};`, `$baud = ${Number(uart.baudRate || 115200)};`, `$durationMs = ${durationMs};`, '$port = [System.IO.Ports.SerialPort]::new($portName, $baud, [System.IO.Ports.Parity]::None, 8, [System.IO.Ports.StopBits]::One);', '$port.Encoding = [System.Text.UTF8Encoding]::new($false);', '$port.ReadTimeout = 200;', 'try { $port.Open(); Start-Sleep -Milliseconds $durationMs; $text = $port.ReadExisting(); $bytes = [System.Text.Encoding]::UTF8.GetBytes($text); [Console]::Out.Write([Convert]::ToBase64String($bytes)) } finally { if ($port.IsOpen) { $port.Close() }; $port.Dispose() }', ].join(' '); const result = await runPowerShell(script, Math.max(5000, durationMs + 5000)); const b64 = result.stdout.trim(); const content = b64 ? Buffer.from(b64, 'base64').toString('utf8') : ''; const logDir = path.join(stateRoot(profile.__workspaceRoot), 'serial'); ensureDir(logDir); const logFile = path.join(logDir, `${uart.id.replace(/\//g, '_')}.jsonl`); fs.appendFileSync(logFile, `${JSON.stringify({ ts: nowIso(), direction: 'read', uartId: uart.id, port: uart.port, baudRate: uart.baudRate, exitCode: result.exitCode, content, stderr: result.stderr })}\n`, 'utf8'); if (result.exitCode !== 0) throw new Error(`uart read failed on ${uart.port}@${uart.baudRate}: ${shortTail(result.stderr || result.stdout)}`); ok('io-probe.uart.read', { uart, durationMs, exitCode: result.exitCode, content, bytes: Buffer.byteLength(content), logFile, stderr: result.stderr }); } async function serialReadAfterLaunchFlash(uartId, options) { const profile = loadProfile(); const uart = resolveUart(profile, uartId, options); const tools = await toolInfo(profile); if (!tools.pyocd.found) throw new Error('pyOCD executable not found'); const debug = profile.debugInterface || {}; const durationMs = Number(options['duration-ms'] || options.durationMs || 5000); const flashBase = String(options['flash-base'] || options.flashBase || debug.flashBase || profile.target?.flashBase || '0x08000000'); const skipHardwareReset = Boolean(options['skip-hardware-reset'] || options.skipHardwareReset); const prepareCommand = skipHardwareReset ? 'halt' : 'reset halt HARDWARE'; const connectMode = options['connect-mode'] || options.connectMode || debug.launchConnectMode || 'attach'; const script = [ '$ErrorActionPreference = \'Stop\';', `$portName = ${psQuote(uart.port)};`, `$baud = ${Number(uart.baudRate || 115200)};`, `$durationMs = ${durationMs};`, `$pyocd = ${psQuote(tools.pyocd.path)};`, `$uid = ${psQuote(debug.probeUid || '')};`, `$target = ${psQuote(debug.pyocdTarget || '')};`, `$frequency = ${psQuote(debug.frequency || '')};`, `$connectMode = ${psQuote(connectMode || '')};`, `$flashBase = ${psQuote(flashBase)};`, `$prepareCommand = ${psQuote(prepareCommand)};`, '$common = @(\'commander\');', 'if ($uid) { $common += @(\'-u\', $uid) }', 'if ($target) { $common += @(\'-t\', $target) }', 'if ($connectMode) { $common += @(\'-M\', $connectMode) }', 'if ($frequency) { $common += @(\'-f\', $frequency) }', '$common += @(\'-W\');', '$port = [System.IO.Ports.SerialPort]::new($portName, $baud, [System.IO.Ports.Parity]::None, 8, [System.IO.Ports.StopBits]::One);', '$port.Encoding = [System.Text.UTF8Encoding]::new($false);', '$port.ReadTimeout = 200;', 'try {', ' $port.Open();', ' $port.DiscardInBuffer();', ' $port.Close();', ' $vectorOutput = & $pyocd @common -c $prepareCommand -c "read32 $flashBase 8" 2>&1;', ' $vectorExitCode = $LASTEXITCODE;', ' $vectorText = ($vectorOutput | Out-String);', ' if ($vectorExitCode -ne 0) { throw "pyOCD vector read failed: $vectorText" }', ' $escapedBase = [regex]::Escape($flashBase.ToLower().Replace("0x", ""));', ' $match = [regex]::Match($vectorText.ToLower(), "$escapedBase\\s*:\\s*([0-9a-f]{8})\\s+([0-9a-f]{8})");', ' if (-not $match.Success) { throw "Unable to parse flash vectors from pyOCD output: $vectorText" }', ' $initialSp = "0x" + $match.Groups[1].Value;', ' $resetHandler = "0x" + $match.Groups[2].Value;', ' $launchOutput = & $pyocd @common -c $prepareCommand -c "write32 0xE000ED08 $flashBase" -c "wreg sp $initialSp" -c "wreg pc $resetHandler" -c "wreg xpsr 0x01000000" -c "wreg primask 0" -c "wreg faultmask 0" -c "wreg basepri 0" -c "go" 2>&1;', ' $launchExitCode = $LASTEXITCODE;', ' $launchText = ($launchOutput | Out-String);', ' if ($launchExitCode -ne 0) { throw "pyOCD flash-vector launch failed: $launchText" }', ' $port.Open();', ' Start-Sleep -Milliseconds $durationMs;', ' $text = $port.ReadExisting();', ' $bytes = [System.Text.Encoding]::UTF8.GetBytes($text);', ' [pscustomobject]@{ contentB64 = [Convert]::ToBase64String($bytes); flashBase = $flashBase; vectorTableRegister = "0xE000ED08"; prepareCommand = $prepareCommand; initialSp = $initialSp; resetHandler = $resetHandler; vectorExitCode = $vectorExitCode; launchExitCode = $launchExitCode; vectorStdout = $vectorText; launchStdout = $launchText } | ConvertTo-Json -Depth 5 -Compress | Write-Output;', '} finally { if ($port.IsOpen) { $port.Close() }; $port.Dispose() }', ].join(' '); const result = await runPowerShell(script, Math.max(15000, durationMs + 20000)); if (result.exitCode !== 0) throw new Error(`uart read-after-launch-flash failed: ${shortTail(result.stdout || result.stderr)}`); const payload = JSON.parse(result.stdout.trim()); const content = payload.contentB64 ? Buffer.from(payload.contentB64, 'base64').toString('utf8') : ''; const logDir = path.join(stateRoot(profile.__workspaceRoot), 'serial'); ensureDir(logDir); const logFile = path.join(logDir, `${uart.id.replace(/\//g, '_')}.jsonl`); fs.appendFileSync(logFile, `${JSON.stringify({ ts: nowIso(), direction: 'read-after-launch-flash', uartId: uart.id, port: uart.port, baudRate: uart.baudRate, content })}\n`, 'utf8'); ok('io-probe.uart.read-after-launch-flash', { uart, durationMs, exitCode: result.exitCode, content, bytes: Buffer.byteLength(content), logFile, stderr: result.stderr, launch: { flashBase: payload.flashBase, vectorTableRegister: payload.vectorTableRegister, prepareCommand: payload.prepareCommand, initialSp: payload.initialSp, resetHandler: payload.resetHandler, vectorExitCode: payload.vectorExitCode, launchExitCode: payload.launchExitCode, vectorStdout: payload.vectorStdout, launchStdout: payload.launchStdout } }); } async function serialWrite(uartId, options) { const profile = loadProfile(); const uart = resolveUart(profile, uartId, options); const message = options.hex ? Buffer.from(String(options._[0] || ''), 'hex').toString('binary') : String(options._[0] || ''); const encoded = Buffer.from(message, options.hex ? 'binary' : 'utf8').toString('base64'); const script = [ '$ErrorActionPreference = \'Stop\';', `$portName = ${psQuote(uart.port)};`, `$baud = ${Number(uart.baudRate || 115200)};`, `$encoded = ${psQuote(encoded)};`, '$bytes = [Convert]::FromBase64String($encoded);', '$port = [System.IO.Ports.SerialPort]::new($portName, $baud, [System.IO.Ports.Parity]::None, 8, [System.IO.Ports.StopBits]::One);', 'try { $port.Open(); $port.BaseStream.Write($bytes, 0, $bytes.Length); $port.BaseStream.Flush(); [Console]::Out.Write($bytes.Length) } finally { if ($port.IsOpen) { $port.Close() }; $port.Dispose() }', ].join(' '); const result = await runPowerShell(script, 10000); if (result.exitCode !== 0) throw new Error(`uart write failed on ${uart.port}@${uart.baudRate}: ${shortTail(result.stderr || result.stdout)}`); ok('io-probe.uart.write', { uart, exitCode: result.exitCode, bytesWritten: Number(result.stdout.trim() || 0), stderr: result.stderr }); } function usage() { ok('help', { usage: [ 'node tools/device-host-cli.mjs --profile .device-pod/.json health', 'node tools/device-host-cli.mjs --pod-id health', 'node tools/device-host-cli.mjs health', 'node tools/device-host-cli.mjs workspace ls [path]', 'node tools/device-host-cli.mjs workspace cat [--limit N]', 'node tools/device-host-cli.mjs workspace rg [path]', 'node tools/device-host-cli.mjs workspace apply-patch [base] --patch-b64 ', 'node tools/device-host-cli.mjs workspace build start|status [jobId]', 'node tools/device-host-cli.mjs debug-probe status|chip-id|bind|download|reset|launch-flash', 'node tools/device-host-cli.mjs io-probe uart/1 ports|read|read-after-launch-flash|write [args]' ] }); } async function main() { const [group, command, ...rest] = GLOBAL_CLI.args; if (!group || group === 'help' || group === '--help') return usage(); if (group === '__job') return await runJob(command); if (group === 'health') return await health(); if (group === 'profile' && command === 'show') return ok('profile.show', loadProfile()); if (group === 'workspace') { if (command === 'ls') return listWorkspace(rest[0] || '.'); if (command === 'cat') return catWorkspace(rest[0], parseArgs(rest.slice(1)).limit); if (command === 'rg') return rgWorkspace(rest[0], rest[1] || '.', parseArgs(rest.slice(2))); if (command === 'apply-patch') return workspaceApplyPatch(rest[0] || '.', parseArgs(rest.slice(1))); if (command === 'build') { const sub = rest[0] || 'start'; if (sub === 'start') return startJob('keil-build', parseArgs(rest.slice(1))); if (sub === 'status') return readJobStatus('keil-build', rest[1]); if (sub === 'wait') return readJobStatus('keil-build', rest[1]); } } if (group === 'debug-probe') { if (command === 'status') return await debugStatus(); if (command === 'chip-id') return await chipId(); if (command === 'bind') return ok('debug-probe.bind', bindUvoptxProbe(loadProfile())); if (command === 'download') { const sub = rest[0] || 'start'; if (sub === 'start') return startJob('keil-download', parseArgs(rest.slice(1))); if (sub === 'status') return readJobStatus('keil-download', rest[1]); } if (command === 'reset') return await resetRun(); if (command === 'launch-flash') return await launchFlash(parseArgs(rest)); } if (group === 'io-probe') { const uartId = command; const sub = rest[0] || 'read'; if (sub === 'ports') return await serialPorts(); if (sub === 'read') return await serialRead(uartId, parseArgs(rest.slice(1))); if (sub === 'read-after-launch-flash') return await serialReadAfterLaunchFlash(uartId, parseArgs(rest.slice(1))); if (sub === 'write') return await serialWrite(uartId, parseArgs(rest.slice(1))); } throw new Error(`unsupported command: ${process.argv.slice(2).join(' ')}`); } main().catch((error) => fail('device-host-cli', error));