## 一、版本功能(防新旧混用) 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` 演练通过且工作区无副作用
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
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} 项,两端一致 ✓`);
|
||
}
|
||
}
|
||
|