2032 lines
102 KiB
JavaScript
2032 lines
102 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawn } from 'node:child_process';
|
|
import { createHash, 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 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 decodeBase64Utf8(value, label = 'base64 value') {
|
|
if (typeof value !== 'string' || !value) throw new Error(`${label} requires a non-empty base64 value`);
|
|
return Buffer.from(value, 'base64').toString('utf8');
|
|
}
|
|
|
|
function optionValue(options, ...keys) {
|
|
for (const key of keys) {
|
|
if (options[key] !== undefined) return options[key];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function optionList(options, ...keys) {
|
|
const values = [];
|
|
for (const key of keys) {
|
|
const value = options[key];
|
|
if (value === undefined || value === false) continue;
|
|
values.push(...(Array.isArray(value) ? value : [value]));
|
|
}
|
|
return values.map((value) => String(value)).filter(Boolean);
|
|
}
|
|
|
|
function booleanOption(options, ...keys) {
|
|
return keys.some((key) => options[key] === true);
|
|
}
|
|
|
|
function optionString(options, ...keys) {
|
|
const value = optionValue(options, ...keys);
|
|
if (value === undefined || value === null || value === false || value === true) return undefined;
|
|
return String(Array.isArray(value) ? value[0] : value);
|
|
}
|
|
|
|
function normalizeTextEncoding(value) {
|
|
const normalized = String(value || 'utf8').trim().toLowerCase().replace(/[-_]/gu, '');
|
|
if (normalized === 'utf8') return 'utf8';
|
|
if (normalized === 'latin1' || normalized === 'binary') return 'latin1';
|
|
throw new Error(`unsupported text encoding: ${value}. Use utf8 or latin1`);
|
|
}
|
|
|
|
function selectedPodId() {
|
|
const explicitPodId = GLOBAL_CLI.options.podId || process.env.DEVICE_POD_ID || '';
|
|
if (explicitPodId) return explicitPodId;
|
|
const requestedProfile = GLOBAL_CLI.options.profile || process.env.DEVICE_POD_PROFILE || '';
|
|
if (requestedProfile) {
|
|
const root = workspaceRoot();
|
|
const file = path.isAbsolute(requestedProfile) ? requestedProfile : path.resolve(root, requestedProfile);
|
|
if (fs.existsSync(file)) {
|
|
const profile = readJson(file);
|
|
const profilePodId = profile.devicePodId || profile.podId;
|
|
if (profilePodId) return profilePodId;
|
|
}
|
|
const basenamePodId = path.basename(file, path.extname(file));
|
|
if (basenamePodId) return basenamePodId;
|
|
}
|
|
throw new Error('device-pod profile requires --pod-id, DEVICE_POD_ID, or an explicit profile containing podId/devicePodId; no default pod is assumed');
|
|
}
|
|
|
|
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 emit(action, data = {}, okValue = true, exitCode = 0) {
|
|
console.log(JSON.stringify({ ok: okValue, action, generatedAt: nowIso(), data }, null, 2));
|
|
process.exitCode = exitCode;
|
|
}
|
|
|
|
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, ...hintForHostFailure(action, message) }, null, 2));
|
|
process.exitCode = exitCode;
|
|
}
|
|
|
|
function hintForHostFailure(action, message) {
|
|
if (action === 'device-host-cli' && /patch|hunk|\*\*\* Begin Patch|\*\*\* End Patch|Update File|Add File|Delete File/iu.test(String(message || ''))) {
|
|
return { patchHint: patchFailureHint(message) };
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function patchFailureHint(message = '') {
|
|
const text = String(message || '');
|
|
const next = ['Re-read the target file with `hwpod <pod>:workspace:/... cat <path>` or `rg` before retrying.'];
|
|
if (/raw file content|Codex patch envelope|patch must start with|patch must end with|one line|stdin heredoc/iu.test(text)) {
|
|
next.unshift('For `apply-patch --add-file <path>`, send raw file content through a real multi-line stdin heredoc: `hwpod <pod>:workspace:/ apply-patch --add-file User/new.c <<\'EOF\'` then file lines, then `EOF` on its own line. Do not include `*** Begin Patch` with --add-file.');
|
|
}
|
|
if (/end with \*\*\* End Patch/iu.test(text)) {
|
|
next.unshift('Add a final `*** End Patch` line. The marker is required and must be exactly capitalized.');
|
|
} else if (/start with \*\*\* Begin Patch/iu.test(text)) {
|
|
next.unshift('Start the patch with an exact `*** Begin Patch` line.');
|
|
} else if (/hunk did not match|must start with @@|invalid update line prefix|ellipsis|cannot use ellipsis/iu.test(text)) {
|
|
next.unshift('Retry with a smaller ordinary hunk using exact current context lines; do not use ellipsis or line-number-only hunks.');
|
|
} else if (/unsupported patch header|capitalization|New File/iu.test(text)) {
|
|
next.unshift('Use exactly `*** Update File:`, `*** Add File:`, or `*** Delete File:` headers.');
|
|
} else if (/add file line must start with \+/iu.test(text)) {
|
|
next.unshift('For direct `*** Add File:` patches, prefix every content line with `+`; for raw new-file content prefer `apply-patch --add-file <path> < file`.');
|
|
}
|
|
next.push('Do not switch to `--replace-file` or `workspace put` for an existing source file unless a smaller hunk cannot safely express the edit.');
|
|
return {
|
|
standardForm: [
|
|
'*** Begin Patch',
|
|
'*** Update File: path/to/file.c',
|
|
'@@',
|
|
' exact unchanged context line',
|
|
'-old line',
|
|
'+new line',
|
|
'*** End Patch'
|
|
],
|
|
next
|
|
};
|
|
}
|
|
|
|
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(decodeBase64Utf8(GLOBAL_CLI.options.profileJsonB64, '--profile-json-b64'));
|
|
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 || 'device-pod'}.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 splitOptionalWorkspacePath(args, fallback = '.') {
|
|
const first = args[0];
|
|
if (first === undefined || String(first).startsWith('-')) return { rel: fallback, optionArgs: args };
|
|
return { rel: first, optionArgs: args.slice(1) };
|
|
}
|
|
|
|
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) {
|
|
return listWorkspaceWithOptions(rel, {});
|
|
}
|
|
|
|
function parsePositiveInt(value, fallback, label, min = 0, max = 10) {
|
|
if (value === undefined || value === false || value === true || value === '') return fallback;
|
|
const number = Number(value);
|
|
if (!Number.isInteger(number) || number < min || number > max) throw new Error(`invalid ${label}: ${value}. Use ${min}..${max}`);
|
|
return number;
|
|
}
|
|
|
|
function listWorkspaceWithOptions(rel, options = {}) {
|
|
const profile = loadProfile();
|
|
const dir = resolveWorkspacePath(profile, rel || '.');
|
|
const depth = parsePositiveInt(optionValue(options, 'depth'), booleanOption(options, 'tree', 'recursive') ? 2 : 0, 'ls depth', 0, 6);
|
|
const limit = parsePositiveInt(optionValue(options, 'limit'), 300, 'ls limit', 1, 2000);
|
|
const entries = readWorkspaceEntries(dir, { root: profile.__workspaceRoot });
|
|
const tree = depth > 0 || booleanOption(options, 'tree', 'recursive')
|
|
? buildWorkspaceTree(dir, { root: profile.__workspaceRoot, maxDepth: depth, limit })
|
|
: undefined;
|
|
const workspaceRelativePath = path.relative(profile.__workspaceRoot, dir) || '.';
|
|
ok('workspace.ls', {
|
|
path: workspaceRelativePath,
|
|
workspaceRoot: profile.__workspaceRoot,
|
|
selectorPath: normalizeSlashPath(workspaceRelativePath),
|
|
entries,
|
|
tree,
|
|
guidance: workspaceNavigationGuidance(selectedPodId(), workspaceRelativePath)
|
|
});
|
|
}
|
|
|
|
function readWorkspaceEntries(dir, context) {
|
|
return 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',
|
|
path: normalizeSlashPath(path.relative(context.root, full)),
|
|
bytes: stat.size,
|
|
mtime: stat.mtime.toISOString()
|
|
};
|
|
}).sort(sortDirFirstByName);
|
|
}
|
|
|
|
function sortDirFirstByName(a, b) {
|
|
const rank = (entry) => entry.type === 'dir' ? 0 : entry.type === 'file' ? 1 : 2;
|
|
return rank(a) - rank(b) || a.name.localeCompare(b.name);
|
|
}
|
|
|
|
function buildWorkspaceTree(dir, context) {
|
|
const out = [];
|
|
const maxDepth = context.maxDepth;
|
|
const limit = context.limit;
|
|
let truncated = false;
|
|
function visit(current, depth) {
|
|
if (out.length >= limit) { truncated = true; return; }
|
|
for (const entry of readWorkspaceEntries(current, context)) {
|
|
if (out.length >= limit) { truncated = true; return; }
|
|
const full = path.join(current, entry.name);
|
|
out.push({ ...entry, depth });
|
|
if (entry.type === 'dir' && depth < maxDepth) visit(full, depth + 1);
|
|
}
|
|
}
|
|
visit(dir, 0);
|
|
return { root: normalizeSlashPath(path.relative(context.root, dir)) || '.', maxDepth, limit, truncated, entries: out };
|
|
}
|
|
|
|
function workspaceNavigationGuidance(podId, workspaceRelativePath) {
|
|
const base = workspaceRelativePath && workspaceRelativePath !== '.' ? `/${normalizeSlashPath(workspaceRelativePath)}` : '/';
|
|
return {
|
|
startOfTask: `Run hwpod bootsharp --pod-id ${podId} at the start of a device-pod task and after context compaction/resume.`,
|
|
explore: [
|
|
`hwpod ${podId}:workspace:${base} ls --tree --depth 2`,
|
|
`hwpod ${podId}:workspace:${base} rg <pattern> [path]`,
|
|
`hwpod ${podId}:workspace:${base} cat <path> --offset 0 --limit 4096`
|
|
],
|
|
edit: [
|
|
`hwpod ${podId}:workspace:${base} apply-patch <<'PATCH'`,
|
|
`hwpod ${podId}:workspace:${base} apply-patch --add-file <path> <<'EOF'`
|
|
],
|
|
note: 'Prefer subdir selectors such as :workspace:/projects/01_baseline so patch paths stay short and relative to the selected base.'
|
|
};
|
|
}
|
|
|
|
function readAgentsMd(profile, baseDir, options = {}) {
|
|
const limit = parsePositiveInt(optionValue(options, 'agents-limit', 'agentsLimit'), 200000, 'AGENTS.md limit', 0, 1000000);
|
|
const candidates = [path.join(baseDir, 'AGENTS.md'), path.join(profile.__workspaceRoot, 'AGENTS.md')];
|
|
const found = candidates.find((candidate, index) => fs.existsSync(candidate) && (index === 0 || candidate !== candidates[0]));
|
|
if (!found) return { found: false, path: null, content: '', bytes: 0, truncated: false };
|
|
const buffer = fs.readFileSync(found);
|
|
const truncated = limit > 0 && buffer.length > limit;
|
|
const content = buffer.subarray(0, limit || buffer.length).toString('utf8');
|
|
return { found: true, path: normalizeSlashPath(path.relative(profile.__workspaceRoot, found)) || 'AGENTS.md', bytes: buffer.length, truncated, content };
|
|
}
|
|
|
|
function bootsharpWorkspace(rel = '.', options = {}) {
|
|
const profile = loadProfile();
|
|
const dir = resolveWorkspacePath(profile, rel || '.');
|
|
const depth = parsePositiveInt(optionValue(options, 'depth'), 2, 'bootsharp depth', 0, 6);
|
|
const limit = parsePositiveInt(optionValue(options, 'limit'), 500, 'bootsharp tree limit', 1, 3000);
|
|
const workspaceRelativePath = path.relative(profile.__workspaceRoot, dir) || '.';
|
|
ok('workspace.bootsharp', {
|
|
podId: selectedPodId(),
|
|
path: workspaceRelativePath,
|
|
workspaceRoot: profile.__workspaceRoot,
|
|
tree: buildWorkspaceTree(dir, { root: profile.__workspaceRoot, maxDepth: depth, limit }),
|
|
agentsMd: readAgentsMd(profile, dir, options),
|
|
guidance: {
|
|
requiredWhen: [
|
|
'Run this command before the first edit in a device-pod task.',
|
|
'Run this command again after context compaction, session resume, or when the active device-pod/workspace is unclear.'
|
|
],
|
|
pathDiscipline: 'Use ls/rg/cat from the selected workspace base before guessing paths. For nested projects, prefer a selector like :workspace:/projects/01_baseline.',
|
|
patchDiscipline: 'Use apply-patch through a real multi-line stdin heredoc. Do not create temporary patch files solely to pass content to hwpod. Use apply-patch --add-file for new files; it creates missing parent directories and expects raw file content, not *** Begin Patch envelopes. Do not use put/replace-file for existing source unless ordinary hunks cannot safely express the edit.',
|
|
examples: workspaceNavigationGuidance(selectedPodId(), workspaceRelativePath)
|
|
}
|
|
});
|
|
}
|
|
|
|
function catWorkspace(rel, options = {}) {
|
|
const profile = loadProfile();
|
|
const file = resolveWorkspacePath(profile, rel);
|
|
const encoding = normalizeTextEncoding(options.encoding || options.charset || 'utf8');
|
|
const offset = Number(options.offset || 0);
|
|
const limit = Number(options.limit || 40000);
|
|
if (!Number.isFinite(offset) || offset < 0) throw new Error(`invalid cat offset: ${options.offset}`);
|
|
if (!Number.isFinite(limit) || limit < 0) throw new Error(`invalid cat limit: ${options.limit}`);
|
|
const buffer = fs.readFileSync(file);
|
|
const chunk = buffer.subarray(offset, Math.min(buffer.length, offset + limit));
|
|
const content = chunk.toString(encoding);
|
|
ok('workspace.cat', {
|
|
path: path.relative(profile.__workspaceRoot, file),
|
|
bytes: buffer.length,
|
|
offset,
|
|
returnedBytes: chunk.length,
|
|
truncated: offset + chunk.length < buffer.length,
|
|
encoding,
|
|
base64: Boolean(options.base64),
|
|
content: options.base64 ? chunk.toString('base64') : content,
|
|
});
|
|
}
|
|
|
|
function workspacePut(rel, options = {}) {
|
|
if (!rel || String(rel).startsWith('-')) throw new Error('workspace put requires a target path');
|
|
const profile = loadProfile();
|
|
const file = resolveWorkspacePath(profile, rel);
|
|
const encoding = normalizeTextEncoding(options.encoding || options.charset || 'utf8');
|
|
const parent = path.dirname(file);
|
|
if ((options['create-only'] === true || options.createOnly === true) && fs.existsSync(file)) throw new Error(`put target already exists: ${rel}`);
|
|
if ((options['update-only'] === true || options.updateOnly === true) && !fs.existsSync(file)) throw new Error(`put target does not exist: ${rel}`);
|
|
const content = decodePutBuffer(options, encoding);
|
|
if (!fs.existsSync(parent)) {
|
|
if (options['create-dirs'] === true || options.createDirs === true) ensureDir(parent);
|
|
else throw new Error(`put parent directory not found for ${rel}: ${path.relative(profile.__workspaceRoot, parent) || '.'}. Check the workspace-relative path or pass --create-dirs when creating a new directory intentionally.`);
|
|
}
|
|
fs.writeFileSync(file, content);
|
|
ok('workspace.put', {
|
|
path: path.relative(profile.__workspaceRoot, file),
|
|
bytes: content.length,
|
|
sha256: createHash('sha256').update(content).digest('hex'),
|
|
encoding,
|
|
createOnly: Boolean(options['create-only'] || options.createOnly),
|
|
updateOnly: Boolean(options['update-only'] || options.updateOnly),
|
|
createDirs: Boolean(options['create-dirs'] || options.createDirs),
|
|
});
|
|
}
|
|
|
|
function workspaceRmdir(rel) {
|
|
if (!rel || String(rel).startsWith('-')) throw new Error('workspace rmdir requires an empty directory path');
|
|
const profile = loadProfile();
|
|
const dir = resolveWorkspacePath(profile, rel);
|
|
if (!fs.existsSync(dir)) throw new Error(`rmdir target not found: ${rel}`);
|
|
if (!fs.statSync(dir).isDirectory()) throw new Error(`rmdir target is not a directory: ${rel}`);
|
|
const entries = fs.readdirSync(dir);
|
|
if (entries.length > 0) throw new Error(`rmdir target is not empty: ${rel}`);
|
|
fs.rmdirSync(dir);
|
|
ok('workspace.rmdir', { path: path.relative(profile.__workspaceRoot, dir) || '.', removed: true });
|
|
}
|
|
|
|
function workspaceRm(rel, options = {}) {
|
|
if (!rel || String(rel).startsWith('-')) throw new Error('workspace rm requires a file path');
|
|
const profile = loadProfile();
|
|
const file = resolveWorkspacePath(profile, rel);
|
|
const missingOk = Boolean(options['missing-ok'] || options.missingOk);
|
|
if (!fs.existsSync(file)) {
|
|
if (missingOk) {
|
|
ok('workspace.rm', { path: normalizeWorkspaceRel(rel), removed: false, reason: 'missing' });
|
|
return;
|
|
}
|
|
throw new Error(`rm target not found: ${rel}`);
|
|
}
|
|
if (!fs.statSync(file).isFile()) throw new Error(`rm target is not a file: ${rel}. Use workspace rmdir for empty directories.`);
|
|
const content = fs.readFileSync(file);
|
|
fs.rmSync(file, { force: true });
|
|
ok('workspace.rm', { path: path.relative(profile.__workspaceRoot, file), removed: true, bytes: content.length, sha256: createHash('sha256').update(content).digest('hex') });
|
|
}
|
|
|
|
function walkFiles(root, rel, results, limit = 20000) {
|
|
if (results.length >= limit) return true;
|
|
const dir = path.join(root, rel);
|
|
let truncated = false;
|
|
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()) truncated = walkFiles(root, childRel, results, limit) || truncated;
|
|
else if (entry.isFile()) results.push(childRel);
|
|
if (results.length >= limit) return true;
|
|
}
|
|
return truncated;
|
|
}
|
|
|
|
function rgWorkspace(pattern, rel, options = {}) {
|
|
const profile = loadProfile();
|
|
const root = resolveWorkspacePath(profile, rel || '.');
|
|
const files = [];
|
|
const scanLimit = parsePositiveInt(optionValue(options, 'limit'), 20000, 'rg limit', 1, 100000);
|
|
let filesTruncated = false;
|
|
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) || '.';
|
|
filesTruncated = walkFiles(root, '.', nested, scanLimit);
|
|
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 nameOnly = Boolean(filesWithMatches || options.nameOnly || options['name-only'] || options.names || options._?.includes('--name-only'));
|
|
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 (nameOnly) {
|
|
if (re.test(fileRel.replace(/\\/gu, '/'))) fileMatches.push(fileRel);
|
|
continue;
|
|
}
|
|
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);
|
|
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: nameOnly ? fileMatches.length : matches.length, files: nameOnly ? fileMatches : undefined, matches: nameOnly ? undefined : matches, options: { globs, types, filesWithMatches, nameOnly, scanLimit, filesScanned: files.length, filesTruncated } });
|
|
}
|
|
|
|
function resolveRgWorkspaceRequest(rest) {
|
|
const parsed = parseArgs(rest);
|
|
const encodedPattern = parsed['pattern-b64'] || parsed.patternB64;
|
|
if (encodedPattern !== undefined) {
|
|
return {
|
|
pattern: decodeBase64Utf8(encodedPattern, '--pattern-b64'),
|
|
rel: optionString(parsed, 'path') || parsed._[0] || '.',
|
|
options: parsed,
|
|
};
|
|
}
|
|
if (parsed.pattern !== undefined) {
|
|
return {
|
|
pattern: String(Array.isArray(parsed.pattern) ? parsed.pattern[0] : parsed.pattern),
|
|
rel: optionString(parsed, 'path') || parsed._[0] || '.',
|
|
options: parsed,
|
|
};
|
|
}
|
|
return {
|
|
pattern: rest[0] || '',
|
|
rel: rest[1] || '.',
|
|
options: parseArgs(rest.slice(2)),
|
|
};
|
|
}
|
|
|
|
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(profile, options);
|
|
const encoding = normalizeTextEncoding(options.encoding || options.charset || 'utf8');
|
|
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}`);
|
|
const parent = path.dirname(target);
|
|
if (!fs.existsSync(parent)) {
|
|
ensureDir(parent);
|
|
}
|
|
fs.writeFileSync(target, operation.content, encoding);
|
|
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, encoding);
|
|
const updated = applyUpdateChunks(original, operation.chunks, operation.path);
|
|
const destination = operation.movePath ? resolvePatchPath(profile, baseDir, operation.movePath) : target;
|
|
const parent = path.dirname(destination);
|
|
if (!fs.existsSync(parent)) {
|
|
if (options['create-dirs'] === true || options.createDirs === true) ensureDir(parent);
|
|
else throw new Error(`update destination parent directory not found for ${operation.movePath || operation.path}: ${path.relative(profile.__workspaceRoot, parent) || '.'}. Check the patch path or pass --create-dirs when creating a new directory intentionally.`);
|
|
}
|
|
fs.writeFileSync(destination, updated, encoding);
|
|
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, encoding });
|
|
}
|
|
|
|
function decodePatchText(profile, options) {
|
|
const patchFile = optionValue(options, 'patch-file', 'patchFile');
|
|
if (typeof patchFile === 'string') {
|
|
const file = resolveWorkspacePath(profile, patchFile);
|
|
return fs.readFileSync(file, 'utf8');
|
|
}
|
|
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-file, --patch-b64, or --patch');
|
|
}
|
|
|
|
function decodePutBuffer(options, encoding) {
|
|
const contentB64 = optionValue(options, 'content-b64', 'contentB64');
|
|
if (typeof contentB64 === 'string') return Buffer.from(contentB64, 'base64');
|
|
const textB64 = optionValue(options, 'text-b64', 'textB64');
|
|
if (typeof textB64 === 'string') return Buffer.from(decodeBase64Utf8(textB64, '--text-b64'), encoding);
|
|
const text = optionValue(options, 'text');
|
|
if (typeof text === 'string') return Buffer.from(text, encoding);
|
|
throw new Error('workspace put requires --content-b64, --text-b64, or --text');
|
|
}
|
|
|
|
function resolvePatchPath(profile, baseDir, patchPath) {
|
|
const raw = String(patchPath || '').replace(/^\/+/, '');
|
|
if (!raw || raw.includes('\0')) throw new Error(`invalid patch path: ${patchPath}`);
|
|
const root = profile.__workspaceRoot;
|
|
const baseRel = normalizeSlashPath(path.relative(root, baseDir));
|
|
const patchRel = normalizeSlashPath(raw);
|
|
const resolved = baseRel && (patchRel === baseRel || patchRel.startsWith(`${baseRel}/`))
|
|
? path.resolve(root, raw)
|
|
: path.resolve(baseDir, raw);
|
|
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 normalizeSlashPath(value) {
|
|
return String(value || '').replace(/\\/gu, '/').replace(/^\/+|\/+$/gu, '').replace(/\/+/gu, '/');
|
|
}
|
|
|
|
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]?.startsWith('--- ') || lines.some((line) => line.startsWith('+++ '))) {
|
|
throw new Error('workspace apply-patch expects Codex patch format, not unified diff. Start with *** Begin Patch and use *** Update File:/*** Add File:/*** Delete File: hunks. Use workspace put only for intentional whole-file replacement.');
|
|
}
|
|
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 (/^\*\*\*\s+(add|delete|update)\s+file:/iu.test(header) && !/^\*\*\*\s+(Add|Delete|Update) File: /u.test(header)) {
|
|
throw new Error(`unsupported patch header capitalization: ${header}. Use exactly *** Update File:, *** Add File:, or *** Delete File:.`);
|
|
}
|
|
if (header.startsWith('*** New File: ')) {
|
|
throw new Error('unsupported patch header: *** New File. Use *** Add File: for patch-created files, or workspace put only for intentional whole-file replacement.');
|
|
}
|
|
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('@@')) continue;
|
|
if (!line.startsWith('+')) {
|
|
throw new Error(`add file line must start with + for ${filePath}. For whole-file creation, prefer workspace apply-patch --add-file ${filePath} < file; if writing a Codex patch directly, omit bare hunk text and prefix every content line with +.`);
|
|
}
|
|
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 @@`);
|
|
if (line.includes('...')) throw new Error(`update hunk for ${filePath} cannot use ellipsis in ${line}. Include exact context lines from workspace cat/rg, then retry workspace apply-patch.`);
|
|
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}. Re-read the target with workspace cat/rg and retry with exact context; do not use abbreviated context or ellipsis. Use workspace put only for intentional whole-file replacement.`);
|
|
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'));
|
|
emit(`${kindPrefix}.status`, job, job.status !== 'failed', job.status === 'failed' ? 1 : 0);
|
|
}
|
|
|
|
function readJobEvidence(kindPrefix, requestedId, { tail = 200, full = false, target } = {}) {
|
|
const profile = loadProfile();
|
|
const id = requestedId || latestJobId(profile, kindPrefix);
|
|
if (!id) {
|
|
return ok(`${kindPrefix}.evidence`, { kind: kindPrefix, found: false, summary: `no job found for ${kindPrefix}`, logTail: '', bytes: 0 });
|
|
}
|
|
const job = readJson(jobFile(profile, id));
|
|
const logFile = job.logFile;
|
|
const found = Boolean(logFile && fs.existsSync(logFile));
|
|
const fullText = found ? fs.readFileSync(logFile, 'utf8') : '';
|
|
const lines = fullText.split(/\r?\n/u);
|
|
const slice = full ? lines : lines.slice(Math.max(0, lines.length - tail));
|
|
const text = slice.join('\n');
|
|
const summary = job.result?.buildSummary || job.result?.failureSummary || (job.status === 'completed' ? 'job completed' : `job ${job.status || 'unknown'}`);
|
|
return ok(`${kindPrefix}.evidence`, {
|
|
kind: kindPrefix,
|
|
jobId: id,
|
|
status: job.status,
|
|
found,
|
|
logFile: logFile ? path.relative(profile.__workspaceRoot, logFile) : null,
|
|
bytes: Buffer.byteLength(fullText, 'utf8'),
|
|
truncated: !full && lines.length > tail,
|
|
tail: full ? lines.length : tail,
|
|
logTail: text,
|
|
summary,
|
|
target: target || job.payload?.target || null,
|
|
updatedAt: job.updatedAt || null
|
|
});
|
|
}
|
|
|
|
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) {
|
|
return keilLogSummary(logText).hasErrors;
|
|
}
|
|
|
|
function keilLogSummary(logText) {
|
|
const text = String(logText || '');
|
|
const summaryMatches = [...text.matchAll(/(\d+)\s+Error\(s\)\s*,\s*(\d+)\s+Warning\(s\)/giu)];
|
|
if (summaryMatches.length) {
|
|
const last = summaryMatches[summaryMatches.length - 1];
|
|
const errors = Number(last[1]);
|
|
const warnings = Number(last[2]);
|
|
return { hasSummary: true, errors, warnings, hasErrors: errors > 0 };
|
|
}
|
|
const errorLines = text.split(/\r?\n/u).filter((line) => {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) return false;
|
|
if (/\bwarning\b/iu.test(trimmed)) return false;
|
|
return /\b(fatal error|error:|failed)\b/iu.test(trimmed);
|
|
});
|
|
return { hasSummary: false, errors: errorLines.length, warnings: null, hasErrors: errorLines.length > 0, errorLines: errorLines.slice(0, 8) };
|
|
}
|
|
|
|
function xmlPosition(text, index) {
|
|
const prefix = text.slice(0, Math.max(0, index));
|
|
const lines = prefix.split(/\n/u);
|
|
return { line: lines.length, column: lines[lines.length - 1].length + 1 };
|
|
}
|
|
|
|
function validateXmlFile(file) {
|
|
const text = fs.readFileSync(file, 'utf8');
|
|
const stack = [];
|
|
const tagRe = /<[^>]+>/gu;
|
|
for (const match of text.matchAll(tagRe)) {
|
|
const tag = match[0];
|
|
if (/^<\?/.test(tag) || /^<!--/.test(tag) || /^<!\[CDATA\[/.test(tag) || /^<!DOCTYPE/i.test(tag)) continue;
|
|
const close = tag.match(/^<\s*\/\s*([A-Za-z_][\w:.-]*)/u);
|
|
if (close) {
|
|
const name = close[1];
|
|
const open = stack.pop();
|
|
if (!open || open.name !== name) {
|
|
const pos = xmlPosition(text, match.index);
|
|
return { ok: false, file, line: pos.line, column: pos.column, message: `XML closing tag mismatch: expected </${open?.name || '(none)'}> but found </${name}>`, expected: open?.name || null, actual: name };
|
|
}
|
|
continue;
|
|
}
|
|
if (/\/\s*>$/u.test(tag)) continue;
|
|
const open = tag.match(/^<\s*([A-Za-z_][\w:.-]*)/u);
|
|
if (open) {
|
|
const pos = xmlPosition(text, match.index);
|
|
stack.push({ name: open[1], line: pos.line, column: pos.column });
|
|
}
|
|
}
|
|
if (stack.length > 0) {
|
|
const open = stack[stack.length - 1];
|
|
return { ok: false, file, line: open.line, column: open.column, message: `XML unclosed tag: <${open.name}>`, expected: open.name, actual: null };
|
|
}
|
|
return { ok: true, file };
|
|
}
|
|
|
|
function xmlTextValue(text, tag) {
|
|
const escaped = String(tag).replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
const match = String(text || '').match(new RegExp(`<${escaped}>\\s*([\\s\\S]*?)\\s*</${escaped}>`, 'iu'));
|
|
return match ? xmlUnescapeText(match[1].trim()) : '';
|
|
}
|
|
|
|
function xmlUnescapeText(value) {
|
|
return String(value || '')
|
|
.replace(/"/gu, '"')
|
|
.replace(/'/gu, "'")
|
|
.replace(/</gu, '<')
|
|
.replace(/>/gu, '>')
|
|
.replace(/&/gu, '&');
|
|
}
|
|
|
|
function keilTargetBlocks(projectText) {
|
|
const blocks = [];
|
|
const re = /<Target\b[^>]*>[\s\S]*?<\/Target>/giu;
|
|
for (const match of String(projectText || '').matchAll(re)) {
|
|
blocks.push({ targetName: xmlTextValue(match[0], 'TargetName'), text: match[0] });
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
function selectedKeilTarget(projectText, requestedTarget) {
|
|
const blocks = keilTargetBlocks(projectText);
|
|
if (blocks.length === 0) return { targetName: requestedTarget || '', text: projectText };
|
|
if (!requestedTarget) return blocks[0];
|
|
const found = blocks.find((block) => block.targetName === requestedTarget);
|
|
if (!found) throw new Error(`Keil target not found in project: ${requestedTarget}`);
|
|
return found;
|
|
}
|
|
|
|
function xmlEscapeText(value) {
|
|
return String(value)
|
|
.replace(/&/gu, '&')
|
|
.replace(/</gu, '<')
|
|
.replace(/>/gu, '>');
|
|
}
|
|
|
|
function resolveKeilDirectory(profile, project, rawValue) {
|
|
const raw = String(rawValue || '').trim();
|
|
if (!raw) return null;
|
|
const normalized = raw.replace(/[\\/]+/gu, path.sep);
|
|
const resolved = path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(path.dirname(project), normalized);
|
|
const root = profile.__workspaceRoot;
|
|
const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
|
|
if (resolved !== root && !resolved.startsWith(rootWithSep)) throw new Error(`Keil clean directory escapes workspace: ${raw}`);
|
|
return resolved;
|
|
}
|
|
|
|
function assertKeilCleanDirectorySafe(profile, project, dir) {
|
|
const root = path.resolve(profile.__workspaceRoot);
|
|
const projectDir = path.dirname(project);
|
|
const resolved = path.resolve(dir);
|
|
const rel = path.relative(root, resolved) || '.';
|
|
if (resolved === root) throw new Error('refusing to clean workspace root as a Keil output directory');
|
|
if (resolved === projectDir) throw new Error(`refusing to clean Keil project directory: ${rel}`);
|
|
if (rel === '.device-pod' || rel.startsWith(`.device-pod${path.sep}`)) throw new Error(`refusing to clean device-pod state directory: ${rel}`);
|
|
if (fs.existsSync(path.join(resolved, path.basename(project)))) throw new Error(`refusing to clean directory containing the Keil project file: ${rel}`);
|
|
}
|
|
|
|
const KEIL_CLEAN_EXTENSIONS = new Set(['.o', '.obj', '.d', '.crf', '.lst', '.map', '.axf', '.elf', '.hex', '.bin', '.htm', '.html', '.lnp', '.dep', '.iex']);
|
|
|
|
function isKeilCleanArtifact(file) {
|
|
return KEIL_CLEAN_EXTENSIONS.has(path.extname(file).toLowerCase());
|
|
}
|
|
|
|
function cleanKeilDirectory(profile, dir, options = {}) {
|
|
const dryRun = Boolean(options['dry-run'] || options.dryRun);
|
|
const removed = [];
|
|
const skipped = [];
|
|
let bytes = 0;
|
|
if (!fs.existsSync(dir)) return { directory: path.relative(profile.__workspaceRoot, dir) || '.', exists: false, removed, skipped, removedCount: 0, removedBytes: 0 };
|
|
if (!fs.statSync(dir).isDirectory()) throw new Error(`Keil clean target is not a directory: ${path.relative(profile.__workspaceRoot, dir)}`);
|
|
|
|
const walk = (current) => {
|
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
const full = path.join(current, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walk(full);
|
|
if (full !== dir && fs.existsSync(full) && fs.readdirSync(full).length === 0 && !dryRun) fs.rmdirSync(full);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) continue;
|
|
const rel = path.relative(profile.__workspaceRoot, full);
|
|
if (!isKeilCleanArtifact(full)) {
|
|
if (skipped.length < 50) skipped.push(rel);
|
|
continue;
|
|
}
|
|
const stat = fs.statSync(full);
|
|
bytes += stat.size;
|
|
removed.push(rel);
|
|
if (!dryRun) fs.rmSync(full, { force: true });
|
|
}
|
|
};
|
|
walk(dir);
|
|
return { directory: path.relative(profile.__workspaceRoot, dir) || '.', exists: true, removed, skipped, removedCount: removed.length, removedBytes: bytes };
|
|
}
|
|
|
|
function cleanKeilBuildOutputs(profile, payload = {}) {
|
|
const project = projectPath(profile);
|
|
const projectXml = validateXmlFile(project);
|
|
if (!projectXml.ok) return { success: false, project, target: payload.target || targetName(profile), directories: [], removedCount: 0, removedBytes: 0, projectXml, failureSummary: projectXml.message };
|
|
const target = optionValue(payload, 'target', 't') || targetName(profile);
|
|
const projectText = fs.readFileSync(project, 'utf8');
|
|
const targetBlock = selectedKeilTarget(projectText, target);
|
|
const rawDirs = [xmlTextValue(targetBlock.text, 'OutputDirectory'), xmlTextValue(targetBlock.text, 'ListingPath')].filter(Boolean);
|
|
if (rawDirs.length === 0) throw new Error(`Keil target ${targetBlock.targetName || target || '(first)'} has no <OutputDirectory> or <ListingPath>`);
|
|
const dirs = [...new Set(rawDirs.map((item) => resolveKeilDirectory(profile, project, item)).filter(Boolean).map((item) => path.resolve(item)) )];
|
|
const directories = [];
|
|
for (const dir of dirs) {
|
|
assertKeilCleanDirectorySafe(profile, project, dir);
|
|
directories.push(cleanKeilDirectory(profile, dir, payload));
|
|
}
|
|
const removedCount = directories.reduce((sum, item) => sum + item.removedCount, 0);
|
|
const removedBytes = directories.reduce((sum, item) => sum + item.removedBytes, 0);
|
|
return { success: true, project, target: targetBlock.targetName || target || '', directories, removedCount, removedBytes, dryRun: Boolean(payload['dry-run'] || payload.dryRun), projectXml };
|
|
}
|
|
|
|
function pathForKeilXml(relPath) {
|
|
return String(relPath || '').replace(/\//gu, '\\');
|
|
}
|
|
|
|
function keilFileName(relPath) {
|
|
return path.basename(String(relPath || '').replace(/\\/gu, '/'));
|
|
}
|
|
|
|
function keilFileType(relPath) {
|
|
const ext = path.extname(String(relPath || '')).toLowerCase();
|
|
if (['.c', '.s', '.asm'].includes(ext)) return '1';
|
|
if (['.h', '.hpp', '.inc'].includes(ext)) return '5';
|
|
return '1';
|
|
}
|
|
|
|
function parseKeilWorkspaceBase(options = {}) {
|
|
return normalizeWorkspaceRel(optionValue(options, 'base') || optionValue(options, 'workspace-base', 'workspaceBase') || '.');
|
|
}
|
|
|
|
function normalizeWorkspaceRel(value) {
|
|
return String(value || '.')
|
|
.replace(/\\/gu, '/')
|
|
.replace(/^\/+/, '')
|
|
.replace(/\/+/gu, '/')
|
|
.replace(/\/+$/u, '') || '.';
|
|
}
|
|
|
|
function keilProjectRel(profile, baseRel) {
|
|
const project = projectPath(profile);
|
|
return path.relative(profile.__workspaceRoot, project).replace(/\\/gu, '/');
|
|
}
|
|
|
|
function filePathFromProject(projectRel, sourceRel) {
|
|
const projectDir = path.dirname(projectRel).replace(/\\/gu, '/');
|
|
let fromProject = path.relative(projectDir, sourceRel).replace(/\\/gu, '/');
|
|
if (!fromProject.startsWith('.')) fromProject = `./${fromProject}`;
|
|
return pathForKeilXml(fromProject);
|
|
}
|
|
|
|
function keilGroupRegex(groupName) {
|
|
const escaped = String(groupName || '').replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
return new RegExp(`(<Group>\\s*<GroupName>${escaped}</GroupName>[\\s\\S]*?<Files>)([\\s\\S]*?)(\\s*</Files>[\\s\\S]*?</Group>)`, 'u');
|
|
}
|
|
|
|
function keilAnyGroupRegex() {
|
|
return /(<Group>\s*<GroupName>([^<]*)<\/GroupName>[\s\S]*?<Files>)([\s\S]*?)(\s*<\/Files>[\s\S]*?<\/Group>)/gu;
|
|
}
|
|
|
|
function removeKeilFileBlock(filesBlock, filePathXml) {
|
|
const marker = `<FilePath>${filePathXml}</FilePath>`;
|
|
let changed = false;
|
|
const updated = String(filesBlock || '').replace(/\n?\s*<File>[\s\S]*?<\/File>/gu, (block) => {
|
|
if (!block.includes(marker)) return block;
|
|
changed = true;
|
|
return '';
|
|
});
|
|
return { changed, updated };
|
|
}
|
|
|
|
function removeKeilFileBlockFromAnyGroup(projectText, filePathXml) {
|
|
const groups = [];
|
|
const updated = String(projectText || '').replace(keilAnyGroupRegex(), (block, prefix, groupName, filesBlock, suffix) => {
|
|
const removed = removeKeilFileBlock(filesBlock, filePathXml);
|
|
if (!removed.changed) return block;
|
|
groups.push(groupName);
|
|
return `${prefix}${removed.updated}${suffix}`;
|
|
});
|
|
return { changed: groups.length > 0, updated, groups };
|
|
}
|
|
|
|
function keilAddSource(args = [], options = {}) {
|
|
const sourceArg = args.find((arg) => arg && !String(arg).startsWith('-')) || optionString(options, 'path', 'source', 'file', 'src');
|
|
if (!sourceArg || String(sourceArg).startsWith('-')) throw new Error('workspace keil add-source requires a workspace-relative source path. Pass it as a positional argument, or use --path <path> / --source <path>. Example: hwpod <pod>:workspace:/projects/app keil add-source User/new_file.c --group User --approved --reason "add source"');
|
|
const profile = loadProfile();
|
|
const baseRel = parseKeilWorkspaceBase(options);
|
|
const sourceRel = normalizeWorkspaceRel(path.posix.join(baseRel === '.' ? '' : baseRel, normalizeWorkspaceRel(sourceArg)));
|
|
const sourceFile = resolveWorkspacePath(profile, sourceRel);
|
|
if (!fs.existsSync(sourceFile)) throw new Error(`source file not found: ${sourceRel}`);
|
|
|
|
const project = projectPath(profile);
|
|
const projectRel = keilProjectRel(profile, baseRel);
|
|
const groupName = String(optionValue(options, 'group', 'group-name', 'groupName') || 'User');
|
|
const fileName = keilFileName(sourceRel);
|
|
const filePath = filePathFromProject(projectRel, sourceRel);
|
|
const filePathXml = xmlEscapeText(filePath);
|
|
const text = fs.readFileSync(project, 'utf8');
|
|
if (text.includes(`<FilePath>${filePathXml}</FilePath>`)) {
|
|
ok('workspace.keil.add-source', { project: path.relative(profile.__workspaceRoot, project), source: sourceRel, group: groupName, changed: false, reason: 'already-present', filePath });
|
|
return;
|
|
}
|
|
const groupRe = keilGroupRegex(groupName);
|
|
const match = text.match(groupRe);
|
|
if (!match) throw new Error(`Keil group not found or has no <Files>: ${groupName}`);
|
|
const indentMatch = match[2].match(/\n(\s*)<File>/u);
|
|
const indent = indentMatch ? indentMatch[1] : ' ';
|
|
const fileBlock = [
|
|
`${indent}<File>`,
|
|
`${indent} <FileName>${xmlEscapeText(fileName)}</FileName>`,
|
|
`${indent} <FileType>${keilFileType(sourceRel)}</FileType>`,
|
|
`${indent} <FilePath>${filePathXml}</FilePath>`,
|
|
`${indent}</File>`
|
|
].join('\n');
|
|
const replacement = `${match[1]}${match[2]}\n${fileBlock}${match[3]}`;
|
|
const updated = text.replace(groupRe, replacement);
|
|
fs.writeFileSync(project, updated, 'utf8');
|
|
const projectXml = validateXmlFile(project);
|
|
if (!projectXml.ok) throw new Error(projectXml.message);
|
|
ok('workspace.keil.add-source', { project: path.relative(profile.__workspaceRoot, project), source: sourceRel, group: groupName, changed: true, fileName, filePath, fileType: keilFileType(sourceRel), projectXml });
|
|
}
|
|
|
|
function keilRemoveSource(args = [], options = {}) {
|
|
const sourceArg = args.find((arg) => arg && !String(arg).startsWith('-')) || optionString(options, 'path', 'source', 'file', 'src');
|
|
if (!sourceArg || String(sourceArg).startsWith('-')) throw new Error('workspace keil remove-source requires a workspace-relative source path. Pass it as a positional argument, or use --path <path> / --source <path>. Omit --group to search every Keil group.');
|
|
const profile = loadProfile();
|
|
const baseRel = parseKeilWorkspaceBase(options);
|
|
const sourceRel = normalizeWorkspaceRel(path.posix.join(baseRel === '.' ? '' : baseRel, normalizeWorkspaceRel(sourceArg)));
|
|
const project = projectPath(profile);
|
|
const projectRel = keilProjectRel(profile, baseRel);
|
|
const groupOption = optionValue(options, 'group', 'group-name', 'groupName');
|
|
const groupName = groupOption === undefined || groupOption === null || String(groupOption) === '' ? null : String(groupOption);
|
|
const filePath = filePathFromProject(projectRel, sourceRel);
|
|
const filePathXml = xmlEscapeText(filePath);
|
|
const text = fs.readFileSync(project, 'utf8');
|
|
if (!groupName) {
|
|
const removed = removeKeilFileBlockFromAnyGroup(text, filePathXml);
|
|
if (!removed.changed) {
|
|
ok('workspace.keil.remove-source', { project: path.relative(profile.__workspaceRoot, project), source: sourceRel, group: null, groups: [], changed: false, reason: 'not-present', filePath });
|
|
return;
|
|
}
|
|
fs.writeFileSync(project, removed.updated, 'utf8');
|
|
const projectXml = validateXmlFile(project);
|
|
if (!projectXml.ok) throw new Error(projectXml.message);
|
|
ok('workspace.keil.remove-source', { project: path.relative(profile.__workspaceRoot, project), source: sourceRel, group: null, groups: removed.groups, changed: true, filePath, projectXml });
|
|
return;
|
|
}
|
|
const groupRe = keilGroupRegex(groupName);
|
|
const match = text.match(groupRe);
|
|
if (!match) throw new Error(`Keil group not found or has no <Files>: ${groupName}`);
|
|
const removed = removeKeilFileBlock(match[2], filePathXml);
|
|
if (!removed.changed) {
|
|
ok('workspace.keil.remove-source', { project: path.relative(profile.__workspaceRoot, project), source: sourceRel, group: groupName, changed: false, reason: 'not-present', filePath });
|
|
return;
|
|
}
|
|
const updated = text.replace(groupRe, `${match[1]}${removed.updated}${match[3]}`);
|
|
fs.writeFileSync(project, updated, 'utf8');
|
|
const projectXml = validateXmlFile(project);
|
|
if (!projectXml.ok) throw new Error(projectXml.message);
|
|
ok('workspace.keil.remove-source', { project: path.relative(profile.__workspaceRoot, project), source: sourceRel, group: groupName, changed: true, filePath, projectXml });
|
|
}
|
|
|
|
function workspaceKeil(command, rest = []) {
|
|
const options = parseArgs(rest);
|
|
if (command === 'add-source') return keilAddSource(options._, options);
|
|
if (command === 'remove-source') return keilRemoveSource(options._, options);
|
|
throw new Error(`unsupported workspace keil command: ${command || '(empty)'}`);
|
|
}
|
|
|
|
async function runKeilBuild(profile, payload = {}) {
|
|
const project = projectPath(profile);
|
|
const target = optionValue(payload, 'target', 't') || targetName(profile);
|
|
const outDir = path.join(stateRoot(profile.__workspaceRoot), 'keil');
|
|
ensureDir(outDir);
|
|
const logFile = path.join(outDir, `build_${safeTarget(target)}_${Date.now()}.log`);
|
|
const projectXml = validateXmlFile(project);
|
|
if (!projectXml.ok) return { success: false, exitCode: 2, timedOut: false, elapsedMs: 0, project, target, logFile, logTail: '', stdout: '', stderr: '', projectXml, failureSummary: projectXml.message };
|
|
const clean = booleanOption(payload, 'clean') ? cleanKeilBuildOutputs(profile, payload) : null;
|
|
const tools = await toolInfo(profile);
|
|
if (!tools.uv4.found) throw new Error('Keil UV4.exe not found');
|
|
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 buildSummary = keilLogSummary(logText);
|
|
const success = buildSummary.hasSummary ? !buildSummary.hasErrors : result.exitCode === 0 && !buildSummary.hasErrors;
|
|
return { success, exitCode: result.exitCode, timedOut: result.timedOut, elapsedMs: result.elapsedMs, project, target, clean, logFile, logTail: shortTail(logText), stdout: shortTail(result.stdout), stderr: shortTail(result.stderr), projectXml, buildSummary };
|
|
}
|
|
|
|
function cleanKeilBuild(payload = {}) {
|
|
const result = cleanKeilBuildOutputs(loadProfile(), payload);
|
|
emit('keil-build.clean', result, result.success !== false, result.success === false ? 1 : 0);
|
|
}
|
|
|
|
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 project = projectPath(profile);
|
|
const target = optionValue(payload, 'target', 't') || targetName(profile);
|
|
const outDir = path.join(stateRoot(profile.__workspaceRoot), 'keil');
|
|
ensureDir(outDir);
|
|
const logFile = path.join(outDir, `download_${safeTarget(target)}_${Date.now()}.log`);
|
|
const projectXml = validateXmlFile(project);
|
|
if (!projectXml.ok) return { success: false, exitCode: 2, timedOut: false, elapsedMs: 0, project, target, binding: null, logFile, logTail: '', stdout: '', stderr: '', projectXml, failureSummary: projectXml.message };
|
|
const tools = await toolInfo(profile);
|
|
if (!tools.uv4.found) throw new Error('Keil UV4.exe not found');
|
|
let binding = null;
|
|
if (profile.debugInterface?.autoBindUvoptx !== false) binding = bindUvoptxProbe(profile);
|
|
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 buildSummary = keilLogSummary(logText);
|
|
const success = buildSummary.hasSummary ? !buildSummary.hasErrors : result.exitCode === 0 && !buildSummary.hasErrors;
|
|
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, projectXml, buildSummary };
|
|
}
|
|
|
|
async function runKeilDownloadWithUartCapture(profile, uv4Path, uv4Args, cwd, payload = {}) {
|
|
const uart = await 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 });
|
|
}
|
|
|
|
function parseRead32Output(text) {
|
|
const values = [];
|
|
const re = /([0-9a-fA-F]{8}):\s+((?:[0-9a-fA-F]{8}\s*)+)/gu;
|
|
for (const match of String(text || '').matchAll(re)) {
|
|
const base = Number.parseInt(match[1], 16);
|
|
const words = match[2].trim().split(/\s+/u).filter(Boolean);
|
|
for (let index = 0; index < words.length; index += 1) {
|
|
values.push({ address: `0x${(base + index * 4).toString(16).padStart(8, '0')}`, value: `0x${words[index].toLowerCase()}` });
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
|
|
async function read32(options = {}) {
|
|
const profile = loadProfile();
|
|
const address = String(options._?.[0] || options.address || '').trim();
|
|
const count = Number(options._?.[1] || options.count || 1);
|
|
if (!/^0x[0-9a-fA-F]+$/u.test(address)) throw new Error('debug-probe read32 requires an address like 0x20000000');
|
|
if (!Number.isInteger(count) || count <= 0 || count > 1024) throw new Error(`invalid read32 count: ${options._?.[1] || options.count}`);
|
|
const prepare = options.prepare ? String(options.prepare) : '';
|
|
const commands = prepare ? [prepare, `read32 ${address} ${count}`] : [`read32 ${address} ${count}`];
|
|
const timeoutMs = Number(options['timeout-ms'] || options.timeoutMs || 30000);
|
|
const { pyocd, args } = await pyocdCommanderArgs(profile, commands, options);
|
|
const result = await runCapture(pyocd, args, { timeoutMs });
|
|
const output = `${result.stdout}\n${result.stderr}`;
|
|
if (result.exitCode !== 0) throw new Error(`pyOCD read32 failed: ${shortTail(output)}`);
|
|
ok('debug-probe.read32', { exitCode: result.exitCode, address, count, prepare: prepare || null, values: parseRead32Output(output), 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 });
|
|
}
|
|
|
|
async function resolveSerialPortNames() {
|
|
const script = "[System.IO.Ports.SerialPort]::GetPortNames() | Sort-Object | ConvertTo-Json -Compress";
|
|
const result = await runPowerShell(script, 15000);
|
|
if (result.exitCode !== 0) return { ports: [], error: shortTail(result.stderr || result.stdout) };
|
|
const text = result.stdout.trim();
|
|
if (!text) return { ports: [], error: null };
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
const ports = (Array.isArray(parsed) ? parsed : [parsed]).map((item) => String(item || '').trim()).filter(Boolean);
|
|
return { ports, error: null };
|
|
} catch (error) {
|
|
return { ports: [], error: `unable to parse serial port list: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
}
|
|
|
|
function sameSerialPort(left, right) {
|
|
return String(left || '').trim().toUpperCase() === String(right || '').trim().toUpperCase();
|
|
}
|
|
|
|
async function resolveUartPort(configuredPort, options = {}) {
|
|
if (options.port) return { port: String(options.port), source: 'explicit-option', configuredPort: configuredPort || null, availablePorts: null, resolutionWarning: null };
|
|
const configured = String(configuredPort || '').trim();
|
|
const discovery = await resolveSerialPortNames();
|
|
const availablePorts = discovery.ports;
|
|
if (configured && availablePorts.some((port) => sameSerialPort(port, configured))) {
|
|
return { port: configured, source: 'profile', configuredPort: configured, availablePorts, resolutionWarning: null };
|
|
}
|
|
if (availablePorts.length > 0) {
|
|
return {
|
|
port: availablePorts[0],
|
|
source: configured ? 'host-auto-detected-profile-missing' : 'host-auto-detected',
|
|
configuredPort: configured || null,
|
|
availablePorts,
|
|
resolutionWarning: configured ? `configured UART port ${configured} is not present on this host; using ${availablePorts[0]}` : null,
|
|
};
|
|
}
|
|
return { port: configured, source: configured ? 'profile-unverified' : 'missing', configuredPort: configured || null, availablePorts, resolutionWarning: discovery.error || 'no serial ports detected on this host' };
|
|
}
|
|
|
|
async 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 };
|
|
const portResolution = await resolveUartPort(resolved.port, options);
|
|
resolved.port = portResolution.port;
|
|
resolved.resolvedPortSource = portResolution.source;
|
|
resolved.configuredPort = portResolution.configuredPort;
|
|
resolved.availablePorts = portResolution.availablePorts;
|
|
if (portResolution.resolutionWarning) resolved.resolutionWarning = portResolution.resolutionWarning;
|
|
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 = await 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 = await 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 = await 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 parseJsonOption(options, key, b64Key) {
|
|
const encoded = optionValue(options, b64Key, b64Key.replace(/-([a-z])/gu, (_, c) => c.toUpperCase()));
|
|
const raw = encoded !== undefined ? decodeBase64Utf8(String(encoded), `--${b64Key}`) : optionValue(options, key);
|
|
if (raw === undefined) return undefined;
|
|
return JSON.parse(String(raw));
|
|
}
|
|
|
|
function buildJsonRpcRequest(options) {
|
|
const request = parseJsonOption(options, 'request', 'request-b64');
|
|
if (request !== undefined) return request;
|
|
const method = String(optionValue(options, 'method') || options._?.[0] || '').trim();
|
|
if (!method) throw new Error('uart jsonrpc requires a method argument or --request JSON');
|
|
const params = parseJsonOption(options, 'params', 'params-b64');
|
|
const rawId = optionValue(options, 'id');
|
|
const id = rawId === undefined ? Date.now() : /^-?\d+$/u.test(String(rawId)) ? Number(rawId) : String(rawId);
|
|
const built = { jsonrpc: '2.0', method, id };
|
|
if (params !== undefined) built.params = params;
|
|
return built;
|
|
}
|
|
|
|
function parseJsonFromSerialContent(content) {
|
|
const trimmed = String(content || '').trim();
|
|
if (!trimmed) return { json: null, error: 'empty response' };
|
|
const candidates = [trimmed, ...trimmed.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).reverse()];
|
|
for (const candidate of candidates) {
|
|
try { return { json: JSON.parse(candidate), error: null }; } catch {}
|
|
}
|
|
const start = trimmed.indexOf('{');
|
|
const end = trimmed.lastIndexOf('}');
|
|
if (start >= 0 && end > start) {
|
|
try { return { json: JSON.parse(trimmed.slice(start, end + 1)), error: null }; } catch (error) { return { json: null, error: error.message }; }
|
|
}
|
|
return { json: null, error: 'no JSON object found in serial response' };
|
|
}
|
|
|
|
function jsonRpcIdsEqual(left, right) {
|
|
if (left === right) return true;
|
|
const leftType = typeof left;
|
|
const rightType = typeof right;
|
|
if ((leftType === 'number' || leftType === 'string') && (rightType === 'number' || rightType === 'string')) {
|
|
return String(left) === String(right);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function jsonRpcValidation(options, request, parsed) {
|
|
const requireJson = booleanOption(options, 'require-json', 'requireJson');
|
|
const requireJsonRpc = booleanOption(options, 'require-jsonrpc', 'requireJsonrpc', 'require-json-rpc', 'requireJsonRpc');
|
|
const requireResult = booleanOption(options, 'require-jsonrpc-result', 'requireJsonrpcResult', 'require-json-rpc-result', 'requireJsonRpcResult');
|
|
const allowIdMismatch = booleanOption(options, 'allow-id-mismatch', 'allowIdMismatch');
|
|
const expectedResultFields = optionList(options, 'expect-result-field', 'expectResultField', 'result-field', 'resultField');
|
|
if ((requireJson || requireJsonRpc || requireResult || expectedResultFields.length > 0) && parsed.error) throw new Error(`uart jsonrpc response is not JSON: ${parsed.error}`);
|
|
if (!(requireJsonRpc || requireResult || expectedResultFields.length > 0)) return;
|
|
const json = parsed.json;
|
|
if (!json || typeof json !== 'object' || Array.isArray(json)) throw new Error('uart jsonrpc response is not a JSON-RPC object');
|
|
if (json.jsonrpc !== '2.0') throw new Error(`uart jsonrpc response missing JSON-RPC 2.0 envelope: jsonrpc=${JSON.stringify(json.jsonrpc)}`);
|
|
if (!allowIdMismatch && Object.prototype.hasOwnProperty.call(request, 'id')) {
|
|
if (!Object.prototype.hasOwnProperty.call(json, 'id')) throw new Error(`uart jsonrpc response missing id for request id ${JSON.stringify(request.id)}`);
|
|
if (!jsonRpcIdsEqual(json.id, request.id)) throw new Error(`uart jsonrpc response id mismatch: expected ${JSON.stringify(request.id)}, got ${JSON.stringify(json.id)}`);
|
|
}
|
|
if (requireResult || expectedResultFields.length > 0) {
|
|
if (Object.prototype.hasOwnProperty.call(json, 'error')) throw new Error(`uart jsonrpc response is JSON-RPC error: ${JSON.stringify(json.error)}`);
|
|
if (!Object.prototype.hasOwnProperty.call(json, 'result')) throw new Error('uart jsonrpc response missing result');
|
|
if (expectedResultFields.length > 0 && (!json.result || typeof json.result !== 'object' || Array.isArray(json.result))) throw new Error('uart jsonrpc result is not an object');
|
|
for (const field of expectedResultFields) {
|
|
if (!Object.prototype.hasOwnProperty.call(json.result, field)) throw new Error(`uart jsonrpc result missing field: ${field}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function serialLineEnding(options) {
|
|
if (options['no-newline'] === true || options.noNewline === true) return { name: 'none', text: '' };
|
|
const raw = String(optionValue(options, 'line-ending', 'lineEnding') || 'crlf').trim().toLowerCase();
|
|
if (raw === 'crlf' || raw === 'rn' || raw === '\\r\\n') return { name: 'crlf', text: '\r\n' };
|
|
if (raw === 'lf' || raw === 'n' || raw === '\\n') return { name: 'lf', text: '\n' };
|
|
if (raw === 'cr' || raw === 'r' || raw === '\\r') return { name: 'cr', text: '\r' };
|
|
if (raw === 'none' || raw === 'off' || raw === 'false') return { name: 'none', text: '' };
|
|
throw new Error(`unsupported UART JSON-RPC line ending: ${raw}. Use crlf, lf, cr, or none`);
|
|
}
|
|
|
|
async function serialJsonRpc(uartId, options) {
|
|
const profile = loadProfile();
|
|
const uart = await resolveUart(profile, uartId, options);
|
|
const request = buildJsonRpcRequest(options);
|
|
const ending = serialLineEnding(options);
|
|
const requestText = `${JSON.stringify(request)}${ending.text}`;
|
|
const durationMs = Number(options['response-timeout-ms'] || options.responseTimeoutMs || options['duration-ms'] || options.durationMs || 1000);
|
|
if (!Number.isFinite(durationMs) || durationMs < 0 || durationMs > 60000) throw new Error(`invalid JSON-RPC response timeout: ${durationMs}`);
|
|
const retries = Number(optionValue(options, 'retry', 'retries') || 0);
|
|
if (!Number.isInteger(retries) || retries < 0 || retries > 10) throw new Error(`invalid JSON-RPC retry count: ${optionValue(options, 'retry', 'retries')}. Use 0..10`);
|
|
const retryDelayMs = Number(optionValue(options, 'retry-delay-ms', 'retryDelayMs') || 200);
|
|
if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0 || retryDelayMs > 10000) throw new Error(`invalid JSON-RPC retry delay: ${optionValue(options, 'retry-delay-ms', 'retryDelayMs')}. Use 0..10000`);
|
|
const lineDelimited = options['line-delimited'] !== false && options.lineDelimited !== false;
|
|
const discardBefore = options['discard-before'] !== false && options.discardBefore !== false;
|
|
const encoded = Buffer.from(requestText, 'utf8').toString('base64');
|
|
const makeScript = () => [
|
|
'$ErrorActionPreference = \'Stop\';',
|
|
`$portName = ${psQuote(uart.port)};`,
|
|
`$baud = ${Number(uart.baudRate || 115200)};`,
|
|
`$durationMs = ${durationMs};`,
|
|
`$lineDelimited = $${lineDelimited ? 'true' : 'false'};`,
|
|
`$discardBefore = $${discardBefore ? 'true' : 'false'};`,
|
|
`$encoded = ${psQuote(encoded)};`,
|
|
'$requestBytes = [Convert]::FromBase64String($encoded);',
|
|
'$requestText = [System.Text.Encoding]::UTF8.GetString($requestBytes);',
|
|
'$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 = 100;',
|
|
'$sw = [System.Diagnostics.Stopwatch]::StartNew();',
|
|
'$content = New-Object System.Text.StringBuilder;',
|
|
'try {',
|
|
' $port.Open();',
|
|
' if ($discardBefore) { $port.DiscardInBuffer(); $port.DiscardOutBuffer(); }',
|
|
' $bytesWritten = [System.Text.Encoding]::UTF8.GetByteCount($requestText);',
|
|
' $port.Write($requestText);',
|
|
' while ($sw.ElapsedMilliseconds -lt $durationMs) {',
|
|
' $chunk = $port.ReadExisting();',
|
|
' if ($chunk.Length -gt 0) { [void]$content.Append($chunk); if ($lineDelimited -and $content.ToString().Contains("`n")) { break } }',
|
|
' Start-Sleep -Milliseconds 20;',
|
|
' }',
|
|
' $text = $content.ToString();',
|
|
' $bytes = [System.Text.Encoding]::UTF8.GetBytes($text);',
|
|
' [pscustomobject]@{ contentB64 = [Convert]::ToBase64String($bytes); bytesWritten = $bytesWritten; elapsedMs = $sw.ElapsedMilliseconds } | ConvertTo-Json -Compress | Write-Output;',
|
|
'} finally { if ($port.IsOpen) { $port.Close() }; $port.Dispose() }',
|
|
].join(' ');
|
|
const logDir = path.join(stateRoot(profile.__workspaceRoot), 'serial');
|
|
ensureDir(logDir);
|
|
const logFile = path.join(logDir, `${uart.id.replace(/\//g, '_')}.jsonl`);
|
|
const attempts = [];
|
|
let lastFailure = null;
|
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
if (attempt > 0 && retryDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
const result = await runPowerShell(makeScript(), Math.max(5000, durationMs + 5000));
|
|
if (result.exitCode !== 0) throw new Error(`uart jsonrpc failed on ${uart.port}@${uart.baudRate}: ${shortTail(result.stderr || result.stdout)}`);
|
|
const payload = JSON.parse(result.stdout.trim());
|
|
const content = payload.contentB64 ? Buffer.from(payload.contentB64, 'base64').toString('utf8') : '';
|
|
const parsed = parseJsonFromSerialContent(content);
|
|
const attemptSummary = { attempt: attempt + 1, content, bytes: Buffer.byteLength(content), responseJson: parsed.json, responseParseError: parsed.error, elapsedMs: payload.elapsedMs, stderr: result.stderr };
|
|
attempts.push(attemptSummary);
|
|
fs.appendFileSync(logFile, `${JSON.stringify({ ts: nowIso(), direction: 'jsonrpc', uartId: uart.id, port: uart.port, baudRate: uart.baudRate, request, content, parsed: parsed.json, parseError: parsed.error, attempt: attempt + 1 })}\n`, 'utf8');
|
|
try {
|
|
if ((options['require-response'] === true || options.requireResponse === true) && !content.trim()) throw new Error('uart jsonrpc received no response');
|
|
jsonRpcValidation(options, request, parsed);
|
|
ok('io-probe.uart.jsonrpc', { uart, request, requestText, lineEnding: ending.name, appendNewline: ending.text.length > 0, durationMs, lineDelimited, retries, retryDelayMs, attempt: attempt + 1, attempts: attempts.map((item) => ({ attempt: item.attempt, bytes: item.bytes, responseParseError: item.responseParseError, responseJson: item.responseJson })), exitCode: result.exitCode, bytesWritten: payload.bytesWritten || Buffer.byteLength(requestText), content, bytes: Buffer.byteLength(content), responseJson: parsed.json, responseParseError: parsed.error, elapsedMs: payload.elapsedMs, logFile, stderr: result.stderr });
|
|
return;
|
|
} catch (error) {
|
|
lastFailure = error;
|
|
if (attempt >= retries) break;
|
|
}
|
|
}
|
|
throw lastFailure || new Error('uart jsonrpc failed');
|
|
}
|
|
|
|
function usage() {
|
|
ok('help', {
|
|
usage: [
|
|
'node tools/device-host-cli.mjs --profile .device-pod/<devicePodId>.json health',
|
|
'node tools/device-host-cli.mjs --pod-id <devicePodId> health',
|
|
'node tools/device-host-cli.mjs health',
|
|
'node tools/device-host-cli.mjs workspace bootsharp [path] [--depth N] [--limit N] [--agents-limit N]',
|
|
'node tools/device-host-cli.mjs workspace ls [path] [--tree] [--depth N] [--limit N]',
|
|
'node tools/device-host-cli.mjs workspace cat <path> [--limit N] [--offset N] [--encoding utf8|latin1] [--base64]',
|
|
'node tools/device-host-cli.mjs workspace rg <pattern> [path]',
|
|
'node tools/device-host-cli.mjs workspace rg --pattern-b64 <base64> [path]',
|
|
'node tools/device-host-cli.mjs workspace put <path> --content-b64 <base64> [--encoding utf8|latin1]',
|
|
'node tools/device-host-cli.mjs workspace apply-patch [base] --patch-file <path>|--patch-b64 <base64> [--encoding utf8|latin1]',
|
|
'node tools/device-host-cli.mjs workspace keil add-source <source|--path source> --group <GroupName> [--base <workspaceRel>]',
|
|
'node tools/device-host-cli.mjs workspace keil remove-source <source|--path source> [--group <GroupName>] [--base <workspaceRel>]',
|
|
'node tools/device-host-cli.mjs workspace rm <file> [--missing-ok]',
|
|
'node tools/device-host-cli.mjs workspace rmdir <empty-dir>',
|
|
'node tools/device-host-cli.mjs workspace build clean [--target <TargetName>]',
|
|
'node tools/device-host-cli.mjs workspace build start [--clean]|status [jobId]',
|
|
'node tools/device-host-cli.mjs workspace evidence [kind=build] [jobId] [--tail N] [--full] [--target <TargetName>]',
|
|
'node tools/device-host-cli.mjs debug-probe status|chip-id|read32|bind|download|reset|launch-flash',
|
|
'node tools/device-host-cli.mjs debug-probe evidence [kind=download] [jobId] [--tail N] [--full] [--target <TargetName>]',
|
|
'node tools/device-host-cli.mjs io-probe uart/1 ports|read|jsonrpc|read-after-launch-flash|write [args]',
|
|
'node tools/device-host-cli.mjs io-probe uart/1 jsonrpc <method> [--params JSON] [--require-jsonrpc-result] [--expect-result-field name] [--retry N] [--line-ending crlf|lf|cr|none]'
|
|
]
|
|
});
|
|
}
|
|
|
|
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 === 'bootsharp') {
|
|
const request = splitOptionalWorkspacePath(rest);
|
|
return bootsharpWorkspace(request.rel, parseArgs(request.optionArgs));
|
|
}
|
|
if (command === 'ls') {
|
|
const request = splitOptionalWorkspacePath(rest);
|
|
return listWorkspaceWithOptions(request.rel, parseArgs(request.optionArgs));
|
|
}
|
|
if (command === 'cat') return catWorkspace(rest[0], parseArgs(rest.slice(1)));
|
|
if (command === 'put') return workspacePut(rest[0], parseArgs(rest.slice(1)));
|
|
if (command === 'rm') return workspaceRm(rest[0], parseArgs(rest.slice(1)));
|
|
if (command === 'rmdir') return workspaceRmdir(rest[0]);
|
|
if (command === 'rg') {
|
|
const request = resolveRgWorkspaceRequest(rest);
|
|
return rgWorkspace(request.pattern, request.rel, request.options);
|
|
}
|
|
if (command === 'apply-patch') {
|
|
const request = splitOptionalWorkspacePath(rest);
|
|
return workspaceApplyPatch(request.rel, parseArgs(request.optionArgs));
|
|
}
|
|
if (command === 'keil') return workspaceKeil(rest[0], rest.slice(1));
|
|
if (command === 'build') {
|
|
const sub = rest[0] || 'start';
|
|
if (sub === 'clean') return cleanKeilBuild(parseArgs(rest.slice(1)));
|
|
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 (command === 'evidence') {
|
|
const sub = rest[0] || 'build';
|
|
const opts = parseArgs(rest.slice(1));
|
|
return readJobEvidence(sub === 'download' ? 'keil-download' : 'keil-build', rest[1], {
|
|
tail: parsePositiveInt(opts.tail, 200),
|
|
full: opts.full === true || opts.full === 'true',
|
|
target: opts.target
|
|
});
|
|
}
|
|
}
|
|
if (group === 'debug-probe') {
|
|
if (command === 'status') return await debugStatus();
|
|
if (command === 'chip-id') return await chipId();
|
|
if (command === 'read32') return await read32(parseArgs(rest));
|
|
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 === 'evidence') {
|
|
const opts = parseArgs(rest.slice(1));
|
|
return readJobEvidence('keil-download', rest[1], {
|
|
tail: parsePositiveInt(opts.tail, 200),
|
|
full: opts.full === true || opts.full === 'true',
|
|
target: opts.target
|
|
});
|
|
}
|
|
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 === 'jsonrpc') return await serialJsonRpc(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(' ')}`);
|
|
}
|
|
|
|
export const __test = { parseJsonFromSerialContent, jsonRpcValidation, jsonRpcIdsEqual };
|
|
|
|
main().catch((error) => fail('device-host-cli', error));
|