From caa5b8a2c1abc884303e5b80430f503bf2e99c86 Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:04:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor(cli):=20=E6=8B=86=E5=88=86=201977=20?= =?UTF-8?q?=E8=A1=8C=E7=9A=84=20index.ts=20=E4=B8=BA=2011=20=E4=B8=AA?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=88=E7=BA=AF=E6=90=AC=E7=A7=BB=EF=BC=8C?= =?UTF-8?q?=E9=9B=B6=E9=80=BB=E8=BE=91=E5=8F=98=E6=9B=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 动机(审查报告 §5.5 / 阿特拉斯报告) 单文件 1977 行难维护、难 review、难以定位改动影响面。本次按职责拆分: src/index.ts 315 行 入口:参数解析后按子命令调度 src/options.ts 155 行 命令行参数解析(parseArgs) src/help.ts 75 行 帮助文本 src/types.ts 74 行 共享类型与常量 src/core.ts 236 行 运行时:客户端/守护进程代理/输出工具/--node 覆盖 src/cmd/conn.ts 217 行 连接与设备(connect/keygen/nodes/use/remove/discover) src/cmd/system.ts 278 行 系统面(info/status/ps/services/exec/restart/audit/metrics/invoke/apps/install) src/cmd/ops.ts 499 行 自动化(bg/tasks/task/fanout/record/deploy/events/macro/clip) src/cmd/gui.ts 173 行 图形(screen/screenshot/mouse/key) src/cmd/fs.ts 172 行 文件(ls/stat/cat/pull/push) src/cmd/net.ts 116 行 网络两阶段提交 ## 拆分中处理的真实工程问题(都不是「机械搬移」能躲过的) 1. **跨模块可变状态**:`--node` 的全局覆盖 `currentNodeOverride` 原来是模块级 let。 ESM 的 import 绑定是只读的,直接 import 后赋值编译不过 → 改为访问器 setNodeOverride()/getNodeOverride()(并把「为什么不能用变量」写进注释)。 2. **散落在函数之间的顶层常量**:CHUNK_BYTES 恰好位于两个函数之间,按函数切分会 误入别的模块 → 归入 types.ts 统一导出。DAEMON_SOCK 同理并去重。 3. 导入按模块职责补齐(parseArgs/getClientConfig 等)。 ## 验证 - 构建 6 包全过;单测 124/124;e2e 26/26 - **真机 CLI 冒烟 25 条命令全通过**(CLI 不在 e2e 覆盖内,必须实测): info/status/ps/services/ls/metrics/audit/nodes/net status/screen/window.list/tasks/clip/apps/exec + push/stat/cat/pull(校验回读内容一致)/screenshot/key type/bg/record/mouse/discover --- apps/cli/src/cmd/conn.ts | 225 +++++ apps/cli/src/cmd/fs.ts | 181 ++++ apps/cli/src/cmd/gui.ts | 182 ++++ apps/cli/src/cmd/net.ts | 125 +++ apps/cli/src/cmd/ops.ts | 509 +++++++++++ apps/cli/src/cmd/system.ts | 287 ++++++ apps/cli/src/core.ts | 220 +++++ apps/cli/src/help.ts | 75 ++ apps/cli/src/index.ts | 1697 +----------------------------------- apps/cli/src/options.ts | 156 ++++ apps/cli/src/types.ts | 74 ++ 11 files changed, 2052 insertions(+), 1679 deletions(-) create mode 100644 apps/cli/src/cmd/conn.ts create mode 100644 apps/cli/src/cmd/fs.ts create mode 100644 apps/cli/src/cmd/gui.ts create mode 100644 apps/cli/src/cmd/net.ts create mode 100644 apps/cli/src/cmd/ops.ts create mode 100644 apps/cli/src/cmd/system.ts create mode 100644 apps/cli/src/core.ts create mode 100644 apps/cli/src/help.ts create mode 100644 apps/cli/src/options.ts create mode 100644 apps/cli/src/types.ts diff --git a/apps/cli/src/cmd/conn.ts b/apps/cli/src/cmd/conn.ts new file mode 100644 index 0000000..814c590 --- /dev/null +++ b/apps/cli/src/cmd/conn.ts @@ -0,0 +1,225 @@ +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, + humanSize, + printJson, + riskIcon, + withClient, + withClientDirect, +} from '../core.js'; +import { CHUNK_BYTES, type Options } from '../types.js'; + +// CLI 命令组:cmd/conn.ts +export async function cmdConnect(host: string, opts: Options): Promise { + const port = Number(opts.port ?? 8765); + const authMode: 'psk' | 'ed25519' = opts.authMode === 'ed25519' ? 'ed25519' : 'psk'; + if (authMode === 'psk' && !opts.key) { + fail('缺少 --key <预共享密钥>(零信任模式请用 --auth-mode ed25519)'); + } + + const name = opts.name ?? host; + const clientId = opts.id ?? 'mac_01'; + const profile: NodeProfile = { + host, + port, + tls: !process.argv.includes('--no-tls'), + insecure: opts.insecure, + key: opts.key ?? '', + auth_mode: authMode, + }; + if (opts.note) profile.note = opts.note; + // v6:经 Hub 中转 + if (opts.hubToken) profile.hub = { token: opts.hubToken, node_id: opts.hubNode ?? name }; + + const keys = authMode === 'ed25519' ? loadKeys() : null; + if (authMode === 'ed25519' && !keys) { + fail(`零信任模式需要本机密钥,请先运行: nodeagent keygen --id ${clientId}`); + } + + const client = new NodeAgentClient({ + url: toWsUrl(profile), + key: profile.key ?? '', + clientId, + insecure: profile.insecure, + authMode, + privateKey: keys?.privateKey, + hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined, + }); + try { + const caps = await client.connect(); + const authorized = client.listAuthorized(); + + // 连接成功才落盘,避免把错配置写进设备表 + const cfg = loadConfig() ?? emptyConfig(clientId); + cfg.client_id = clientId; + cfg.nodes[name] = profile; + cfg.current = name; + saveConfig(cfg); + + const via = profile.hub ? `经 Hub ${profile.host}:${profile.port}` : `${profile.host}:${profile.port}`; + console.log( + `✓ 已连接并保存设备「${name}」${via}(${profile.tls ? 'wss' : 'ws'},${authMode})`, + ); + console.log(` 配置: ${configPath()}(当前设备 ${name},共 ${Object.keys(cfg.nodes).length} 台)`); + + const isOk = (n: string): boolean => !authorized || authorized.some((p) => matchPattern(p, n)); + console.log(`\n被控端声明 ${caps.length} 项能力${authorized ? `,其中 ${authorized.length} 项已授权` : ''}:`); + for (const c of caps) { + console.log(` ${isOk(c.name) ? riskIcon(c.risk) : '🚫'} ${c.name.padEnd(24)} ${c.description}`); + } + } catch (err) { + if (err instanceof ClientError) fail(`连接失败 ${err.name}: ${err.message}`); + throw err; + } finally { + client.close(); + } +} + +// ---------- v5 多设备管理 ---------- + +export async function cmdNodes(opts: Options): Promise { + const cfg = loadConfig(); + if (!cfg || Object.keys(cfg.nodes).length === 0) { + console.log('尚未配置任何设备。用 nodeagent connect --key <密钥> 添加,或 nodeagent discover 先发现。'); + return; + } + const names = Object.keys(cfg.nodes); + if (opts.json) { + return printJson({ current: cfg.current, client_id: cfg.client_id, nodes: cfg.nodes }); + } + console.log(`控制端身份: ${cfg.client_id} 共 ${names.length} 台设备\n`); + console.log(` ${'名称'.padEnd(16)} ${'地址'.padEnd(22)} ${'协议'.padEnd(6)} ${'认证'.padEnd(9)} 备注`); + for (const n of names) { + const p = cfg.nodes[n]!; + const mark = n === cfg.current ? '●' : ' '; + const auth = `${p.auth_mode ?? 'psk'}${p.insecure ? '*' : ''}`; + console.log( + `${mark} ${n.padEnd(16)} ${`${p.host}:${p.port}`.padEnd(22)} ${(p.tls ? 'wss' : 'ws').padEnd(6)} ${auth.padEnd(9)} ${p.note ?? ''}`, + ); + } + console.log('\n(● = 当前设备;认证列 * = 跳过证书校验)'); +} + +export async function cmdUse(name: string | undefined, opts: Options): Promise { + if (!name) fail('用法: nodeagent use <设备名>'); + const cfg = loadConfig(); + if (!cfg) fail('尚未配置任何设备。请先运行: nodeagent connect --key <密钥>'); + if (!cfg.nodes[name]) { + fail(`未配置的设备: ${name};已配置:${Object.keys(cfg.nodes).join(', ') || '(空)'}`); + } + cfg.current = name; + saveConfig(cfg); + if (opts.json) return printJson({ current: cfg.current }); + const p = cfg.nodes[name]!; + console.log(`✓ 当前设备已切换为「${name}」 → ${p.host}:${p.port}`); +} + +export async function cmdRemove(name: string | undefined, opts: Options): Promise { + if (!name) fail('用法: nodeagent remove <设备名>'); + const cfg = loadConfig(); + if (!cfg || !cfg.nodes[name]) fail(`未配置的设备: ${name}`); + delete cfg.nodes[name]; + if (cfg.current === name) cfg.current = Object.keys(cfg.nodes)[0] ?? ''; + saveConfig(cfg); + if (opts.json) return printJson({ removed: name, current: cfg.current }); + console.log(`✓ 已移除设备「${name}」${cfg.current ? `,当前设备: ${cfg.current}` : ''}`); +} + +/** v3:生成 Ed25519 密钥对,并输出可直接粘贴的被控端 ACL 配置片段。 */ + +export async function cmdKeygen(opts: Options): Promise { + const clientId = opts.id ?? 'mac_01'; + const keys = createKeys(clientId); + console.log('✓ 已生成 Ed25519 密钥对'); + console.log(` client_id : ${keys.client_id}`); + console.log(` key_id : ${keys.key_id}`); + console.log(` 私钥 : ${keysFilePath()} (600 权限,永不外传)`); + console.log('\n请把下面这段加入被控端 agent.json 的 acl.clients 数组:\n'); + console.log( + JSON.stringify( + { + client_id: keys.client_id, + pubkey: keys.publicKey, + allow: ['system.*', 'app.list', 'screen.*'], + deny: ['input.*'], + note: 'generated by nodeagent keygen', + }, + null, + 2, + ), + ); + console.log('\n然后让被控端启用零信任模式:agent.json 设 "auth_mode": "ed25519" 并重启 Agent。'); +} + +export async function cmdDiscover(opts: Options): Promise { + const waitMs = (opts.wait ? Number(opts.wait) : 5) * 1000; + const port = opts.discoveryPort ? Number(opts.discoveryPort) : DEFAULT_DISCOVERY_PORT; + + console.log(`正在监听局域网广播(UDP ${port},最多 ${waitMs / 1000}s)...\n`); + + let nodes: Awaited>; + try { + nodes = await discoverOnce(waitMs, { port }); + } catch (err) { + fail(`监听失败:${err instanceof Error ? err.message : String(err)}`); + } + + if (nodes.length === 0) { + console.log('未发现设备。请检查:'); + console.log(' 1) 被控端与本机在同一局域网(跨网段广播通常不通)'); + console.log(' 2) 被控端配置未关闭 discovery.enabled'); + console.log(` 3) 防火墙未拦截 UDP ${port}`); + return; + } + + if (opts.json) return printJson(nodes); + + console.log(`发现 ${nodes.length} 台被控端:\n`); + console.log(` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} 平台`); + for (const n of nodes) { + const ctl = n.input_enabled ? ' [输入控制已开]' : ''; + console.log( + ` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${n.platform}${ctl}`, + ); + } + const first = nodes[0]!; + console.log('\n连接示例:'); + console.log(` nodeagent connect ${first.host} --port ${first.port} --key <密钥>${first.tls ? ' --insecure' : ''}`); +} + +// ---------- v5 文件传输 ---------- + +/** 分块大小:1MB(与被控端 max_bytes 默认值配合) */ diff --git a/apps/cli/src/cmd/fs.ts b/apps/cli/src/cmd/fs.ts new file mode 100644 index 0000000..986a577 --- /dev/null +++ b/apps/cli/src/cmd/fs.ts @@ -0,0 +1,181 @@ +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, + humanSize, + printJson, + riskIcon, + withClient, + withClientDirect, +} from '../core.js'; +import { CHUNK_BYTES, type Options } from '../types.js'; + +// CLI 命令组:cmd/fs.ts +export async function cmdLs(pathArg: string | undefined, opts: Options): Promise { + if (!pathArg) fail('用法: nodeagent ls <远端路径> [--recursive] [--pattern "*.log"]'); + const args: Record = { path: pathArg }; + if (opts.recursive) args['recursive'] = true; + if (opts.pattern) args['pattern'] = opts.pattern; + + await withClient((c) => + callAndPrint(c, CapabilityNames.FsList, args, opts.json, (data) => { + const d = data as { + entries: Array<{ name: string; type: string; size: number; mtime: number }>; + total: number; + truncated: boolean; + }; + for (const e of d.entries) { + const icon = e.type === 'dir' ? '📁' : '📄'; + const when = new Date(e.mtime).toLocaleString('zh-CN'); + console.log(` ${icon} ${e.name.padEnd(34)} ${humanSize(e.size).padStart(10)} ${when}`); + } + console.log(`\n共 ${d.total} 项${d.truncated ? '(已截断,可加 --pattern 过滤)' : ''}`); + }), + ); +} + +export async function cmdStat(pathArg: string | undefined, opts: Options): Promise { + if (!pathArg) fail('用法: nodeagent stat <远端路径>'); + await withClient((c) => + callAndPrint(c, CapabilityNames.FsStat, { path: pathArg }, opts.json, (data) => { + const d = data as { path: string; type: string; size: number; mtime: number; exists: boolean }; + if (!d.exists) { + console.log(`✗ 不存在: ${d.path}`); + return; + } + console.log(` 路径 : ${d.path}`); + console.log(` 类型 : ${d.type}`); + console.log(` 大小 : ${humanSize(d.size)}`); + console.log(` 修改 : ${new Date(d.mtime).toLocaleString('zh-CN')}`); + }), + ); +} + +export async function cmdCat(pathArg: string | undefined, opts: Options): Promise { + if (!pathArg) fail('用法: nodeagent cat <远端路径> [--out 本地文件]'); + await withClient((c) => + callAndPrint(c, CapabilityNames.FsRead, { path: pathArg, encoding: 'utf8' }, opts.json, (data) => { + const d = data as { data: string; bytes: number; total_bytes: number; eof: boolean }; + if (opts.out) { + writeFileSync(opts.out, d.data, 'utf8'); + console.log(`✓ 已保存 ${opts.out}(${d.bytes} 字节${d.eof ? '' : ',文件较大仅首块,请用 pull 下载完整文件'})`); + return; + } + process.stdout.write(d.data); + if (!d.eof) console.log(`\n\n… 仅显示首 ${humanSize(d.bytes)}(共 ${humanSize(d.total_bytes)}),完整下载请用 pull`); + }), + ); +} + +export async function cmdPull(remote: string | undefined, opts: Options): Promise { + if (!remote) fail('用法: nodeagent pull <远端路径> [--out 本地文件]'); + const local = opts.out ?? remote.split(/[\\/]/).pop() ?? 'download.bin'; + + await withClient(async (c) => { + const statRes = await c.invoke(CapabilityNames.FsStat, { path: remote }); + if (statRes.status !== 'ok') fail(`读取远端信息失败: ${statRes.error?.message}`); + const st = statRes.data as { exists: boolean; type: string; size: number }; + if (!st.exists) fail(`远端文件不存在: ${remote}`); + if (st.type === 'dir') fail(`目标是目录,不是文件: ${remote}(先用 ls 查看)`); + + const tmp = `${local}.nodeagent-part`; + const fd = openSync(tmp, 'w'); + let offset = 0; + try { + for (;;) { + const r = await c.invoke(CapabilityNames.FsRead, { + path: remote, + encoding: 'base64', + offset, + max_bytes: CHUNK_BYTES, + }); + if (r.status !== 'ok') fail(`下载失败: ${r.error?.name}: ${r.error?.message}`); + const d = r.data as { data: string; bytes: number; eof: boolean }; + if (d.bytes > 0) writeSync(fd, Buffer.from(d.data, 'base64')); + offset += d.bytes; + if (d.eof || d.bytes === 0) break; + } + } finally { + closeSync(fd); + } + renameSync(tmp, local); // 原子落盘,避免半截文件 + console.log(`✓ 已下载 ${remote} → ${local}(${humanSize(offset)})`); + }); +} + +export async function cmdPush(local: string | undefined, remote: string | undefined, opts: Options): Promise { + if (!local || !remote) fail('用法: nodeagent push <本地文件> <远端路径> [--create-dirs]'); + let st; + try { + st = statSync(local); + } catch { + fail(`本地文件不存在: ${local}`); + } + if (!st.isFile()) fail(`不是文件: ${local}`); + + await withClient(async (c) => { + const fd = openSync(local, 'r'); + let offset = 0; + let first = true; + try { + while (offset < st.size) { + const len = Math.min(CHUNK_BYTES, st.size - offset); + const buf = Buffer.alloc(len); + readSync(fd, buf, 0, len, offset); + const r = await c.invoke(CapabilityNames.FsWrite, { + path: remote, + data: buf.toString('base64'), + encoding: 'base64', + append: !first, + create_dirs: first && opts.createDirs === true, + }); + if (r.status !== 'ok') fail(`上传失败: ${r.error?.name}: ${r.error?.message}`); + offset += len; + first = false; + } + if (st.size === 0) { + const r = await c.invoke(CapabilityNames.FsWrite, { + path: remote, + data: '', + create_dirs: opts.createDirs === true, + }); + if (r.status !== 'ok') fail(`上传失败: ${r.error?.message}`); + } + } finally { + closeSync(fd); + } + console.log(`✓ 已上传 ${local} → ${remote}(${humanSize(offset)})`); + }); +} diff --git a/apps/cli/src/cmd/gui.ts b/apps/cli/src/cmd/gui.ts new file mode 100644 index 0000000..2da1a09 --- /dev/null +++ b/apps/cli/src/cmd/gui.ts @@ -0,0 +1,182 @@ +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, + humanSize, + printJson, + riskIcon, + withClient, + withClientDirect, +} from '../core.js'; +import { CHUNK_BYTES, type Options } from '../types.js'; + +// CLI 命令组:cmd/gui.ts +export async function cmdScreen(opts: Options): Promise { + await withClient((c) => + callAndPrint(c, CapabilityNames.ScreenInfo, {}, opts.json, (data) => { + const rows = ( + data as { displays: Array<{ id: number; name: string; width: number; height: number; is_primary: boolean }> } + ).displays; + for (const d of rows) { + console.log( + ` #${d.id} ${String(d.width).padStart(5)}x${String(d.height).padEnd(5)} ${d.is_primary ? '[主屏]' : ' '} ${d.name}`, + ); + } + }), + ); +} + +export async function cmdScreenshot(opts: Options): Promise { + const args: Record = {}; + if (opts.format) args['format'] = opts.format; + if (opts.scale) args['scale'] = Number(opts.scale); + if (opts.region) { + const nums = opts.region.split(',').map(Number); + if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) fail('--region 格式应为 x,y,width,height'); + args['region'] = { x: nums[0], y: nums[1], width: nums[2], height: nums[3] }; + } + await withClient((c) => + callAndPrint(c, CapabilityNames.ScreenCapture, args, opts.json, (data) => { + const d = data as { image: string; format: string; width: number; height: number; bytes: number }; + const out = opts.out ?? `screenshot.${d.format === 'png' ? 'png' : 'jpg'}`; + writeFileSync(out, Buffer.from(d.image, 'base64')); + console.log(`✓ 已保存 ${out} ${d.width}x${d.height} ${humanSize(d.bytes)}`); + }), + ); +} + +export async function cmdMouse(action: string | undefined, positionals: string[], opts: Options): Promise { + const [a, b] = positionals; + switch (action) { + case 'move': { + if (!a || !b) fail('用法: nodeagent mouse move [--duration 300]'); + const args: Record = { x: Number(a), y: Number(b) }; + if (opts.duration) args['duration_ms'] = Number(opts.duration); + await withClient((c) => + callAndPrint(c, CapabilityNames.MouseMove, args, opts.json, (d) => { + const r = d as { x: number; y: number }; + console.log(`✓ 鼠标已移动到 (${r.x}, ${r.y})`); + }), + ); + return; + } + case 'click': { + const args: Record = {}; + if (a && b) { + args['x'] = Number(a); + args['y'] = Number(b); + } + if (opts.button) args['button'] = opts.button; + await withClient((c) => + callAndPrint(c, CapabilityNames.MouseClick, args, opts.json, (d) => { + const r = d as { x: number; y: number; button: string }; + console.log(`✓ 已${r.button}键点击 (${r.x}, ${r.y})`); + }), + ); + return; + } + case 'scroll': { + if (!a) fail('用法: nodeagent mouse scroll [y]'); + const args: Record = { delta: Number(a) }; + if (b) args['y'] = Number(b); + await withClient((c) => + callAndPrint(c, CapabilityNames.MouseScroll, args, opts.json, (d) => { + const r = d as { delta: number }; + console.log(`✓ 已滚动 ${r.delta} 格`); + }), + ); + return; + } + case 'drag': { + const [x1, y1, x2, y2] = positionals; + if (!x1 || !y1 || !x2 || !y2) { + fail('用法: nodeagent mouse drag [--button left|right|middle]'); + } + const args: Record = { + from_x: Number(x1), + from_y: Number(y1), + to_x: Number(x2), + to_y: Number(y2), + }; + if (opts.button) args['button'] = opts.button; + if (opts.duration) args['step_delay_ms'] = Number(opts.duration); + await withClient((c) => + callAndPrint(c, CapabilityNames.MouseDrag, args, opts.json, (d) => { + const r = d as { from: { x: number; y: number }; to: { x: number; y: number }; steps: number }; + console.log( + `✓ 已拖拽 (${r.from.x}, ${r.from.y}) → (${r.to.x}, ${r.to.y}),${r.steps} 步`, + ); + }), + ); + return; + } + default: + fail('用法: nodeagent mouse ...'); + } +} + +export async function cmdKey(action: string | undefined, positionals: string[], opts: Options): Promise { + switch (action) { + case 'type': { + const text = positionals.join(' '); + if (!text) fail('用法: nodeagent key type "<文本>"'); + const args: Record = { text }; + if (opts.interval) args['interval_ms'] = Number(opts.interval); + await withClient((c) => + callAndPrint(c, CapabilityNames.KeyType, args, opts.json, (d) => { + const r = d as { length: number }; + console.log(`✓ 已输入 ${r.length} 个字符`); + }), + ); + return; + } + case 'press': { + if (positionals.length === 0) fail('用法: nodeagent key press <键1> [键2] ...(如 ctrl c)'); + await withClient((c) => + callAndPrint(c, CapabilityNames.KeyPress, { keys: positionals }, opts.json, (d) => { + const r = d as { keys: string[] }; + console.log(`✓ 已按下 ${r.keys.join('+')}`); + }), + ); + return; + } + default: + fail('用法: nodeagent key ...'); + } +} + +// ---------- v3+ 审计 ---------- + +/** v11:审计链完整性校验。 */ diff --git a/apps/cli/src/cmd/net.ts b/apps/cli/src/cmd/net.ts new file mode 100644 index 0000000..e0e0b64 --- /dev/null +++ b/apps/cli/src/cmd/net.ts @@ -0,0 +1,125 @@ +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, + humanSize, + printJson, + riskIcon, + withClient, + withClientDirect, +} from '../core.js'; +import { CHUNK_BYTES, type Options } from '../types.js'; + +// CLI 命令组:cmd/net.ts +export async function cmdNet(sub: string | undefined, opts: Options): Promise { + if (sub === 'status' || sub === undefined) { + await withClient((c) => + callAndPrint(c, CapabilityNames.NetStatus, {}, opts.json, (data) => { + const d = data as { + interfaces: Array>; + pending: Array<{ task_name: string; seconds_left: number; rollback_at: number }>; + pending_count: number; + note: string; + }; + console.log('网卡地址:'); + for (const i of d.interfaces ?? []) { + console.log(` ${String(i['InterfaceAlias'] ?? '?')} ip=${String(i['ip'] ?? '-')} gw=${String(i['gw'] ?? '-')}`); + } + if (d.pending_count > 0) { + console.log('\n⏳ 待确认的网络变更(逾期自动回滚):'); + for (const p of d.pending) { + console.log(` ${p.task_name} 剩余 ${p.seconds_left}s`); + } + console.log(' 确认提交:nodeagent net confirm'); + } else { + console.log('\n(无待确认变更)'); + } + }), + ); + return; + } + + if (sub === 'confirm') { + const args: Record = {}; + if (opts.taskName) args['task_name'] = opts.taskName; + await withClient((c) => + callAndPrint(c, CapabilityNames.NetConfirm, args, opts.json, (data) => { + const d = data as { confirmed: number; cancelled: string[]; remaining: string[]; note?: string }; + if (d.confirmed === 0) console.log(`✓ 无需确认:${d.note ?? '没有待确认的网络变更'}`); + else console.log(`✓ 已确认(提交)${d.confirmed} 项:${d.cancelled.join(', ')}`); + if ((d.remaining ?? []).length > 0) console.error(`⚠️ 仍有未取消:${d.remaining.join(', ')}`); + }), + ); + return; + } + + if (sub === 'apply') { + const modeRaw = opts.mode ?? 'static'; + const args: Record = { mode: modeRaw }; + if (opts.iface) args['interface'] = opts.iface; + if (opts.ip) args['ip'] = opts.ip; + if (opts.mask) args['mask'] = opts.mask; + if (opts.gateway) args['gateway'] = opts.gateway; + if (opts.dns) args['dns'] = String(opts.dns).split(',').map((x) => x.trim()); + if (opts.command) args['command'] = opts.command; + if (opts.confirmWithin) args['confirm_within_ms'] = Number(opts.confirmWithin); + if (!opts.yes) { + fail( + '网络变更属高危操作,需显式加 --yes 确认。\n' + + '行为:备份当前配置 → 应用变更 → 注册 OS 级定时回滚任务 → 你在窗口期内 net confirm 即提交;\n' + + ' 逾期未确认则自动回滚到变更前配置(不会把自己关在门外)。', + ); + } + // 网络变更本身耗时较长(netsh 重配网卡 + 注册计划任务),默认 60s 不够 + await withClient((c) => + callAndPrint(c, CapabilityNames.NetApply, args, opts.json, (data) => { + const d = data as { + applied: boolean; + interface: string; + backup_path: string; + task_name: string; + confirm_within_ms: number; + hint: string; + }; + console.log(`✓ 已应用变更(网卡 ${d.interface}),备份:${d.backup_path}`); + console.log(` 自动回滚任务:${d.task_name}(${Math.round(d.confirm_within_ms / 1000)}s 后触发)`); + console.log(` ${d.hint}`); + }, 200_000), + ); + return; + } + + fail('用法: nodeagent net [status] | net apply --mode static|dhcp|command [...] --yes | net confirm [--task-name X]'); +} diff --git a/apps/cli/src/cmd/ops.ts b/apps/cli/src/cmd/ops.ts new file mode 100644 index 0000000..32d98a8 --- /dev/null +++ b/apps/cli/src/cmd/ops.ts @@ -0,0 +1,509 @@ +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, + 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 { + 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 { + 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 { + 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 { + 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 = {}; + if (opts.args) { + try { + args = JSON.parse(opts.args) as Record; + } 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 { + const args: Record = {}; + 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 → 受控重启 → 复验能力数。 + * 全程无需手工拷文件或起计划任务。 + */ + +export async function cmdDeploy(localFile: string, opts: Options): Promise { + if (!existsSync(localFile)) fail(`本地文件不存在: ${localFile}`); + const buf = readFileSync(localFile); + if (buf.length < 10_000) fail(`文件过小,疑似不是 agent 包: ${localFile}`); + + await withClient(async (c) => { + const info = await c.invoke>(CapabilityNames.SystemInfo, {}); + if (info.status === 'failed') fail('无法读取被控端信息'); + const d = info.data as { + agent_script?: string; + pid?: number; + node_path?: string; + agent_home?: string; + }; + const target = opts.path ?? d.agent_script; + if (!target) fail('被控端未上报入口路径,请用 --path <远端路径> 指定'); + console.log(`目标: ${target}(当前 PID ${d.pid ?? '?'})`); + + // 备份现有文件(便于回滚) + const backup = `${target}.bak-${Date.now()}`; + if (d.node_path) { + const cp = await c.invoke(CapabilityNames.ShellExec, { + command: `Copy-Item '${target}' '${backup}' -Force -ErrorAction SilentlyContinue; Write-Output 'ok'`, + timeout_ms: 15_000, + }); + if (cp.status === 'ok') console.log(`已备份: ${backup}`); + } + + // 上传(fs.write 分块;由 agent 内部处理,无需本地→远端路径映射) + 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`); + + // 受控重启(复用 v7 能力,无需外部计划任务) + const r = await c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'deploy' }); + if (r.status === 'failed') fail(`重启失败: ${r.error?.message}`); + console.log('✓ 已触发重启,等待恢复…'); + }); + + // 等待新版本上线并复验 + await new Promise((t) => setTimeout(t, 9000)); + await withClient(async (c) => { + const info = await c.invoke<{ capabilities?: unknown[] }>(CapabilityNames.SystemInfo, {}); + const caps = c.listCapabilities(); + console.log(`✓ 新版本已上线:PID ${(info.data as { pid?: number })?.pid ?? '?'},能力 ${caps.length} 项`); + }); +} + +/** + * v12 / E1+E2:订阅被控端事件并实时接收推送。 + * 与普通命令不同,它需要**保持连接**一段时间(长连接 + 推送),因此不走 withClient 的「用完即关」。 + */ + +export async function cmdEvents(opts: Options): Promise { + 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> = []; + + 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 = { 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((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 { + 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 = {}; + 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 { + // 图片上传:从本地文件读入(避免超长命令行参数) + 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('(剪贴板为空或非文本)'); + }), + ); +} diff --git a/apps/cli/src/cmd/system.ts b/apps/cli/src/cmd/system.ts new file mode 100644 index 0000000..46581ad --- /dev/null +++ b/apps/cli/src/cmd/system.ts @@ -0,0 +1,287 @@ +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, + humanSize, + printJson, + riskIcon, + withClient, + withClientDirect, +} from '../core.js'; +import { CHUNK_BYTES, type Options } from '../types.js'; + +// CLI 命令组:cmd/system.ts +export async function cmdList(opts: Options): Promise { + const caps = await withClient(async (c) => { + return c.listCapabilities(); + }); + if (opts.json) return printJson(caps); + console.log(`被控端可用能力(${caps.length}):`); + for (const c of caps as CapabilityDescriptor[]) { + console.log(` ${riskIcon(c.risk)} ${c.name.padEnd(24)} ${c.description}`); + } +} + +export async function cmdStatus(opts: Options): Promise { + await withClient((c) => + callAndPrint(c, CapabilityNames.SystemStatus, {}, opts.json, (data) => { + const d = data as { + cpu_pct: number; + memory_used: number; + memory_total: number; + memory_pct: number; + disks: Array<{ drive: string; total: number; free: number; used_pct: number }>; + net: Array<{ adapter: string; ip: string }>; + }; + console.log(`CPU 占用 : ${d.cpu_pct}%`); + console.log(`内存 : ${humanSize(d.memory_used)} / ${humanSize(d.memory_total)} (${d.memory_pct}%)`); + console.log('磁盘 :'); + for (const disk of d.disks) { + console.log(` ${disk.drive.padEnd(12)} ${humanSize(disk.total - disk.free)} / ${humanSize(disk.total)} 已用 ${disk.used_pct}%`); + } + console.log('网络 :'); + for (const n of d.net) console.log(` ${n.adapter.padEnd(16)} ${n.ip}`); + }), + ); +} + +export async function cmdInfo(opts: Options): Promise { + await withClient((c) => + callAndPrint(c, CapabilityNames.SystemInfo, {}, opts.json, (data) => { + const d = data as Record; + for (const [k, v] of Object.entries(d)) { + const val = k === 'memory_total' && typeof v === 'number' ? humanSize(v) : String(v); + console.log(`${k.padEnd(14)}: ${val}`); + } + }), + ); +} + +export async function cmdPs(opts: Options): Promise { + const limit = Number(opts.limit ?? 20); + await withClient((c) => + callAndPrint(c, CapabilityNames.ProcessList, { limit }, opts.json, (data) => { + const rows = (data as { processes: Array<{ pid: number; name: string; cpu_pct: number; memory_bytes: number }> }).processes; + console.log(`${'PID'.padStart(7)} ${'CPU%'.padStart(7)} ${'内存'.padStart(9)} 名称`); + for (const p of rows) { + console.log(`${String(p.pid).padStart(7)} ${String(p.cpu_pct).padStart(7)} ${humanSize(p.memory_bytes).padStart(9)} ${p.name}`); + } + }), + ); +} + +export async function cmdServices(opts: Options): Promise { + const limit = Number(opts.limit ?? 20); + await withClient((c) => + callAndPrint(c, CapabilityNames.ServiceList, { limit }, opts.json, (data) => { + const rows = (data as { services: Array<{ name: string; display_name: string; state: string }> }).services; + console.log(`${'状态'.padEnd(10)} ${'服务名'.padEnd(28)} 显示名`); + for (const s of rows) console.log(`${s.state.padEnd(10)} ${s.name.padEnd(28)} ${s.display_name}`); + }), + ); +} + +export async function cmdExec(command: string, opts: Options): Promise { + await withClient((c) => + callAndPrint(c, CapabilityNames.ShellExec, { command }, opts.json, (data) => { + const d = data as { exit_code: number; stdout: string; stderr: string; duration_ms: number; truncated: boolean }; + // 命令自身的输出原样打到 stdout —— 保证 `nodeagent exec "..." | jq` 不被污染 + if (d.stdout) console.log(d.stdout); + if (d.stderr) console.error(d.stderr); + // 诊断信息(退出码/耗时)走 stderr:终端下照常可见,管道里不干扰数据 + console.error( + `[退出码 ${d.exit_code} · ${d.duration_ms}ms${d.truncated ? ' · 输出已截断' : ''}]`, + ); + }), + ); +} + +export async function cmdRestart(opts: Options): Promise { + const delayMs = opts.delay ? Number(opts.delay) : 2000; + await withClient((c) => + callAndPrint(c, CapabilityNames.AgentRestart, { delay_ms: delayMs, reason: 'cli' }, opts.json, (data) => { + const d = data as { scheduled: boolean; delay_ms: number; mechanism: string; message: string }; + console.log(`✓ ${d.message}`); + console.log(` 机制: ${d.mechanism}`); + console.log(' 提示: 约 3~4 秒后重连,可执行 `nodeagent info` 验证是否已恢复'); + }), + ); +} + +export async function cmdAuditVerify(opts: Options): Promise { + await withClient((c) => + callAndPrint(c, CapabilityNames.AuditVerify, {}, opts.json, (data) => { + const d = data as { + ok: boolean; + checked: number; + legacy: number; + broken_at?: { file: string; line: number; reason: string }; + }; + if (d.ok) { + console.log(`✓ 审计链完整(校验 ${d.checked} 条${d.legacy ? `,跳过历史条目 ${d.legacy} 条` : ''})`); + return; + } + console.error(`✗ 审计链已损坏:${d.broken_at?.reason ?? '未知原因'}`); + if (d.broken_at) console.error(` 位置: ${d.broken_at.file}:${d.broken_at.line}`); + console.error(` 已校验 ${d.checked} 条`); + process.exitCode = 1; + }), + ); +} + +export async function cmdAudit(opts: Options): Promise { + const args: Record = {}; + if (opts.limit) args['limit'] = Number(opts.limit); + if (opts.since) args['since'] = Number(opts.since); + if (opts.type) args['type'] = opts.type; + if (opts.clientId) args['client_id'] = opts.clientId; + + await withClient((c) => + callAndPrint(c, CapabilityNames.AuditList, args, opts.json, (data) => { + const d = data as { entries: Array>; total: number; file: string }; + console.log(`审计文件: ${d.file}`); + console.log(`读取 ${d.total} 条,展示最新 ${d.entries.length} 条:\n`); + for (const e of d.entries) { + const ts = new Date(Number(e['ts'])).toLocaleString('zh-CN'); + const cols = [ + ts.padEnd(20), + String(e['type'] ?? '').padEnd(17), + String(e['client_id'] ?? '-').padEnd(10), + String(e['capability'] ?? '-').padEnd(20), + String(e['status'] ?? '-').padEnd(7), + e['duration_ms'] !== undefined ? `${e['duration_ms']}ms` : '', + e['error'] ? `err=${e['error']}` : '', + e['reason'] ? String(e['reason']) : '', + ]; + console.log(' ' + cols.filter((x) => x !== '').join(' ')); + } + }), + ); +} + +// ---------- v4 局域网发现 ---------- + +export async function cmdMetrics(opts: Options): Promise { + const args: Record = {}; + if (opts.since) args['since'] = Number(opts.since); + await withClient((c) => + callAndPrint(c, CapabilityNames.Metrics, args, opts.json, (data) => { + const m = data as { + close_loop: { attempts: number; ok: number; failed: number; success_rate: number }; + app_install: { attempts: number; ok: number; failed: number; success_rate: number | null }; + latency: { + p50_ms: number; + p95_ms: number; + max_ms: number; + timeouts: number; + fast: { samples: number; p50_ms: number; p95_ms: number; max_ms: number }; + slow: { samples: number; p50_ms: number; p95_ms: number; max_ms: number; capabilities: string[] }; + }; + security: { acl_denied: number; rate_limited: number; auth_failures: number; interception_rate: number }; + verdict: { + close_loop_ok: boolean; + app_install_ok: boolean; + p95_ok: boolean; + interception_ok: boolean; + all_pass: boolean; + }; + targets: { close_loop_success_rate: number; app_install_success_rate: number; p95_ms: number }; + by_capability: Record; + window: { from: number; to: number; entries: number }; + }; + const pct = (v: number): string => `${(v * 100).toFixed(1)}%`; + const mark = (ok: boolean): string => (ok ? '✅' : '❌'); + const from = m.window.from ? new Date(m.window.from).toLocaleString('zh-CN') : '-'; + const to = m.window.to ? new Date(m.window.to).toLocaleString('zh-CN') : '-'; + console.log(`窗口: ${from} → ${to} (审计 ${m.window.entries} 条)\n`); + console.log(`闭环成功率 ${pct(m.close_loop.success_rate).padStart(7)} ${mark(m.verdict.close_loop_ok)} 目标 ≥${pct(m.targets.close_loop_success_rate)} 样本 ${m.close_loop.ok}/${m.close_loop.attempts}`); + const ir = m.app_install.success_rate; + console.log(`装软件成功率 ${ir === null ? ' 无样本' : pct(ir).padStart(7)} ${mark(m.verdict.app_install_ok)} 目标 ≥${pct(m.targets.app_install_success_rate)} 样本 ${m.app_install.ok}/${m.app_install.attempts}`); + console.log(`P95 时延 ${String(m.latency.fast.p95_ms + 'ms').padStart(7)} ${mark(m.verdict.p95_ok)} 交互层(目标 <${m.targets.p95_ms}ms,样本 ${m.latency.fast.samples}:P50 ${m.latency.fast.p50_ms}ms / 最大 ${m.latency.fast.max_ms}ms${m.latency.timeouts ? ` / 超时 ${m.latency.timeouts}` : ''})`); + console.log(`慢操作耗时 ${String(m.latency.slow.p95_ms + 'ms').padStart(7)} —— 样本 ${m.latency.slow.samples}(${m.latency.slow.capabilities.join(' / ')};本身耗时由业务决定,不计入达标判定)`); + console.log(`安全拦截 ${pct(m.security.interception_rate).padStart(7)} ${mark(m.verdict.interception_ok)} ACL 拒绝 ${m.security.acl_denied} / 超频 ${m.security.rate_limited} / 认证失败 ${m.security.auth_failures}`); + console.log(`\n总体: ${m.verdict.all_pass ? '✅ 四项全达标' : '⚠️ 存在未达标项'}`); + + const caps = Object.entries(m.by_capability).sort((a, b) => b[1].attempts - a[1].attempts); + if (caps.length > 0) { + console.log('\n按能力细分(Top 10):'); + console.log(` ${'能力'.padEnd(26)} ${'次数'.padStart(6)} ${'成功率'.padStart(8)} ${'P95'.padStart(9)}`); + for (const [name, st] of caps.slice(0, 10)) { + console.log(` ${name.padEnd(26)} ${String(st.attempts).padStart(6)} ${pct(st.success_rate).padStart(8)} ${String(st.p95_ms + 'ms').padStart(9)}`); + } + } + }), + ); +} + +/** v16:网络变更两阶段提交(备份 → 应用 → 定时回滚 → 确认提交)。 */ + +export async function cmdInvoke(capability: string, opts: Options): Promise { + let args: Record = {}; + if (opts.args) { + try { + args = JSON.parse(opts.args) as Record; + } catch { + fail('--args 必须是合法 JSON'); + } + } + await withClient(async (c) => { + const result: InvokeResult = await c.invoke(capability, args); + printJson(result); + if (result.status === 'failed') process.exitCode = 2; + }); +} + +// ---------- v2 图形操作 ---------- + +export async function cmdApps(opts: Options): Promise { + await withClient((c) => + callAndPrint(c, CapabilityNames.AppList, {}, opts.json, (data) => { + const rows = (data as { apps: Array<{ name: string; version: string; publisher: string }> }).apps; + console.log(`已安装软件(${rows.length}):`); + for (const a of rows) console.log(` ${a.name.padEnd(40)} ${(a.version || '-').padEnd(14)} ${a.publisher}`); + }), + ); +} + +export async function cmdInstall(pkg: string, opts: Options): Promise { + await withClient((c) => + callAndPrint(c, CapabilityNames.AppInstall, { package: pkg, id: opts.id }, opts.json, (data) => { + const d = data as { installed: boolean; name: string; version: string; detail: string }; + console.log(d.installed ? `✓ 安装成功: ${d.name} ${d.version}` : `✗ 安装失败: ${d.name}`); + if (d.detail) console.log(`\n${d.detail}`); + }), + ); +} diff --git a/apps/cli/src/core.ts b/apps/cli/src/core.ts new file mode 100644 index 0000000..eef85d3 --- /dev/null +++ b/apps/cli/src/core.ts @@ -0,0 +1,220 @@ +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 { type Options } from './types.js'; + +// CLI 共享运行时(客户端/守护进程代理/输出工具) +export function getClientConfig(): ClientConfig { + const cfg = loadConfig(); + if (!cfg) { + fail('尚未配置被控端。请先运行:\n nodeagent connect --port 8765 --key <密钥>'); + } + return cfg; +} + +/** 本次命令的临时目标设备(来自全局 --node)。 */ +// v16:全局 --node 的临时覆盖。跨模块共享可变状态:ESM 的 import 绑定只读, +// 故一律经访问器读写(直接 import 变量再赋值会编译不过)。 +let currentNodeOverride: string | undefined; + +/** 设置本次命令的 --node 覆盖(由入口在解析参数后调用)。 */ +export function setNodeOverride(v: string | undefined): void { + currentNodeOverride = v; +} + +/** 读取当前生效的 --node 覆盖。 */ +export function getNodeOverride(): string | undefined { + return currentNodeOverride; +} + +// ---------- v10 daemon 转发 ---------- + +export const DAEMON_SOCK = path.join(os.tmpdir(), 'nodeagentd.sock'); + +interface DaemonResponse { + status: 'ok' | 'failed' | 'daemon_error'; + data?: unknown; + error?: { name: string; message: string } | string; +} + +/** 经 daemon 转发一次能力调用(JSON lines over UDS)。 */ + + +export function viaDaemon(node: string, capability: string, args: Record, timeoutMs?: number): Promise { + return new Promise((resolve, reject) => { + const s = net.connect(DAEMON_SOCK); + s.setTimeout(timeoutMs ?? 60_000); + let buf = ''; + const failOnce = (err: Error): void => { + s.destroy(); + reject(err); + }; + s.once('error', failOnce); + s.once('timeout', () => failOnce(new Error('daemon 转发超时'))); + s.once('connect', () => { + s.write(JSON.stringify({ node, capability, args, timeout_ms: timeoutMs }) + '\n'); + }); + s.on('data', (chunk) => { + buf += chunk.toString('utf8'); + const idx = buf.indexOf('\n'); + if (idx >= 0) { + try { + resolve(JSON.parse(buf.slice(0, idx)) as DaemonResponse); + } catch (err) { + failOnce(err instanceof Error ? err : new Error(String(err))); + return; + } + s.end(); + } + }); + }); +} + +/** 「虚拟客户端」:invoke 转发给 daemon,业务错误原样返回、daemon 自身错误抛异常触发回退。 */ + +export function daemonProxyClient(nodeName: string): { invoke: NodeAgentClient['invoke'] } { + return { + async invoke(capability: string, args: Record, timeoutMs?: number) { + const resp = await viaDaemon(nodeName, capability, args, timeoutMs); + if (resp.status === 'daemon_error') { + throw new Error(typeof resp.error === 'string' ? resp.error : resp.error?.message); + } + if (resp.status === 'failed') { + const e = resp.error; + return { + status: 'failed' as const, + error: typeof e === 'string' ? { name: 'E_EXECUTION_FAILED', message: e } : e, + } as never; + } + return { status: 'ok' as const, data: resp.data } as never; + }, + }; +} + +export async function withClient(fn: (client: NodeAgentClient) => Promise, nodeName?: string): Promise { + const cfg = getClientConfig(); + let target: ResolvedTarget; + try { + target = resolveTarget(cfg, nodeName ?? getNodeOverride()); + } catch (err) { + fail(err instanceof Error ? err.message : String(err)); + } + const node = nodeName ?? getNodeOverride() ?? cfg.current; + // v10 daemon 快路径:socket 存活则经 daemon 复用长连接(省 1~2s TLS+握手) + if (node && existsSync(DAEMON_SOCK)) { + try { + return await fn(daemonProxyClient(node) as unknown as NodeAgentClient); + } catch (err) { + // daemon 自身错误(转发失败/超时)→ 静默回退直连;业务错误原样抛出 + if (!(err instanceof ClientError)) { + return withClientDirect(fn, nodeName); + } + throw err; + } + } + return withClientDirect(fn, nodeName); +} + +export async function withClientDirect(fn: (client: NodeAgentClient) => Promise, nodeName?: string): Promise { + const cfg = getClientConfig(); + let target: ResolvedTarget; + try { + target = resolveTarget(cfg, nodeName ?? getNodeOverride()); + } catch (err) { + fail(err instanceof Error ? err.message : String(err)); + } + const { profile, clientId } = target; + const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null; + 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, + }); + try { + await client.connect(); + return await fn(client); + } catch (err) { + if (err instanceof ClientError) { + fail(`${err.name}: ${err.message}`); + } + throw err; + } finally { + client.close(); + } +} + +export function fail(msg: string): never { + console.error(`✗ ${msg}`); + process.exit(1); +} + +export function printJson(value: unknown): void { + console.log(JSON.stringify(value, null, 2)); +} + +/** 调用能力并处理 status === 'failed' 的业务失败。 */ + +export async function callAndPrint( + client: NodeAgentClient, + capability: string, + args: Record, + json: boolean, + render: (data: unknown) => void, + timeoutMs?: number, +): Promise { + const result = await client.invoke(capability, args, timeoutMs); + if (result.status === 'failed') { + fail(`${result.error?.name ?? 'E_EXECUTION_FAILED'}: ${result.error?.message ?? '执行失败'}`); + } + if (json) printJson(result.data); + else render(result.data); +} + +export function humanSize(bytes: number): string { + if (!Number.isFinite(bytes)) return String(bytes); + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let v = bytes; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i += 1; + } + return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; +} + +export function riskIcon(risk: string): string { + return risk === 'high' ? '🔴' : risk === 'medium' ? '🟡' : '🟢'; +} diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts new file mode 100644 index 0000000..9902ba2 --- /dev/null +++ b/apps/cli/src/help.ts @@ -0,0 +1,75 @@ +// CLI 帮助文本(从 index.ts 拆出) +export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI) + +用法: + nodeagent connect [--port 8765] [--key ] [--insecure] [--id mac_01] [--auth-mode ed25519] + 配置并连接被控端(握手成功后打印能力清单) + 经 Hub 中转: 追加 --hub-token --hub-node <被控端 node_id> (v6) + + nodeagent keygen [--id mac_01] 生成 Ed25519 密钥对并输出被控端 ACL 配置片段 (v3 零信任) + nodeagent nodes 列出已配置的被控端设备 (v5 多设备) + nodeagent use <设备名> 切换当前默认设备 + nodeagent remove <设备名> 移除设备 + nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since ] + 查询被控端审计日志 (v3+) + nodeagent audit verify 校验审计链完整性(防篡改检测)(v11) + nodeagent metrics [--since ] 成功指标:闭环率/装软件率/P95/拦截率 (v13) + nodeagent net [status] 网络现状 + 待确认变更 (v16) + nodeagent net apply --mode static --ip --mask [--gateway g] --yes + 改网络(两阶段提交:未确认则自动回滚,不会失联) + nodeagent net confirm [--task-name X] 确认提交网络变更(取消自动回滚) + nodeagent discover [--wait 5] 发现局域网内的被控端(UDP 广播,免手抄 IP)(v4) + +文件传输 (v5): + nodeagent ls <远端路径> [--recursive] [--pattern "*.log"] 列目录 + nodeagent stat <远端路径> 看元信息 + nodeagent cat <远端路径> [--out 本地文件] 读文本(首块) + nodeagent pull <远端路径> [--out 本地文件] 下载(自动分块,支持大文件) + nodeagent push <本地文件> <远端路径> [--create-dirs] 上传(自动分块) + + nodeagent info 查看系统信息 (system.info) + nodeagent status 查看资源状态 (system.status) + nodeagent ps [--limit 20] 查看进程 (system.process.list) + nodeagent services [--limit 20] 查看服务 (system.service.list) + nodeagent exec "<命令>" 执行命令 (system.shell.exec) + nodeagent apps 列出已安装软件 (app.list) + nodeagent install <包名|ID> 安装软件 (app.install) + nodeagent restart [--delay 2000] 受控重启被控端(配置变更后让自身生效)(v7) + nodeagent bg "<命令>" [--timeout-ms N] 后台执行长命令,立即返回 task_id (v10) + nodeagent tasks 列出后台任务 + nodeagent task [--offset N] [--kill] 读取/终止后台任务(支持增量续读) + nodeagent clip [--set "文本"] [--out <路径>] [--image-file <路径>] + 读/写剪贴板文本或图片(--out 保存图片)(v11) + nodeagent macro run [--var k=v] [--out <目录>] + 回放 GUI 宏(步骤序列,逐步校验)(v12) + nodeagent macro validate | macro init [文件] + nodeagent events [--kind file|process|net] [--path <路径>] [--pattern G] [--seconds 15] + 订阅事件并实时接收推送(无 --kind 则列出订阅)(v12) + nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h] + 录屏为帧序列(有 ffmpeg 则封装 mp4)(v11) + nodeagent deploy [--path <远端路径>] + 一键升级被控端(备份 → 上传 → 重启 → 复验)(v11) + nodeagent group [add <组名> <设备...> | remove <组名>] 设备分组管理 (v12) + nodeagent fanout <能力名> [--nodes a,b|@组名] [--args JSON] + 多设备并发调用并汇总 (v11) + nodeagent daemon [start|stop|status] 常驻连接池(批量操作提速 5~10 倍)(v10) + nodeagent list 列出被控端可用能力 + nodeagent invoke [--args ''] 通用调用 + +图形操作 (v2,输入控制需被控端开启 allow_input): + nodeagent screen 显示器信息 (screen.info) + nodeagent screenshot [--out f.jpg] [--scale 0.5] [--region x,y,w,h] [--format jpeg] + 截屏并保存 (screen.capture) + nodeagent mouse move [--duration 300] + nodeagent mouse click [ ] [--button left|right|middle] + nodeagent mouse drag 鼠标拖拽(拖文件/框选)(v11) + nodeagent mouse scroll + nodeagent key type "<文本>" [--interval 10] + nodeagent key press <键1> [键2] ... (组合键,如 ctrl c) + +通用选项: + --node <设备名> 本次命令临时指定目标设备(不改变 current) + 可放命令后(nodeagent info --node win_b)或前置(nodeagent --node=win_b info) + --json 以原始 JSON 输出 + --config 显示当前配置路径 +`; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index b1a75af..91128f1 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,10 +1,8 @@ -import { closeSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs'; -import { existsSync } from 'node:fs'; +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 { parseArgs } from 'node:util'; import { NodeAgentClient, loadMacroFile, @@ -32,1681 +30,22 @@ import { type CapabilityDescriptor, type InvokeResult, } from '@nodeagent/protocol'; - -const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI) - -用法: - nodeagent connect [--port 8765] [--key ] [--insecure] [--id mac_01] [--auth-mode ed25519] - 配置并连接被控端(握手成功后打印能力清单) - 经 Hub 中转: 追加 --hub-token --hub-node <被控端 node_id> (v6) - - nodeagent keygen [--id mac_01] 生成 Ed25519 密钥对并输出被控端 ACL 配置片段 (v3 零信任) - nodeagent nodes 列出已配置的被控端设备 (v5 多设备) - nodeagent use <设备名> 切换当前默认设备 - nodeagent remove <设备名> 移除设备 - nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since ] - 查询被控端审计日志 (v3+) - nodeagent audit verify 校验审计链完整性(防篡改检测)(v11) - nodeagent metrics [--since ] 成功指标:闭环率/装软件率/P95/拦截率 (v13) - nodeagent net [status] 网络现状 + 待确认变更 (v16) - nodeagent net apply --mode static --ip --mask [--gateway g] --yes - 改网络(两阶段提交:未确认则自动回滚,不会失联) - nodeagent net confirm [--task-name X] 确认提交网络变更(取消自动回滚) - nodeagent discover [--wait 5] 发现局域网内的被控端(UDP 广播,免手抄 IP)(v4) - -文件传输 (v5): - nodeagent ls <远端路径> [--recursive] [--pattern "*.log"] 列目录 - nodeagent stat <远端路径> 看元信息 - nodeagent cat <远端路径> [--out 本地文件] 读文本(首块) - nodeagent pull <远端路径> [--out 本地文件] 下载(自动分块,支持大文件) - nodeagent push <本地文件> <远端路径> [--create-dirs] 上传(自动分块) - - nodeagent info 查看系统信息 (system.info) - nodeagent status 查看资源状态 (system.status) - nodeagent ps [--limit 20] 查看进程 (system.process.list) - nodeagent services [--limit 20] 查看服务 (system.service.list) - nodeagent exec "<命令>" 执行命令 (system.shell.exec) - nodeagent apps 列出已安装软件 (app.list) - nodeagent install <包名|ID> 安装软件 (app.install) - nodeagent restart [--delay 2000] 受控重启被控端(配置变更后让自身生效)(v7) - nodeagent bg "<命令>" [--timeout-ms N] 后台执行长命令,立即返回 task_id (v10) - nodeagent tasks 列出后台任务 - nodeagent task [--offset N] [--kill] 读取/终止后台任务(支持增量续读) - nodeagent clip [--set "文本"] [--out <路径>] [--image-file <路径>] - 读/写剪贴板文本或图片(--out 保存图片)(v11) - nodeagent macro run [--var k=v] [--out <目录>] - 回放 GUI 宏(步骤序列,逐步校验)(v12) - nodeagent macro validate | macro init [文件] - nodeagent events [--kind file|process|net] [--path <路径>] [--pattern G] [--seconds 15] - 订阅事件并实时接收推送(无 --kind 则列出订阅)(v12) - nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h] - 录屏为帧序列(有 ffmpeg 则封装 mp4)(v11) - nodeagent deploy [--path <远端路径>] - 一键升级被控端(备份 → 上传 → 重启 → 复验)(v11) - nodeagent group [add <组名> <设备...> | remove <组名>] 设备分组管理 (v12) - nodeagent fanout <能力名> [--nodes a,b|@组名] [--args JSON] - 多设备并发调用并汇总 (v11) - nodeagent daemon [start|stop|status] 常驻连接池(批量操作提速 5~10 倍)(v10) - nodeagent list 列出被控端可用能力 - nodeagent invoke [--args ''] 通用调用 - -图形操作 (v2,输入控制需被控端开启 allow_input): - nodeagent screen 显示器信息 (screen.info) - nodeagent screenshot [--out f.jpg] [--scale 0.5] [--region x,y,w,h] [--format jpeg] - 截屏并保存 (screen.capture) - nodeagent mouse move [--duration 300] - nodeagent mouse click [ ] [--button left|right|middle] - nodeagent mouse drag 鼠标拖拽(拖文件/框选)(v11) - nodeagent mouse scroll - nodeagent key type "<文本>" [--interval 10] - nodeagent key press <键1> [键2] ... (组合键,如 ctrl c) - -通用选项: - --node <设备名> 本次命令临时指定目标设备(不改变 current) - 可放命令后(nodeagent info --node win_b)或前置(nodeagent --node=win_b info) - --json 以原始 JSON 输出 - --config 显示当前配置路径 -`; - -/** 已解析的常用选项。 */ -interface Options { - json: boolean; - insecure: boolean; - port?: string; - key?: string; - id?: string; - limit?: string; - args?: string; - // v2 图形操作 - out?: string; - format?: string; - scale?: string; - region?: string; - button?: string; - duration?: string; - interval?: string; - /** v3:认证模式 psk | ed25519 */ - authMode?: string; - /** v3+ 审计查询 */ - since?: string; - type?: string; - clientId?: string; - /** v4 发现 */ - wait?: string; - discoveryPort?: string; - /** v5 多设备 */ - node?: string; - name?: string; - note?: string; - /** v5 文件 */ - recursive?: boolean; - pattern?: string; - createDirs?: boolean; - /** v6 Hub */ - hubToken?: string; - hubNode?: string; - /** v7 受控重启:延时毫秒 */ - delay?: string; - /** v10 后台任务 / 剪贴板 */ - timeoutMs?: string; - kill?: boolean; - offset?: string; - set?: string; - /** v11 剪贴板图片 */ - imageFile?: string; - /** v11 录屏 */ - fps?: string; - /** v11 多设备并发 */ - nodes?: string; - /** v11 部署目标路径 */ - path?: string; - /** v12 事件订阅 */ - kind?: string; - /** v12 宏变量 */ - vars?: string[]; - /** v16 网络变更 */ - mode?: string; - iface?: string; - ip?: string; - mask?: string; - gateway?: string; - dns?: string; - command?: string; - confirmWithin?: string; - taskName?: string; - yes?: boolean; - seconds?: string; - intervalMs?: string; -} - -function getClientConfig(): ClientConfig { - const cfg = loadConfig(); - if (!cfg) { - fail('尚未配置被控端。请先运行:\n nodeagent connect --port 8765 --key <密钥>'); - } - return cfg; -} - -/** 本次命令的临时目标设备(来自全局 --node)。 */ -let currentNodeOverride: string | undefined; - -// ---------- v10 daemon 转发 ---------- - -const DAEMON_SOCK = path.join(os.tmpdir(), 'nodeagentd.sock'); - -interface DaemonResponse { - status: 'ok' | 'failed' | 'daemon_error'; - data?: unknown; - error?: { name: string; message: string } | string; -} - -/** 经 daemon 转发一次能力调用(JSON lines over UDS)。 */ -function viaDaemon(node: string, capability: string, args: Record, timeoutMs?: number): Promise { - return new Promise((resolve, reject) => { - const s = net.connect(DAEMON_SOCK); - s.setTimeout(timeoutMs ?? 60_000); - let buf = ''; - const failOnce = (err: Error): void => { - s.destroy(); - reject(err); - }; - s.once('error', failOnce); - s.once('timeout', () => failOnce(new Error('daemon 转发超时'))); - s.once('connect', () => { - s.write(JSON.stringify({ node, capability, args, timeout_ms: timeoutMs }) + '\n'); - }); - s.on('data', (chunk) => { - buf += chunk.toString('utf8'); - const idx = buf.indexOf('\n'); - if (idx >= 0) { - try { - resolve(JSON.parse(buf.slice(0, idx)) as DaemonResponse); - } catch (err) { - failOnce(err instanceof Error ? err : new Error(String(err))); - return; - } - s.end(); - } - }); - }); -} - -/** 「虚拟客户端」:invoke 转发给 daemon,业务错误原样返回、daemon 自身错误抛异常触发回退。 */ -function daemonProxyClient(nodeName: string): { invoke: NodeAgentClient['invoke'] } { - return { - async invoke(capability: string, args: Record, timeoutMs?: number) { - const resp = await viaDaemon(nodeName, capability, args, timeoutMs); - if (resp.status === 'daemon_error') { - throw new Error(typeof resp.error === 'string' ? resp.error : resp.error?.message); - } - if (resp.status === 'failed') { - const e = resp.error; - return { - status: 'failed' as const, - error: typeof e === 'string' ? { name: 'E_EXECUTION_FAILED', message: e } : e, - } as never; - } - return { status: 'ok' as const, data: resp.data } as never; - }, - }; -} - -async function withClient(fn: (client: NodeAgentClient) => Promise, nodeName?: string): Promise { - const cfg = getClientConfig(); - let target: ResolvedTarget; - try { - target = resolveTarget(cfg, nodeName ?? currentNodeOverride); - } catch (err) { - fail(err instanceof Error ? err.message : String(err)); - } - const node = nodeName ?? currentNodeOverride ?? cfg.current; - // v10 daemon 快路径:socket 存活则经 daemon 复用长连接(省 1~2s TLS+握手) - if (node && existsSync(DAEMON_SOCK)) { - try { - return await fn(daemonProxyClient(node) as unknown as NodeAgentClient); - } catch (err) { - // daemon 自身错误(转发失败/超时)→ 静默回退直连;业务错误原样抛出 - if (!(err instanceof ClientError)) { - return withClientDirect(fn, nodeName); - } - throw err; - } - } - return withClientDirect(fn, nodeName); -} - -async function withClientDirect(fn: (client: NodeAgentClient) => Promise, nodeName?: string): Promise { - const cfg = getClientConfig(); - let target: ResolvedTarget; - try { - target = resolveTarget(cfg, nodeName ?? currentNodeOverride); - } catch (err) { - fail(err instanceof Error ? err.message : String(err)); - } - const { profile, clientId } = target; - const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null; - 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, - }); - try { - await client.connect(); - return await fn(client); - } catch (err) { - if (err instanceof ClientError) { - fail(`${err.name}: ${err.message}`); - } - throw err; - } finally { - client.close(); - } -} - -function fail(msg: string): never { - console.error(`✗ ${msg}`); - process.exit(1); -} - -function printJson(value: unknown): void { - console.log(JSON.stringify(value, null, 2)); -} - -/** 调用能力并处理 status === 'failed' 的业务失败。 */ -async function callAndPrint( - client: NodeAgentClient, - capability: string, - args: Record, - json: boolean, - render: (data: unknown) => void, - timeoutMs?: number, -): Promise { - const result = await client.invoke(capability, args, timeoutMs); - if (result.status === 'failed') { - fail(`${result.error?.name ?? 'E_EXECUTION_FAILED'}: ${result.error?.message ?? '执行失败'}`); - } - if (json) printJson(result.data); - else render(result.data); -} - -function humanSize(bytes: number): string { - if (!Number.isFinite(bytes)) return String(bytes); - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let v = bytes; - let i = 0; - while (v >= 1024 && i < units.length - 1) { - v /= 1024; - i += 1; - } - return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; -} - -async function cmdConnect(host: string, opts: Options): Promise { - const port = Number(opts.port ?? 8765); - const authMode: 'psk' | 'ed25519' = opts.authMode === 'ed25519' ? 'ed25519' : 'psk'; - if (authMode === 'psk' && !opts.key) { - fail('缺少 --key <预共享密钥>(零信任模式请用 --auth-mode ed25519)'); - } - - const name = opts.name ?? host; - const clientId = opts.id ?? 'mac_01'; - const profile: NodeProfile = { - host, - port, - tls: !process.argv.includes('--no-tls'), - insecure: opts.insecure, - key: opts.key ?? '', - auth_mode: authMode, - }; - if (opts.note) profile.note = opts.note; - // v6:经 Hub 中转 - if (opts.hubToken) profile.hub = { token: opts.hubToken, node_id: opts.hubNode ?? name }; - - const keys = authMode === 'ed25519' ? loadKeys() : null; - if (authMode === 'ed25519' && !keys) { - fail(`零信任模式需要本机密钥,请先运行: nodeagent keygen --id ${clientId}`); - } - - const client = new NodeAgentClient({ - url: toWsUrl(profile), - key: profile.key ?? '', - clientId, - insecure: profile.insecure, - authMode, - privateKey: keys?.privateKey, - hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined, - }); - try { - const caps = await client.connect(); - const authorized = client.listAuthorized(); - - // 连接成功才落盘,避免把错配置写进设备表 - const cfg = loadConfig() ?? emptyConfig(clientId); - cfg.client_id = clientId; - cfg.nodes[name] = profile; - cfg.current = name; - saveConfig(cfg); - - const via = profile.hub ? `经 Hub ${profile.host}:${profile.port}` : `${profile.host}:${profile.port}`; - console.log( - `✓ 已连接并保存设备「${name}」${via}(${profile.tls ? 'wss' : 'ws'},${authMode})`, - ); - console.log(` 配置: ${configPath()}(当前设备 ${name},共 ${Object.keys(cfg.nodes).length} 台)`); - - const isOk = (n: string): boolean => !authorized || authorized.some((p) => matchPattern(p, n)); - console.log(`\n被控端声明 ${caps.length} 项能力${authorized ? `,其中 ${authorized.length} 项已授权` : ''}:`); - for (const c of caps) { - console.log(` ${isOk(c.name) ? riskIcon(c.risk) : '🚫'} ${c.name.padEnd(24)} ${c.description}`); - } - } catch (err) { - if (err instanceof ClientError) fail(`连接失败 ${err.name}: ${err.message}`); - throw err; - } finally { - client.close(); - } -} - -// ---------- v5 多设备管理 ---------- - -async function cmdNodes(opts: Options): Promise { - const cfg = loadConfig(); - if (!cfg || Object.keys(cfg.nodes).length === 0) { - console.log('尚未配置任何设备。用 nodeagent connect --key <密钥> 添加,或 nodeagent discover 先发现。'); - return; - } - const names = Object.keys(cfg.nodes); - if (opts.json) { - return printJson({ current: cfg.current, client_id: cfg.client_id, nodes: cfg.nodes }); - } - console.log(`控制端身份: ${cfg.client_id} 共 ${names.length} 台设备\n`); - console.log(` ${'名称'.padEnd(16)} ${'地址'.padEnd(22)} ${'协议'.padEnd(6)} ${'认证'.padEnd(9)} 备注`); - for (const n of names) { - const p = cfg.nodes[n]!; - const mark = n === cfg.current ? '●' : ' '; - const auth = `${p.auth_mode ?? 'psk'}${p.insecure ? '*' : ''}`; - console.log( - `${mark} ${n.padEnd(16)} ${`${p.host}:${p.port}`.padEnd(22)} ${(p.tls ? 'wss' : 'ws').padEnd(6)} ${auth.padEnd(9)} ${p.note ?? ''}`, - ); - } - console.log('\n(● = 当前设备;认证列 * = 跳过证书校验)'); -} - -async function cmdUse(name: string | undefined, opts: Options): Promise { - if (!name) fail('用法: nodeagent use <设备名>'); - const cfg = loadConfig(); - if (!cfg) fail('尚未配置任何设备。请先运行: nodeagent connect --key <密钥>'); - if (!cfg.nodes[name]) { - fail(`未配置的设备: ${name};已配置:${Object.keys(cfg.nodes).join(', ') || '(空)'}`); - } - cfg.current = name; - saveConfig(cfg); - if (opts.json) return printJson({ current: cfg.current }); - const p = cfg.nodes[name]!; - console.log(`✓ 当前设备已切换为「${name}」 → ${p.host}:${p.port}`); -} - -async function cmdRemove(name: string | undefined, opts: Options): Promise { - if (!name) fail('用法: nodeagent remove <设备名>'); - const cfg = loadConfig(); - if (!cfg || !cfg.nodes[name]) fail(`未配置的设备: ${name}`); - delete cfg.nodes[name]; - if (cfg.current === name) cfg.current = Object.keys(cfg.nodes)[0] ?? ''; - saveConfig(cfg); - if (opts.json) return printJson({ removed: name, current: cfg.current }); - console.log(`✓ 已移除设备「${name}」${cfg.current ? `,当前设备: ${cfg.current}` : ''}`); -} - -/** v3:生成 Ed25519 密钥对,并输出可直接粘贴的被控端 ACL 配置片段。 */ -async function cmdKeygen(opts: Options): Promise { - const clientId = opts.id ?? 'mac_01'; - const keys = createKeys(clientId); - console.log('✓ 已生成 Ed25519 密钥对'); - console.log(` client_id : ${keys.client_id}`); - console.log(` key_id : ${keys.key_id}`); - console.log(` 私钥 : ${keysFilePath()} (600 权限,永不外传)`); - console.log('\n请把下面这段加入被控端 agent.json 的 acl.clients 数组:\n'); - console.log( - JSON.stringify( - { - client_id: keys.client_id, - pubkey: keys.publicKey, - allow: ['system.*', 'app.list', 'screen.*'], - deny: ['input.*'], - note: 'generated by nodeagent keygen', - }, - null, - 2, - ), - ); - console.log('\n然后让被控端启用零信任模式:agent.json 设 "auth_mode": "ed25519" 并重启 Agent。'); -} - -function riskIcon(risk: string): string { - return risk === 'high' ? '🔴' : risk === 'medium' ? '🟡' : '🟢'; -} - -async function cmdList(opts: Options): Promise { - const caps = await withClient(async (c) => { - return c.listCapabilities(); - }); - if (opts.json) return printJson(caps); - console.log(`被控端可用能力(${caps.length}):`); - for (const c of caps as CapabilityDescriptor[]) { - console.log(` ${riskIcon(c.risk)} ${c.name.padEnd(24)} ${c.description}`); - } -} - -async function cmdStatus(opts: Options): Promise { - await withClient((c) => - callAndPrint(c, CapabilityNames.SystemStatus, {}, opts.json, (data) => { - const d = data as { - cpu_pct: number; - memory_used: number; - memory_total: number; - memory_pct: number; - disks: Array<{ drive: string; total: number; free: number; used_pct: number }>; - net: Array<{ adapter: string; ip: string }>; - }; - console.log(`CPU 占用 : ${d.cpu_pct}%`); - console.log(`内存 : ${humanSize(d.memory_used)} / ${humanSize(d.memory_total)} (${d.memory_pct}%)`); - console.log('磁盘 :'); - for (const disk of d.disks) { - console.log(` ${disk.drive.padEnd(12)} ${humanSize(disk.total - disk.free)} / ${humanSize(disk.total)} 已用 ${disk.used_pct}%`); - } - console.log('网络 :'); - for (const n of d.net) console.log(` ${n.adapter.padEnd(16)} ${n.ip}`); - }), - ); -} - -async function cmdInfo(opts: Options): Promise { - await withClient((c) => - callAndPrint(c, CapabilityNames.SystemInfo, {}, opts.json, (data) => { - const d = data as Record; - for (const [k, v] of Object.entries(d)) { - const val = k === 'memory_total' && typeof v === 'number' ? humanSize(v) : String(v); - console.log(`${k.padEnd(14)}: ${val}`); - } - }), - ); -} - -async function cmdPs(opts: Options): Promise { - const limit = Number(opts.limit ?? 20); - await withClient((c) => - callAndPrint(c, CapabilityNames.ProcessList, { limit }, opts.json, (data) => { - const rows = (data as { processes: Array<{ pid: number; name: string; cpu_pct: number; memory_bytes: number }> }).processes; - console.log(`${'PID'.padStart(7)} ${'CPU%'.padStart(7)} ${'内存'.padStart(9)} 名称`); - for (const p of rows) { - console.log(`${String(p.pid).padStart(7)} ${String(p.cpu_pct).padStart(7)} ${humanSize(p.memory_bytes).padStart(9)} ${p.name}`); - } - }), - ); -} - -async function cmdServices(opts: Options): Promise { - const limit = Number(opts.limit ?? 20); - await withClient((c) => - callAndPrint(c, CapabilityNames.ServiceList, { limit }, opts.json, (data) => { - const rows = (data as { services: Array<{ name: string; display_name: string; state: string }> }).services; - console.log(`${'状态'.padEnd(10)} ${'服务名'.padEnd(28)} 显示名`); - for (const s of rows) console.log(`${s.state.padEnd(10)} ${s.name.padEnd(28)} ${s.display_name}`); - }), - ); -} - -async function cmdExec(command: string, opts: Options): Promise { - await withClient((c) => - callAndPrint(c, CapabilityNames.ShellExec, { command }, opts.json, (data) => { - const d = data as { exit_code: number; stdout: string; stderr: string; duration_ms: number; truncated: boolean }; - // 命令自身的输出原样打到 stdout —— 保证 `nodeagent exec "..." | jq` 不被污染 - if (d.stdout) console.log(d.stdout); - if (d.stderr) console.error(d.stderr); - // 诊断信息(退出码/耗时)走 stderr:终端下照常可见,管道里不干扰数据 - console.error( - `[退出码 ${d.exit_code} · ${d.duration_ms}ms${d.truncated ? ' · 输出已截断' : ''}]`, - ); - }), - ); -} - -async function cmdRestart(opts: Options): Promise { - const delayMs = opts.delay ? Number(opts.delay) : 2000; - await withClient((c) => - callAndPrint(c, CapabilityNames.AgentRestart, { delay_ms: delayMs, reason: 'cli' }, opts.json, (data) => { - const d = data as { scheduled: boolean; delay_ms: number; mechanism: string; message: string }; - console.log(`✓ ${d.message}`); - console.log(` 机制: ${d.mechanism}`); - console.log(' 提示: 约 3~4 秒后重连,可执行 `nodeagent info` 验证是否已恢复'); - }), - ); -} - -async function cmdBg(command: string, opts: Options): Promise { - 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'); - }, - ), - ); -} - -async function cmdTasks(opts: Options): Promise { - 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 ?? '-'}`, - ); - } - }), - ); -} - -async function cmdTask(taskId: string, opts: Options): Promise { - 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),任一失败不影响其他。 - */ -async function cmdFanout(capability: string, opts: Options): Promise { - 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 = {}; - if (opts.args) { - try { - args = JSON.parse(opts.args) as Record; - } 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)}`); - } - }); -} - -async function cmdRecord(opts: Options): Promise { - const args: Record = {}; - 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 → 受控重启 → 复验能力数。 - * 全程无需手工拷文件或起计划任务。 - */ -async function cmdDeploy(localFile: string, opts: Options): Promise { - if (!existsSync(localFile)) fail(`本地文件不存在: ${localFile}`); - const buf = readFileSync(localFile); - if (buf.length < 10_000) fail(`文件过小,疑似不是 agent 包: ${localFile}`); - - await withClient(async (c) => { - const info = await c.invoke>(CapabilityNames.SystemInfo, {}); - if (info.status === 'failed') fail('无法读取被控端信息'); - const d = info.data as { - agent_script?: string; - pid?: number; - node_path?: string; - agent_home?: string; - }; - const target = opts.path ?? d.agent_script; - if (!target) fail('被控端未上报入口路径,请用 --path <远端路径> 指定'); - console.log(`目标: ${target}(当前 PID ${d.pid ?? '?'})`); - - // 备份现有文件(便于回滚) - const backup = `${target}.bak-${Date.now()}`; - if (d.node_path) { - const cp = await c.invoke(CapabilityNames.ShellExec, { - command: `Copy-Item '${target}' '${backup}' -Force -ErrorAction SilentlyContinue; Write-Output 'ok'`, - timeout_ms: 15_000, - }); - if (cp.status === 'ok') console.log(`已备份: ${backup}`); - } - - // 上传(fs.write 分块;由 agent 内部处理,无需本地→远端路径映射) - 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`); - - // 受控重启(复用 v7 能力,无需外部计划任务) - const r = await c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'deploy' }); - if (r.status === 'failed') fail(`重启失败: ${r.error?.message}`); - console.log('✓ 已触发重启,等待恢复…'); - }); - - // 等待新版本上线并复验 - await new Promise((t) => setTimeout(t, 9000)); - await withClient(async (c) => { - const info = await c.invoke<{ capabilities?: unknown[] }>(CapabilityNames.SystemInfo, {}); - const caps = c.listCapabilities(); - console.log(`✓ 新版本已上线:PID ${(info.data as { pid?: number })?.pid ?? '?'},能力 ${caps.length} 项`); - }); -} - -/** - * v12 / E1+E2:订阅被控端事件并实时接收推送。 - * 与普通命令不同,它需要**保持连接**一段时间(长连接 + 推送),因此不走 withClient 的「用完即关」。 - */ -async function cmdEvents(opts: Options): Promise { - const cfg = getClientConfig(); - let target: ResolvedTarget; - try { - target = resolveTarget(cfg, currentNodeOverride); - } 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> = []; - - 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 = { 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((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(生成示例) - */ -async function cmdMacro(sub: string | undefined, file: string | undefined, opts: Options): Promise { - 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 = {}; - 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 四项验收指标)。 */ -async function cmdMetrics(opts: Options): Promise { - const args: Record = {}; - if (opts.since) args['since'] = Number(opts.since); - await withClient((c) => - callAndPrint(c, CapabilityNames.Metrics, args, opts.json, (data) => { - const m = data as { - close_loop: { attempts: number; ok: number; failed: number; success_rate: number }; - app_install: { attempts: number; ok: number; failed: number; success_rate: number | null }; - latency: { - p50_ms: number; - p95_ms: number; - max_ms: number; - timeouts: number; - fast: { samples: number; p50_ms: number; p95_ms: number; max_ms: number }; - slow: { samples: number; p50_ms: number; p95_ms: number; max_ms: number; capabilities: string[] }; - }; - security: { acl_denied: number; rate_limited: number; auth_failures: number; interception_rate: number }; - verdict: { - close_loop_ok: boolean; - app_install_ok: boolean; - p95_ok: boolean; - interception_ok: boolean; - all_pass: boolean; - }; - targets: { close_loop_success_rate: number; app_install_success_rate: number; p95_ms: number }; - by_capability: Record; - window: { from: number; to: number; entries: number }; - }; - const pct = (v: number): string => `${(v * 100).toFixed(1)}%`; - const mark = (ok: boolean): string => (ok ? '✅' : '❌'); - const from = m.window.from ? new Date(m.window.from).toLocaleString('zh-CN') : '-'; - const to = m.window.to ? new Date(m.window.to).toLocaleString('zh-CN') : '-'; - console.log(`窗口: ${from} → ${to} (审计 ${m.window.entries} 条)\n`); - console.log(`闭环成功率 ${pct(m.close_loop.success_rate).padStart(7)} ${mark(m.verdict.close_loop_ok)} 目标 ≥${pct(m.targets.close_loop_success_rate)} 样本 ${m.close_loop.ok}/${m.close_loop.attempts}`); - const ir = m.app_install.success_rate; - console.log(`装软件成功率 ${ir === null ? ' 无样本' : pct(ir).padStart(7)} ${mark(m.verdict.app_install_ok)} 目标 ≥${pct(m.targets.app_install_success_rate)} 样本 ${m.app_install.ok}/${m.app_install.attempts}`); - console.log(`P95 时延 ${String(m.latency.fast.p95_ms + 'ms').padStart(7)} ${mark(m.verdict.p95_ok)} 交互层(目标 <${m.targets.p95_ms}ms,样本 ${m.latency.fast.samples}:P50 ${m.latency.fast.p50_ms}ms / 最大 ${m.latency.fast.max_ms}ms${m.latency.timeouts ? ` / 超时 ${m.latency.timeouts}` : ''})`); - console.log(`慢操作耗时 ${String(m.latency.slow.p95_ms + 'ms').padStart(7)} —— 样本 ${m.latency.slow.samples}(${m.latency.slow.capabilities.join(' / ')};本身耗时由业务决定,不计入达标判定)`); - console.log(`安全拦截 ${pct(m.security.interception_rate).padStart(7)} ${mark(m.verdict.interception_ok)} ACL 拒绝 ${m.security.acl_denied} / 超频 ${m.security.rate_limited} / 认证失败 ${m.security.auth_failures}`); - console.log(`\n总体: ${m.verdict.all_pass ? '✅ 四项全达标' : '⚠️ 存在未达标项'}`); - - const caps = Object.entries(m.by_capability).sort((a, b) => b[1].attempts - a[1].attempts); - if (caps.length > 0) { - console.log('\n按能力细分(Top 10):'); - console.log(` ${'能力'.padEnd(26)} ${'次数'.padStart(6)} ${'成功率'.padStart(8)} ${'P95'.padStart(9)}`); - for (const [name, st] of caps.slice(0, 10)) { - console.log(` ${name.padEnd(26)} ${String(st.attempts).padStart(6)} ${pct(st.success_rate).padStart(8)} ${String(st.p95_ms + 'ms').padStart(9)}`); - } - } - }), - ); -} - -/** v16:网络变更两阶段提交(备份 → 应用 → 定时回滚 → 确认提交)。 */ -async function cmdNet(sub: string | undefined, opts: Options): Promise { - if (sub === 'status' || sub === undefined) { - await withClient((c) => - callAndPrint(c, CapabilityNames.NetStatus, {}, opts.json, (data) => { - const d = data as { - interfaces: Array>; - pending: Array<{ task_name: string; seconds_left: number; rollback_at: number }>; - pending_count: number; - note: string; - }; - console.log('网卡地址:'); - for (const i of d.interfaces ?? []) { - console.log(` ${String(i['InterfaceAlias'] ?? '?')} ip=${String(i['ip'] ?? '-')} gw=${String(i['gw'] ?? '-')}`); - } - if (d.pending_count > 0) { - console.log('\n⏳ 待确认的网络变更(逾期自动回滚):'); - for (const p of d.pending) { - console.log(` ${p.task_name} 剩余 ${p.seconds_left}s`); - } - console.log(' 确认提交:nodeagent net confirm'); - } else { - console.log('\n(无待确认变更)'); - } - }), - ); - return; - } - - if (sub === 'confirm') { - const args: Record = {}; - if (opts.taskName) args['task_name'] = opts.taskName; - await withClient((c) => - callAndPrint(c, CapabilityNames.NetConfirm, args, opts.json, (data) => { - const d = data as { confirmed: number; cancelled: string[]; remaining: string[]; note?: string }; - if (d.confirmed === 0) console.log(`✓ 无需确认:${d.note ?? '没有待确认的网络变更'}`); - else console.log(`✓ 已确认(提交)${d.confirmed} 项:${d.cancelled.join(', ')}`); - if ((d.remaining ?? []).length > 0) console.error(`⚠️ 仍有未取消:${d.remaining.join(', ')}`); - }), - ); - return; - } - - if (sub === 'apply') { - const modeRaw = opts.mode ?? 'static'; - const args: Record = { mode: modeRaw }; - if (opts.iface) args['interface'] = opts.iface; - if (opts.ip) args['ip'] = opts.ip; - if (opts.mask) args['mask'] = opts.mask; - if (opts.gateway) args['gateway'] = opts.gateway; - if (opts.dns) args['dns'] = String(opts.dns).split(',').map((x) => x.trim()); - if (opts.command) args['command'] = opts.command; - if (opts.confirmWithin) args['confirm_within_ms'] = Number(opts.confirmWithin); - if (!opts.yes) { - fail( - '网络变更属高危操作,需显式加 --yes 确认。\n' + - '行为:备份当前配置 → 应用变更 → 注册 OS 级定时回滚任务 → 你在窗口期内 net confirm 即提交;\n' + - ' 逾期未确认则自动回滚到变更前配置(不会把自己关在门外)。', - ); - } - // 网络变更本身耗时较长(netsh 重配网卡 + 注册计划任务),默认 60s 不够 - await withClient((c) => - callAndPrint(c, CapabilityNames.NetApply, args, opts.json, (data) => { - const d = data as { - applied: boolean; - interface: string; - backup_path: string; - task_name: string; - confirm_within_ms: number; - hint: string; - }; - console.log(`✓ 已应用变更(网卡 ${d.interface}),备份:${d.backup_path}`); - console.log(` 自动回滚任务:${d.task_name}(${Math.round(d.confirm_within_ms / 1000)}s 后触发)`); - console.log(` ${d.hint}`); - }, 200_000), - ); - return; - } - - fail('用法: nodeagent net [status] | net apply --mode static|dhcp|command [...] --yes | net confirm [--task-name X]'); -} - -async function cmdClip(opts: Options): Promise { - // 图片上传:从本地文件读入(避免超长命令行参数) - 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('(剪贴板为空或非文本)'); - }), - ); -} - -async function cmdApps(opts: Options): Promise { - await withClient((c) => - callAndPrint(c, CapabilityNames.AppList, {}, opts.json, (data) => { - const rows = (data as { apps: Array<{ name: string; version: string; publisher: string }> }).apps; - console.log(`已安装软件(${rows.length}):`); - for (const a of rows) console.log(` ${a.name.padEnd(40)} ${(a.version || '-').padEnd(14)} ${a.publisher}`); - }), - ); -} - -async function cmdInstall(pkg: string, opts: Options): Promise { - await withClient((c) => - callAndPrint(c, CapabilityNames.AppInstall, { package: pkg, id: opts.id }, opts.json, (data) => { - const d = data as { installed: boolean; name: string; version: string; detail: string }; - console.log(d.installed ? `✓ 安装成功: ${d.name} ${d.version}` : `✗ 安装失败: ${d.name}`); - if (d.detail) console.log(`\n${d.detail}`); - }), - ); -} - -async function cmdInvoke(capability: string, opts: Options): Promise { - let args: Record = {}; - if (opts.args) { - try { - args = JSON.parse(opts.args) as Record; - } catch { - fail('--args 必须是合法 JSON'); - } - } - await withClient(async (c) => { - const result: InvokeResult = await c.invoke(capability, args); - printJson(result); - if (result.status === 'failed') process.exitCode = 2; - }); -} - -// ---------- v2 图形操作 ---------- - -async function cmdScreen(opts: Options): Promise { - await withClient((c) => - callAndPrint(c, CapabilityNames.ScreenInfo, {}, opts.json, (data) => { - const rows = ( - data as { displays: Array<{ id: number; name: string; width: number; height: number; is_primary: boolean }> } - ).displays; - for (const d of rows) { - console.log( - ` #${d.id} ${String(d.width).padStart(5)}x${String(d.height).padEnd(5)} ${d.is_primary ? '[主屏]' : ' '} ${d.name}`, - ); - } - }), - ); -} - -async function cmdScreenshot(opts: Options): Promise { - const args: Record = {}; - if (opts.format) args['format'] = opts.format; - if (opts.scale) args['scale'] = Number(opts.scale); - if (opts.region) { - const nums = opts.region.split(',').map(Number); - if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) fail('--region 格式应为 x,y,width,height'); - args['region'] = { x: nums[0], y: nums[1], width: nums[2], height: nums[3] }; - } - await withClient((c) => - callAndPrint(c, CapabilityNames.ScreenCapture, args, opts.json, (data) => { - const d = data as { image: string; format: string; width: number; height: number; bytes: number }; - const out = opts.out ?? `screenshot.${d.format === 'png' ? 'png' : 'jpg'}`; - writeFileSync(out, Buffer.from(d.image, 'base64')); - console.log(`✓ 已保存 ${out} ${d.width}x${d.height} ${humanSize(d.bytes)}`); - }), - ); -} - -async function cmdMouse(action: string | undefined, positionals: string[], opts: Options): Promise { - const [a, b] = positionals; - switch (action) { - case 'move': { - if (!a || !b) fail('用法: nodeagent mouse move [--duration 300]'); - const args: Record = { x: Number(a), y: Number(b) }; - if (opts.duration) args['duration_ms'] = Number(opts.duration); - await withClient((c) => - callAndPrint(c, CapabilityNames.MouseMove, args, opts.json, (d) => { - const r = d as { x: number; y: number }; - console.log(`✓ 鼠标已移动到 (${r.x}, ${r.y})`); - }), - ); - return; - } - case 'click': { - const args: Record = {}; - if (a && b) { - args['x'] = Number(a); - args['y'] = Number(b); - } - if (opts.button) args['button'] = opts.button; - await withClient((c) => - callAndPrint(c, CapabilityNames.MouseClick, args, opts.json, (d) => { - const r = d as { x: number; y: number; button: string }; - console.log(`✓ 已${r.button}键点击 (${r.x}, ${r.y})`); - }), - ); - return; - } - case 'scroll': { - if (!a) fail('用法: nodeagent mouse scroll [y]'); - const args: Record = { delta: Number(a) }; - if (b) args['y'] = Number(b); - await withClient((c) => - callAndPrint(c, CapabilityNames.MouseScroll, args, opts.json, (d) => { - const r = d as { delta: number }; - console.log(`✓ 已滚动 ${r.delta} 格`); - }), - ); - return; - } - case 'drag': { - const [x1, y1, x2, y2] = positionals; - if (!x1 || !y1 || !x2 || !y2) { - fail('用法: nodeagent mouse drag [--button left|right|middle]'); - } - const args: Record = { - from_x: Number(x1), - from_y: Number(y1), - to_x: Number(x2), - to_y: Number(y2), - }; - if (opts.button) args['button'] = opts.button; - if (opts.duration) args['step_delay_ms'] = Number(opts.duration); - await withClient((c) => - callAndPrint(c, CapabilityNames.MouseDrag, args, opts.json, (d) => { - const r = d as { from: { x: number; y: number }; to: { x: number; y: number }; steps: number }; - console.log( - `✓ 已拖拽 (${r.from.x}, ${r.from.y}) → (${r.to.x}, ${r.to.y}),${r.steps} 步`, - ); - }), - ); - return; - } - default: - fail('用法: nodeagent mouse ...'); - } -} - -async function cmdKey(action: string | undefined, positionals: string[], opts: Options): Promise { - switch (action) { - case 'type': { - const text = positionals.join(' '); - if (!text) fail('用法: nodeagent key type "<文本>"'); - const args: Record = { text }; - if (opts.interval) args['interval_ms'] = Number(opts.interval); - await withClient((c) => - callAndPrint(c, CapabilityNames.KeyType, args, opts.json, (d) => { - const r = d as { length: number }; - console.log(`✓ 已输入 ${r.length} 个字符`); - }), - ); - return; - } - case 'press': { - if (positionals.length === 0) fail('用法: nodeagent key press <键1> [键2] ...(如 ctrl c)'); - await withClient((c) => - callAndPrint(c, CapabilityNames.KeyPress, { keys: positionals }, opts.json, (d) => { - const r = d as { keys: string[] }; - console.log(`✓ 已按下 ${r.keys.join('+')}`); - }), - ); - return; - } - default: - fail('用法: nodeagent key ...'); - } -} - -// ---------- v3+ 审计 ---------- - -/** v11:审计链完整性校验。 */ -async function cmdAuditVerify(opts: Options): Promise { - await withClient((c) => - callAndPrint(c, CapabilityNames.AuditVerify, {}, opts.json, (data) => { - const d = data as { - ok: boolean; - checked: number; - legacy: number; - broken_at?: { file: string; line: number; reason: string }; - }; - if (d.ok) { - console.log(`✓ 审计链完整(校验 ${d.checked} 条${d.legacy ? `,跳过历史条目 ${d.legacy} 条` : ''})`); - return; - } - console.error(`✗ 审计链已损坏:${d.broken_at?.reason ?? '未知原因'}`); - if (d.broken_at) console.error(` 位置: ${d.broken_at.file}:${d.broken_at.line}`); - console.error(` 已校验 ${d.checked} 条`); - process.exitCode = 1; - }), - ); -} - -async function cmdAudit(opts: Options): Promise { - const args: Record = {}; - if (opts.limit) args['limit'] = Number(opts.limit); - if (opts.since) args['since'] = Number(opts.since); - if (opts.type) args['type'] = opts.type; - if (opts.clientId) args['client_id'] = opts.clientId; - - await withClient((c) => - callAndPrint(c, CapabilityNames.AuditList, args, opts.json, (data) => { - const d = data as { entries: Array>; total: number; file: string }; - console.log(`审计文件: ${d.file}`); - console.log(`读取 ${d.total} 条,展示最新 ${d.entries.length} 条:\n`); - for (const e of d.entries) { - const ts = new Date(Number(e['ts'])).toLocaleString('zh-CN'); - const cols = [ - ts.padEnd(20), - String(e['type'] ?? '').padEnd(17), - String(e['client_id'] ?? '-').padEnd(10), - String(e['capability'] ?? '-').padEnd(20), - String(e['status'] ?? '-').padEnd(7), - e['duration_ms'] !== undefined ? `${e['duration_ms']}ms` : '', - e['error'] ? `err=${e['error']}` : '', - e['reason'] ? String(e['reason']) : '', - ]; - console.log(' ' + cols.filter((x) => x !== '').join(' ')); - } - }), - ); -} - -// ---------- v4 局域网发现 ---------- - -async function cmdDiscover(opts: Options): Promise { - const waitMs = (opts.wait ? Number(opts.wait) : 5) * 1000; - const port = opts.discoveryPort ? Number(opts.discoveryPort) : DEFAULT_DISCOVERY_PORT; - - console.log(`正在监听局域网广播(UDP ${port},最多 ${waitMs / 1000}s)...\n`); - - let nodes: Awaited>; - try { - nodes = await discoverOnce(waitMs, { port }); - } catch (err) { - fail(`监听失败:${err instanceof Error ? err.message : String(err)}`); - } - - if (nodes.length === 0) { - console.log('未发现设备。请检查:'); - console.log(' 1) 被控端与本机在同一局域网(跨网段广播通常不通)'); - console.log(' 2) 被控端配置未关闭 discovery.enabled'); - console.log(` 3) 防火墙未拦截 UDP ${port}`); - return; - } - - if (opts.json) return printJson(nodes); - - console.log(`发现 ${nodes.length} 台被控端:\n`); - console.log(` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} 平台`); - for (const n of nodes) { - const ctl = n.input_enabled ? ' [输入控制已开]' : ''; - console.log( - ` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${n.platform}${ctl}`, - ); - } - const first = nodes[0]!; - console.log('\n连接示例:'); - console.log(` nodeagent connect ${first.host} --port ${first.port} --key <密钥>${first.tls ? ' --insecure' : ''}`); -} - -// ---------- v5 文件传输 ---------- - -/** 分块大小:1MB(与被控端 max_bytes 默认值配合) */ -const CHUNK_BYTES = 1024 * 1024; - -async function cmdLs(pathArg: string | undefined, opts: Options): Promise { - if (!pathArg) fail('用法: nodeagent ls <远端路径> [--recursive] [--pattern "*.log"]'); - const args: Record = { path: pathArg }; - if (opts.recursive) args['recursive'] = true; - if (opts.pattern) args['pattern'] = opts.pattern; - - await withClient((c) => - callAndPrint(c, CapabilityNames.FsList, args, opts.json, (data) => { - const d = data as { - entries: Array<{ name: string; type: string; size: number; mtime: number }>; - total: number; - truncated: boolean; - }; - for (const e of d.entries) { - const icon = e.type === 'dir' ? '📁' : '📄'; - const when = new Date(e.mtime).toLocaleString('zh-CN'); - console.log(` ${icon} ${e.name.padEnd(34)} ${humanSize(e.size).padStart(10)} ${when}`); - } - console.log(`\n共 ${d.total} 项${d.truncated ? '(已截断,可加 --pattern 过滤)' : ''}`); - }), - ); -} - -async function cmdStat(pathArg: string | undefined, opts: Options): Promise { - if (!pathArg) fail('用法: nodeagent stat <远端路径>'); - await withClient((c) => - callAndPrint(c, CapabilityNames.FsStat, { path: pathArg }, opts.json, (data) => { - const d = data as { path: string; type: string; size: number; mtime: number; exists: boolean }; - if (!d.exists) { - console.log(`✗ 不存在: ${d.path}`); - return; - } - console.log(` 路径 : ${d.path}`); - console.log(` 类型 : ${d.type}`); - console.log(` 大小 : ${humanSize(d.size)}`); - console.log(` 修改 : ${new Date(d.mtime).toLocaleString('zh-CN')}`); - }), - ); -} - -async function cmdCat(pathArg: string | undefined, opts: Options): Promise { - if (!pathArg) fail('用法: nodeagent cat <远端路径> [--out 本地文件]'); - await withClient((c) => - callAndPrint(c, CapabilityNames.FsRead, { path: pathArg, encoding: 'utf8' }, opts.json, (data) => { - const d = data as { data: string; bytes: number; total_bytes: number; eof: boolean }; - if (opts.out) { - writeFileSync(opts.out, d.data, 'utf8'); - console.log(`✓ 已保存 ${opts.out}(${d.bytes} 字节${d.eof ? '' : ',文件较大仅首块,请用 pull 下载完整文件'})`); - return; - } - process.stdout.write(d.data); - if (!d.eof) console.log(`\n\n… 仅显示首 ${humanSize(d.bytes)}(共 ${humanSize(d.total_bytes)}),完整下载请用 pull`); - }), - ); -} - -async function cmdPull(remote: string | undefined, opts: Options): Promise { - if (!remote) fail('用法: nodeagent pull <远端路径> [--out 本地文件]'); - const local = opts.out ?? remote.split(/[\\/]/).pop() ?? 'download.bin'; - - await withClient(async (c) => { - const statRes = await c.invoke(CapabilityNames.FsStat, { path: remote }); - if (statRes.status !== 'ok') fail(`读取远端信息失败: ${statRes.error?.message}`); - const st = statRes.data as { exists: boolean; type: string; size: number }; - if (!st.exists) fail(`远端文件不存在: ${remote}`); - if (st.type === 'dir') fail(`目标是目录,不是文件: ${remote}(先用 ls 查看)`); - - const tmp = `${local}.nodeagent-part`; - const fd = openSync(tmp, 'w'); - let offset = 0; - try { - for (;;) { - const r = await c.invoke(CapabilityNames.FsRead, { - path: remote, - encoding: 'base64', - offset, - max_bytes: CHUNK_BYTES, - }); - if (r.status !== 'ok') fail(`下载失败: ${r.error?.name}: ${r.error?.message}`); - const d = r.data as { data: string; bytes: number; eof: boolean }; - if (d.bytes > 0) writeSync(fd, Buffer.from(d.data, 'base64')); - offset += d.bytes; - if (d.eof || d.bytes === 0) break; - } - } finally { - closeSync(fd); - } - renameSync(tmp, local); // 原子落盘,避免半截文件 - console.log(`✓ 已下载 ${remote} → ${local}(${humanSize(offset)})`); - }); -} - -async function cmdPush(local: string | undefined, remote: string | undefined, opts: Options): Promise { - if (!local || !remote) fail('用法: nodeagent push <本地文件> <远端路径> [--create-dirs]'); - let st; - try { - st = statSync(local); - } catch { - fail(`本地文件不存在: ${local}`); - } - if (!st.isFile()) fail(`不是文件: ${local}`); - - await withClient(async (c) => { - const fd = openSync(local, 'r'); - let offset = 0; - let first = true; - try { - while (offset < st.size) { - const len = Math.min(CHUNK_BYTES, st.size - offset); - const buf = Buffer.alloc(len); - readSync(fd, buf, 0, len, offset); - const r = await c.invoke(CapabilityNames.FsWrite, { - path: remote, - data: buf.toString('base64'), - encoding: 'base64', - append: !first, - create_dirs: first && opts.createDirs === true, - }); - if (r.status !== 'ok') fail(`上传失败: ${r.error?.name}: ${r.error?.message}`); - offset += len; - first = false; - } - if (st.size === 0) { - const r = await c.invoke(CapabilityNames.FsWrite, { - path: remote, - data: '', - create_dirs: opts.createDirs === true, - }); - if (r.status !== 'ok') fail(`上传失败: ${r.error?.message}`); - } - } finally { - closeSync(fd); - } - console.log(`✓ 已上传 ${local} → ${remote}(${humanSize(offset)})`); - }); -} - -function parseOptions(rest: string[]): { opts: Options; positionals: string[] } { - const { values, positionals } = parseArgs({ - args: rest, - options: { - json: { type: 'boolean', default: false }, - insecure: { type: 'boolean', default: false }, - port: { type: 'string' }, - key: { type: 'string' }, - id: { type: 'string' }, - limit: { type: 'string' }, - args: { type: 'string' }, - config: { type: 'boolean', default: false }, - // v2 图形操作 - out: { type: 'string' }, - format: { type: 'string' }, - scale: { type: 'string' }, - region: { type: 'string' }, - button: { type: 'string' }, - duration: { type: 'string' }, - interval: { type: 'string' }, - 'auth-mode': { type: 'string' }, - since: { type: 'string' }, - type: { type: 'string' }, - 'client-id': { type: 'string' }, - wait: { type: 'string' }, - 'discovery-port': { type: 'string' }, - node: { type: 'string' }, - name: { type: 'string' }, - note: { type: 'string' }, - recursive: { type: 'boolean', default: false }, - pattern: { type: 'string' }, - 'create-dirs': { type: 'boolean', default: false }, - 'hub-token': { type: 'string' }, - 'hub-node': { type: 'string' }, - // v7 受控重启 - delay: { type: 'string' }, - // v10 后台任务 / 剪贴板 - 'timeout-ms': { type: 'string' }, - kill: { type: 'boolean', default: false }, - offset: { type: 'string' }, - set: { type: 'string' }, - 'image-file': { type: 'string' }, - fps: { type: 'string' }, - nodes: { type: 'string' }, - path: { type: 'string' }, - kind: { type: 'string' }, - var: { type: 'string', multiple: true }, - mode: { type: 'string' }, - iface: { type: 'string' }, - ip: { type: 'string' }, - mask: { type: 'string' }, - gateway: { type: 'string' }, - dns: { type: 'string' }, - command: { type: 'string' }, - 'confirm-within': { type: 'string' }, - 'task-name': { type: 'string' }, - yes: { type: 'boolean', default: false }, - seconds: { type: 'string' }, - 'interval-ms': { type: 'string' }, - }, - allowPositionals: true, - strict: false, - }); - return { - opts: { - json: Boolean(values['json']), - insecure: Boolean(values['insecure']), - port: values['port'] as string | undefined, - key: values['key'] as string | undefined, - id: values['id'] as string | undefined, - limit: values['limit'] as string | undefined, - args: values['args'] as string | undefined, - out: values['out'] as string | undefined, - format: values['format'] as string | undefined, - scale: values['scale'] as string | undefined, - region: values['region'] as string | undefined, - button: values['button'] as string | undefined, - duration: values['duration'] as string | undefined, - interval: values['interval'] as string | undefined, - authMode: values['auth-mode'] as string | undefined, - since: values['since'] as string | undefined, - type: values['type'] as string | undefined, - clientId: values['client-id'] as string | undefined, - wait: values['wait'] as string | undefined, - discoveryPort: values['discovery-port'] as string | undefined, - node: values['node'] as string | undefined, - delay: values['delay'] as string | undefined, - timeoutMs: values['timeout-ms'] as string | undefined, - kill: Boolean(values['kill']), - offset: values['offset'] as string | undefined, - set: values['set'] as string | undefined, - imageFile: values['image-file'] as string | undefined, - fps: values['fps'] as string | undefined, - nodes: values['nodes'] as string | undefined, - path: values['path'] as string | undefined, - kind: values['kind'] as string | undefined, - vars: (values['var'] as string[] | undefined) ?? [], - mode: values['mode'] as string | undefined, - iface: values['iface'] as string | undefined, - ip: values['ip'] as string | undefined, - mask: values['mask'] as string | undefined, - gateway: values['gateway'] as string | undefined, - dns: values['dns'] as string | undefined, - command: values['command'] as string | undefined, - confirmWithin: values['confirm-within'] as string | undefined, - taskName: values['task-name'] as string | undefined, - yes: Boolean(values['yes']), - seconds: values['seconds'] as string | undefined, - intervalMs: values['interval-ms'] as string | undefined, - name: values['name'] as string | undefined, - note: values['note'] as string | undefined, - recursive: Boolean(values['recursive']), - pattern: values['pattern'] as string | undefined, - createDirs: Boolean(values['create-dirs']), - hubToken: values['hub-token'] as string | undefined, - hubNode: values['hub-node'] as string | undefined, - }, - positionals, - }; -} - +import { HELP } from './help.js'; +import { parseOptions } from './options.js'; +import { cmdConnect, cmdDiscover, cmdKeygen, cmdNodes, cmdRemove, cmdUse } from './cmd/conn.js'; +import { + cmdApps, cmdAudit, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList, + cmdMetrics, cmdPs, cmdRestart, cmdServices, cmdStatus, +} from './cmd/system.js'; +import { + cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdMacro, cmdRecord, cmdTask, cmdTasks, +} from './cmd/ops.js'; +import { cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js'; +import { cmdCat, cmdLs, cmdPull, cmdPush, cmdStat } from './cmd/fs.js'; +import { cmdNet } from './cmd/net.js'; +import { DAEMON_SOCK, fail, getClientConfig, setNodeOverride } from './core.js'; + +// CLI 入口:参数解析后按子命令调度 async function main(): Promise { const argv = process.argv.slice(2); // 命令 = 第一个非选项 token,故全局选项可前置(--node=win_b info) @@ -1714,7 +53,7 @@ async function main(): Promise { const command = cmdIdx >= 0 ? argv[cmdIdx] : undefined; const rest = cmdIdx >= 0 ? [...argv.slice(0, cmdIdx), ...argv.slice(cmdIdx + 1)] : argv; const { opts, positionals } = parseOptions(rest); - currentNodeOverride = opts.node; // 全局 --node 生效于本次命令 + setNodeOverride(opts.node); // 全局 --node 生效于本次命令 if (rest.includes('--config')) { console.log(configPath()); diff --git a/apps/cli/src/options.ts b/apps/cli/src/options.ts new file mode 100644 index 0000000..226d550 --- /dev/null +++ b/apps/cli/src/options.ts @@ -0,0 +1,156 @@ +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 { parseArgs } from 'node:util'; +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 { type Options } from './types.js'; + +// 命令行参数解析(从 index.ts 拆出) +export function parseOptions(rest: string[]): { opts: Options; positionals: string[] } { + const { values, positionals } = parseArgs({ + args: rest, + options: { + json: { type: 'boolean', default: false }, + insecure: { type: 'boolean', default: false }, + port: { type: 'string' }, + key: { type: 'string' }, + id: { type: 'string' }, + limit: { type: 'string' }, + args: { type: 'string' }, + config: { type: 'boolean', default: false }, + // v2 图形操作 + out: { type: 'string' }, + format: { type: 'string' }, + scale: { type: 'string' }, + region: { type: 'string' }, + button: { type: 'string' }, + duration: { type: 'string' }, + interval: { type: 'string' }, + 'auth-mode': { type: 'string' }, + since: { type: 'string' }, + type: { type: 'string' }, + 'client-id': { type: 'string' }, + wait: { type: 'string' }, + 'discovery-port': { type: 'string' }, + node: { type: 'string' }, + name: { type: 'string' }, + note: { type: 'string' }, + recursive: { type: 'boolean', default: false }, + pattern: { type: 'string' }, + 'create-dirs': { type: 'boolean', default: false }, + 'hub-token': { type: 'string' }, + 'hub-node': { type: 'string' }, + // v7 受控重启 + delay: { type: 'string' }, + // v10 后台任务 / 剪贴板 + 'timeout-ms': { type: 'string' }, + kill: { type: 'boolean', default: false }, + offset: { type: 'string' }, + set: { type: 'string' }, + 'image-file': { type: 'string' }, + fps: { type: 'string' }, + nodes: { type: 'string' }, + path: { type: 'string' }, + kind: { type: 'string' }, + var: { type: 'string', multiple: true }, + mode: { type: 'string' }, + iface: { type: 'string' }, + ip: { type: 'string' }, + mask: { type: 'string' }, + gateway: { type: 'string' }, + dns: { type: 'string' }, + command: { type: 'string' }, + 'confirm-within': { type: 'string' }, + 'task-name': { type: 'string' }, + yes: { type: 'boolean', default: false }, + seconds: { type: 'string' }, + 'interval-ms': { type: 'string' }, + }, + allowPositionals: true, + strict: false, + }); + return { + opts: { + json: Boolean(values['json']), + insecure: Boolean(values['insecure']), + port: values['port'] as string | undefined, + key: values['key'] as string | undefined, + id: values['id'] as string | undefined, + limit: values['limit'] as string | undefined, + args: values['args'] as string | undefined, + out: values['out'] as string | undefined, + format: values['format'] as string | undefined, + scale: values['scale'] as string | undefined, + region: values['region'] as string | undefined, + button: values['button'] as string | undefined, + duration: values['duration'] as string | undefined, + interval: values['interval'] as string | undefined, + authMode: values['auth-mode'] as string | undefined, + since: values['since'] as string | undefined, + type: values['type'] as string | undefined, + clientId: values['client-id'] as string | undefined, + wait: values['wait'] as string | undefined, + discoveryPort: values['discovery-port'] as string | undefined, + node: values['node'] as string | undefined, + delay: values['delay'] as string | undefined, + timeoutMs: values['timeout-ms'] as string | undefined, + kill: Boolean(values['kill']), + offset: values['offset'] as string | undefined, + set: values['set'] as string | undefined, + imageFile: values['image-file'] as string | undefined, + fps: values['fps'] as string | undefined, + nodes: values['nodes'] as string | undefined, + path: values['path'] as string | undefined, + kind: values['kind'] as string | undefined, + vars: (values['var'] as string[] | undefined) ?? [], + mode: values['mode'] as string | undefined, + iface: values['iface'] as string | undefined, + ip: values['ip'] as string | undefined, + mask: values['mask'] as string | undefined, + gateway: values['gateway'] as string | undefined, + dns: values['dns'] as string | undefined, + command: values['command'] as string | undefined, + confirmWithin: values['confirm-within'] as string | undefined, + taskName: values['task-name'] as string | undefined, + yes: Boolean(values['yes']), + seconds: values['seconds'] as string | undefined, + intervalMs: values['interval-ms'] as string | undefined, + name: values['name'] as string | undefined, + note: values['note'] as string | undefined, + recursive: Boolean(values['recursive']), + pattern: values['pattern'] as string | undefined, + createDirs: Boolean(values['create-dirs']), + hubToken: values['hub-token'] as string | undefined, + hubNode: values['hub-node'] as string | undefined, + }, + positionals, + }; +} diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts new file mode 100644 index 0000000..df4002f --- /dev/null +++ b/apps/cli/src/types.ts @@ -0,0 +1,74 @@ +// CLI 共享类型与常量(从 index.ts 拆出) +/** 已解析的常用选项。 */ +export interface Options { + json: boolean; + insecure: boolean; + port?: string; + key?: string; + id?: string; + limit?: string; + args?: string; + // v2 图形操作 + out?: string; + format?: string; + scale?: string; + region?: string; + button?: string; + duration?: string; + interval?: string; + /** v3:认证模式 psk | ed25519 */ + authMode?: string; + /** v3+ 审计查询 */ + since?: string; + type?: string; + clientId?: string; + /** v4 发现 */ + wait?: string; + discoveryPort?: string; + /** v5 多设备 */ + node?: string; + name?: string; + note?: string; + /** v5 文件 */ + recursive?: boolean; + pattern?: string; + createDirs?: boolean; + /** v6 Hub */ + hubToken?: string; + hubNode?: string; + /** v7 受控重启:延时毫秒 */ + delay?: string; + /** v10 后台任务 / 剪贴板 */ + timeoutMs?: string; + kill?: boolean; + offset?: string; + set?: string; + /** v11 剪贴板图片 */ + imageFile?: string; + /** v11 录屏 */ + fps?: string; + /** v11 多设备并发 */ + nodes?: string; + /** v11 部署目标路径 */ + path?: string; + /** v12 事件订阅 */ + kind?: string; + /** v12 宏变量 */ + vars?: string[]; + /** v16 网络变更 */ + mode?: string; + iface?: string; + ip?: string; + mask?: string; + gateway?: string; + dns?: string; + command?: string; + confirmWithin?: string; + taskName?: string; + yes?: boolean; + seconds?: string; + intervalMs?: string; +} + +/** 文件分块传输的块大小(fs.read/write 用,v5) */ +export const CHUNK_BYTES = 1024 * 1024;