Files
nodeagent/apps/cli/src/core.ts
T
jkooe cedc46d090 feat(v20): 版本纪律 + 发版自动化(对齐「做好版本功能 + GitHub 同步资产」)
## 一、版本功能(防新旧混用)
1. **单一版本源 + 构建注入**:根 package.json 的 version、git sha、构建时间经 esbuild
   `--define` 注入 agent;`system.info.build` 新增 `version/commit/built_at`(原有 `hash` 指纹不变)。
   开发态用 typeof 探测(未注入也不崩)。
2. **握手携带版本**:`auth_ok` 新增可选字段 `agent_version` / `build` / `protocol`(只增不减 → 旧控制端忽略即可)。
3. **能力级兼容比对**:新增纯函数 `capabilityDiff(local, remote)` ——
   - `missingOnRemote`:本地有远端没有 → **远端较旧**,提示更新被控端
   - `unknownLocally`:远端有本地没有 → **本控制端较旧**(不影响既有功能)
   控制端不比版本号而比**能力集合**:版本号可能相同而内容不同,能力集合不会骗人。
4. **CLI 体检**:`nodeagent info` / `connect` 直接显示被控端版本与能力对齐情况,
   把"调用时才炸"变成一句人话提示。

## 二、发版自动化(GitHub 资产同步)
`pnpm release <x.y.z|patch|minor|major>`:前置检查(干净/在 main/与远端同步)→ 改 7 个 package.json
→ 构建+打包 → 生成资产 → 提交打标签推送 → `gh release create` 上传。
- 资产:`agent.mjs`(**自更新载荷**)、`nodeagent-win-x64.zip`(人工安装包)、
  `latest.json`(version/commit/能力数 + 两个资产的 url 与 sha256)、`SHA256SUMS`
- `--dry-run` 无副作用演练;`--no-publish` 只到本地标签
- 能力数从 dist 动态取(避免写死漂移);刻意排除 `.github/`(缺 workflow 权限推不了)

## 三、兼容性契约文档
新增 `docs/VERSIONING.md`:semver 各位的含义、**只增不改**六条硬规则、
升级顺序建议(**先被控端**:新被控端向后兼容旧 CLI)、两条更新路径、发版流程、排查手册。

## 验证
- 单测 147 → **149**(新增 capabilityDiff 用例:远端过旧 / 本机过旧 / 完全一致 / 真实清单自比无差异)
- e2e 28 全绿;`node scripts/release.mjs 1.0.0 --dry-run` 演练通过且工作区无副作用
2026-10-04 23:54:51 +08:00

300 lines
10 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 {
CAPABILITY_MANIFEST,
CapabilityNames,
capabilityDiff,
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' ? '🟡' : '🟢';
}
/**
* 探测一次被控端(用于「等待重启/上线」这类**需要反复重试**的场景)。
*
* ⚠️ 不能用 withClient:它在连接失败时会直接 `fail()` **退出进程**(见 withClientDirect),
* 于是重试循环第一次连不上就整个 CLI 退出了 —— 真机踩过:部署其实已成功,
* CLI 却报 E_NODE_OFFLINE,且看不到任何自定义提示。本函数**永不抛出**。
*/
export async function probeOnce(nodeName?: string): Promise<{
ok: boolean;
pid?: number;
buildHash?: string;
caps: number;
error?: string;
}> {
try {
const cfg = getClientConfig();
const target = resolveTarget(cfg, nodeName ?? getNodeOverride());
const keys = target.profile.auth_mode === 'ed25519' ? loadKeys() : null;
const client = new NodeAgentClient({
url: toWsUrl(target.profile),
key: target.profile.key ?? '',
clientId: target.clientId,
insecure: target.profile.insecure,
authMode: target.profile.auth_mode,
privateKey: keys?.privateKey,
hub: target.profile.hub ? { token: target.profile.hub.token, nodeId: target.profile.hub.node_id } : undefined,
});
await client.connect();
const r = await client.invoke<{ pid?: number; build?: { hash?: string } }>(CapabilityNames.SystemInfo, {});
const caps = client.listCapabilities().length;
client.close();
const d = r.data ?? {};
return { ok: r.status === 'ok', pid: d.pid, buildHash: d.build?.hash, caps };
} catch (err) {
return { ok: false, caps: 0, error: err instanceof Error ? err.message : String(err) };
}
}
/**
* 新旧版本兼容提示(v20)。
*
* 背景:agent 与 CLI 是**分开更新**的(CLI 随 Mac 装,agent 推/拉到被控端),
* 必然出现新旧混用。等到调用时才抛 E_CAPABILITY_NOT_FOUND 会让人摸不着头脑,
* 所以在握手后就明确告诉用户「谁旧、缺什么、怎么升」。
*
* 兼容性依据(见 docs/VERSIONING.md):**只增不改** ——
* 新 agent 向后兼容旧 CLI(新增能力/可选参数),因此升级顺序建议先被控端。
*/
export function reportCompat(client: NodeAgentClient): void {
const remoteNames = client.listCapabilities().map((c) => c.name);
const localNames = CAPABILITY_MANIFEST.map((c) => c.name);
const diff = capabilityDiff(localNames, remoteNames);
const v = client.getAgentVersion();
const b = client.getAgentBuild();
if (v) {
console.log(`被控端版本 : v${v}${b?.commit ? ` @ ${b.commit}` : ''}${b?.hash ? ` 构建 ${b.hash}` : ''}`);
} else {
console.log('被控端版本 : 未上报(多为 v20 之前的版本,建议更新一次)');
}
if (diff.missingOnRemote.length > 0) {
console.warn(`⚠️ 被控端缺少 ${diff.missingOnRemote.length} 项本控制端已知的能力 —— 远端较旧:`);
console.warn(` ${diff.missingOnRemote.join(', ')}`);
console.warn(' → 建议更新被控端:nodeagent update --url <包地址> --sha256 <哈希>');
}
if (diff.unknownLocally.length > 0) {
console.warn(`ℹ️ 被控端还有 ${diff.unknownLocally.length} 项本控制端不认识的能力 —— 本 CLI 较旧:`);
console.warn(` ${diff.unknownLocally.join(', ')}`);
console.warn(' → 不影响既有功能;要用到这些新能力需更新控制端(git pull 后重跑 pack:mac + install-macos.sh)');
}
if (diff.missingOnRemote.length === 0 && diff.unknownLocally.length === 0) {
console.log(`能力对齐 : ${localNames.length} 项,两端一致 ✓`);
}
}