feat(v18): 更新链路可靠化 —— 构建指纹 + 备份 + 轮询校验(附 6 个真机 bug 修复)
## 皇上的问题 「这个版本如果有更新,可以在客户端上便捷的更新吗?」 答案:本来就有 `nodeagent deploy`(比最初的一键升级还早),但**它不可信**: 部署后只看「PID 变了 / 能力数对了」,证明不了真的换成了新代码(旧版可能能力数一样), 而且备份与失败判定都存在问题。本轮把这条链路做成**可信**的。 ## 新增 1. **构建指纹**:agent 上报 `build.hash`(运行中脚本的 sha256 前 12 位)+ bytes/mtime/node/uptime。 部署后按指纹校验「远端真的是新代码」,而非靠 PID/能力数猜。 2. **`deploy --check`**:只比对指纹不动文件(退出码 2 = 需要更新),适合脚本/巡检。 3. **`deploy --force`**:指纹相同也强制覆盖。 4. **`pnpm update:win [设备名]`**:一条命令完成「打包 → 校验产物确实更新 → 部署」 (脚本刻意防呆:pack 后产物时间戳没变就拒绝部署,避免部署旧包 —— 真机踩过)。 5. **NODEAGENT_DEBUG=1**:CLI 打印异常堆栈(异步链问题必备)。 ## 修掉的 6 个真机 bug(本轮逐个实测暴露) 1. **备份用 `Copy-Item`**(Windows 专用 shell 命令)→ macOS 被控端静默不备份。 改为协议自带 fs.read/fs.write,跨平台。 2. **备份读取漏 `encoding: 'base64'`** → 二进制被按 utf8 读(损坏),再当 base64 写回 → 备份文件只有 33 字节垃圾(实测:`fs.read` 返回 524288 字符而非 base64)。 3. **备份循环用 fs.read 返回的 `offset` 当前进量** → 那是「本次读取的起始位置」, 于是原地打转 → 死循环 + 文件暴涨 + 部署卡死 200s。 改为自算 len(对齐 cmdPush 成熟写法)+ 无进展即跳出。 4. **轮询用 withClient → 直接退出进程**:`withClientDirect` 在连接失败且是 ClientError 时 调 `fail()` 退出 —— 轮询第一次连接必失败(agent 正在重启)→ CLI 立刻退出, 报 E_NODE_OFFLINE,而**部署其实已成功**(本轮 3 次「失败」全是这个假象)。 新增 `probeOnce()`:永不抛出的探测函数,专用于「等待上线/重启」类重试。 5. **盲等 9 秒**判定部署结果 → 改为**按墙钟**限时轮询指纹(90s),失败时报真实耗时。 6. **重启会切断连接**:该中断属预期,此前未处理会把成功误判为失败 → 显式吞掉并说明。 ## 验证(本地全程真跑,非纸面) 用「起本地 agent → 造一个改动过的 bundle → 部署」的方式把整条链路跑通: - 同版本 `--check` → 「已是最新」退出码 0 ✓ - 不同版本 `--check` → 报不一致退出码 2 ✓ - 完整部署 → 备份(979578B,`cmp` 逐字节一致)→ 上传 → 重启 → **指纹校验通过** ✓ - 防呆:文件过小/不存在被拒 ✓ - 单测 125 / e2e 27 全绿
This commit is contained in:
1 parent
9853ee8cdb
commit
e6a9a3d7ea
10 files changed
+292
-35
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
@@ -44,6 +46,43 @@ async function isAdmin(): Promise<boolean> {
|
||||
|
||||
// ---------------- system.info ----------------
|
||||
|
||||
|
||||
// ---------- 构建指纹(v18)----------
|
||||
|
||||
const STARTED_AT = Date.now();
|
||||
let buildCache: { hash: string; bytes: number; mtime_ms: number } | null = null;
|
||||
|
||||
/**
|
||||
* 计算「正在运行的这一份 agent 脚本」的指纹(sha256 前 12 位)。
|
||||
*
|
||||
* 用途:部署后**校验远端真的换成了新代码**。仅靠「PID 变了 / 能力数对了」证明不了这点
|
||||
* (旧版可能恰好也是同样的能力数);内容哈希是唯一可靠的判据。
|
||||
* 懒计算 + 缓存:文件约 1MB,只在首次 system.info 时读一次。
|
||||
*/
|
||||
function buildInfo(): { hash: string; bytes: number; mtime_ms: number; node: string; started_at: number; uptime_ms: number } {
|
||||
if (!buildCache) {
|
||||
let hash = 'unknown';
|
||||
let bytes = 0;
|
||||
let mtime = 0;
|
||||
try {
|
||||
const scriptPath = process.argv[1] ?? '';
|
||||
const buf = readFileSync(scriptPath);
|
||||
bytes = buf.length;
|
||||
mtime = statSync(scriptPath).mtimeMs;
|
||||
hash = createHash('sha256').update(buf).digest('hex').slice(0, 12);
|
||||
} catch {
|
||||
/* 读不到脚本(如被内联启动)时保持 unknown,不影响其他功能 */
|
||||
}
|
||||
buildCache = { hash, bytes, mtime_ms: Math.round(mtime) };
|
||||
}
|
||||
return {
|
||||
...buildCache,
|
||||
node: process.version,
|
||||
started_at: STARTED_AT,
|
||||
uptime_ms: Date.now() - STARTED_AT,
|
||||
};
|
||||
}
|
||||
|
||||
export async function systemInfo(args: Args): Promise<unknown> {
|
||||
const fields = args['fields'] as string[] | undefined;
|
||||
const info: Record<string, unknown> = {
|
||||
@@ -63,6 +102,8 @@ export async function systemInfo(args: Args): Promise<unknown> {
|
||||
node_path: process.execPath,
|
||||
// v15:PowerShell 常驻助手状态(排查 GUI 能力性能/降级时用)
|
||||
ps_helper: allPsShellStats(),
|
||||
// v18:构建指纹(部署校验用)
|
||||
build: buildInfo(),
|
||||
};
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
|
||||
+145
-32
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
||||
import fsSync from 'node:fs';
|
||||
import net from 'node:net';
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
fail,
|
||||
getClientConfig,
|
||||
getNodeOverride,
|
||||
probeOnce,
|
||||
humanSize,
|
||||
printJson,
|
||||
riskIcon,
|
||||
@@ -208,35 +210,111 @@ export async function cmdRecord(opts: Options): Promise<void> {
|
||||
* 全程无需手工拷文件或起计划任务。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 部署/更新被控端 agent(v18 起带指纹校验)。
|
||||
*
|
||||
* 流程:本地算 sha256 → 问远端构建指纹 → 相同则跳过(--force 可强制)→
|
||||
* 备份 → 分块上传 → 受控重启 → **轮询直到远端指纹等于本地**(替代盲等)。
|
||||
*
|
||||
* 为什么必须比指纹:只看「PID 变了 / 能力数对了」证明不了换成了新代码
|
||||
* (旧版可能恰好有同样多的能力)。内容哈希是唯一可靠判据。
|
||||
*/
|
||||
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}`);
|
||||
const localHash = createHash('sha256').update(buf).digest('hex').slice(0, 12);
|
||||
|
||||
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 ?? '?'})`);
|
||||
// 单条 RPC 的安全分块(与上传保持一致)
|
||||
const BACKUP_CHUNK = 512 * 1024;
|
||||
|
||||
// 备份现有文件(便于回滚)
|
||||
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}`);
|
||||
type Info = {
|
||||
agent_script?: string;
|
||||
pid?: number;
|
||||
node_path?: string;
|
||||
build?: { hash?: string; node?: string; started_at?: number };
|
||||
};
|
||||
|
||||
const remote = await withClient((c) => c.invoke<Info>(CapabilityNames.SystemInfo, {}));
|
||||
if (remote.status === 'failed') fail('无法读取被控端信息');
|
||||
const info: Info = remote.data ?? {};
|
||||
const remoteHash = info.build?.hash ?? 'unknown';
|
||||
const target = opts.path ?? info.agent_script;
|
||||
if (!target) fail('被控端未上报入口路径,请用 --path <远端路径> 指定');
|
||||
|
||||
console.log(`本地 : ${localHash} (${(buf.length / 1024).toFixed(0)} KB)`);
|
||||
console.log(`远端 : ${remoteHash} ${info.build?.node ? `(node ${info.build.node})` : ''}`);
|
||||
console.log(`目标 : ${target}(当前 PID ${info.pid ?? '?'})`);
|
||||
|
||||
const unknownRemote = remoteHash === 'unknown';
|
||||
if (unknownRemote) {
|
||||
console.log('ℹ️ 远端未上报构建指纹(多为 v18 之前的旧版)—— 无法比对,建议直接部署');
|
||||
}
|
||||
|
||||
if (opts.check === true) {
|
||||
if (remoteHash === localHash) {
|
||||
console.log('✓ 已是最新,无需更新');
|
||||
} else if (unknownRemote) {
|
||||
console.log('⚠️ 无法比对(远端过旧),建议执行 `nodeagent deploy <文件>` 更新');
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
console.log('⚠️ 版本不一致 —— 执行 `nodeagent deploy <文件>` 更新(加 --force 可强制覆盖)');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!unknownRemote && remoteHash === localHash && opts.force !== true) {
|
||||
console.log('✓ 远端指纹与本地一致,已是最新(如需强制覆盖请加 --force)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 备份 + 上传 + 重启
|
||||
const uploadInfo = await withClient(async (c) => {
|
||||
// 备份走协议自带的 fs 能力(而非 shell 的 Copy-Item)——
|
||||
// Copy-Item 是 Windows 专用,在 macOS 被控端上会静默失败(真机踩过:以为备份了,其实没有)。
|
||||
const backup = `${target}.bak-${Date.now()}`;
|
||||
let backupPath = '';
|
||||
try {
|
||||
const st = await c.invoke<{ size?: number; exists?: boolean }>(CapabilityNames.FsStat, { path: target });
|
||||
const size = st.status === 'ok' ? (st.data?.size ?? 0) : 0;
|
||||
if (size > 0) {
|
||||
let off = 0;
|
||||
let first = true;
|
||||
while (off < size) {
|
||||
// 自行计算本次长度并据此前进(对齐 cmdPush 的成熟写法):
|
||||
// fs.read 返回的 offset 是**本次读取的起始位置**,拿它当前进量会原地打转
|
||||
// → 死循环 + 文件暴涨(真机踩过:备份 33B→268KB 且部署卡死 200s)。
|
||||
const len = Math.min(BACKUP_CHUNK, size - off);
|
||||
const r = await c.invoke<{ data?: string }>(CapabilityNames.FsRead, {
|
||||
path: target,
|
||||
offset: off,
|
||||
max_bytes: len,
|
||||
// ⚠️ 必须显式 base64:默认 utf8 会把二进制按文本读(损坏),
|
||||
// 再当 base64 写回就得到几十字节的垃圾备份(真机踩过:备份 33 字节)
|
||||
encoding: 'base64',
|
||||
});
|
||||
if (r.status !== 'ok') break;
|
||||
const chunk = r.data?.data ?? '';
|
||||
if (!chunk) break;
|
||||
const w = await c.invoke(CapabilityNames.FsWrite, {
|
||||
path: backup,
|
||||
data: chunk,
|
||||
encoding: 'base64',
|
||||
append: !first,
|
||||
});
|
||||
if (w.status !== 'ok') break;
|
||||
first = false;
|
||||
off += len;
|
||||
}
|
||||
if (!first) backupPath = backup;
|
||||
}
|
||||
} catch {
|
||||
/* 备份失败不阻断部署,但下面会提示 */
|
||||
}
|
||||
if (backupPath) console.log(`已备份: ${backupPath}`);
|
||||
else console.log('⚠️ 备份未成功(部署继续,但无自动回滚点)');
|
||||
|
||||
// 上传(fs.write 分块;由 agent 内部处理,无需本地→远端路径映射)
|
||||
const CHUNK = 512 * 1024;
|
||||
let offset = 0;
|
||||
while (offset < buf.length) {
|
||||
@@ -251,20 +329,55 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
|
||||
offset += part.length;
|
||||
}
|
||||
console.log(`✓ 已上传 ${(buf.length / 1024).toFixed(0)} KB`);
|
||||
return { backupPath };
|
||||
});
|
||||
|
||||
// 受控重启(复用 v7 能力,无需外部计划任务)
|
||||
const r = await c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'deploy' });
|
||||
// 触发重启:**用独立连接且吞掉连接中断**。
|
||||
// 重启会把本连接切断,这属于预期行为;若当成失败抛出,整个部署会被误判为失败
|
||||
// (真机踩过:文件其实已换好、新进程也起来了,CLI 却报 E_NODE_OFFLINE)。
|
||||
try {
|
||||
const r = await withClient((c) =>
|
||||
c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'deploy' }),
|
||||
);
|
||||
if (r.status === 'failed') fail(`重启失败: ${r.error?.message}`);
|
||||
console.log('✓ 已触发重启,等待恢复…');
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(`(重启切断了连接,属预期:${err instanceof Error ? err.message : String(err)})`);
|
||||
}
|
||||
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} 项`);
|
||||
});
|
||||
// 轮询校验:直到远端指纹 == 本地指纹。
|
||||
// 用 probeOnce(永不退出),**不能用 withClient** —— 后者连不上会 fail() 退出,
|
||||
// 使得「等待重连」根本无法实现(真机踩过:部署成功却报 E_NODE_OFFLINE)。
|
||||
// 按墙钟限时而非固定次数:每次连接耗时不定,按次数算总时长不可预期。
|
||||
let verified = false;
|
||||
let lastSeen = '';
|
||||
let pidSeen: number | undefined;
|
||||
let capsSeen = 0;
|
||||
const deadline = Date.now() + 90_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((t) => setTimeout(t, 1500));
|
||||
const p = await probeOnce();
|
||||
if (!p.ok) continue;
|
||||
lastSeen = p.buildHash ?? 'unknown';
|
||||
pidSeen = p.pid;
|
||||
capsSeen = p.caps;
|
||||
if (lastSeen === localHash) {
|
||||
verified = true;
|
||||
console.log(`✓ 新版本已上线并校验通过:指纹 ${lastSeen},PID ${pidSeen ?? '?'},能力 ${capsSeen} 项`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
const waited = Math.round((Date.now() - (deadline - 90_000)) / 1000);
|
||||
console.error(`✗ ${waited}s 内未校验到新版本(当前远端指纹 ${lastSeen || '连不上'})`);
|
||||
if (uploadInfo.backupPath) {
|
||||
console.error(` 回滚方式:把备份复制回目标即可 ——`);
|
||||
console.error(` nodeagent exec "Copy-Item '${uploadInfo.backupPath}' '${target}' -Force"`);
|
||||
}
|
||||
console.error(' 排查:被控端杀软(主流杀软/杀软)可能处置了被覆盖的 agent 文件');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -218,3 +218,41 @@ export function humanSize(bytes: number): string {
|
||||
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) };
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,9 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
订阅事件并实时接收推送(无 --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 deploy <agent.mjs> [--path <远端路径>] [--check] [--force]
|
||||
一键升级被控端 (v11/v18):比指纹 → 备份 → 上传 → 重启
|
||||
→ 轮询校验;--check 只比对不部署
|
||||
nodeagent group [add <组名> <设备...> | remove <组名>] 设备分组管理 (v12)
|
||||
nodeagent fanout <能力名> [--nodes a,b|@组名] [--args JSON]
|
||||
多设备并发调用并汇总 (v11)
|
||||
|
||||
@@ -314,5 +314,9 @@ async function main(): Promise<void> {
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error(`✗ ${err instanceof Error ? err.message : String(err)}`);
|
||||
// NODEAGENT_DEBUG=1 时打印堆栈 —— CLI 定位问题(尤其是异步链里的异常)必备
|
||||
if (process.env['NODEAGENT_DEBUG'] === '1' && err instanceof Error && err.stack) {
|
||||
console.error(err.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -91,6 +91,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
'confirm-within': { type: 'string' },
|
||||
'task-name': { type: 'string' },
|
||||
yes: { type: 'boolean', default: false },
|
||||
check: { type: 'boolean', default: false },
|
||||
force: { type: 'boolean', default: false },
|
||||
mute: { type: 'string' },
|
||||
volume: { type: 'string' },
|
||||
seconds: { type: 'string' },
|
||||
@@ -143,6 +145,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
confirmWithin: values['confirm-within'] as string | undefined,
|
||||
taskName: values['task-name'] as string | undefined,
|
||||
yes: Boolean(values['yes']),
|
||||
check: Boolean(values['check']),
|
||||
force: Boolean(values['force']),
|
||||
mute: values['mute'] as string | undefined,
|
||||
volume: values['volume'] as string | undefined,
|
||||
seconds: values['seconds'] as string | undefined,
|
||||
|
||||
@@ -55,6 +55,9 @@ export interface Options {
|
||||
kind?: string;
|
||||
/** v12 宏变量 */
|
||||
vars?: string[];
|
||||
/** v18 部署校验 */
|
||||
check?: boolean;
|
||||
force?: boolean;
|
||||
/** v17 音频 */
|
||||
mute?: string;
|
||||
volume?: string;
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@
|
||||
"test:windows": "node tests/e2e/windows.mjs",
|
||||
"verify": "node scripts/verify.mjs",
|
||||
"pack:win": "node scripts/pack.mjs",
|
||||
"pack:mac": "node scripts/pack-macos.mjs"
|
||||
"pack:mac": "node scripts/pack-macos.mjs",
|
||||
"update:win": "node scripts/update-win.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
|
||||
@@ -40,6 +40,12 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
type: 'object',
|
||||
description: 'PowerShell 常驻助手状态(v15):spawned/hits/failures/consecutiveFailures/avg_ms 等',
|
||||
},
|
||||
build: {
|
||||
type: 'object',
|
||||
description:
|
||||
'v18 构建指纹:hash(运行中 agent 脚本的 sha256 前 12 位,部署校验用)、bytes、mtime_ms、' +
|
||||
'node(运行时版本)、started_at、uptime_ms',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 一条命令更新被控端(Windows):打包 → 部署 → 校验。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/update-win.mjs [设备名] [--check] [--force]
|
||||
* 设备名省略时用控制端配置里的 current
|
||||
*
|
||||
* 等价于:
|
||||
* pnpm pack:win
|
||||
* nodeagent --node <设备名> deploy release/nodeagent-win-x64/agent.mjs
|
||||
*
|
||||
* 为什么要有它:手动流程要记住「先 pack、产物在 release/nodeagent-win-x64/、再 deploy」
|
||||
* 三个知识点,容易出错(尤其 pack 失败时 deploy 的是旧包 —— 真机踩过)。
|
||||
* 本脚本在 pack 之后**校验产物确已更新**,再部署。
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2);
|
||||
const flags = args.filter((a) => a.startsWith('--'));
|
||||
const node = args.find((a) => !a.startsWith('--')) ?? '';
|
||||
|
||||
const bundle = join(root, 'release', 'nodeagent-win-x64', 'agent.mjs');
|
||||
const before = existsSync(bundle) ? statSync(bundle).mtimeMs : 0;
|
||||
|
||||
console.log('=== ① 打包(pnpm pack:win)===');
|
||||
execFileSync('pnpm', ['pack:win'], { cwd: root, stdio: 'inherit' });
|
||||
|
||||
if (!existsSync(bundle)) {
|
||||
console.error('✗ 打包后仍找不到产物:', bundle);
|
||||
process.exit(1);
|
||||
}
|
||||
const after = statSync(bundle).mtimeMs;
|
||||
if (after === before) {
|
||||
console.error('✗ 产物时间戳未变化 —— 打包可能失败,拒绝部署旧包(这是刻意的保护)');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✓ 产物已更新:${bundle}`);
|
||||
|
||||
console.log('=== ② 部署到被控端 ===');
|
||||
const target = node ? ['--node', node] : [];
|
||||
execFileSync('nodeagent', [...target, 'deploy', bundle, ...flags], { cwd: root, stdio: 'inherit' });
|
||||
Reference in new issue
Block a user