Files
nodeagent/apps/cli/src/cmd/fs.ts
T
jkooe caa5b8a2c1 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
2026-10-01 21:04:24 +08:00

182 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)})`);
});
}