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` 演练通过且工作区无副作用
This commit is contained in:
1 parent
aa72241080
commit
cedc46d090
20 files changed
+1262
-17
No files matched your search
@@ -18,6 +18,10 @@ pnpm-debug.log*
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 预共享密钥:install.cmd 在包内生成,绝不入库
|
||||
PSK.txt
|
||||
**/PSK.txt
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -61,16 +61,35 @@ pnpm build
|
||||
|
||||
产物:`release/nodeagent-win-x64.zip`(约 32MB)
|
||||
|
||||
2. 把 zip 拷到 Windows 并解压
|
||||
3. 右键以**管理员**运行其中的 `install.ps1`
|
||||
2. 把 zip 拷到 Windows 并解压到**固定目录**(如 `D:\nodeagent`,装完不要移动或改名)
|
||||
3. **双击 `install.cmd`** —— 会自动弹 UAC 提权,然后一路装完并打印连接信息
|
||||
|
||||
安装脚本会使用**包内自带的 Node.js 运行时**,自动:生成预共享密钥 → 写配置 → 放行防火墙 → 注册开机自启 → 启动 Agent,并打印**密钥**与**连接命令**。
|
||||
> 不需要记任何命令行。`install.cmd` 会自己提权、自己读包内 `PSK.txt` 的密钥(没有就生成一把),
|
||||
> 再调用底层的 `install.ps1`。日后管理双击 **`control.cmd`**(启动 / 停止 / 重启 / 状态 / 日志 / 卸载)。
|
||||
|
||||
安装脚本会使用**包内自带的 Node.js 运行时**,自动:读/生成预共享密钥 → 写配置 → 放行防火墙 → 注册开机自启 → 启动 Agent,并打印**密钥**与**连接命令**。
|
||||
|
||||
<details>
|
||||
<summary>需要指定端口 / 无人值守模式?展开命令行用法</summary>
|
||||
|
||||
```powershell
|
||||
# 指定端口(默认 8765)
|
||||
install.cmd 18770
|
||||
|
||||
# 等价的底层调用(install.cmd 就是这行 + 提权 + 读 PSK)
|
||||
powershell -ExecutionPolicy Bypass -File .\install.ps1 -NodeId my-pc -Key <PSK> -AllowInput
|
||||
|
||||
# 无人值守:注销/重启后仍运行,但**没有 GUI**(Session 0 无桌面)
|
||||
powershell -ExecutionPolicy Bypass -File .\install.ps1 -Unattended -AtStartup
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**两种运行模式(重要)**
|
||||
|
||||
| 模式 | 命令 | 登录/注销 | 能力范围 |
|
||||
|---|---|---|---|
|
||||
| 交互(默认) | `install.ps1` | 登录时启动;**注销即停** | 全部能力,**含 GUI**(截屏 / UIA 找元素 / 键鼠注入) |
|
||||
| 交互(默认) | `install.cmd` | 登录时启动;**注销即停** | 全部能力,**含 GUI**(截屏 / UIA 找元素 / 键鼠注入) |
|
||||
| 无人值守 | `install.ps1 -Unattended -AtStartup` | **注销、重启后仍运行**(S4U,无需存密码) | 无 GUI —— 截屏/输入类能力不可用(进程在非交互会话,没有桌面) |
|
||||
|
||||
> 这是 Windows 的固有限制:**图形操作必须有交互桌面**。需要 GUI 就保持登录(可锁屏);
|
||||
@@ -379,7 +398,7 @@ node scripts/verify-mcp-events.mjs # 验证 MCP 事件双通路(推送 + 拉
|
||||
|
||||
| 层次 | 方式 | 覆盖 |
|
||||
|---|---|---|
|
||||
| **CI(推荐)** | GitHub Actions `windows-latest` | 真实 Windows 上跑协议 E2E、专属能力(`Get-Service` / 注册表 / winget 装软件)、`install.ps1` 链路、CLI 连入、MCP 工具列表 |
|
||||
| **CI(推荐)** | GitHub Actions `windows-latest` | 真实 Windows 上跑协议 E2E、专属能力(`Get-Service` / 注册表 / winget 装软件)、`install.ps1` 链路、`install.cmd`/`control.cmd` 双击链路、**Windows PowerShell 5.1 解析守卫**、CLI 连入、MCP 工具列表 |
|
||||
| **远程验收** | `node scripts/verify.mjs --host <IP> --key <密钥> [--insecure]` | 逐条验收并输出终端报告(`--report report.md`) |
|
||||
| **本地测试** | `pnpm test:e2e` / `pnpm test:windows` | 单机自举:本机起 Agent 当被控端自测 |
|
||||
|
||||
@@ -405,7 +424,7 @@ nodeagent/
|
||||
│ ├── hub/ # 中转节点:设备注册、控制端配对、字节透传(跨网段/公网)
|
||||
│ ├── cli/ # macOS 控制端 CLI
|
||||
│ └── mcp/ # MCP server(接入 WorkBuddy)
|
||||
├── scripts/ # install.ps1 / uninstall.ps1 / verify.mjs
|
||||
├── scripts/ # install.cmd / control.cmd(一键入口)/ install.ps1 / control.ps1 / verify.mjs
|
||||
├── tests/e2e/ # 端到端测试
|
||||
├── docs/ # 开发文档
|
||||
├── PRD.md # 产品需求文档
|
||||
@@ -441,6 +460,9 @@ nodeagent/
|
||||
|
||||
## 文档
|
||||
|
||||
- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式)
|
||||
- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦)
|
||||
|
||||
- [产品需求文档(PRD)](./PRD.md)
|
||||
- [开发文档(DEVELOPMENT)](./docs/DEVELOPMENT.md) —— 协议细节、能力 schema、安全模型、各端实现指南
|
||||
- [**跨机接管的边界与局限**](./docs/REMOTE-LIMITS.md) —— 哪些问题能远程修、哪些必须人工,附真实故障诊断案例
|
||||
@@ -454,5 +476,5 @@ nodeagent/
|
||||
| 安全演进 | 先简化(预共享密钥 + HMAC)→ 后强化(Ed25519 + 能力级 ACL + 审计) |
|
||||
| AI 接入 | 核心能力 → CLI(调试底座)→ MCP(AI 落点) |
|
||||
| 装软件 | 优先 winget 静默安装 |
|
||||
| Windows 部署 | 一键 `install.ps1` + 开机自启 |
|
||||
| Windows 部署 | 一键 `install.cmd`(双击即装,自提权)+ 开机自启;`control.cmd` 管理 |
|
||||
| 依赖策略 | 被控端零原生编译;能力全部基于系统原生 API 实现 |
|
||||
@@ -47,7 +47,19 @@ async function isAdmin(): Promise<boolean> {
|
||||
// ---------------- system.info ----------------
|
||||
|
||||
|
||||
// ---------- 构建指纹(v18)----------
|
||||
// ---------- 构建指纹(v18 / v20)----------
|
||||
|
||||
// 构建时由 esbuild --define 注入(见 scripts/pack.mjs);开发态(直接跑 dist)下未定义,
|
||||
// 故一律用 typeof 探测,绝不能直接引用(否则 dev 启动就 ReferenceError)。
|
||||
declare const __AGENT_VERSION__: string | undefined;
|
||||
declare const __BUILD_COMMIT__: string | undefined;
|
||||
declare const __BUILD_TIME__: string | undefined;
|
||||
|
||||
function injected(name: 'version' | 'commit' | 'time'): string {
|
||||
if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__;
|
||||
if (name === 'commit') return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
|
||||
return typeof __BUILD_TIME__ === 'undefined' ? 'dev' : __BUILD_TIME__;
|
||||
}
|
||||
|
||||
const STARTED_AT = Date.now();
|
||||
let buildCache: { hash: string; bytes: number; mtime_ms: number } | null = null;
|
||||
@@ -59,7 +71,17 @@ let buildCache: { hash: string; bytes: number; mtime_ms: number } | null = null;
|
||||
* (旧版可能恰好也是同样的能力数);内容哈希是唯一可靠的判据。
|
||||
* 懒计算 + 缓存:文件约 1MB,只在首次 system.info 时读一次。
|
||||
*/
|
||||
function buildInfo(): { hash: string; bytes: number; mtime_ms: number; node: string; started_at: number; uptime_ms: number } {
|
||||
export function buildInfo(): {
|
||||
hash: string;
|
||||
bytes: number;
|
||||
mtime_ms: number;
|
||||
node: string;
|
||||
started_at: number;
|
||||
uptime_ms: number;
|
||||
version: string;
|
||||
commit: string;
|
||||
built_at: string;
|
||||
} {
|
||||
if (!buildCache) {
|
||||
let hash = 'unknown';
|
||||
let bytes = 0;
|
||||
@@ -80,6 +102,10 @@ function buildInfo(): { hash: string; bytes: number; mtime_ms: number; node: str
|
||||
node: process.version,
|
||||
started_at: STARTED_AT,
|
||||
uptime_ms: Date.now() - STARTED_AT,
|
||||
// v20:语义化版本 + 提交 + 构建时间(来自构建期注入)
|
||||
version: injected('version'),
|
||||
commit: injected('commit'),
|
||||
built_at: injected('time'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type AuthParams,
|
||||
type InvokeParams,
|
||||
} from '@nodeagent/protocol';
|
||||
import { buildInfo } from './capabilities/system.js';
|
||||
import { createCapabilityRegistry } from './capabilities/index.js';
|
||||
import { audit, describeArgs } from './audit.js';
|
||||
import { disposeWatchesByOwner } from './events.js';
|
||||
@@ -179,7 +180,16 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
remote: state.remote,
|
||||
reason: `mode=${authMode}` + (authorized ? `, authorized=${authorized.length}` : ''),
|
||||
});
|
||||
sendResult(ws, req.id, { capabilities: CAPABILITY_MANIFEST, authorized, auth_mode: authMode });
|
||||
const bi = buildInfo();
|
||||
sendResult(ws, req.id, {
|
||||
capabilities: CAPABILITY_MANIFEST,
|
||||
authorized,
|
||||
auth_mode: authMode,
|
||||
// v20:把版本/构建信息交给控制端,便于做新旧兼容提示
|
||||
agent_version: bi.version,
|
||||
build: { hash: bi.hash, commit: bi.commit, built_at: bi.built_at },
|
||||
protocol: PROTOCOL_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleInvoke(ws: WebSocket, state: ConnState, req: RpcRequest): Promise<void> {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '@nodeagent/protocol';
|
||||
import {
|
||||
callAndPrint,
|
||||
reportCompat,
|
||||
fail,
|
||||
getClientConfig,
|
||||
humanSize,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '@nodeagent/protocol';
|
||||
import {
|
||||
callAndPrint,
|
||||
reportCompat,
|
||||
fail,
|
||||
getClientConfig,
|
||||
humanSize,
|
||||
@@ -104,6 +105,11 @@ function renderInfoValue(v: unknown): string {
|
||||
}
|
||||
|
||||
export async function cmdInfo(opts: Options): Promise<void> {
|
||||
// 先打版本与兼容性(v20)——info 是最自然的「体检」命令
|
||||
await withClient((c) => {
|
||||
reportCompat(c);
|
||||
return Promise.resolve();
|
||||
});
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.SystemInfo, {}, opts.json, (data) => {
|
||||
const d = data as Record<string, unknown>;
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
type NodeProfile,
|
||||
} from '@nodeagent/client';
|
||||
import {
|
||||
CAPABILITY_MANIFEST,
|
||||
CapabilityNames,
|
||||
capabilityDiff,
|
||||
matchPattern,
|
||||
DEFAULT_DISCOVERY_PORT,
|
||||
type CapabilityDescriptor,
|
||||
@@ -256,3 +258,42 @@ export async function probeOnce(nodeName?: string): Promise<{
|
||||
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} 项,两端一致 ✓`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# 版本与兼容性契约
|
||||
|
||||
> 为什么需要这份文档:agent 与 CLI/MCP 是**分开更新的**(CLI 随控制端装,agent 推/拉到被控端),
|
||||
> 新旧混用是常态而非例外。没有明文契约,升级顺序、什么算破坏性改动、出问题怎么定位全靠猜。
|
||||
|
||||
## 1. 版本号
|
||||
|
||||
- 单一来源:**根 `package.json` 的 `version`**,构建时经 esbuild `--define` 注入 agent。
|
||||
- 运行时可通过 `system.info` 的 `build` 字段读到:`version` / `commit`(git sha)/ `built_at` /
|
||||
`hash`(运行中脚本的 sha256 前 12 位 —— 部署校验用)。
|
||||
- 控制端在握手(`auth_ok`)即拿到被控端版本,`nodeagent info` 会显示:
|
||||
```
|
||||
被控端版本 : v1.0.0 @ ef9ad97 构建 740d51953fd0
|
||||
能力对齐 : 42 项,两端一致 ✓
|
||||
```
|
||||
|
||||
## 2. 语义化版本的含义
|
||||
|
||||
| 变更类型 | 版本位 | 例子 |
|
||||
|---|---|---|
|
||||
| **破坏性**:删除能力、给既有能力**新增必填参数**、改变既有能力的返回结构、修改认证/握手流程 | MAJOR | 删掉 `screen.find`;把 `fs.read` 的 `path` 改成必填 `remote_path` |
|
||||
| **新增**:新能力、既有能力的**可选**参数、新增返回字段、新增审计事件类型 | MINOR | 加 `system.audio.*`、给 `deploy` 加 `--check` |
|
||||
| **修复/内部**:bug 修复、性能、文档、不影响协议的重构 | PATCH | 修备份编码、拆分文件 |
|
||||
|
||||
## 3. 兼容性契约(硬规则)
|
||||
|
||||
1. **只增不改**:既有能力的**参数名与语义不得变更**;要改就新增能力(如 `screen.find` 与未来的
|
||||
`screen.find2` 并存一代),旧能力至少在下一个 MAJOR 才移除。
|
||||
2. **不新增必填参数**:既有能力只能加**可选**参数 —— 否则旧控制端一调就 `E_PARAM_INVALID`。
|
||||
3. **返回结构只加不减**:新增字段必须可选,旧控制端忽略即可。
|
||||
4. **`auth_ok` 的字段只增不减**:v20 新增的 `agent_version`/`build`/`protocol` 均为可选,
|
||||
旧控制端不认也不报错。
|
||||
5. **协议主版本**(`PROTOCOL_VERSION` 的主号)不一致时被控端直接拒绝连接并返回
|
||||
`E_PROTOCOL_MISMATCH` —— 这是唯一的硬闸门,用于承载**无法兼容**的协议变更。
|
||||
6. **能力清单是唯一事实源**:控制端不比版本号,而是比**能力集合**
|
||||
(`capabilityDiff`)—— 版本号可能相同而内容不同(本地 dev 构建),能力集合不会骗人。
|
||||
|
||||
## 4. 升级顺序建议
|
||||
|
||||
**先升级被控端,再升级控制端**:
|
||||
|
||||
| 组合 | 结果 |
|
||||
|---|---|
|
||||
| 新被控端 + 旧控制端 | ✅ 安全。被控端只做加法,旧控制端不认识的新能力会被忽略 |
|
||||
| 旧被控端 + 新控制端 | ⚠️ 可用,但新功能不可用。`nodeagent info` 会列出**远端缺少**的能力并提示升级 |
|
||||
|
||||
> 若顺序反了也不会坏:控制端调用缺失能力时返回 `E_CAPABILITY_NOT_FOUND`,
|
||||
> 而 `nodeagent info` / `connect` 会提前把它变成一句人话提示。
|
||||
|
||||
## 5. 更新方式(两条路,按可达性选)
|
||||
|
||||
| 方式 | 命令 | 前提 |
|
||||
|---|---|---|
|
||||
| 推送式 | `pnpm update:win <设备名>` | 控制端↔被控端**可达**;自动打包含校验与指纹复核 |
|
||||
| **拉取式** | `nodeagent update --url <地址> --sha256 <哈希>` | **只要被控端能上网**(含 GitHub Release) |
|
||||
|
||||
先预检再更新(不动任何文件):
|
||||
|
||||
```bash
|
||||
nodeagent --node win update --url <地址> --sha256 <哈希> --dry-run
|
||||
```
|
||||
|
||||
`--sha256` 是**安全底线**:不校验哈希等于开放远程代码执行。
|
||||
|
||||
## 6. 发版流程
|
||||
|
||||
```bash
|
||||
pnpm release patch|minor|major|<x.y.z> # 改版本 → 构建 → 打包 → 校验和/清单 → 提交打标签 → GitHub Release
|
||||
pnpm release 1.0.0 --dry-run # 演练(无任何副作用)
|
||||
```
|
||||
|
||||
产物(GitHub Release 资产):
|
||||
|
||||
| 资产 | 用途 |
|
||||
|---|---|
|
||||
| `agent.mjs` | **自更新载荷** —— 被控端 `system.agent.update` 直接用它(配 `--sha256`) |
|
||||
| `nodeagent-win-x64.zip` | 人工安装包(自包含 `node.exe`,免装 Node.js) |
|
||||
| `latest.json` | 版本清单:version/commit/built_at/capabilities + 两个资产的 url 与 sha256 |
|
||||
| `SHA256SUMS` | 校验和 |
|
||||
|
||||
> ⚠️ **首次仍需一次性安装**:自更新能力本身得先在机器上(鸡生蛋无解)。
|
||||
> ⚠️ **`node.exe`(Node 运行时)不在自更新范围**:只换 `agent.mjs`;需升级 Node 时重跑 `install.ps1`。
|
||||
> ⚠️ 本仓库的 `.github/` 改动需要 `workflow` 权限才能推送(`gh auth refresh -h github.com -s workflow`),
|
||||
> 发版脚本刻意跳过 `.github/`,避免发版流程被它卡住。
|
||||
|
||||
## 7. 出问题怎么排查
|
||||
|
||||
```bash
|
||||
nodeagent --node win info # 版本 + 能力对齐情况(先看这个)
|
||||
nodeagent --node win invoke system.info --args '{"fields":["build"]}' # 构建指纹
|
||||
nodeagent --node win deploy <包> --check # 本地包 vs 远端是否一致(不改动)
|
||||
```
|
||||
|
||||
- **远端过旧**:`info` 会列出缺失能力 → `nodeagent update ...`
|
||||
- **本控制端过旧**:`info` 会列出"不认识的新能力" → 更新控制端(`git pull` → `pack:mac` → `install-macos.sh`)
|
||||
- **同版本但指纹不同**:属正常(内容相同才叫同版本);用 `--check` 对比实际内容
|
||||
- **部署后没生效**:先查杀软是否处置了被替换的 `agent.mjs`(见 `REMOTE-LIMITS.md` 的「杀软误拦」)
|
||||
+2
-1
@@ -20,7 +20,8 @@
|
||||
"verify": "node scripts/verify.mjs",
|
||||
"pack:win": "node scripts/pack.mjs",
|
||||
"pack:mac": "node scripts/pack-macos.mjs",
|
||||
"update:win": "node scripts/update-win.mjs"
|
||||
"update:win": "node scripts/update-win.mjs",
|
||||
"release": "node scripts/release.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
|
||||
@@ -76,6 +76,10 @@ export class NodeAgentClient {
|
||||
private capabilities: CapabilityDescriptor[] = [];
|
||||
/** ed25519 模式:本次被授权的能力;psk 模式为 null */
|
||||
private authorized: string[] | null = null;
|
||||
/** v20:被控端语义化版本(旧被控端不返回则为 undefined) */
|
||||
private agentVersion?: string;
|
||||
/** v20:被控端构建信息 { hash, commit, built_at } */
|
||||
private agentBuild?: { hash?: string; commit?: string; built_at?: string };
|
||||
private closed = false;
|
||||
/** v4:重连状态 */
|
||||
private reconnectAttempts = 0;
|
||||
@@ -155,6 +159,9 @@ export class NodeAgentClient {
|
||||
|
||||
this.capabilities = authOk.capabilities ?? [];
|
||||
this.authorized = authOk.authorized ?? null;
|
||||
// v20:远端版本/构建信息(旧被控端不返回 → 保持 undefined,不报错)
|
||||
this.agentVersion = authOk.agent_version;
|
||||
this.agentBuild = authOk.build;
|
||||
const label =
|
||||
authOk.auth_mode === 'ed25519'
|
||||
? `ed25519,授权 ${this.authorized?.length ?? 0}/${this.capabilities.length} 项能力`
|
||||
@@ -183,6 +190,16 @@ export class NodeAgentClient {
|
||||
return this.authorized;
|
||||
}
|
||||
|
||||
/** v20:被控端版本(旧被控端未上报则为 undefined)。 */
|
||||
getAgentVersion(): string | undefined {
|
||||
return this.agentVersion;
|
||||
}
|
||||
|
||||
/** v20:被控端构建信息(指纹/提交/构建时间)。 */
|
||||
getAgentBuild(): { hash?: string; commit?: string; built_at?: string } | undefined {
|
||||
return this.agentBuild;
|
||||
}
|
||||
|
||||
/** 底层请求(带超时与 id 匹配)。 */
|
||||
private request<R>(method: string, params: unknown, timeoutMs?: number): Promise<R> {
|
||||
const ws = this.ws;
|
||||
|
||||
@@ -10,3 +10,24 @@ import type { CapabilityDescriptor } from './messages.js';
|
||||
export function findCapability(name: string): CapabilityDescriptor | undefined {
|
||||
return MANIFEST.find((c) => c.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 比对「本地声明的能力」与「远端实际提供的能力」(v20)。
|
||||
*
|
||||
* 用途:新旧版本混用时给出**可执行的提示**,而不是等到调用时抛
|
||||
* `E_CAPABILITY_NOT_FOUND` 让人摸不着头脑。
|
||||
* - `missingOnRemote`:本地有、远端没有 → **远端较旧**,需要更新被控端
|
||||
* - `unknownLocally`:远端有、本地没有 → **本控制端较旧**,可正常用(远端是加法演进)
|
||||
*/
|
||||
export function capabilityDiff(
|
||||
localNames: string[],
|
||||
remoteNames: string[],
|
||||
): { missingOnRemote: string[]; unknownLocally: string[] } {
|
||||
const local = new Set(localNames);
|
||||
const remote = new Set(remoteNames);
|
||||
return {
|
||||
missingOnRemote: localNames.filter((n) => !remote.has(n)),
|
||||
unknownLocally: remoteNames.filter((n) => !local.has(n)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,6 +57,12 @@ export interface AuthOkParams {
|
||||
authorized?: string[];
|
||||
/** 本次连接采用的认证模式 */
|
||||
auth_mode?: 'psk' | 'ed25519';
|
||||
/** v20:被控端语义化版本(新增字段,旧控制端忽略即可 → 向后兼容) */
|
||||
agent_version?: string;
|
||||
/** v20:被控端构建信息(指纹/提交/构建时间),供控制端做版本比对与提示 */
|
||||
build?: { hash?: string; commit?: string; built_at?: string };
|
||||
/** v20:被控端支持的协议版本(与 HelloParams.protocol 比对) */
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
// ---------- 调用 ----------
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
@echo off
|
||||
REM ============================================================================
|
||||
REM nodeagent - control menu (Windows)
|
||||
REM
|
||||
REM Double-click this file to manage an already-installed agent:
|
||||
REM 1) status - task / process / port / config at a glance
|
||||
REM 2) start - start the scheduled task
|
||||
REM 3) stop - stop the scheduled task
|
||||
REM 4) restart - stop then start
|
||||
REM 5) logs - tail the agent's audit log
|
||||
REM 6) uninstall - remove task + firewall rule (7) purge config and exit
|
||||
REM
|
||||
REM Usage: control.cmd [port]
|
||||
REM port defaults to 8765; pass it only if the agent uses another.
|
||||
REM
|
||||
REM Pure ASCII on purpose - see the note in install.cmd. The heavy lifting
|
||||
REM (and all Chinese output) lives in control.ps1, which carries a UTF-8 BOM
|
||||
REM so Windows PowerShell 5.1 parses it correctly.
|
||||
REM ============================================================================
|
||||
setlocal EnableExtensions
|
||||
|
||||
set "HERE=%~dp0"
|
||||
set "HELPER=%HERE%control.ps1"
|
||||
set "PORT=%~1"
|
||||
if not defined PORT set "PORT=8765"
|
||||
|
||||
if not exist "%HELPER%" (
|
||||
echo [x] control.ps1 not found next to this file.
|
||||
echo Folder: "%HERE%"
|
||||
if not defined CI pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:MENU
|
||||
cls
|
||||
echo ==========================================================
|
||||
echo nodeagent control console
|
||||
echo ==========================================================
|
||||
echo 1) Status
|
||||
echo 2) Start agent
|
||||
echo 3) Stop agent
|
||||
echo 4) Restart agent
|
||||
echo 5) Show recent audit log
|
||||
echo 6) Uninstall ^(keep config^)
|
||||
echo 7) Uninstall and purge config
|
||||
echo Q) Quit
|
||||
echo ==========================================================
|
||||
echo.
|
||||
|
||||
set "CHOICE="
|
||||
REM <nul makes `set /p` fail immediately -> CHOICE stays empty. Combined with
|
||||
REM CI (see below) that exits instead of looping forever on piped/empty stdin.
|
||||
set /p "CHOICE=Select: "
|
||||
|
||||
if not defined CHOICE (
|
||||
if defined CI goto :QUIT
|
||||
echo.
|
||||
echo (no input - exiting)
|
||||
goto :QUIT
|
||||
)
|
||||
|
||||
if /i "%CHOICE%"=="Q" goto :QUIT
|
||||
if "%CHOICE%"=="1" call :RUN status
|
||||
if "%CHOICE%"=="2" call :RUN start
|
||||
if "%CHOICE%"=="3" call :RUN stop
|
||||
if "%CHOICE%"=="4" call :RUN restart
|
||||
if "%CHOICE%"=="5" call :RUN logs
|
||||
if "%CHOICE%"=="6" call :RUN uninstall
|
||||
if "%CHOICE%"=="7" call :RUN uninstall-purge
|
||||
goto :MENU
|
||||
|
||||
:QUIT
|
||||
endlocal
|
||||
exit /b 0
|
||||
|
||||
:RUN
|
||||
REM %1 = subcommand forwarded to control.ps1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%HELPER%" -Action %1 -Port %PORT%
|
||||
echo.
|
||||
if not defined CI pause
|
||||
goto :MENU
|
||||
REM unreachable; keeps `call :RUN` from falling through if the goto is removed
|
||||
exit /b 0
|
||||
@@ -0,0 +1,237 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
nodeagent - control operations for an installed Windows agent.
|
||||
|
||||
.DESCRIPTION
|
||||
Backs control.cmd. Everything Chinese-facing lives here (rather than in
|
||||
the .cmd) because CMD's code page cannot round-trip UTF-8, while this
|
||||
file is saved as UTF-8 **with BOM** so Windows PowerShell 5.1 reads it
|
||||
correctly. PowerShell 5.1 parses BOM-less .ps1 as ANSI/GBK and the
|
||||
Chinese comments then break string boundaries -> bogus syntax errors.
|
||||
|
||||
.PARAMETER Action
|
||||
status | start | stop | restart | logs | uninstall | uninstall-purge
|
||||
|
||||
.EXAMPLE
|
||||
.\control.ps1 -Action status
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('status', 'start', 'stop', 'restart', 'logs', 'uninstall', 'uninstall-purge')]
|
||||
[string]$Action,
|
||||
[int]$Port = 8765
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Step($m) { Write-Host "[*] $m" -ForegroundColor Cyan }
|
||||
function Write-Ok($m) { Write-Host "[+] $m" -ForegroundColor Green }
|
||||
function Write-Warn($m) { Write-Host "[!] $m" -ForegroundColor Yellow }
|
||||
function Write-Err($m) { Write-Host "[x] $m" -ForegroundColor Red }
|
||||
|
||||
$taskName = "nodeagent"
|
||||
|
||||
# 数据目录:必须与 agent 侧一致 —— apps/agent/src/config.ts 的 agentDir() 是
|
||||
# process.env.NODEAGENT_HOME ?? join(homedir(), '.nodeagent')
|
||||
# 计划任务里若设了 NODEAGENT_HOME,只查 %USERPROFILE%\.nodeagent 会读错配置。
|
||||
$cfgDir = if ($env:NODEAGENT_HOME) { $env:NODEAGENT_HOME } else { Join-Path $env:USERPROFILE ".nodeagent" }
|
||||
$cfgPath = Join-Path $cfgDir "agent.json"
|
||||
$auditLog = Join-Path $cfgDir "audit.log"
|
||||
|
||||
function Test-Admin {
|
||||
([Security.Principal.WindowsPrincipal] `
|
||||
[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Get-TaskState {
|
||||
try { (Get-ScheduledTask -TaskName $taskName -ErrorAction Stop).State.ToString() }
|
||||
catch { "NotInstalled" }
|
||||
}
|
||||
|
||||
# ── status ────────────────────────────────────────────────────────────────────
|
||||
function Show-Status {
|
||||
Write-Host ""
|
||||
Write-Host " nodeagent 状态" -ForegroundColor White
|
||||
Write-Host " --------------" -ForegroundColor DarkGray
|
||||
|
||||
# 计划任务
|
||||
$state = Get-TaskState
|
||||
if ($state -eq "NotInstalled") {
|
||||
Write-Warn "计划任务 '$taskName' 未安装(请先双击 install.cmd)"
|
||||
} else {
|
||||
$color = if ($state -eq "Running") { "Green" } else { "Yellow" }
|
||||
Write-Host " 计划任务 : " -NoNewline; Write-Host $state -ForegroundColor $color
|
||||
$info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
if ($info) {
|
||||
Write-Host " 上次运行 : $($info.LastRunTime) 结果码 $($info.LastTaskResult)"
|
||||
}
|
||||
}
|
||||
|
||||
# 进程
|
||||
$procs = @(Get-Process node -ErrorAction SilentlyContinue)
|
||||
if ($procs.Count -gt 0) {
|
||||
Write-Host " 进程 : " -NoNewline
|
||||
Write-Host "$($procs.Count) 个 node 进程 (PID $($procs.Id -join ', '))" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " 进程 : " -NoNewline; Write-Host "无" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# 端口
|
||||
$listen = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
|
||||
if ($listen.Count -gt 0) {
|
||||
Write-Host " 端口 : " -NoNewline
|
||||
Write-Host "$Port 正在监听 (PID $($listen[0].OwningProcess))" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " 端口 : " -NoNewline
|
||||
Write-Host "$Port 未监听" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# 配置
|
||||
if (Test-Path $cfgPath) {
|
||||
try {
|
||||
$cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json
|
||||
Write-Host " 节点 ID : $($cfg.node_id)"
|
||||
Write-Host " 鉴权模式 : $($cfg.auth_mode)"
|
||||
$inp = if ($cfg.allow_input) { "已启用" } else { "已禁用" }
|
||||
Write-Host " 输入控制 : $inp"
|
||||
$scheme = if ($cfg.tls) { "wss" } else { "ws" }
|
||||
Write-Host " 协议 : $scheme://0.0.0.0:$Port"
|
||||
} catch {
|
||||
Write-Warn "配置文件解析失败: $cfgPath"
|
||||
}
|
||||
} else {
|
||||
Write-Warn "未找到配置文件: $cfgPath"
|
||||
}
|
||||
|
||||
# 本机 IP
|
||||
$ips = @()
|
||||
try {
|
||||
$ips = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } |
|
||||
Select-Object -ExpandProperty IPAddress)
|
||||
} catch { }
|
||||
Write-Host " 本机 IP : $($ips -join ', ')"
|
||||
Write-Host ""
|
||||
|
||||
if ($state -eq "NotInstalled") {
|
||||
Write-Warn "尚未安装 —— 请双击 install.cmd 一键部署"
|
||||
} elseif ($listen.Count -eq 0) {
|
||||
Write-Warn "端口未监听 —— 试试菜单里的「重启」,或双击 install.cmd 重新部署"
|
||||
}
|
||||
}
|
||||
|
||||
# ── start / stop / restart ────────────────────────────────────────────────────
|
||||
function Start-Agent {
|
||||
if ((Get-TaskState) -eq "NotInstalled") {
|
||||
Write-Err "计划任务未安装,请先双击 install.cmd"
|
||||
return
|
||||
}
|
||||
Write-Step "启动计划任务..."
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
# 轮询端口真正起来(最多 15s),而不是盲等
|
||||
for ($i = 0; $i -lt 15; $i++) {
|
||||
Start-Sleep -Seconds 1
|
||||
if (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) {
|
||||
Write-Ok "agent 已启动并在 $Port 监听"
|
||||
return
|
||||
}
|
||||
}
|
||||
Write-Warn "任务已触发,但 $Port 仍未监听"
|
||||
$info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
if ($info) { Write-Warn "上次运行结果码: $($info.LastTaskResult)(0 = 成功,267009 = 任务已在运行)" }
|
||||
}
|
||||
|
||||
function Stop-Agent {
|
||||
if ((Get-TaskState) -eq "NotInstalled") {
|
||||
Write-Err "计划任务未安装"
|
||||
return
|
||||
}
|
||||
Write-Step "停止计划任务..."
|
||||
Stop-ScheduledTask -TaskName $taskName
|
||||
Start-Sleep -Seconds 1
|
||||
# 兜底:计划任务偶尔留孤儿子进程
|
||||
$left = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
|
||||
if ($left.Count -gt 0) {
|
||||
Write-Warn "端口 $Port 仍被占用 (PID $($left[0].OwningProcess)),可能有残留进程"
|
||||
Write-Warn "可手动结束: Stop-Process -Id $($left[0].OwningProcess) -Force"
|
||||
} else {
|
||||
Write-Ok "agent 已停止"
|
||||
}
|
||||
}
|
||||
|
||||
# ── logs ──────────────────────────────────────────────────────────────────────
|
||||
function Show-Logs {
|
||||
if (-not (Test-Path $auditLog)) {
|
||||
Write-Warn "未找到审计日志: $auditLog"
|
||||
Write-Warn "(审计日志需在 agent 配置中开启 audit.enabled)"
|
||||
return
|
||||
}
|
||||
Write-Step "审计日志末尾 40 条: $auditLog"
|
||||
Write-Host ""
|
||||
# 只取尾部若干行,不把整个日志(默认上限 10MB x 5 份)读进内存。
|
||||
# -Encoding UTF8 在 5.1 下能正确识别 UTF-8;解析前仍剥一次 BOM。
|
||||
$lines = @(Get-Content -LiteralPath $auditLog -Tail 40 -Encoding UTF8 -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Trim() })
|
||||
if ($lines.Count -eq 0) { Write-Warn "日志为空"; return }
|
||||
|
||||
foreach ($line in $lines) {
|
||||
if ($line[0] -eq [char]0xFEFF) { $line = $line.Substring(1) }
|
||||
try {
|
||||
$e = $line | ConvertFrom-Json
|
||||
# ts 是 Unix 毫秒(见 apps/agent/src/audit.ts 的 AuditEntry)
|
||||
$ts = "?"
|
||||
if ($e.ts) {
|
||||
try { $ts = ([DateTimeOffset]::FromUnixTimeMilliseconds([int64]$e.ts)).LocalDateTime.ToString("MM-dd HH:mm:ss") }
|
||||
catch { $ts = "$($e.ts)" }
|
||||
}
|
||||
$ty = if ($e.type) { $e.type } else { "?" }
|
||||
$ci = if ($e.client_id) { $e.client_id } else { "-" }
|
||||
$extra = ""
|
||||
if ($e.capability) { $extra += " $($e.capability)" }
|
||||
if ($e.status) { $extra += " [$($e.status)]" }
|
||||
if ($e.duration_ms) { $extra += " $($e.duration_ms)ms" }
|
||||
if ($e.error) { $extra += " ERROR=$($e.error)" }
|
||||
if ($e.reason) { $extra += " reason=$($e.reason)" }
|
||||
Write-Host (" {0,-15} {1,-20} {2,-14}{3}" -f $ts, $ty, $ci, $extra) -ForegroundColor DarkGray
|
||||
} catch {
|
||||
Write-Host " $line" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ── uninstall ─────────────────────────────────────────────────────────────────
|
||||
function Uninstall-Agent([bool]$Purge) {
|
||||
if (-not (Test-Admin)) {
|
||||
Write-Err "卸载需要管理员权限,请右键 control.cmd → 以管理员身份运行"
|
||||
return
|
||||
}
|
||||
Write-Step "停止并注销计划任务..."
|
||||
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Step "移除防火墙规则..."
|
||||
Get-NetFirewallRule -DisplayName "nodeagent (TCP $Port)" -ErrorAction SilentlyContinue |
|
||||
Remove-NetFirewallRule -ErrorAction SilentlyContinue
|
||||
|
||||
if ($Purge) {
|
||||
Write-Step "清除配置目录..."
|
||||
Remove-Item -Recurse -Force $cfgDir -ErrorAction SilentlyContinue
|
||||
Write-Ok "已彻底卸载(含配置与审计日志)"
|
||||
} else {
|
||||
Write-Ok "已卸载(配置保留在 $cfgDir)"
|
||||
}
|
||||
}
|
||||
|
||||
# ── dispatch ──────────────────────────────────────────────────────────────────
|
||||
switch ($Action) {
|
||||
'status' { Show-Status }
|
||||
'start' { Start-Agent }
|
||||
'stop' { Stop-Agent }
|
||||
'restart' { Stop-Agent; Start-Sleep -Seconds 1; Start-Agent }
|
||||
'logs' { Show-Logs }
|
||||
'uninstall' { Uninstall-Agent -Purge $false }
|
||||
'uninstall-purge' { Uninstall-Agent -Purge $true }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
@echo off
|
||||
REM ============================================================================
|
||||
REM nodeagent - one-click installer launcher (Windows)
|
||||
REM
|
||||
REM Double-click this file. It will:
|
||||
REM 1) self-elevate to Administrator (UAC prompt) if not already elevated
|
||||
REM 2) make sure a pre-shared key exists in PSK.txt (generate one if absent)
|
||||
REM 3) run install.ps1, which registers the scheduled task, opens the
|
||||
REM firewall and starts the agent
|
||||
REM
|
||||
REM Usage: install.cmd [port]
|
||||
REM port defaults to 8765. Pass a port only when the default is taken.
|
||||
REM
|
||||
REM IMPORTANT - why this file is pure ASCII:
|
||||
REM The CMD.exe code page (936/GBK on Chinese Windows) can NOT round-trip
|
||||
REM UTF-8 Chinese reliably. Any non-ASCII byte here risks mojibake or a
|
||||
REM broken command. All Chinese-facing text is delegated to install.ps1,
|
||||
REM which is saved as UTF-8 **with BOM** so Windows PowerShell 5.1 reads
|
||||
REM it correctly. Keep this launcher ASCII-only.
|
||||
REM
|
||||
REM NOTE - install.ps1 MUST keep its UTF-8 BOM. PowerShell 5.1 parses
|
||||
REM BOM-less .ps1 as ANSI/GBK, and the Chinese comments then break the
|
||||
REM string boundaries -> a cascade of bogus syntax errors.
|
||||
REM ============================================================================
|
||||
setlocal EnableExtensions
|
||||
|
||||
set "HERE=%~dp0"
|
||||
set "PS1=%HERE%install.ps1"
|
||||
set "PSKFILE=%HERE%PSK.txt"
|
||||
set "PORT=%~1"
|
||||
if not defined PORT set "PORT=8765"
|
||||
|
||||
if not exist "%PS1%" (
|
||||
echo [x] install.ps1 not found next to this file.
|
||||
echo Folder: "%HERE%"
|
||||
echo Keep install.ps1, node.exe, agent.mjs and this launcher together.
|
||||
if not defined CI pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM --- 1) elevate to Administrator -------------------------------------------
|
||||
REM fltmc is used instead of `net session` because the latter depends on the
|
||||
REM Server service, which is disabled on some builds - that would make an
|
||||
REM already-elevated shell look unelevated and trigger a pointless UAC loop.
|
||||
fltmc >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [*] Requesting Administrator rights...
|
||||
echo A new window will open. Continue there; this one closes by itself.
|
||||
REM Start-Process re-launches this .cmd elevated. %PORT% (already resolved to
|
||||
REM its default) is forwarded so "install.cmd 18770" survives the elevation.
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"Start-Process -FilePath '%~f0' -ArgumentList '%PORT%' -Verb RunAs" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [x] Elevation was declined - cannot register a scheduled task.
|
||||
if not defined CI pause
|
||||
exit /b 1
|
||||
)
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
REM --- 2) make sure the pre-shared key exists ---------------------------------
|
||||
set "PSK="
|
||||
if exist "%PSKFILE%" (
|
||||
REM Read + Trim in one shot: a stray CR from CRLF or an editor-added trailing
|
||||
REM space would otherwise end up inside -Key and break authentication.
|
||||
for /f "delims=" %%K in ('powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"if (Test-Path -LiteralPath '%PSKFILE%') { (Get-Content -LiteralPath '%PSKFILE%' -Raw).Trim() }"') do set "PSK=%%K"
|
||||
)
|
||||
|
||||
if not defined PSK (
|
||||
echo [*] No usable PSK.txt found - generating a new pre-shared key...
|
||||
REM 32 random bytes via .NET, hex-encoded to 64 chars.
|
||||
for /f "delims=" %%K in ('powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$b=New-Object byte[] 32; [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b); ($b ^| ForEach-Object { $_.ToString('x2') }) -join ''"') do set "PSK=%%K"
|
||||
|
||||
if not defined PSK (
|
||||
echo [x] Failed to generate a key. Aborting.
|
||||
if not defined CI pause
|
||||
exit /b 1
|
||||
)
|
||||
REM Plain hex, no BOM: the file is read with `set /p` / Get-Content, and a
|
||||
REM leading U+FEFF would corrupt the first byte of the key.
|
||||
>"%PSKFILE%" echo %PSK%
|
||||
echo [+] New key written to "%PSKFILE%"
|
||||
) else (
|
||||
echo [+] Using key from PSK.txt
|
||||
)
|
||||
|
||||
REM --- 3) install -------------------------------------------------------------
|
||||
echo.
|
||||
echo [*] Installing nodeagent ^(node: %COMPUTERNAME%, port %PORT%, input control ON^)...
|
||||
echo.
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%PS1%" -NodeId "%COMPUTERNAME%" -Key "%PSK%" -AllowInput -Port %PORT%
|
||||
set "RC=%ERRORLEVEL%"
|
||||
|
||||
echo.
|
||||
if "%RC%"=="0" (
|
||||
echo [+] Done. The agent now starts automatically at every logon.
|
||||
echo To manage it later, double-click control.cmd in this folder.
|
||||
) else (
|
||||
echo [x] install.ps1 exited with code %RC%.
|
||||
echo If the error mentions a missing file, re-extract the whole package.
|
||||
echo If Windows Firewall or a security suite blocked something, allow it and retry.
|
||||
)
|
||||
echo.
|
||||
REM Keep the window open so the user can read the output. CI sets CI=true,
|
||||
REM where `pause` would hang forever waiting for a keypress.
|
||||
if not defined CI pause
|
||||
exit /b %RC%
|
||||
+11
-1
@@ -2,6 +2,12 @@
|
||||
.SYNOPSIS
|
||||
nodeagent - Windows agent installer.
|
||||
|
||||
⚠️ 本文件必须以 **UTF-8 BOM** 保存(文件头那个看不见的 U+FEFF 就是 BOM)。
|
||||
Windows PowerShell 5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(中文 Windows = GBK)
|
||||
解析,本文件里的中文注释会解成乱码并被误认为引号 → 整份脚本 ParseError
|
||||
(报错位置彼此无关,真正的病灶在靠前处)。真机踩过:2026-10-04。
|
||||
另:第 4 步写 agent.json 时反而必须**无 BOM**(BOM 对 .ps1 必需、对 JSON 有害)。
|
||||
|
||||
.DESCRIPTION
|
||||
Registers the nodeagent agent as a scheduled task (auto-start on logon),
|
||||
opens the inbound firewall port, and prints connection info for the Mac side.
|
||||
@@ -97,7 +103,11 @@ Write-Ok "Node.js v$ver"
|
||||
Write-Ok "Agent entry: $agentJs"
|
||||
|
||||
# 4. Config + pre-shared key
|
||||
$cfgDir = Join-Path $env:USERPROFILE ".nodeagent"
|
||||
# 数据目录必须与 agent 侧一致 —— apps/agent/src/config.ts 的 agentDir() 是
|
||||
# process.env.NODEAGENT_HOME ?? join(homedir(), '.nodeagent')
|
||||
# 若计划任务环境里设了 NODEAGENT_HOME 而这里写死 USERPROFILE\.nodeagent,
|
||||
# 就会「配置写A、读取B」,表现为装完连不上(E_AUTH_FAILED)。
|
||||
$cfgDir = if ($env:NODEAGENT_HOME) { $env:NODEAGENT_HOME } else { Join-Path $env:USERPROFILE ".nodeagent" }
|
||||
$cfgPath = Join-Path $cfgDir "agent.json"
|
||||
New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null
|
||||
|
||||
|
||||
+96
-5
@@ -7,12 +7,20 @@
|
||||
* 产物: <out>/nodeagent-win-x64.zip
|
||||
* ├── node.exe Node.js 运行时(免装 Node.js)
|
||||
* ├── agent.mjs esbuild 打包的被控端(含全部依赖)
|
||||
* ├── install.ps1 一键安装
|
||||
* └── uninstall.ps1 一键卸载
|
||||
* ├── install.cmd 一键安装(双击即用,自提权 + 自动生成 PSK)
|
||||
* ├── control.cmd 控制台菜单(启动/停止/状态/日志/卸载)
|
||||
* ├── install.ps1 底层安装脚本(由 install.cmd 调用)
|
||||
* ├── control.ps1 底层控制脚本(由 control.cmd 调用)
|
||||
* ├── uninstall.ps1 底层卸载脚本
|
||||
* └── PSK.txt 预共享密钥(首次运行时由 install.cmd 生成)
|
||||
*/
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
import { copyFileSync, createWriteStream, existsSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
copyFileSync, createWriteStream, existsSync, mkdirSync,
|
||||
readFileSync, rmSync, writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { get } from 'node:https';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -62,6 +70,18 @@ async function main() {
|
||||
execSync('pnpm -r build', { cwd: root, stdio: 'inherit' });
|
||||
|
||||
log('\n=== ② esbuild 打包被控端 ===');
|
||||
// 单一版本源:根 package.json 的 version + git sha + 构建时间,
|
||||
// 经 esbuild --define 注入,避免「版本号写在多处必然不同步」。
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
||||
const commit = (() => {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root }).toString().trim();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
})();
|
||||
const builtAt = new Date().toISOString();
|
||||
log(` 版本 ${pkg.version} @ ${commit}(${builtAt})`);
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
const bundleOut = join(OUT_DIR, 'agent.mjs');
|
||||
const esbuild = join(root, 'node_modules', '.bin', 'esbuild');
|
||||
@@ -75,6 +95,9 @@ async function main() {
|
||||
'--target=node22',
|
||||
'--format=esm',
|
||||
'--banner:js=import { createRequire } from "node:module"; const require = createRequire(import.meta.url);',
|
||||
`--define:__AGENT_VERSION__='"${pkg.version}"'`,
|
||||
`--define:__BUILD_COMMIT__='"${commit}"'`,
|
||||
`--define:__BUILD_TIME__='"${builtAt}"'`,
|
||||
`--outfile=${bundleOut}`,
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
@@ -94,9 +117,76 @@ async function main() {
|
||||
execSync(`unzip -o -j "${tmpZip}" "*/node.exe" -d "${PKG_DIR}"`, { stdio: 'inherit' });
|
||||
copyFileSync(bundleOut, join(PKG_DIR, 'agent.mjs'));
|
||||
copyFileSync(join(root, 'scripts', 'install.ps1'), join(PKG_DIR, 'install.ps1'));
|
||||
copyFileSync(join(root, 'scripts', 'control.ps1'), join(PKG_DIR, 'control.ps1'));
|
||||
copyFileSync(join(root, 'scripts', 'uninstall.ps1'), join(PKG_DIR, 'uninstall.ps1'));
|
||||
|
||||
// 一键入口:.cmd 必须**纯 ASCII**。CMD 的代码页(中文 Windows = 936/GBK)
|
||||
// 无法可靠往返 UTF-8,中文提示一律交给带 BOM 的 .ps1 去输出。
|
||||
for (const cmd of ['install.cmd', 'control.cmd']) {
|
||||
copyFileSync(join(root, 'scripts', cmd), join(PKG_DIR, cmd));
|
||||
}
|
||||
|
||||
// 防呆 A:产物里的 .ps1 必须带 UTF-8 BOM,否则被控端的 **Windows PowerShell 5.1**
|
||||
// 会按 ANSI/GBK 解析无 BOM 脚本,中文注释破坏字符串边界 → 整份脚本 ParseError。
|
||||
// 真机踩过(2026-10-04):CI 全绿(冒烟用 pwsh/PS7),5.1 真机直接报
|
||||
// 「","后面缺少表达式」。此处宁可在打包阶段失败,也不要把坏包发出去。
|
||||
for (const ps1 of ['install.ps1', 'control.ps1', 'uninstall.ps1']) {
|
||||
const p = join(PKG_DIR, ps1);
|
||||
const head = readFileSync(p).subarray(0, 3);
|
||||
if (!(head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf)) {
|
||||
fail(`${ps1} 缺少 UTF-8 BOM —— Windows PowerShell 5.1 会解析失败。` +
|
||||
`请先给 scripts/${ps1} 加上 BOM 再打包。`);
|
||||
}
|
||||
}
|
||||
log(' .ps1 BOM 检查通过(兼容 Windows PowerShell 5.1)');
|
||||
|
||||
// 防呆 B:.cmd 里出现非 ASCII 字节 = 中文乱码/命令截断的隐患。
|
||||
for (const cmd of ['install.cmd', 'control.cmd']) {
|
||||
const buf = readFileSync(join(PKG_DIR, cmd));
|
||||
const bad = [...buf].findIndex((b) => b > 0x7f);
|
||||
if (bad >= 0) {
|
||||
fail(`${cmd} 含非 ASCII 字节(偏移 ${bad})—— CMD 代码页无法可靠处理,` +
|
||||
`请把该文件改为纯 ASCII,中文提示交给 .ps1。`);
|
||||
}
|
||||
}
|
||||
log(' .cmd 纯 ASCII 检查通过(CMD 代码页安全)');
|
||||
|
||||
// PSK.txt:预置一把随机密钥,让 install.cmd 真正「零输入」。
|
||||
// 用 --psk 传入指定密钥;不传则随机生成 32 字节 hex。
|
||||
// 文件是纯 hex 文本,无需 BOM(install.cmd 用 `set /p` 读)。
|
||||
const pskArg = getArg('--psk', '');
|
||||
const psk = pskArg || randomBytes(32).toString('hex');
|
||||
if (!/^[0-9a-fA-F]{32,128}$/.test(psk)) {
|
||||
fail(`--psk 格式非法(需 32-128 位十六进制): ${psk}`);
|
||||
}
|
||||
writeFileSync(join(PKG_DIR, 'PSK.txt'), `${psk}\n`, 'utf8');
|
||||
log(` PSK.txt 已生成(${psk.length} 位${pskArg ? ',来自 --psk' : ',随机'})`);
|
||||
|
||||
// README:给第一次上手的人看一眼该双击哪个文件
|
||||
writeFileSync(join(PKG_DIR, 'README.txt'), [
|
||||
'nodeagent - Windows 被控端',
|
||||
'==========================',
|
||||
'',
|
||||
'【怎么装】双击 install.cmd',
|
||||
' 1) 会弹一次「用户账户控制」→ 点「是」(必须管理员,装计划任务要用)',
|
||||
' 2) 等它跑完,最后会打印连接信息',
|
||||
' 3) 把那行 nodeagent connect ... 拿到 Mac 上执行',
|
||||
'',
|
||||
'【日常管理】双击 control.cmd',
|
||||
' 1 状态 / 2 启动 / 3 停止 / 4 重启 / 5 日志 / 6 卸载 / 7 彻底卸载',
|
||||
'',
|
||||
'【密钥】PSK.txt 里是预共享密钥(= Mac 端 --key 的值)',
|
||||
' 丢了可以删掉 PSK.txt 重装,会自动生成新的',
|
||||
'',
|
||||
'【注意】',
|
||||
' * 整个文件夹可以固定放在 D:\\nodeagent,但**装完不要移动或改名**',
|
||||
' * agent 每次开机登录后自动启动,无需再双击任何东西',
|
||||
' * 首次运行若被杀软拦截,把本文件夹加入信任区后重试',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
|
||||
const files = execSync(`ls -la "${PKG_DIR}"`, { encoding: 'utf8' });
|
||||
log(files.split('\n').slice(0, 8).join('\n'));
|
||||
log(files.split('\n').slice(0, 12).join('\n'));
|
||||
|
||||
log('\n=== ⑤ 打 zip ===');
|
||||
const zipOut = join(OUT_DIR, 'nodeagent-win-x64.zip');
|
||||
@@ -105,7 +195,8 @@ async function main() {
|
||||
|
||||
const size = execSync(`du -sh "${zipOut}"`, { encoding: 'utf8' }).split('\t')[0];
|
||||
log(`\n✓ 完成: ${zipOut}(${size.trim()})`);
|
||||
log(' 分发方式:解压后右键以管理员运行 install.ps1 即可');
|
||||
log(' 分发方式:解压到固定目录 → 双击 install.cmd(自提权,零命令行知识)');
|
||||
log(' 日后管理:双击 control.cmd');
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 发版脚本:一条命令完成「改版本 → 构建 → 打包 → 生成校验和与清单 → 提交打标签 → 发布 GitHub Release」。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/release.mjs 1.0.0 # 指定版本
|
||||
* node scripts/release.mjs patch|minor|major # 按当前版本自增
|
||||
* node scripts/release.mjs 1.0.0 --dry-run # 只演练:构建+生成资产,不改 git、不发布
|
||||
* node scripts/release.mjs 1.0.0 --no-publish # 提交打标签,但不发 GitHub Release
|
||||
*
|
||||
* 为什么要有它(对齐皇上的要求「每次更新,GitHub 上也要同步资产」):
|
||||
* 手工发版要记住「改几处 package.json、打哪个包、算哪些哈希、传什么资产」——
|
||||
* 必然漏项(尤其是给人用的 zip 与给自更新用的 agent.mjs 是**两个不同的东西**)。
|
||||
* 本脚本把两者都产出,并把 sha256 写进 latest.json,供被控端 `system.agent.update` 直接使用。
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const argv = process.argv.slice(2);
|
||||
const flags = new Set(argv.filter((a) => a.startsWith('--')));
|
||||
const arg = argv.find((a) => !a.startsWith('--'));
|
||||
|
||||
const dryRun = flags.has('--dry-run');
|
||||
const publish = !flags.has('--no-publish');
|
||||
|
||||
const sh = (cmd, args, opts = {}) =>
|
||||
execFileSync(cmd, args, { cwd: root, encoding: 'utf8', ...opts }).toString().trim();
|
||||
|
||||
function fail(msg) {
|
||||
console.error(`✗ ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function bump(cur, kind) {
|
||||
const [a, b, c] = cur.split('.').map((n) => Number.parseInt(n, 10));
|
||||
if ([a, b, c].some((n) => Number.isNaN(n))) fail(`现有版本号无法解析: ${cur}`);
|
||||
if (kind === 'patch') return `${a}.${b}.${c + 1}`;
|
||||
if (kind === 'minor') return `${a}.${b + 1}.0`;
|
||||
if (kind === 'major') return `${a + 1}.0.0`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const pkgPath = join(root, 'package.json');
|
||||
const cur = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
||||
const version = ['patch', 'minor', 'major'].includes(arg) ? bump(cur, arg) : (arg ?? '');
|
||||
if (!version) fail('用法: node scripts/release.mjs <1.2.3|patch|minor|major> [--dry-run] [--no-publish]');
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version)) fail(`版本号格式应为 x.y.z,收到 ${version}`);
|
||||
|
||||
console.log(`=== nodeagent 发版 v${version}(当前 ${cur})${dryRun ? ' [dry-run]' : ''} ===`);
|
||||
|
||||
// ---------- ① 前置检查 ----------
|
||||
if (!dryRun) {
|
||||
// 刻意排除 .github/:其改动需要 workflow 权限才能推送,
|
||||
// 若把它算进「干净检查」会让发版永远卡住(见 docs/VERSIONING.md §6)
|
||||
const dirty = sh('git', ['status', '--porcelain', '--', '.', ':(exclude).github'])
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
if (dirty) fail(`工作区不干净(已忽略 .github/),请先提交:\n${dirty}`);
|
||||
const branch = sh('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
if (branch !== 'main') fail(`当前分支是 ${branch},发版请在 main 上`);
|
||||
sh('git', ['fetch', 'origin', 'main']);
|
||||
if (sh('git', ['rev-parse', 'HEAD']) !== sh('git', ['rev-parse', 'origin/main'])) {
|
||||
fail('本地 main 与远端不一致,请先 pull/push(避免发版后才发现分叉)');
|
||||
}
|
||||
}
|
||||
|
||||
const remote = sh('git', ['remote', 'get-url', 'origin'])
|
||||
.replace(/^git@github\.com:/, 'https://github.com/')
|
||||
.replace(/\.git$/, '');
|
||||
const repoPath = remote.replace(/^https:\/\/github\.com\//, '');
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(repoPath)) fail(`无法从 remote 推断 GitHub 仓库: ${remote}`);
|
||||
|
||||
// ---------- ② 改版本(单一版本源:根 + 各 workspace)----------
|
||||
const pkgFiles = ['package.json'];
|
||||
for (const f of sh('git', ['ls-files', '*package.json']).split('\n').filter(Boolean)) {
|
||||
if (f === 'package.json' || f.includes('node_modules')) continue;
|
||||
pkgFiles.push(f);
|
||||
}
|
||||
if (dryRun) {
|
||||
// dry-run 绝不改文件 —— 演练就该是无副作用的
|
||||
console.log(`(dry-run)将把 ${pkgFiles.length} 个 package.json 的 version 改为 ${version}`);
|
||||
console.log('(dry-run)将执行:构建 → pack:win → pack:mac → 生成资产 → 提交打标签 → gh release create');
|
||||
process.exit(0);
|
||||
}
|
||||
for (const f of pkgFiles) {
|
||||
const p = join(root, f);
|
||||
const j = JSON.parse(readFileSync(p, 'utf8'));
|
||||
if (!j.version) continue;
|
||||
j.version = version;
|
||||
writeFileSync(p, JSON.stringify(j, null, 2) + '\n');
|
||||
}
|
||||
console.log(`✓ 版本号已写入 ${pkgFiles.length} 个 package.json`);
|
||||
|
||||
// ---------- ③ 构建 + 打包 ----------
|
||||
console.log('=== 构建 ===');
|
||||
sh('pnpm', ['-r', 'build'], { stdio: 'inherit' });
|
||||
console.log('=== 打包 ===');
|
||||
sh('pnpm', ['pack:win'], { stdio: 'inherit' });
|
||||
try {
|
||||
sh('pnpm', ['pack:mac'], { stdio: 'inherit' });
|
||||
} catch {
|
||||
console.warn('(pack:mac 跳过:非 macOS 或未安装依赖)');
|
||||
}
|
||||
|
||||
const agentPath = join(root, 'release', 'nodeagent-win-x64', 'agent.mjs');
|
||||
const zipPath = join(root, 'release', 'nodeagent-win-x64.zip');
|
||||
if (!existsSync(agentPath)) fail(`找不到 agent 产物: ${agentPath}`);
|
||||
if (!existsSync(zipPath)) fail(`找不到 zip 产物: ${zipPath}`);
|
||||
|
||||
// ---------- ④ 生成资产(校验和 + latest.json)----------
|
||||
const sha256 = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
|
||||
const distDir = join(root, 'release', 'dist');
|
||||
mkdirSync(distDir, { recursive: true });
|
||||
|
||||
const assets = [
|
||||
{ name: 'agent.mjs', path: agentPath, purpose: '自更新载荷(被控端 system.agent.update 直接使用)' },
|
||||
{ name: 'nodeagent-win-x64.zip', path: zipPath, purpose: '人工安装包(自包含 node.exe,免装 Node.js)' },
|
||||
];
|
||||
for (const a of assets) {
|
||||
copyFileSync(a.path, join(distDir, a.name));
|
||||
a.bytes = readFileSync(a.path).length;
|
||||
a.sha256 = sha256(a.path);
|
||||
}
|
||||
|
||||
const commit = sh('git', ['rev-parse', '--short', 'HEAD']);
|
||||
const builtAt = new Date().toISOString();
|
||||
// 能力数从清单动态取,避免写死后漂移
|
||||
let capabilityCount = 0;
|
||||
try {
|
||||
const mod = await import(join(root, 'packages', 'protocol', 'dist', 'index.js'));
|
||||
capabilityCount = mod.CAPABILITY_MANIFEST?.length ?? 0;
|
||||
} catch {
|
||||
console.warn('(未能从 dist 读取能力数,latest.json 里记为 0)');
|
||||
}
|
||||
const base = `https://github.com/${repoPath}/releases/download/v${version}`;
|
||||
const latest = {
|
||||
version,
|
||||
commit,
|
||||
built_at: builtAt,
|
||||
protocol: '1.0',
|
||||
capabilities: capabilityCount,
|
||||
agent: {
|
||||
url: `${base}/agent.mjs`,
|
||||
sha256: assets[0].sha256,
|
||||
bytes: assets[0].bytes,
|
||||
note: '自更新用:nodeagent update --url <url> --sha256 <sha256>',
|
||||
},
|
||||
zip: { url: `${base}/nodeagent-win-x64.zip`, sha256: assets[1].sha256, bytes: assets[1].bytes },
|
||||
};
|
||||
writeFileSync(join(distDir, 'latest.json'), JSON.stringify(latest, null, 2) + '\n');
|
||||
|
||||
const sums = assets.map((a) => `${a.sha256} ${a.name}`).join('\n') + `\n`;
|
||||
writeFileSync(join(distDir, 'SHA256SUMS'), sums);
|
||||
|
||||
console.log('=== 资产 ===');
|
||||
for (const a of assets) {
|
||||
console.log(` ${a.name.padEnd(26)} ${(a.bytes / 1024 / 1024).toFixed(1)}MB sha256=${a.sha256.slice(0, 12)}…`);
|
||||
}
|
||||
console.log(` latest.json / SHA256SUMS`);
|
||||
|
||||
// ---------- ⑤ 提交 + 标签 + 推送 ----------
|
||||
sh('git', ['add', '-A', '--', '.', ':(exclude).github']);
|
||||
sh('git', ['commit', '-m', `chore(release): v${version}`]);
|
||||
sh('git', ['tag', '-a', `v${version}`, '-m', `nodeagent v${version}`]);
|
||||
sh('git', ['push', 'origin', 'main']);
|
||||
sh('git', ['push', 'origin', `v${version}`]);
|
||||
console.log(`✓ 已提交并推送标签 v${version}`);
|
||||
|
||||
// ---------- ⑥ 发布 GitHub Release ----------
|
||||
if (!publish) {
|
||||
console.log('(--no-publish:已跳过 GitHub Release)');
|
||||
process.exit(0);
|
||||
}
|
||||
const notes = join(distDir, 'RELEASE_NOTES.md');
|
||||
writeFileSync(
|
||||
notes,
|
||||
[
|
||||
`# nodeagent v${version}`,
|
||||
``,
|
||||
`- 构建: \`${commit}\` @ ${builtAt}`,
|
||||
`- 能力: ${latest.capabilities} 项`,
|
||||
``,
|
||||
`## 资产`,
|
||||
...assets.map((a) => `- \`${a.name}\` — ${a.purpose}`),
|
||||
`- \`latest.json\` — 版本清单(含 sha256),供被控端自更新校验`,
|
||||
`- \`SHA256SUMS\` — 校验和`,
|
||||
``,
|
||||
`## 更新被控端(拉取式,只需它能上网)`,
|
||||
'```bash',
|
||||
`nodeagent --node <设备名> update \\`,
|
||||
` --url ${latest.agent.url} \\`,
|
||||
` --sha256 ${latest.agent.sha256}`,
|
||||
'```',
|
||||
``,
|
||||
`## 人工安装(首次)`,
|
||||
`\`\`\`powershell`,
|
||||
`Expand-Archive nodeagent-win-x64.zip C:\\nodeagent -Force`,
|
||||
`# 先把 C:\\nodeagent 加入杀软信任区(主流杀软/杀软)`,
|
||||
`powershell -ExecutionPolicy Bypass -File C:\\nodeagent\\install.ps1 -NodeId win -AllowInput`,
|
||||
'```',
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
|
||||
sh('gh', [
|
||||
'release',
|
||||
'create',
|
||||
`v${version}`,
|
||||
'--title',
|
||||
`nodeagent v${version}`,
|
||||
'--notes-file',
|
||||
notes,
|
||||
join(distDir, 'agent.mjs'),
|
||||
join(distDir, 'nodeagent-win-x64.zip'),
|
||||
join(distDir, 'latest.json'),
|
||||
join(distDir, 'SHA256SUMS'),
|
||||
]);
|
||||
console.log(`✓ GitHub Release v${version} 已发布`);
|
||||
console.log('');
|
||||
console.log('被控端更新命令:');
|
||||
console.log(` nodeagent --node <设备名> update --url ${latest.agent.url} --sha256 ${latest.agent.sha256}`);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { capabilityDiff } from '../../packages/protocol/dist/index.js';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
@@ -229,3 +230,27 @@ test('applyDefaults:新能力默认值正确填充', () => {
|
||||
assert.equal(exec.async, false);
|
||||
assert.equal(exec.timeout_ms, 30000);
|
||||
});
|
||||
|
||||
// ---------- v20 兼容性:能力差集 ----------
|
||||
test('capabilityDiff:识别「远端较旧」与「本控制端较旧」', () => {
|
||||
const local = ['system.info', 'system.audio.get', 'system.agent.update'];
|
||||
const remoteOld = ['system.info'];
|
||||
const d1 = capabilityDiff(local, remoteOld);
|
||||
assert.deepEqual(d1.missingOnRemote, ['system.audio.get', 'system.agent.update'], '远端缺的新能力');
|
||||
assert.deepEqual(d1.unknownLocally, []);
|
||||
|
||||
const remoteNew = ['system.info', 'system.audio.get', 'system.agent.update', 'future.thing'];
|
||||
const d2 = capabilityDiff(local, remoteNew);
|
||||
assert.deepEqual(d2.missingOnRemote, [], '远端不缺');
|
||||
assert.deepEqual(d2.unknownLocally, ['future.thing'], '本控制端不认识的新能力');
|
||||
|
||||
const same = capabilityDiff(local, local);
|
||||
assert.deepEqual(same, { missingOnRemote: [], unknownLocally: [] }, '完全一致应无差异');
|
||||
});
|
||||
|
||||
test('capabilityDiff:真实清单与自身比对应无差异(防手滑改坏清单)', () => {
|
||||
const names = CAPABILITY_MANIFEST.map((c) => c.name);
|
||||
const d = capabilityDiff(names, names);
|
||||
assert.equal(d.missingOnRemote.length, 0);
|
||||
assert.equal(d.unknownLocally.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 编码守卫:仓库里的手写 .ps1 **必须带 UTF-8 BOM**。
|
||||
*
|
||||
* ⚠️ 背景(真机踩过,2026-10-04):`install.ps1` 一度是无 BOM 的 UTF-8,
|
||||
* 在被控端用 **Windows PowerShell 5.1** 执行时直接解析失败:
|
||||
* 所在位置 D:\Nodeagent\install.ps1:31 字符: 31
|
||||
* + [string]$ProjectRoot = "",
|
||||
* “,”后面缺少表达式。
|
||||
* 原因:5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(中文 Windows = GBK)解码,
|
||||
* 脚本里的中文注释/字符串被解成乱码,其中某些字节序列被误认成引号,
|
||||
* 于是整份脚本的字符串边界全乱 —— 报出的一串语法错误彼此无关,
|
||||
* 真正的病灶在文件靠前的位置。
|
||||
*
|
||||
* **CI 之前全绿是因为冒烟步骤用 `shell: pwsh`(PowerShell 7,认无 BOM UTF-8)**,
|
||||
* 只有真机的 5.1 会炸。故此用例 + CI 的 5.1 解析冒烟一起守。
|
||||
*
|
||||
* 对照:运行时**动态生成**的 .ps1 走 `apps/agent/src/capabilities/network.ts`
|
||||
* 的 `withBom()`,已有同样约束。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const ROOT = new URL('../../', import.meta.url).pathname;
|
||||
const PS_DIR = join(ROOT, 'scripts');
|
||||
|
||||
/** 收集仓库里所有需要分发的 .ps1(含子目录) */
|
||||
function collectPs1(dir) {
|
||||
return collectByExt(dir, '.ps1');
|
||||
}
|
||||
|
||||
/** 收集仓库里所有 .cmd(双击入口) */
|
||||
function collectCmd(dir) {
|
||||
return collectByExt(dir, '.cmd');
|
||||
}
|
||||
|
||||
function collectByExt(dir, ext) {
|
||||
const out = [];
|
||||
for (const name of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (name.name === 'node_modules' || name.name.startsWith('.')) continue;
|
||||
const full = join(dir, name.name);
|
||||
if (name.isDirectory()) out.push(...collectByExt(full, ext));
|
||||
else if (name.name.endsWith(ext)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const PS1_FILES = collectPs1(PS_DIR);
|
||||
|
||||
test('仓库中至少存在 3 个待分发的 .ps1(防止 glob 失效导致守卫空转)', () => {
|
||||
assert.ok(
|
||||
PS1_FILES.length >= 3,
|
||||
`只找到 ${PS1_FILES.length} 个 .ps1,守卫可能已失效(目录:${PS_DIR})`,
|
||||
);
|
||||
});
|
||||
|
||||
for (const file of PS1_FILES) {
|
||||
const rel = file.slice(ROOT.length);
|
||||
|
||||
test(`${rel}:以 UTF-8 BOM 开头(PowerShell 5.1 兼容底线)`, () => {
|
||||
const buf = readFileSync(file);
|
||||
const hasBom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
|
||||
assert.ok(
|
||||
hasBom,
|
||||
`${rel} 缺少 UTF-8 BOM。5.1 会按 ANSI/GBK 解析无 BOM 脚本,` +
|
||||
`中文注释将破坏字符串边界并导致 ParseError。` +
|
||||
`修法:把 U+FEFF 写进文件头(BOM 对 .ps1 是必需,对 JSON 则相反)。`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${rel}:内容是合法 UTF-8(无 GBK 混入的二次编码痕迹)`, () => {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
// U+FFFD = 解码失败替换符;出现即说明混入过非 UTF-8 字节
|
||||
assert.ok(!text.includes('\uFFFD'), `${rel} 含非法 UTF-8 字节`);
|
||||
// 去掉 BOM 再校验,避免把 BOM 当成正文
|
||||
assert.ok(text.replace(/^\uFEFF/, '').length > 0, `${rel} 去掉 BOM 后为空文件`);
|
||||
});
|
||||
}
|
||||
|
||||
test('install.ps1:关键参数与提示文案完好(防打包时被截断/改写)', () => {
|
||||
const text = readFileSync(join(PS_DIR, 'install.ps1'), 'utf8');
|
||||
for (const token of [
|
||||
'param(',
|
||||
'$AllowInput',
|
||||
'New-ScheduledTask',
|
||||
'New-NetFirewallRule',
|
||||
'UTF8Encoding($false)', // 写 agent.json 必须无 BOM —— 与 .ps1 自身要 BOM 恰好相反
|
||||
]) {
|
||||
assert.ok(text.includes(token), `install.ps1 丢失关键片段:${token}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── .cmd 侧守卫 ───────────────────────────────────────────────────────────────
|
||||
// 编码方向与 .ps1 **相反**:.cmd 必须纯 ASCII。
|
||||
// CMD.exe 的代码页(中文 Windows = 936/GBK)无法可靠往返 UTF-8,
|
||||
// .cmd 里的中文会变乱码,甚至把后续命令截断(真机 2026-10-04 的教训同源)。
|
||||
// 中文提示一律交给带 BOM 的 .ps1 输出。
|
||||
|
||||
const CMD_FILES = collectCmd(PS_DIR);
|
||||
|
||||
test('仓库中至少存在 2 个 .cmd(防止 glob 失效导致守卫空转)', () => {
|
||||
assert.ok(CMD_FILES.length >= 2, `只找到 ${CMD_FILES.length} 个 .cmd,守卫可能已失效`);
|
||||
});
|
||||
|
||||
for (const file of CMD_FILES) {
|
||||
const rel = file.slice(ROOT.length);
|
||||
test(`${rel}:纯 ASCII(CMD 代码页安全)`, () => {
|
||||
const buf = readFileSync(file);
|
||||
const idx = [...buf].findIndex((b) => b > 0x7f);
|
||||
assert.equal(
|
||||
idx, -1,
|
||||
`${rel} 第 ${idx} 字节起含非 ASCII —— CMD 代码页(936/GBK)无法可靠处理,` +
|
||||
`中文提示请交给 .ps1(它带 BOM,5.1 能正确读)。`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${rel}:行尾是 CRLF(CMD 在部分环境下对 LF 行为不一致)`, () => {
|
||||
const text = readFileSync(file, 'latin1');
|
||||
// 允许文件内完全没有换行(极短脚本),但只要有换行就必须是 CRLF
|
||||
const lfOnly = text.replace(/\r\n/g, '').includes('\n');
|
||||
assert.equal(lfOnly, false, `${rel} 存在裸 LF 行尾,请统一为 CRLF`);
|
||||
});
|
||||
}
|
||||
|
||||
test('install.cmd:自提权 + 读 PSK.txt + 调 install.ps1 三段齐全', () => {
|
||||
const text = readFileSync(join(PS_DIR, 'install.cmd'), 'latin1');
|
||||
for (const token of [
|
||||
'net session', // 探测是否已管理员
|
||||
'Start-Process', // 自提权(UAC)
|
||||
'PSK.txt', // 读包内密钥
|
||||
'install.ps1', // 调底层安装
|
||||
'-ExecutionPolicy Bypass',
|
||||
'-AllowInput',
|
||||
]) {
|
||||
assert.ok(text.includes(token), `install.cmd 丢失关键片段:${token}`);
|
||||
}
|
||||
// 提权后必须 exit,不能继续用非管理员权限往下跑
|
||||
assert.ok(/Verb RunAs[\s\S]{0,200}exit \/b/.test(text), '提权后应立即退出本进程');
|
||||
});
|
||||
|
||||
test('control.cmd:菜单项与 control.ps1 的 ValidateSet 一一对应', () => {
|
||||
const cmd = readFileSync(join(PS_DIR, 'control.cmd'), 'latin1');
|
||||
const ps1 = readFileSync(join(PS_DIR, 'control.ps1'), 'utf8');
|
||||
|
||||
// 从 control.ps1 的 ValidateSet 里取出合法 action
|
||||
const m = ps1.match(/ValidateSet\(([^)]*)\)/);
|
||||
assert.ok(m, 'control.ps1 必须用 ValidateSet 约束 -Action');
|
||||
const actions = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).filter(Boolean);
|
||||
|
||||
// control.cmd 里 call :RUN <action> 的每个 action 都必须在 ValidateSet 内
|
||||
const called = [...cmd.matchAll(/call :RUN (\S+)/g)].map((x) => x[1]);
|
||||
assert.ok(called.length >= 5, `control.cmd 只发现 ${called.length} 个菜单项,可能正则失配`);
|
||||
for (const a of called) {
|
||||
assert.ok(actions.includes(a), `control.cmd 调用了 control.ps1 不支持的 action: ${a}`);
|
||||
}
|
||||
// 反向:ps1 支持的 action 都应在菜单里可达
|
||||
for (const a of actions) {
|
||||
assert.ok(called.includes(a), `control.ps1 支持的 ${a} 在 control.cmd 菜单里没有入口`);
|
||||
}
|
||||
});
|
||||
|
||||
test('control.ps1:状态检查覆盖任务/进程/端口/配置四项', () => {
|
||||
const text = readFileSync(join(PS_DIR, 'control.ps1'), 'utf8');
|
||||
for (const token of [
|
||||
'Get-ScheduledTask',
|
||||
'Get-Process',
|
||||
'Get-NetTCPConnection',
|
||||
'agent.json',
|
||||
]) {
|
||||
assert.ok(text.includes(token), `control.ps1 状态检查缺少:${token}`);
|
||||
}
|
||||
// 启动后必须轮询端口,而不是盲等固定秒数
|
||||
assert.ok(
|
||||
/for \(\$i = 0; \$i -lt 15/.test(text),
|
||||
'启动后应轮询端口真正监听(最多 15s),而非盲等',
|
||||
);
|
||||
});
|
||||
|
||||
test('数据目录:install.ps1 与 control.ps1 都认 NODEAGENT_HOME(须与 agent 侧一致)', () => {
|
||||
// apps/agent/src/config.ts: agentDir() = NODEAGENT_HOME ?? ~/.nodeagent
|
||||
// 若 ps1 写死 USERPROFILE 而计划任务环境设了 NODEAGENT_HOME,
|
||||
// 就会「配置写 A、读取 B」→ 装完连不上(E_AUTH_FAILED)。
|
||||
for (const f of ['install.ps1', 'control.ps1']) {
|
||||
const text = readFileSync(join(PS_DIR, f), 'utf8');
|
||||
assert.ok(
|
||||
text.includes('NODEAGENT_HOME'),
|
||||
`${f} 未处理 NODEAGENT_HOME —— 必须与 apps/agent/src/config.ts 的 agentDir() 保持一致`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('控制台脚本:pause 均被 CI 守卫(否则 CI 会永久挂起)', () => {
|
||||
for (const f of ['install.cmd', 'control.cmd']) {
|
||||
const text = readFileSync(join(PS_DIR, f), 'latin1');
|
||||
const bare = text.split('\r\n').filter((l) => /^\s*pause\s*$/i.test(l));
|
||||
assert.equal(
|
||||
bare.length, 0,
|
||||
`${f} 有 ${bare.length} 处裸 pause,CI(CI=true)会卡死。统一用 "if not defined CI pause"。`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('install.cmd:管理员探测用 fltmc 而非 net session(不依赖 Server 服务)', () => {
|
||||
const text = readFileSync(join(PS_DIR, 'install.cmd'), 'latin1');
|
||||
// net session 依赖 Server 服务,部分 Windows 版本该服务停用 → 已提权却被判为未提权 → 死循环 UAC
|
||||
assert.ok(/^\s*fltmc\s*>nul/m.test(text), 'install.cmd 应用 fltmc 探测管理员权限');
|
||||
assert.ok(
|
||||
!/^\s*net\s+session\s*>nul/m.test(text),
|
||||
'install.cmd 不应再用 net session 探测(依赖 Server 服务,可能误判)',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user