## 问题(皇上指出) 项目里两套编号并行,无法判断哪个是"当前版本": - **发布版本**(semver):v1.1.0 ~ v1.6.0、v2.0.0 - **开发批次代号**:v1 ~ v26(README 路线表 / 代码注释 / 文档) 时间线证据:v1.1.0 首发(09-29)时批次已推进到 v12.4 —— **二者从来不是一对一**(26 批次只发过 10 版)。 ## 规则(新增 docs/VERSIONING.md §0 版本号唯一性规范) - **唯一版本号 = semver `vX.Y.Z`**,两个来源必须永远一致:git tag 与 package.json(构建期注入 agent) - **批次代号不是版本号**,不再单独出现;需要区分同版内多批改动时用**功能名** - 附「批次 → 归属发布版本」映射表(按 tag 时间线与功能内容推定,并注明以 CHANGELOG 为准) ## 统一动作 1. **批次 v23 ~ v26 → v2.0.0**(22 文件 / 53 处)—— 它们确实属于 v2.0.0 2. **臣的批次号 v1.7 / v1.8 → v2.0.0**(27 文件)—— 未单独发版 3. **简写补全三段式**:v1.5 → v1.5.0、v1.6 → v1.6.0 4. **README 路线表**:`**v1**` → `**批次 1**`;表头加「批次 ≠ 版本号」说明并链到 §0; 补齐批次 16~29 与 **v2.0.0 发布行**;删掉过时的「待办」行(提到的分支早已删除) 5. `docs/VERSIONING.md` 里**故意保留** 3 处批次号 —— 那是规范说明本身 ## 保留项(真实发布版本,不动) v1.1.0 / v1.2.1 / v1.3.0 / v1.4.0~v1.4.3 / v1.5.0 / v1.6.0 / v2.0.0(10 个 tag) ## 验证 - 版本号一致性:package.json = tag = 8 个 workspace = tauri.conf.json = **2.0.0** ✓ - v1.7/v1.8/v23~v26 全仓残留 **3 处**(均在 VERSIONING.md 规范说明中,属预期) - 7 包构建 · 单测 273 · e2e 30 · ps1-encoding 24 全绿
755 lines
30 KiB
TypeScript
755 lines
30 KiB
TypeScript
import { createHash } from 'node:crypto';
|
||
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
||
import fsSync from 'node:fs';
|
||
import net from 'node:net';
|
||
import os from 'node:os';
|
||
import path, { join } from 'node:path';
|
||
import {
|
||
NodeAgentClient,
|
||
loadMacroFile,
|
||
runMacro,
|
||
ClientError,
|
||
loadConfig,
|
||
saveConfig,
|
||
configPath,
|
||
toWsUrl,
|
||
loadKeys,
|
||
createKeys,
|
||
keysFilePath,
|
||
discoverOnce,
|
||
resolveTarget,
|
||
resolveNodeSelector,
|
||
emptyConfig,
|
||
type ClientConfig,
|
||
type ResolvedTarget,
|
||
type NodeProfile,
|
||
} from '@nodeagent/client';
|
||
import {
|
||
CapabilityNames,
|
||
matchPattern,
|
||
DEFAULT_DISCOVERY_PORT,
|
||
type CapabilityDescriptor,
|
||
type InvokeResult,
|
||
} from '@nodeagent/protocol';
|
||
import {
|
||
callAndPrint,
|
||
fail,
|
||
getClientConfig,
|
||
getNodeOverride,
|
||
probeOnce,
|
||
humanSize,
|
||
printJson,
|
||
riskIcon,
|
||
withClient,
|
||
withClientDirect,
|
||
} from '../core.js';
|
||
import { CHUNK_BYTES, type Options } from '../types.js';
|
||
|
||
// CLI 命令组:cmd/ops.ts
|
||
export async function cmdBg(command: string, opts: Options): Promise<void> {
|
||
await withClient((c) =>
|
||
callAndPrint(
|
||
c,
|
||
CapabilityNames.ShellExec,
|
||
{ command, async: true, timeout_ms: opts.timeoutMs ? Number(opts.timeoutMs) : undefined },
|
||
opts.json,
|
||
(data) => {
|
||
const d = data as { task_id: string; state: string };
|
||
console.log(`✓ 后台任务已启动: ${d.task_id}(${d.state})`);
|
||
console.log(' 查询: nodeagent task ' + d.task_id);
|
||
console.log(' 终止: nodeagent task ' + d.task_id + ' --kill');
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
export async function cmdTasks(opts: Options): Promise<void> {
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.TaskList, {}, opts.json, (data) => {
|
||
const rows = (data as { tasks: Array<{ task_id: string; state: string; duration_ms: number; exit_code?: number }> }).tasks;
|
||
if (rows.length === 0) {
|
||
console.log('当前没有后台任务');
|
||
return;
|
||
}
|
||
console.log(`${'任务ID'.padEnd(26)} ${'状态'.padEnd(9)} ${'耗时'.padStart(9)} 退出码`);
|
||
for (const t of rows) {
|
||
console.log(
|
||
`${t.task_id.padEnd(26)} ${t.state.padEnd(9)} ${String(t.duration_ms + 'ms').padStart(9)} ${t.exit_code ?? '-'}`,
|
||
);
|
||
}
|
||
}),
|
||
);
|
||
}
|
||
|
||
export async function cmdTask(taskId: string, opts: Options): Promise<void> {
|
||
if (opts.kill) {
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.TaskKill, { task_id: taskId }, opts.json, (data) => {
|
||
const d = data as { killed: boolean };
|
||
console.log(d.killed ? `✓ 已终止 ${taskId}` : `任务 ${taskId} 已不在运行`);
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
const offset = opts.offset ? Number(opts.offset) : 0;
|
||
await withClient((c) =>
|
||
callAndPrint(
|
||
c,
|
||
CapabilityNames.TaskGet,
|
||
{ task_id: taskId, offset },
|
||
opts.json,
|
||
(data) => {
|
||
const d = data as {
|
||
state: string;
|
||
exit_code: number | null;
|
||
duration_ms: number;
|
||
data: string;
|
||
offset: number;
|
||
total_bytes: number;
|
||
};
|
||
if (d.data) console.log(d.data);
|
||
console.error(
|
||
`[${d.state} · ${d.duration_ms}ms · 已读 ${d.offset}/${d.total_bytes} 字节${d.exit_code !== null && d.state !== 'running' ? ` · 退出码 ${d.exit_code}` : ''}]`,
|
||
);
|
||
if (d.state === 'running' && d.offset < d.total_bytes) {
|
||
console.error(' 续读: nodeagent task ' + taskId + ' --offset ' + d.offset);
|
||
}
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* v11 / C5:把一个能力并发下发到多台设备并汇总结果。
|
||
* 每台设备独立连接、互不阻塞(allSettled),任一失败不影响其他。
|
||
*/
|
||
|
||
export async function cmdFanout(capability: string, opts: Options): Promise<void> {
|
||
const cfg = getClientConfig();
|
||
const names = (opts.nodes ?? '')
|
||
.split(',')
|
||
.map((x) => x.trim())
|
||
.filter(Boolean);
|
||
const selector = names.length > 0 ? names : Object.keys(cfg.nodes);
|
||
// v12:支持 @组名(组内可嵌套引用其它组)
|
||
const { nodes: targets, resolvedGroups } = resolveNodeSelector(cfg, selector);
|
||
if (targets.length === 0) fail('没有可用的设备,请先 nodeagent connect 或检查分组定义');
|
||
for (const [g, members] of Object.entries(resolvedGroups)) {
|
||
if (members.length === 0) fail(`分组「${g}」为空或不存在(可用: ${Object.keys(cfg.groups ?? {}).join(', ') || '无'})`);
|
||
}
|
||
|
||
let args: Record<string, unknown> = {};
|
||
if (opts.args) {
|
||
try {
|
||
args = JSON.parse(opts.args) as Record<string, unknown>;
|
||
} catch {
|
||
fail('--args 需为合法 JSON,例如 --args {"limit":5}');
|
||
}
|
||
}
|
||
|
||
const started = Date.now();
|
||
const results = await Promise.allSettled(
|
||
targets.map(async (n) => {
|
||
const t0 = Date.now();
|
||
const data = await withClient(async (c) => {
|
||
const r = await c.invoke(capability, args, Number(opts.timeoutMs ?? 60_000));
|
||
if (r.status === 'failed') throw new Error(`${r.error?.name}: ${r.error?.message}`);
|
||
return r.data;
|
||
}, n);
|
||
return { node: n, ms: Date.now() - t0, data };
|
||
}),
|
||
);
|
||
|
||
if (opts.json) {
|
||
printJson(
|
||
results.map((r, i) =>
|
||
r.status === 'fulfilled'
|
||
? { node: r.value.node, ok: true, ms: r.value.ms, data: r.value.data }
|
||
: { node: targets[i], ok: false, error: String(r.reason) },
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
console.log(`并发下发 ${capability} → ${targets.length} 台设备(总耗时 ${Date.now() - started}ms)\n`);
|
||
results.forEach((r, i) => {
|
||
const name = (targets[i] ?? '?').padEnd(16);
|
||
if (r.status === 'fulfilled') {
|
||
const preview = JSON.stringify(r.value.data);
|
||
console.log(` ✓ ${name} ${String(r.value.ms + 'ms').padStart(7)} ${preview.slice(0, 150)}`);
|
||
} else {
|
||
console.log(` ✗ ${name} ${'—'.padStart(7)} ${String(r.reason).slice(0, 150)}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
export async function cmdRecord(opts: Options): Promise<void> {
|
||
const args: Record<string, unknown> = {};
|
||
if (opts.duration) args['duration_ms'] = Number(opts.duration);
|
||
if (opts.fps) args['fps'] = Number(opts.fps);
|
||
if (opts.scale) args['scale'] = Number(opts.scale);
|
||
if (opts.region) {
|
||
const n = opts.region.split(',').map(Number);
|
||
if (n.length !== 4 || n.some((v) => !Number.isFinite(v))) fail('region 格式应为 "x,y,width,height"');
|
||
args['region'] = { x: n[0], y: n[1], width: n[2], height: n[3] };
|
||
}
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.ScreenRecord, args, opts.json, (data) => {
|
||
const d = data as { dir: string; frames: number; fps: number; elapsed_ms: number; video_path?: string; frames_only: boolean };
|
||
console.log(`✓ 录制完成:${d.frames} 帧 @ ${d.fps}fps(${d.elapsed_ms}ms)`);
|
||
console.log(` 目录: ${d.dir}`);
|
||
if (d.video_path) console.log(` 视频: ${d.video_path}`);
|
||
else if (d.frames_only) console.log(' 提示: 无 ffmpeg,用 `nodeagent pull` 或 `nodeagent ls` 取回帧');
|
||
}),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* v11 / D2:一键部署 / 升级被控端 agent。
|
||
* 流程:被控端自报入口路径(system.info)→ 上传新 agent.mjs → 受控重启 → 复验能力数。
|
||
* 全程无需手工拷文件或起计划任务。
|
||
*/
|
||
|
||
/**
|
||
* 部署/更新被控端 agent(v18 起带指纹校验)。
|
||
*
|
||
* 流程:本地算 sha256 → 问远端构建指纹 → 相同则跳过(--force 可强制)→
|
||
* 备份 → 分块上传 → 受控重启 → **轮询直到远端指纹等于本地**(替代盲等)。
|
||
*
|
||
* 为什么必须比指纹:只看「PID 变了 / 能力数对了」证明不了换成了新代码
|
||
* (旧版可能恰好有同样多的能力)。内容哈希是唯一可靠判据。
|
||
*/
|
||
export async function cmdDeploy(localFile: string, opts: Options): Promise<void> {
|
||
if (!existsSync(localFile)) fail(`本地文件不存在: ${localFile}`);
|
||
const buf = readFileSync(localFile);
|
||
if (buf.length < 10_000) fail(`文件过小,疑似不是 agent 包: ${localFile}`);
|
||
const localHash = createHash('sha256').update(buf).digest('hex').slice(0, 12);
|
||
|
||
// 单条 RPC 的安全分块(与上传保持一致)
|
||
const BACKUP_CHUNK = 512 * 1024;
|
||
|
||
type Info = {
|
||
agent_script?: string;
|
||
pid?: number;
|
||
node_path?: string;
|
||
build?: { hash?: string; node?: string; started_at?: number };
|
||
};
|
||
|
||
const remote = await withClient((c) => c.invoke<Info>(CapabilityNames.SystemInfo, {}));
|
||
if (remote.status === 'failed') fail('无法读取被控端信息');
|
||
const info: Info = remote.data ?? {};
|
||
const remoteHash = info.build?.hash ?? 'unknown';
|
||
const target = opts.path ?? info.agent_script;
|
||
if (!target) fail('被控端未上报入口路径,请用 --path <远端路径> 指定');
|
||
|
||
console.log(`本地 : ${localHash} (${(buf.length / 1024).toFixed(0)} KB)`);
|
||
console.log(`远端 : ${remoteHash} ${info.build?.node ? `(node ${info.build.node})` : ''}`);
|
||
console.log(`目标 : ${target}(当前 PID ${info.pid ?? '?'})`);
|
||
|
||
const unknownRemote = remoteHash === 'unknown';
|
||
if (unknownRemote) {
|
||
console.log('ℹ️ 远端未上报构建指纹(多为 v18 之前的旧版)—— 无法比对,建议直接部署');
|
||
}
|
||
|
||
if (opts.check === true) {
|
||
if (remoteHash === localHash) {
|
||
console.log('✓ 已是最新,无需更新');
|
||
} else if (unknownRemote) {
|
||
console.log('⚠️ 无法比对(远端过旧),建议执行 `nodeagent deploy <文件>` 更新');
|
||
process.exitCode = 2;
|
||
} else {
|
||
console.log('⚠️ 版本不一致 —— 执行 `nodeagent deploy <文件>` 更新(加 --force 可强制覆盖)');
|
||
process.exitCode = 2;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!unknownRemote && remoteHash === localHash && opts.force !== true) {
|
||
console.log('✓ 远端指纹与本地一致,已是最新(如需强制覆盖请加 --force)');
|
||
return;
|
||
}
|
||
|
||
// 备份 + 上传 + 重启
|
||
const uploadInfo = await withClient(async (c) => {
|
||
// 备份走协议自带的 fs 能力(而非 shell 的 Copy-Item)——
|
||
// Copy-Item 是 Windows 专用,在 macOS 被控端上会静默失败(真机踩过:以为备份了,其实没有)。
|
||
// 备份用**固定名**(覆盖式):此前用 `.bak-<时间戳>`,每部署一次就多一个文件 ——
|
||
// 真机 2026-10-09 发现 D:\Nodeagent 下堆了 15+ 个 bak(单文件 1MB,纯浪费)。
|
||
// 部署回滚只需要「上一个可用版本」,一份就够,故改为覆盖写。
|
||
const backup = `${target}.bak`;
|
||
let backupPath = '';
|
||
try {
|
||
const st = await c.invoke<{ size?: number; exists?: boolean }>(CapabilityNames.FsStat, { path: target });
|
||
const size = st.status === 'ok' ? (st.data?.size ?? 0) : 0;
|
||
if (size > 0) {
|
||
let off = 0;
|
||
let first = true;
|
||
while (off < size) {
|
||
// 自行计算本次长度并据此前进(对齐 cmdPush 的成熟写法):
|
||
// fs.read 返回的 offset 是**本次读取的起始位置**,拿它当前进量会原地打转
|
||
// → 死循环 + 文件暴涨(真机踩过:备份 33B→268KB 且部署卡死 200s)。
|
||
const len = Math.min(BACKUP_CHUNK, size - off);
|
||
const r = await c.invoke<{ data?: string }>(CapabilityNames.FsRead, {
|
||
path: target,
|
||
offset: off,
|
||
max_bytes: len,
|
||
// ⚠️ 必须显式 base64:默认 utf8 会把二进制按文本读(损坏),
|
||
// 再当 base64 写回就得到几十字节的垃圾备份(真机踩过:备份 33 字节)
|
||
encoding: 'base64',
|
||
});
|
||
if (r.status !== 'ok') break;
|
||
const chunk = r.data?.data ?? '';
|
||
if (!chunk) break;
|
||
const w = await c.invoke(CapabilityNames.FsWrite, {
|
||
path: backup,
|
||
data: chunk,
|
||
encoding: 'base64',
|
||
append: !first,
|
||
});
|
||
if (w.status !== 'ok') break;
|
||
first = false;
|
||
off += len;
|
||
}
|
||
if (!first) backupPath = backup;
|
||
}
|
||
} catch {
|
||
/* 备份失败不阻断部署,但下面会提示 */
|
||
}
|
||
if (backupPath) console.log(`已备份: ${backupPath}`);
|
||
else console.log('⚠️ 备份未成功(部署继续,但无自动回滚点)');
|
||
|
||
const CHUNK = 512 * 1024;
|
||
let offset = 0;
|
||
while (offset < buf.length) {
|
||
const part = buf.subarray(offset, Math.min(offset + CHUNK, buf.length));
|
||
const w = await c.invoke(CapabilityNames.FsWrite, {
|
||
path: target,
|
||
data: part.toString('base64'),
|
||
encoding: 'base64',
|
||
append: offset > 0,
|
||
});
|
||
if (w.status === 'failed') fail(`上传失败于 offset ${offset}: ${w.error?.message}`);
|
||
offset += part.length;
|
||
}
|
||
console.log(`✓ 已上传 ${(buf.length / 1024).toFixed(0)} KB`);
|
||
|
||
// best-effort 清理历史的时间戳备份(老格式 `.bak-<ts>`)。
|
||
// 平台分流:Windows 用 PS,类 Unix 用 rm。清不掉不影响部署,只提示。
|
||
try {
|
||
const isWin = String((info as { os?: string }).os ?? '').toLowerCase().startsWith('win');
|
||
const cleanup = isWin
|
||
? `Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Remove-Item -Force -EA SilentlyContinue; (Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Measure-Object).Count`
|
||
: `rm -f '${target}.bak-'* 2>/dev/null; ls '${target}.bak-'* 2>/dev/null | wc -l`;
|
||
const r = await c.invoke<{ stdout?: string }>(CapabilityNames.ShellExec, { command: cleanup, timeout_ms: 15_000 });
|
||
const left = Number((r.data?.stdout ?? '').trim());
|
||
if (r.status === 'ok' && Number.isFinite(left)) {
|
||
if (left === 0) console.log('✓ 已清理历史时间戳备份(老格式)');
|
||
else console.log(`⚠️ 仍有 ${left} 个历史备份未清理(可手动删除)`);
|
||
}
|
||
} catch {
|
||
/* 清理是附加项,失败不影响部署结果 */
|
||
}
|
||
return { backupPath };
|
||
});
|
||
|
||
// 触发重启:**用独立连接且吞掉连接中断**。
|
||
// 重启会把本连接切断,这属于预期行为;若当成失败抛出,整个部署会被误判为失败
|
||
// (真机踩过:文件其实已换好、新进程也起来了,CLI 却报 E_NODE_OFFLINE)。
|
||
try {
|
||
const r = await withClient((c) =>
|
||
c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'deploy' }),
|
||
);
|
||
if (r.status === 'failed') fail(`重启失败: ${r.error?.message}`);
|
||
} catch (err) {
|
||
console.log(`(重启切断了连接,属预期:${err instanceof Error ? err.message : String(err)})`);
|
||
}
|
||
console.log('✓ 已触发重启,校验新版本上线…');
|
||
|
||
// 轮询校验:直到远端指纹 == 本地指纹。
|
||
// 用 probeOnce(永不退出),**不能用 withClient** —— 后者连不上会 fail() 退出,
|
||
// 使得「等待重连」根本无法实现(真机踩过:部署成功却报 E_NODE_OFFLINE)。
|
||
// 按墙钟限时而非固定次数:每次连接耗时不定,按次数算总时长不可预期。
|
||
let verified = false;
|
||
let lastSeen = '';
|
||
let pidSeen: number | undefined;
|
||
let capsSeen = 0;
|
||
const deadline = Date.now() + 90_000;
|
||
while (Date.now() < deadline) {
|
||
await new Promise((t) => setTimeout(t, 1500));
|
||
const p = await probeOnce();
|
||
if (!p.ok) continue;
|
||
lastSeen = p.buildHash ?? 'unknown';
|
||
pidSeen = p.pid;
|
||
capsSeen = p.caps;
|
||
if (lastSeen === localHash) {
|
||
verified = true;
|
||
console.log(`✓ 新版本已上线并校验通过:指纹 ${lastSeen},PID ${pidSeen ?? '?'},能力 ${capsSeen} 项`);
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!verified) {
|
||
const waited = Math.round((Date.now() - (deadline - 90_000)) / 1000);
|
||
console.error(`✗ ${waited}s 内未校验到新版本(当前远端指纹 ${lastSeen || '连不上'})`);
|
||
if (uploadInfo.backupPath) {
|
||
console.error(` 回滚方式:把备份复制回目标即可 ——`);
|
||
console.error(` nodeagent exec "Copy-Item '${uploadInfo.backupPath}' '${target}' -Force"`);
|
||
}
|
||
console.error(' 排查:被控端杀软可能处置了被覆盖的 agent 文件');
|
||
process.exitCode = 1;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* v12 / E1+E2:订阅被控端事件并实时接收推送。
|
||
* 与普通命令不同,它需要**保持连接**一段时间(长连接 + 推送),因此不走 withClient 的「用完即关」。
|
||
*/
|
||
|
||
export async function cmdEvents(opts: Options): Promise<void> {
|
||
const cfg = getClientConfig();
|
||
let target: ResolvedTarget;
|
||
try {
|
||
target = resolveTarget(cfg, getNodeOverride());
|
||
} catch (err) {
|
||
fail(err instanceof Error ? err.message : String(err));
|
||
}
|
||
|
||
// 无 --kind 时列出当前订阅
|
||
if (!opts.kind) {
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.EventList, {}, opts.json, (data) => {
|
||
const d = data as {
|
||
watches: Array<{ watch_id: string; kind: string; description: string; events: number }>;
|
||
buffered: number;
|
||
};
|
||
if (d.watches.length === 0) {
|
||
console.log(`当前没有事件订阅(缓冲 ${d.buffered} 条)`);
|
||
console.log('用法: nodeagent events --kind file --path <路径> [--seconds 20]');
|
||
return;
|
||
}
|
||
console.log(`${'订阅ID'.padEnd(24)} ${'类型'.padEnd(8)} 事件数 说明`);
|
||
for (const w of d.watches) {
|
||
console.log(`${w.watch_id.padEnd(24)} ${w.kind.padEnd(8)} ${String(w.events).padStart(6)} ${w.description}`);
|
||
}
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const seconds = Math.max(1, Math.min(600, Number(opts.seconds ?? 15)));
|
||
const { profile, clientId } = target;
|
||
const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null;
|
||
const received: Array<Record<string, unknown>> = [];
|
||
|
||
const client = new NodeAgentClient({
|
||
url: toWsUrl(profile),
|
||
key: profile.key ?? '',
|
||
clientId,
|
||
insecure: profile.insecure,
|
||
authMode: profile.auth_mode,
|
||
privateKey: keys?.privateKey,
|
||
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
|
||
onEvent: (evt) => {
|
||
received.push(evt);
|
||
const ts = new Date(Number(evt['ts'] ?? Date.now())).toLocaleTimeString('zh-CN');
|
||
const line = `${ts} ${String(evt['action'] ?? '').padEnd(20)} ${String(evt['target'] ?? '')}${evt['detail'] ? ` (${evt['detail']})` : ''}`;
|
||
if (opts.json) console.log(JSON.stringify(evt));
|
||
else console.log(` ${line}`);
|
||
},
|
||
});
|
||
|
||
try {
|
||
await client.connect();
|
||
const args: Record<string, unknown> = { kind: opts.kind };
|
||
if (opts.path) args['path'] = opts.path;
|
||
if (opts.pattern) args['pattern'] = opts.pattern;
|
||
if (opts.recursive) args['recursive'] = true;
|
||
if (opts.intervalMs) args['interval_ms'] = Number(opts.intervalMs);
|
||
|
||
const r = await client.invoke<{ watch_id: string; description: string }>(
|
||
CapabilityNames.EventWatch,
|
||
args,
|
||
);
|
||
if (r.status === 'failed') fail(`${r.error?.name}: ${r.error?.message}`);
|
||
const watchId = (r.data as { watch_id: string }).watch_id;
|
||
if (!opts.json) {
|
||
console.log(`已订阅:${(r.data as { description: string }).description}`);
|
||
console.log(`订阅 ID: ${watchId} 监听 ${seconds}s(Ctrl+C 可提前结束)\n`);
|
||
}
|
||
|
||
await new Promise<void>((resolve) => {
|
||
const t = setTimeout(resolve, seconds * 1000);
|
||
process.once('SIGINT', () => {
|
||
clearTimeout(t);
|
||
resolve();
|
||
});
|
||
});
|
||
|
||
await client.invoke(CapabilityNames.EventUnwatch, { watch_id: watchId });
|
||
if (!opts.json) {
|
||
console.log(`\n共收到 ${received.length} 个事件(订阅已取消)`);
|
||
}
|
||
} catch (err) {
|
||
if (err instanceof ClientError) fail(`${err.name}: ${err.message}`);
|
||
throw err;
|
||
} finally {
|
||
client.close();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* v12 / E4:GUI 宏 —— 把一次成功操作固化为可重放的步骤序列。
|
||
* 子命令:run(回放)/ validate(仅校验文件)/ init(生成示例)
|
||
*/
|
||
|
||
export async function cmdMacro(sub: string | undefined, file: string | undefined, opts: Options): Promise<void> {
|
||
if (sub === 'init') {
|
||
const target = file ?? 'macro.json';
|
||
if (existsSync(target)) fail(`文件已存在: ${target}`);
|
||
const sample = {
|
||
name: '示例:在记事本里打字并复制',
|
||
description: '演示 focus / find / type / key / clip / assert 组合;坐标与文本请按实际调整',
|
||
default_delay_ms: 300,
|
||
steps: [
|
||
{ action: 'focus', title: 'Notepad', retry: 3, interval_ms: 800 },
|
||
{ action: 'key', keys: ['ctrl', 'a'] },
|
||
{ action: 'key', keys: ['delete'] },
|
||
{ action: 'type', text: '宏回放演示 ${STAMP:-demo}' },
|
||
{ action: 'sleep', ms: 300 },
|
||
{ action: 'key', keys: ['ctrl', 'a'] },
|
||
{ action: 'key', keys: ['ctrl', 'c'] },
|
||
{ action: 'clip', expect: '宏回放演示' },
|
||
{ action: 'assert', kind: 'window', text: 'Notepad' },
|
||
],
|
||
};
|
||
writeFileSync(target, JSON.stringify(sample, null, 2) + '\n');
|
||
console.log(`✓ 已生成示例宏: ${target}`);
|
||
console.log(' 试用: nodeagent --node=<设备> macro run ' + target + ' --var STAMP=hello');
|
||
return;
|
||
}
|
||
|
||
if (!file) fail('用法: nodeagent macro run <文件.json> [--var k=v] / macro validate <文件.json> / macro init [文件.json]');
|
||
if (!existsSync(file)) fail(`宏文件不存在: ${file}`);
|
||
|
||
const macro = loadMacroFile(file);
|
||
|
||
if (sub === 'validate') {
|
||
const actions = macro.steps.map((s) => String(s['action'] ?? '?'));
|
||
console.log(`✓ 语法有效:${macro.steps.length} 个步骤`);
|
||
console.log(` 名称: ${macro.name ?? '(未命名)'}`);
|
||
console.log(` 步骤: ${actions.join(' → ')}`);
|
||
return;
|
||
}
|
||
|
||
// 解析 --var k=v
|
||
const vars: Record<string, string> = {};
|
||
for (const kv of opts.vars ?? []) {
|
||
const i = kv.indexOf('=');
|
||
if (i <= 0) fail(`--var 需为 k=v 形式: ${kv}`);
|
||
vars[kv.slice(0, i)] = kv.slice(i + 1);
|
||
}
|
||
|
||
await withClient(async (c) => {
|
||
const outDir = opts.out ?? '.';
|
||
const t0 = Date.now();
|
||
const res = await runMacro(
|
||
{
|
||
client: c,
|
||
saveCapture: opts.out
|
||
? (name, b64) => {
|
||
const p = join(outDir, name);
|
||
writeFileSync(p, Buffer.from(b64, 'base64'));
|
||
return p;
|
||
}
|
||
: undefined,
|
||
log: (m) => console.error(m),
|
||
},
|
||
macro,
|
||
vars,
|
||
);
|
||
|
||
if (opts.json) {
|
||
printJson(res);
|
||
if (!res.ok) process.exitCode = 1;
|
||
return;
|
||
}
|
||
console.log(`宏「${res.name}」共 ${macro.steps.length} 步,耗时 ${Date.now() - t0}ms\n`);
|
||
for (const s of res.steps) {
|
||
const mark = s.ok ? '✓' : '✗';
|
||
console.log(` ${mark} [${String(s.index).padStart(2)}] ${s.action.padEnd(8)} ${String(s.ms + 'ms').padStart(7)} ${s.detail ?? ''}`);
|
||
}
|
||
if (res.ok) {
|
||
console.log('\n✓ 全部步骤通过');
|
||
} else {
|
||
console.error(`\n✗ 在第 ${res.failed_at} 步失败,宏已中止`);
|
||
process.exitCode = 1;
|
||
}
|
||
});
|
||
}
|
||
|
||
/** v13:成功指标(对齐 PRD 2.2 四项验收指标)。 */
|
||
|
||
export async function cmdClip(opts: Options): Promise<void> {
|
||
// 图片上传:从本地文件读入(避免超长命令行参数)
|
||
if (opts.imageFile) {
|
||
const p = opts.imageFile;
|
||
if (!existsSync(p)) fail(`文件不存在: ${p}`);
|
||
const b64 = readFileSync(p).toString('base64');
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.ClipSet, { image_base64: b64 }, opts.json, (data) => {
|
||
const d = data as { written_bytes: number };
|
||
console.log(`✓ 图片已写入剪贴板(${d.written_bytes} 字节)`);
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
if (opts.set !== undefined) {
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.ClipSet, { text: opts.set }, opts.json, (data) => {
|
||
const d = data as { written: number };
|
||
console.log(`✓ 已写入剪贴板 ${d.written} 字符`);
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
// 图片下载:--out 指定保存路径
|
||
if (opts.out) {
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.ClipGet, { format: 'image' }, opts.json, (data) => {
|
||
const d = data as { image_base64: string; bytes: number; format: string };
|
||
writeFileSync(opts.out!, Buffer.from(d.image_base64, 'base64'));
|
||
console.log(`✓ 剪贴板图片已保存 ${opts.out}(${d.bytes} 字节,${d.format})`);
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.ClipGet, { format: opts.format ?? 'auto' }, opts.json, (data) => {
|
||
const d = data as {
|
||
type?: string;
|
||
text?: string;
|
||
bytes?: number;
|
||
image_base64?: string;
|
||
};
|
||
if (d.type === 'image') {
|
||
console.log(`(剪贴板为图片,${d.bytes} 字节;用 --out <路径> 保存)`);
|
||
return;
|
||
}
|
||
if (d.text) console.log(d.text);
|
||
else console.log('(剪贴板为空或非文本)');
|
||
}),
|
||
);
|
||
}
|
||
|
||
// ---------- v2.0.0 状态采样(monitor / log) ----------
|
||
|
||
/**
|
||
* 状态采样与回看。
|
||
*
|
||
* nodeagent monitor start --source port --target 127.0.0.1:8765 --interval 2000
|
||
* nodeagent monitor report <id>
|
||
* nodeagent monitor stop <id>
|
||
* nodeagent monitor list
|
||
* nodeagent monitor delete <id>
|
||
*/
|
||
export async function cmdMonitor(action: string | undefined, positional: string[], opts: Options): Promise<void> {
|
||
switch (action) {
|
||
case 'start': {
|
||
const source = opts.source;
|
||
if (!source) fail('用法: nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id name]');
|
||
const args: Record<string, unknown> = { source };
|
||
if (opts.target !== undefined) args['target'] = opts.target;
|
||
if (opts.interval !== undefined) args['interval_ms'] = Number(opts.interval);
|
||
if (positional[0]) args['id'] = positional[0];
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.MonitorStart, args, opts.json, (d) => {
|
||
const r = d as { id: string; source: string; target: string; interval_ms: number; file: string };
|
||
console.log(`✓ 已启动监控 ${r.id}(${r.source}${r.target ? ' → ' + r.target : ''},每 ${r.interval_ms}ms 一针)`);
|
||
console.log(` 样本文件:${r.file}`);
|
||
console.log(` 回看:nodeagent monitor report ${r.id}`);
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
case 'report': {
|
||
const id = positional[0];
|
||
if (!id) fail('用法: nodeagent monitor report <id> [--limit n]');
|
||
const args: Record<string, unknown> = { id };
|
||
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.MonitorReport, args, opts.json, (d) => {
|
||
const r = d as {
|
||
running: boolean; source: string; target: string; samples: number;
|
||
summary: Record<string, number>;
|
||
};
|
||
console.log(`${r.running ? '● 运行中' : '○ 已停止'} ${r.source}${r.target ? ' → ' + r.target : ''},共 ${r.samples} 个样本`);
|
||
// 摘要直接说人话(这是本能力的主要价值)
|
||
const sm = r.summary;
|
||
if (sm.down_count !== undefined) {
|
||
console.log(` 连通:${sm.up_count} 通 / ${sm.down_count} 断;最长连续中断 ${sm.longest_outage_samples} 针` +
|
||
(sm.ms_avg !== undefined ? `;延迟 min/avg/max = ${sm.ms_min}/${sm.ms_avg}/${sm.ms_max}ms` : ''));
|
||
}
|
||
if (sm.dead_count !== undefined) console.log(` 存活:${sm.alive_count} 在 / ${sm.dead_count} 不在;最长连续缺失 ${sm.longest_dead_samples} 针`);
|
||
if (sm.fail_count !== undefined) console.log(` 命令:${sm.ok_count} 成功 / ${sm.fail_count} 失败;退出码 ${JSON.stringify(sm.exit_codes_seen)}`);
|
||
if (sm.cpu_avg !== undefined) console.log(` CPU min/avg/max = ${sm.cpu_min}/${sm.cpu_avg}/${sm.cpu_max}%;内存 avg ${sm.mem_avg}%`);
|
||
if (sm.window_ms !== undefined) console.log(` 时间窗:${(sm.window_ms / 1000).toFixed(1)}s`);
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
case 'stop': {
|
||
const id = positional[0];
|
||
if (!id) fail('用法: nodeagent monitor stop <id>');
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.MonitorStop, { id }, opts.json, (d) => {
|
||
const r = d as { id: string; samples: number; file: string };
|
||
console.log(`✓ 已停止 ${r.id}(共 ${r.samples} 个样本);样本留在 ${r.file}`);
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
case 'list': {
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.MonitorList, {}, opts.json, (d) => {
|
||
const r = d as { running: Array<{ id: string; source: string; target: string; samples: number }>; running_count: number; history_files: string[] };
|
||
console.log(`运行中 ${r.running_count} 个:`);
|
||
for (const m of r.running) console.log(` ● ${m.id}(${m.source}${m.target ? ' → ' + m.target : ''},${m.samples} 样本)`);
|
||
if (r.history_files.length) console.log(`历史文件:${r.history_files.join(', ')}`);
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
case 'delete': {
|
||
const id = positional[0];
|
||
if (!id) fail('用法: nodeagent monitor delete <id>(运行中需先 stop)');
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.MonitorDelete, { id }, opts.json, () => console.log(`✓ 已删除 ${id} 的历史样本`)),
|
||
);
|
||
return;
|
||
}
|
||
default:
|
||
fail('用法: nodeagent monitor <start|report|stop|list|delete> ...');
|
||
}
|
||
}
|
||
|
||
/** 在被控端侧过滤日志(v2.0.0):日志常几万行,过滤必须发生在被控端。 */
|
||
export async function cmdLog(opts: Options): Promise<void> {
|
||
const logPath = opts.path;
|
||
if (!logPath) fail('用法: nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]');
|
||
const args: Record<string, unknown> = { path: logPath };
|
||
if (opts.pattern !== undefined) args['pattern'] = opts.pattern;
|
||
if (opts.level !== undefined) args['level'] = opts.level;
|
||
if (opts.since !== undefined) args['since'] = Number(opts.since);
|
||
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
|
||
if (opts.offset !== undefined) args['offset'] = Number(opts.offset);
|
||
if (opts.tail === true) args['tail'] = true;
|
||
await withClient((c) =>
|
||
callAndPrint(c, CapabilityNames.LogQuery, args, opts.json, (d) => {
|
||
const r = d as { matched: Array<{ n: number; text: string }>; scanned: number; truncated: boolean; note?: string };
|
||
console.log(`✓ 扫描 ${r.scanned} 行,命中 ${r.matched.length} 条${r.truncated ? '(结果被截断)' : ''}`);
|
||
for (const m of r.matched) console.log(` ${String(m.n).padStart(6)} ${m.text.slice(0, 160)}`);
|
||
if (r.note) console.log(` ℹ️ ${r.note}`);
|
||
}),
|
||
);
|
||
}
|