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:
jkooe committed 2026-10-04 23:54:51 +08:00
1 parent aa72241080
commit cedc46d090
20 files changed
+1262 -17

No files matched your search

+83
View File
@@ -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
+237
View File
@@ -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 }
}
+110
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+225
View File
@@ -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}`);