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:
jkooe committed 2026-10-01 21:04:24 +08:00
1 parent 411a483c76
commit caa5b8a2c1
11 files changed
+2052 -1679

No files matched your search

+225
View File
@@ -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 默认值配合) */
+181
View File
@@ -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)})`);
});
}
+182
View File
@@ -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:审计链完整性校验。 */
+125
View File
@@ -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]');
}
+509
View File
@@ -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('(剪贴板为空或非文本)');
}),
);
}
+287
View File
@@ -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}`);
}),
);
}
+220
View File
@@ -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' ? '🟡' : '🟢';
}
+75
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+156
View File
@@ -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,
};
}
+74
View File
@@ -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;