703 lines
31 KiB
JavaScript
703 lines
31 KiB
JavaScript
#!/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 = process.env.DEVICE_POD_ID || 'device-pod-71-freq';
|
|
|
|
function workspaceRoot() {
|
|
return path.resolve(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 = DEFAULT_POD_ID) {
|
|
return path.join(devicePodDir(root), `${podId}.json`);
|
|
}
|
|
|
|
function loadProfile() {
|
|
const root = workspaceRoot();
|
|
const file = process.env.DEVICE_POD_PROFILE || profilePath(root);
|
|
if (!fs.existsSync(file)) throw new Error(`device-pod profile not found: ${file}`);
|
|
const profile = readJson(file);
|
|
profile.__profilePath = file;
|
|
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 parseArgs(args) {
|
|
const out = { _: [] };
|
|
for (let i = 0; i < args.length; i += 1) {
|
|
const arg = args[i];
|
|
if (!arg.startsWith('--')) {
|
|
out._.push(arg);
|
|
continue;
|
|
}
|
|
const eq = arg.indexOf('=');
|
|
if (eq >= 0) {
|
|
out[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
continue;
|
|
}
|
|
const key = arg.slice(2);
|
|
const next = args[i + 1];
|
|
if (next !== undefined && !next.startsWith('--')) {
|
|
out[key] = next;
|
|
i += 1;
|
|
} else {
|
|
out[key] = true;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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) {
|
|
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 walkFiles(root, '.', files);
|
|
const re = new RegExp(pattern, 'i');
|
|
const matches = [];
|
|
for (const fileRel of files) {
|
|
if (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);
|
|
lines.forEach((line, index) => {
|
|
if (matches.length < 100 && re.test(line)) matches.push({ path: fileRel, line: index + 1, text: line.slice(0, 500) });
|
|
});
|
|
}
|
|
ok('workspace.rg', { pattern, root: path.relative(profile.__workspaceRoot, root) || '.', count: matches.length, matches });
|
|
}
|
|
|
|
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 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, binding, logFile, logTail: shortTail(logText), stdout: shortTail(result.stdout), stderr: shortTail(result.stderr) };
|
|
}
|
|
|
|
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 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 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) {
|
|
const normalized = String(uartId || 'uart/1').replace(/^\/+/, '');
|
|
const item = (profile.ioInterface?.uart || []).find((entry) => entry.id === normalized);
|
|
if (!item) throw new Error(`uart profile not found: ${normalized}`);
|
|
return item;
|
|
}
|
|
|
|
async function serialRead(uartId, options) {
|
|
const profile = loadProfile();
|
|
const uart = resolveUart(profile, uartId);
|
|
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, content })}\n`, 'utf8');
|
|
ok('io-probe.uart.read', { uart, durationMs, exitCode: result.exitCode, content, bytes: Buffer.byteLength(content), logFile, stderr: result.stderr });
|
|
}
|
|
|
|
async function serialWrite(uartId, options) {
|
|
const profile = loadProfile();
|
|
const uart = resolveUart(profile, uartId);
|
|
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);
|
|
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 health',
|
|
'node tools/device-host-cli.mjs workspace ls [path]',
|
|
'node tools/device-host-cli.mjs workspace cat <path> [--limit N]',
|
|
'node tools/device-host-cli.mjs workspace rg <pattern> [path]',
|
|
'node tools/device-host-cli.mjs workspace apply-patch [base] --patch-b64 <base64>',
|
|
'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',
|
|
'node tools/device-host-cli.mjs io-probe uart/1 ports|read|write [args]'
|
|
]
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const [group, command, ...rest] = process.argv.slice(2);
|
|
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] || '.');
|
|
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 (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 === '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));
|