refactor(cli): 拆分 1977 行的 index.ts 为 11 个模块(纯搬移,零逻辑变更)
## 动机(审查报告 §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
This commit is contained in:
1 parent
411a483c76
commit
caa5b8a2c1
11 files changed
+2052
-1679
No files matched your search
@@ -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<void> {
|
||||
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<void> {
|
||||
const cfg = loadConfig();
|
||||
if (!cfg || Object.keys(cfg.nodes).length === 0) {
|
||||
console.log('尚未配置任何设备。用 nodeagent connect <host> --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<void> {
|
||||
if (!name) fail('用法: nodeagent use <设备名>');
|
||||
const cfg = loadConfig();
|
||||
if (!cfg) fail('尚未配置任何设备。请先运行: nodeagent connect <host> --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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ReturnType<typeof discoverOnce>>;
|
||||
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 默认值配合) */
|
||||
@@ -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<void> {
|
||||
if (!pathArg) fail('用法: nodeagent ls <远端路径> [--recursive] [--pattern "*.log"]');
|
||||
const args: Record<string, unknown> = { 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)})`);
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
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<void> {
|
||||
const [a, b] = positionals;
|
||||
switch (action) {
|
||||
case 'move': {
|
||||
if (!a || !b) fail('用法: nodeagent mouse move <x> <y> [--duration 300]');
|
||||
const args: Record<string, unknown> = { 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<string, unknown> = {};
|
||||
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 <delta> [y]');
|
||||
const args: Record<string, unknown> = { 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 <x1> <y1> <x2> <y2> [--button left|right|middle]');
|
||||
}
|
||||
const args: Record<string, unknown> = {
|
||||
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 <move|click|scroll> ...');
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdKey(action: string | undefined, positionals: string[], opts: Options): Promise<void> {
|
||||
switch (action) {
|
||||
case 'type': {
|
||||
const text = positionals.join(' ');
|
||||
if (!text) fail('用法: nodeagent key type "<文本>"');
|
||||
const args: Record<string, unknown> = { 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 <type|press> ...');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- v3+ 审计 ----------
|
||||
|
||||
/** v11:审计链完整性校验。 */
|
||||
@@ -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<void> {
|
||||
if (sub === 'status' || sub === undefined) {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.NetStatus, {}, opts.json, (data) => {
|
||||
const d = data as {
|
||||
interfaces: Array<Record<string, unknown>>;
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> = { 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]');
|
||||
}
|
||||
@@ -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<void> {
|
||||
await withClient((c) =>
|
||||
callAndPrint(
|
||||
c,
|
||||
CapabilityNames.ShellExec,
|
||||
{ command, async: true, timeout_ms: opts.timeoutMs ? Number(opts.timeoutMs) : undefined },
|
||||
opts.json,
|
||||
(data) => {
|
||||
const d = data as { task_id: string; state: string };
|
||||
console.log(`✓ 后台任务已启动: ${d.task_id}(${d.state})`);
|
||||
console.log(' 查询: nodeagent task ' + d.task_id);
|
||||
console.log(' 终止: nodeagent task ' + d.task_id + ' --kill');
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdTasks(opts: Options): Promise<void> {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.TaskList, {}, opts.json, (data) => {
|
||||
const rows = (data as { tasks: Array<{ task_id: string; state: string; duration_ms: number; exit_code?: number }> }).tasks;
|
||||
if (rows.length === 0) {
|
||||
console.log('当前没有后台任务');
|
||||
return;
|
||||
}
|
||||
console.log(`${'任务ID'.padEnd(26)} ${'状态'.padEnd(9)} ${'耗时'.padStart(9)} 退出码`);
|
||||
for (const t of rows) {
|
||||
console.log(
|
||||
`${t.task_id.padEnd(26)} ${t.state.padEnd(9)} ${String(t.duration_ms + 'ms').padStart(9)} ${t.exit_code ?? '-'}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdTask(taskId: string, opts: Options): Promise<void> {
|
||||
if (opts.kill) {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.TaskKill, { task_id: taskId }, opts.json, (data) => {
|
||||
const d = data as { killed: boolean };
|
||||
console.log(d.killed ? `✓ 已终止 ${taskId}` : `任务 ${taskId} 已不在运行`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const offset = opts.offset ? Number(opts.offset) : 0;
|
||||
await withClient((c) =>
|
||||
callAndPrint(
|
||||
c,
|
||||
CapabilityNames.TaskGet,
|
||||
{ task_id: taskId, offset },
|
||||
opts.json,
|
||||
(data) => {
|
||||
const d = data as {
|
||||
state: string;
|
||||
exit_code: number | null;
|
||||
duration_ms: number;
|
||||
data: string;
|
||||
offset: number;
|
||||
total_bytes: number;
|
||||
};
|
||||
if (d.data) console.log(d.data);
|
||||
console.error(
|
||||
`[${d.state} · ${d.duration_ms}ms · 已读 ${d.offset}/${d.total_bytes} 字节${d.exit_code !== null && d.state !== 'running' ? ` · 退出码 ${d.exit_code}` : ''}]`,
|
||||
);
|
||||
if (d.state === 'running' && d.offset < d.total_bytes) {
|
||||
console.error(' 续读: nodeagent task ' + taskId + ' --offset ' + d.offset);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v11 / C5:把一个能力并发下发到多台设备并汇总结果。
|
||||
* 每台设备独立连接、互不阻塞(allSettled),任一失败不影响其他。
|
||||
*/
|
||||
|
||||
export async function cmdFanout(capability: string, opts: Options): Promise<void> {
|
||||
const cfg = getClientConfig();
|
||||
const names = (opts.nodes ?? '')
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
const selector = names.length > 0 ? names : Object.keys(cfg.nodes);
|
||||
// v12:支持 @组名(组内可嵌套引用其它组)
|
||||
const { nodes: targets, resolvedGroups } = resolveNodeSelector(cfg, selector);
|
||||
if (targets.length === 0) fail('没有可用的设备,请先 nodeagent connect 或检查分组定义');
|
||||
for (const [g, members] of Object.entries(resolvedGroups)) {
|
||||
if (members.length === 0) fail(`分组「${g}」为空或不存在(可用: ${Object.keys(cfg.groups ?? {}).join(', ') || '无'})`);
|
||||
}
|
||||
|
||||
let args: Record<string, unknown> = {};
|
||||
if (opts.args) {
|
||||
try {
|
||||
args = JSON.parse(opts.args) as Record<string, unknown>;
|
||||
} catch {
|
||||
fail('--args 需为合法 JSON,例如 --args {"limit":5}');
|
||||
}
|
||||
}
|
||||
|
||||
const started = Date.now();
|
||||
const results = await Promise.allSettled(
|
||||
targets.map(async (n) => {
|
||||
const t0 = Date.now();
|
||||
const data = await withClient(async (c) => {
|
||||
const r = await c.invoke(capability, args, Number(opts.timeoutMs ?? 60_000));
|
||||
if (r.status === 'failed') throw new Error(`${r.error?.name}: ${r.error?.message}`);
|
||||
return r.data;
|
||||
}, n);
|
||||
return { node: n, ms: Date.now() - t0, data };
|
||||
}),
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
printJson(
|
||||
results.map((r, i) =>
|
||||
r.status === 'fulfilled'
|
||||
? { node: r.value.node, ok: true, ms: r.value.ms, data: r.value.data }
|
||||
: { node: targets[i], ok: false, error: String(r.reason) },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`并发下发 ${capability} → ${targets.length} 台设备(总耗时 ${Date.now() - started}ms)\n`);
|
||||
results.forEach((r, i) => {
|
||||
const name = (targets[i] ?? '?').padEnd(16);
|
||||
if (r.status === 'fulfilled') {
|
||||
const preview = JSON.stringify(r.value.data);
|
||||
console.log(` ✓ ${name} ${String(r.value.ms + 'ms').padStart(7)} ${preview.slice(0, 150)}`);
|
||||
} else {
|
||||
console.log(` ✗ ${name} ${'—'.padStart(7)} ${String(r.reason).slice(0, 150)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdRecord(opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.duration) args['duration_ms'] = Number(opts.duration);
|
||||
if (opts.fps) args['fps'] = Number(opts.fps);
|
||||
if (opts.scale) args['scale'] = Number(opts.scale);
|
||||
if (opts.region) {
|
||||
const n = opts.region.split(',').map(Number);
|
||||
if (n.length !== 4 || n.some((v) => !Number.isFinite(v))) fail('region 格式应为 "x,y,width,height"');
|
||||
args['region'] = { x: n[0], y: n[1], width: n[2], height: n[3] };
|
||||
}
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ScreenRecord, args, opts.json, (data) => {
|
||||
const d = data as { dir: string; frames: number; fps: number; elapsed_ms: number; video_path?: string; frames_only: boolean };
|
||||
console.log(`✓ 录制完成:${d.frames} 帧 @ ${d.fps}fps(${d.elapsed_ms}ms)`);
|
||||
console.log(` 目录: ${d.dir}`);
|
||||
if (d.video_path) console.log(` 视频: ${d.video_path}`);
|
||||
else if (d.frames_only) console.log(' 提示: 无 ffmpeg,用 `nodeagent pull` 或 `nodeagent ls` 取回帧');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v11 / D2:一键部署 / 升级被控端 agent。
|
||||
* 流程:被控端自报入口路径(system.info)→ 上传新 agent.mjs → 受控重启 → 复验能力数。
|
||||
* 全程无需手工拷文件或起计划任务。
|
||||
*/
|
||||
|
||||
export async function cmdDeploy(localFile: string, opts: Options): Promise<void> {
|
||||
if (!existsSync(localFile)) fail(`本地文件不存在: ${localFile}`);
|
||||
const buf = readFileSync(localFile);
|
||||
if (buf.length < 10_000) fail(`文件过小,疑似不是 agent 包: ${localFile}`);
|
||||
|
||||
await withClient(async (c) => {
|
||||
const info = await c.invoke<Record<string, unknown>>(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<void> {
|
||||
const cfg = getClientConfig();
|
||||
let target: ResolvedTarget;
|
||||
try {
|
||||
target = resolveTarget(cfg, getNodeOverride());
|
||||
} catch (err) {
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
// 无 --kind 时列出当前订阅
|
||||
if (!opts.kind) {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.EventList, {}, opts.json, (data) => {
|
||||
const d = data as {
|
||||
watches: Array<{ watch_id: string; kind: string; description: string; events: number }>;
|
||||
buffered: number;
|
||||
};
|
||||
if (d.watches.length === 0) {
|
||||
console.log(`当前没有事件订阅(缓冲 ${d.buffered} 条)`);
|
||||
console.log('用法: nodeagent events --kind file --path <路径> [--seconds 20]');
|
||||
return;
|
||||
}
|
||||
console.log(`${'订阅ID'.padEnd(24)} ${'类型'.padEnd(8)} 事件数 说明`);
|
||||
for (const w of d.watches) {
|
||||
console.log(`${w.watch_id.padEnd(24)} ${w.kind.padEnd(8)} ${String(w.events).padStart(6)} ${w.description}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const seconds = Math.max(1, Math.min(600, Number(opts.seconds ?? 15)));
|
||||
const { profile, clientId } = target;
|
||||
const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null;
|
||||
const received: Array<Record<string, unknown>> = [];
|
||||
|
||||
const client = new NodeAgentClient({
|
||||
url: toWsUrl(profile),
|
||||
key: profile.key ?? '',
|
||||
clientId,
|
||||
insecure: profile.insecure,
|
||||
authMode: profile.auth_mode,
|
||||
privateKey: keys?.privateKey,
|
||||
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
|
||||
onEvent: (evt) => {
|
||||
received.push(evt);
|
||||
const ts = new Date(Number(evt['ts'] ?? Date.now())).toLocaleTimeString('zh-CN');
|
||||
const line = `${ts} ${String(evt['action'] ?? '').padEnd(20)} ${String(evt['target'] ?? '')}${evt['detail'] ? ` (${evt['detail']})` : ''}`;
|
||||
if (opts.json) console.log(JSON.stringify(evt));
|
||||
else console.log(` ${line}`);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const args: Record<string, unknown> = { kind: opts.kind };
|
||||
if (opts.path) args['path'] = opts.path;
|
||||
if (opts.pattern) args['pattern'] = opts.pattern;
|
||||
if (opts.recursive) args['recursive'] = true;
|
||||
if (opts.intervalMs) args['interval_ms'] = Number(opts.intervalMs);
|
||||
|
||||
const r = await client.invoke<{ watch_id: string; description: string }>(
|
||||
CapabilityNames.EventWatch,
|
||||
args,
|
||||
);
|
||||
if (r.status === 'failed') fail(`${r.error?.name}: ${r.error?.message}`);
|
||||
const watchId = (r.data as { watch_id: string }).watch_id;
|
||||
if (!opts.json) {
|
||||
console.log(`已订阅:${(r.data as { description: string }).description}`);
|
||||
console.log(`订阅 ID: ${watchId} 监听 ${seconds}s(Ctrl+C 可提前结束)\n`);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const t = setTimeout(resolve, seconds * 1000);
|
||||
process.once('SIGINT', () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await client.invoke(CapabilityNames.EventUnwatch, { watch_id: watchId });
|
||||
if (!opts.json) {
|
||||
console.log(`\n共收到 ${received.length} 个事件(订阅已取消)`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ClientError) fail(`${err.name}: ${err.message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v12 / E4:GUI 宏 —— 把一次成功操作固化为可重放的步骤序列。
|
||||
* 子命令:run(回放)/ validate(仅校验文件)/ init(生成示例)
|
||||
*/
|
||||
|
||||
export async function cmdMacro(sub: string | undefined, file: string | undefined, opts: Options): Promise<void> {
|
||||
if (sub === 'init') {
|
||||
const target = file ?? 'macro.json';
|
||||
if (existsSync(target)) fail(`文件已存在: ${target}`);
|
||||
const sample = {
|
||||
name: '示例:在记事本里打字并复制',
|
||||
description: '演示 focus / find / type / key / clip / assert 组合;坐标与文本请按实际调整',
|
||||
default_delay_ms: 300,
|
||||
steps: [
|
||||
{ action: 'focus', title: 'Notepad', retry: 3, interval_ms: 800 },
|
||||
{ action: 'key', keys: ['ctrl', 'a'] },
|
||||
{ action: 'key', keys: ['delete'] },
|
||||
{ action: 'type', text: '宏回放演示 ${STAMP:-demo}' },
|
||||
{ action: 'sleep', ms: 300 },
|
||||
{ action: 'key', keys: ['ctrl', 'a'] },
|
||||
{ action: 'key', keys: ['ctrl', 'c'] },
|
||||
{ action: 'clip', expect: '宏回放演示' },
|
||||
{ action: 'assert', kind: 'window', text: 'Notepad' },
|
||||
],
|
||||
};
|
||||
writeFileSync(target, JSON.stringify(sample, null, 2) + '\n');
|
||||
console.log(`✓ 已生成示例宏: ${target}`);
|
||||
console.log(' 试用: nodeagent --node=<设备> macro run ' + target + ' --var STAMP=hello');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file) fail('用法: nodeagent macro run <文件.json> [--var k=v] / macro validate <文件.json> / macro init [文件.json]');
|
||||
if (!existsSync(file)) fail(`宏文件不存在: ${file}`);
|
||||
|
||||
const macro = loadMacroFile(file);
|
||||
|
||||
if (sub === 'validate') {
|
||||
const actions = macro.steps.map((s) => String(s['action'] ?? '?'));
|
||||
console.log(`✓ 语法有效:${macro.steps.length} 个步骤`);
|
||||
console.log(` 名称: ${macro.name ?? '(未命名)'}`);
|
||||
console.log(` 步骤: ${actions.join(' → ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 --var k=v
|
||||
const vars: Record<string, string> = {};
|
||||
for (const kv of opts.vars ?? []) {
|
||||
const i = kv.indexOf('=');
|
||||
if (i <= 0) fail(`--var 需为 k=v 形式: ${kv}`);
|
||||
vars[kv.slice(0, i)] = kv.slice(i + 1);
|
||||
}
|
||||
|
||||
await withClient(async (c) => {
|
||||
const outDir = opts.out ?? '.';
|
||||
const t0 = Date.now();
|
||||
const res = await runMacro(
|
||||
{
|
||||
client: c,
|
||||
saveCapture: opts.out
|
||||
? (name, b64) => {
|
||||
const p = join(outDir, name);
|
||||
writeFileSync(p, Buffer.from(b64, 'base64'));
|
||||
return p;
|
||||
}
|
||||
: undefined,
|
||||
log: (m) => console.error(m),
|
||||
},
|
||||
macro,
|
||||
vars,
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
printJson(res);
|
||||
if (!res.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(`宏「${res.name}」共 ${macro.steps.length} 步,耗时 ${Date.now() - t0}ms\n`);
|
||||
for (const s of res.steps) {
|
||||
const mark = s.ok ? '✓' : '✗';
|
||||
console.log(` ${mark} [${String(s.index).padStart(2)}] ${s.action.padEnd(8)} ${String(s.ms + 'ms').padStart(7)} ${s.detail ?? ''}`);
|
||||
}
|
||||
if (res.ok) {
|
||||
console.log('\n✓ 全部步骤通过');
|
||||
} else {
|
||||
console.error(`\n✗ 在第 ${res.failed_at} 步失败,宏已中止`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** v13:成功指标(对齐 PRD 2.2 四项验收指标)。 */
|
||||
|
||||
export async function cmdClip(opts: Options): Promise<void> {
|
||||
// 图片上传:从本地文件读入(避免超长命令行参数)
|
||||
if (opts.imageFile) {
|
||||
const p = opts.imageFile;
|
||||
if (!existsSync(p)) fail(`文件不存在: ${p}`);
|
||||
const b64 = readFileSync(p).toString('base64');
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ClipSet, { image_base64: b64 }, opts.json, (data) => {
|
||||
const d = data as { written_bytes: number };
|
||||
console.log(`✓ 图片已写入剪贴板(${d.written_bytes} 字节)`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (opts.set !== undefined) {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ClipSet, { text: opts.set }, opts.json, (data) => {
|
||||
const d = data as { written: number };
|
||||
console.log(`✓ 已写入剪贴板 ${d.written} 字符`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 图片下载:--out 指定保存路径
|
||||
if (opts.out) {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ClipGet, { format: 'image' }, opts.json, (data) => {
|
||||
const d = data as { image_base64: string; bytes: number; format: string };
|
||||
writeFileSync(opts.out!, Buffer.from(d.image_base64, 'base64'));
|
||||
console.log(`✓ 剪贴板图片已保存 ${opts.out}(${d.bytes} 字节,${d.format})`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.ClipGet, { format: opts.format ?? 'auto' }, opts.json, (data) => {
|
||||
const d = data as {
|
||||
type?: string;
|
||||
text?: string;
|
||||
bytes?: number;
|
||||
image_base64?: string;
|
||||
};
|
||||
if (d.type === 'image') {
|
||||
console.log(`(剪贴板为图片,${d.bytes} 字节;用 --out <路径> 保存)`);
|
||||
return;
|
||||
}
|
||||
if (d.text) console.log(d.text);
|
||||
else console.log('(剪贴板为空或非文本)');
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.SystemInfo, {}, opts.json, (data) => {
|
||||
const d = data as Record<string, unknown>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
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<Record<string, unknown>>; 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<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
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<string, { attempts: number; ok: number; success_rate: number; p95_ms: number }>;
|
||||
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<void> {
|
||||
let args: Record<string, unknown> = {};
|
||||
if (opts.args) {
|
||||
try {
|
||||
args = JSON.parse(opts.args) as Record<string, unknown>;
|
||||
} 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<void> {
|
||||
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<void> {
|
||||
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}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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 <host> --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<string, unknown>, timeoutMs?: number): Promise<DaemonResponse> {
|
||||
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<T>(capability: string, args: Record<string, unknown>, 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<T>(fn: (client: NodeAgentClient) => Promise<T>, nodeName?: string): Promise<T> {
|
||||
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<T>(fn: (client: NodeAgentClient) => Promise<T>, nodeName?: string): Promise<T> {
|
||||
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<string, unknown>,
|
||||
json: boolean,
|
||||
render: (data: unknown) => void,
|
||||
timeoutMs?: number,
|
||||
): Promise<void> {
|
||||
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' ? '🟡' : '🟢';
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// CLI 帮助文本(从 index.ts 拆出)
|
||||
export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
|
||||
用法:
|
||||
nodeagent connect <host> [--port 8765] [--key <key>] [--insecure] [--id mac_01] [--auth-mode ed25519]
|
||||
配置并连接被控端(握手成功后打印能力清单)
|
||||
经 Hub 中转: 追加 --hub-token <Hub 令牌> --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 <ms>]
|
||||
查询被控端审计日志 (v3+)
|
||||
nodeagent audit verify 校验审计链完整性(防篡改检测)(v11)
|
||||
nodeagent metrics [--since <ms>] 成功指标:闭环率/装软件率/P95/拦截率 (v13)
|
||||
nodeagent net [status] 网络现状 + 待确认变更 (v16)
|
||||
nodeagent net apply --mode static --ip <ip> --mask <m> [--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 <id> [--offset N] [--kill] 读取/终止后台任务(支持增量续读)
|
||||
nodeagent clip [--set "文本"] [--out <路径>] [--image-file <路径>]
|
||||
读/写剪贴板文本或图片(--out 保存图片)(v11)
|
||||
nodeagent macro run <file.json> [--var k=v] [--out <目录>]
|
||||
回放 GUI 宏(步骤序列,逐步校验)(v12)
|
||||
nodeagent macro validate <file.json> | 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 <agent.mjs> [--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 <capability> [--args '<json>'] 通用调用
|
||||
|
||||
图形操作 (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 <x> <y> [--duration 300]
|
||||
nodeagent mouse click [<x> <y>] [--button left|right|middle]
|
||||
nodeagent mouse drag <x1> <y1> <x2> <y2> 鼠标拖拽(拖文件/框选)(v11)
|
||||
nodeagent mouse scroll <delta>
|
||||
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 显示当前配置路径
|
||||
`;
|
||||
+18
-1679
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user