Przeglądaj źródła

feat: stage npm server updates before explicit restart

Samien 1 miesiąc temu
rodzic
commit
2bb3293514

+ 3 - 1
README.md

@@ -100,7 +100,9 @@ iOS 统一连接 Web 地址,通过设置中的手机登录二维码或账号
 
 ## 数据与升级
 
-Web 默认从 [GitHub Releases](https://github.com/Lsamien/yaoyao/releases) 检查稳定版本;桌面菜单“检查 App 更新…”可下载并校验 DMG 后手动安装。旧官方发布源会自动归一化为 GitHub,自定义仓库保留。详见 [GitHub 版本升级](docs/github-updates.md)。
+npm 安装的 macOS 服务支持在「更新与回滚」中下载 npm 新版本,准备完成后点击「重启服务器」生效,保留上一版本用于回滚。参见 [npm 安装说明](docs/npm-install.md)。
+
+源码 Web 默认从 [GitHub Releases](https://github.com/Lsamien/yaoyao/releases) 检查稳定版本;桌面菜单“检查 App 更新…”可下载并校验 DMG 后手动安装。旧官方发布源会自动归一化为 GitHub,自定义仓库保留。详见 [GitHub 版本升级](docs/github-updates.md)。
 
 发布版本由 `release.json` 记录。macOS 本机服务支持在系统设置中升级 Web 或回滚到上一个版本;其他源码部署更新代码、重新构建并重启服务,Docker 部署通过拉取远程镜像(或自行构建)并重建容器更新。升级前备份 Web 数据目录,客户端版本要求见对应发布说明。
 

+ 66 - 1
bin/hermes-yaoyao-updater.mjs

@@ -1,16 +1,21 @@
 #!/usr/bin/env node
 
 import { execFileSync } from 'node:child_process'
+import { createHash } from 'node:crypto'
 import {
+  closeSync,
   existsSync,
   lstatSync,
   mkdirSync,
+  openSync,
   readFileSync,
   readlinkSync,
   renameSync,
   rmSync,
   symlinkSync,
   writeFileSync,
+  writeSync,
+  realpathSync,
 } from 'node:fs'
 import { dirname, join, relative, resolve, sep } from 'node:path'
 import { fileURLToPath } from 'node:url'
@@ -18,6 +23,7 @@ import { fileURLToPath } from 'node:url'
 const label = 'com.samien.hermes-yaoyao'
 import { LaunchAgentService, updateMutex, recoverTransition, transitionService } from './lib/service-update.mjs'
 import { assertRollbackCompatible } from './lib/service-data.mjs'
+import { NPM_PACKAGE, npmRuntimeDigest, parseNpmRelease } from './lib/npm-release.mjs'
 
 const uid = process.getuid?.() ?? 501
 const domain = `gui/${uid}`
@@ -252,6 +258,61 @@ function stageRelease(job, jobPath) {
   return { finalRoot, commit }
 }
 
+export async function stageNpmRelease(job, jobPath, { fetchImpl = fetch, install = (archive, prefix) => run('npm', [
+  'install', '--prefix', prefix, '--no-save', '--package-lock=false', '--omit=dev', '--no-audit', '--no-fund', '--engine-strict', '--allow-file=all', archive,
+], { cwd: prefix, timeout: 600000 }) } = {}) {
+  const { plan, target, id } = job
+  validateManifest(target)
+  const npm = parseNpmRelease({ name: NPM_PACKAGE, version: plan.npm?.version, dist: plan.npm })
+  if (npm.version !== target.releaseVersion) fail('npm 发布信息与目标版本不一致')
+  const releases = join(plan.releaseRoot, 'releases'), staging = join(releases, `.staging-${id}`)
+  mkdirSync(releases, { recursive: true, mode: 0o700 })
+  removeInside(staging, releases); mkdirSync(staging, { mode: 0o700 })
+  const archive = join(staging, 'release.tgz')
+  try {
+    updateJob(jobPath, { state: 'downloading', message: `正在下载服务器 ${npm.version}…`, received: 0, total: 0 })
+    const response = await fetchImpl(npm.tarball, { signal: AbortSignal.timeout(600000) })
+    if (!response.ok || !response.body) fail(`npm 安装包下载失败:HTTP ${response.status}`)
+    const total = Number(response.headers.get('content-length')) || 0, limit = 512 * 1024 * 1024
+    if (total > limit) fail('npm 安装包超过下载大小限制')
+    const hash = createHash('sha512'), descriptor = openSync(archive, 'wx', 0o600)
+    let received = 0, lastProgress = 0
+    try {
+      for await (const chunk of response.body) {
+        received += chunk.length
+        if (received > limit) fail('npm 安装包超过下载大小限制')
+        for (let offset = 0; offset < chunk.length;) offset += writeSync(descriptor, chunk, offset, chunk.length - offset)
+        hash.update(chunk)
+        if (Date.now() - lastProgress > 250) {
+          updateJob(jobPath, { received, total, message: total > 0 ? `正在下载服务器更新 · ${Math.min(100, Math.floor(received / total * 100))}%` : '正在下载服务器更新…' })
+          lastProgress = Date.now()
+        }
+      }
+    } finally { closeSync(descriptor) }
+    if (`sha512-${hash.digest('base64')}` !== npm.integrity) fail('npm 安装包完整性校验失败,原服务保持运行')
+    updateJob(jobPath, { state: 'building', message: '正在安装依赖并准备新版本…', received, total })
+    await install(archive, staging)
+    const packageRoot = join(staging, 'node_modules', NPM_PACKAGE)
+    const digest = npmRuntimeDigest(packageRoot, npm.version)
+    rmSync(archive)
+    const final = join(releases, `${npm.version}-npm-${id}`)
+    renameSync(staging, final)
+    const prepared = { root: join(final, 'node_modules', NPM_PACKAGE), digest }
+    updateJob(jobPath, { state: 'prepared', message: `服务器 ${npm.version} 已准备完成,点击“重启服务器”生效`, plan: { ...plan, prepared } })
+    return prepared
+  } catch (error) {
+    removeInside(staging, releases)
+    throw error
+  }
+}
+
+export function verifyPreparedRelease(plan, target) {
+  const root = realpathSync(plan.prepared.root), releases = realpathSync(join(plan.releaseRoot, 'releases'))
+  if (!root.startsWith(`${releases}${sep}`) || npmRuntimeDigest(root, target.releaseVersion) !== plan.prepared.digest)
+    fail('已准备更新的文件已改变,请重新下载')
+  return root
+}
+
 function serviceOptions(jobPath, job) {
   const home = job.plan.home || resolve(dirname(jobPath), '..')
   const options = { home, releaseRoot: job.plan.releaseRoot, port: job.plan.port || Number(process.env.HERMES_YAOYAO_PORT || 15300) }
@@ -261,7 +322,11 @@ function serviceOptions(jobPath, job) {
 async function runUpdate(jobPath, job) {
   const options = serviceOptions(jobPath, job)
   await recoverTransition(options)
-  const { finalRoot } = stageRelease(job, jobPath)
+  if (job.plan.npm && !job.plan.prepared) {
+    await stageNpmRelease(job, jobPath)
+    return
+  }
+  const finalRoot = job.plan.prepared ? verifyPreparedRelease(job.plan, job.target) : stageRelease(job, jobPath).finalRoot
   updateJob(jobPath, { state: 'installing', message: '正在准备切换 Web 服务' })
   await transitionService({ ...options, finalRoot })
   updateJob(jobPath, { state: 'succeeded', message: `已升级 Web ${job.target.webVersion}` })

+ 7 - 0
bin/lib/npm-release.d.mts

@@ -0,0 +1,7 @@
+export interface NpmRelease { version: string; integrity: string; tarball: string }
+export const NPM_PACKAGE: string
+export const NPM_RELEASE_SOURCE: string
+export function isNpmInstallation(root: string): boolean
+export function parseNpmRelease(value: unknown): NpmRelease
+export function inspectNpmRelease(cwd: string): Promise<NpmRelease>
+export function npmRuntimeDigest(root: string, version: string): string

+ 66 - 0
bin/lib/npm-release.mjs

@@ -0,0 +1,66 @@
+import { execFile } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'
+import { join, sep } from 'node:path'
+
+export const NPM_PACKAGE = '@lsamien/yaoyao'
+export const NPM_RELEASE_SOURCE = `npm:${NPM_PACKAGE}`
+const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
+
+export function isNpmInstallation(root) {
+  if (!root.includes(`${sep}node_modules${sep}`) || existsSync(join(root, '.git'))) return false
+  try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name === NPM_PACKAGE }
+  catch { return false }
+}
+
+export function parseNpmRelease(value) {
+  if (!value || value.name !== NPM_PACKAGE || typeof value.version !== 'string' || !versionPattern.test(value.version))
+    throw new Error('npm 发布包名称或版本无效')
+  const { integrity, tarball } = value.dist ?? {}
+  if (typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity))
+    throw new Error('npm 发布包缺少 SHA-512 完整性信息')
+  let url
+  try { url = new URL(tarball) } catch { throw new Error('npm 下载地址无效') }
+  if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
+    throw new Error('npm 下载地址无效')
+  return { version: value.version, integrity, tarball: url.href }
+}
+
+export function inspectNpmRelease(cwd) {
+  return new Promise((resolve, reject) => {
+    execFile('npm', ['view', `${NPM_PACKAGE}@latest`, '--json'], {
+      cwd, encoding: 'utf8', timeout: 60000, maxBuffer: 4 * 1024 * 1024,
+    }, (error, stdout) => {
+      if (error) { reject(new Error('无法读取 npm 最新版本,请检查仓库连接和 npm 配置')); return }
+      try { resolve(parseNpmRelease(JSON.parse(stdout))) } catch (cause) { reject(cause) }
+    })
+  })
+}
+
+/** Hash the published application's files. npm installs dependencies separately;
+ * these files must remain identical between preparation and activation. */
+export function npmRuntimeDigest(root, version) {
+  const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
+  const release = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8'))
+  const build = JSON.parse(readFileSync(join(root, 'build-info.json'), 'utf8'))
+  if (packageJson.name !== NPM_PACKAGE || packageJson.version !== version
+    || release.schemaVersion !== 1 || release.releaseVersion !== version || release.webVersion !== version || release.gitTag !== `v${version}`)
+    throw new Error('npm 包与目标发布版本不一致')
+  if (!/^[a-f0-9]{40,64}$/.test(build.commit) || !Number.isSafeInteger(build.buildNumber))
+    throw new Error('npm 包缺少有效的构建信息')
+  for (const file of ['dist/index.html', 'dist-server/server/index.js', 'bin/hermes-yaoyao.mjs', 'bin/hermes-yaoyao-updater.mjs']) {
+    if (!lstatSync(join(root, file)).isFile()) throw new Error(`npm 包缺少服务文件:${file}`)
+  }
+  const hash = createHash('sha256')
+  function visit(directory, prefix = '') {
+    for (const name of readdirSync(directory).sort()) {
+      if (name === 'node_modules') continue
+      const path = join(directory, name), relative = `${prefix}${name}`, stat = lstatSync(path)
+      if (stat.isDirectory()) visit(path, `${relative}/`)
+      else if (stat.isFile()) hash.update(relative).update('\0').update(createHash('sha256').update(readFileSync(path)).digest())
+      else throw new Error('npm 服务文件包含不支持的链接或文件')
+    }
+  }
+  visit(root)
+  return hash.digest('hex')
+}

+ 1 - 1
docs/github-updates.md

@@ -12,7 +12,7 @@ macOS 独立 Web 继续使用现有升级事务:构建新版本、等待任务
 
 ## macOS App
 
-App 更新直接显示在侧栏和账号菜单中的更新入口,不再打开独立窗口。发现新版本后显示“可更新”标识,点击后在同一位置显示圆形下载进度,准备完成后变为“重新启动”。“夭夭 → 检查 App 更新…”也在当前窗口检查;尚未连接服务器时,状态显示在启动页内。连接旧版服务器时,客户端会补上原地更新入口,无需先升级服务器。客户端与服务器模式均可使用,不依赖账号的管理员权限、本机 Web 或 Hermes。此入口更新当前电脑的 App;远程服务器升级仍由服务器管理员管理。更新直接连接官方 GitHub,不受 Web 自定义源设置影响。
+App 更新直接显示在小铃铛旁边的更新图标,没有更新时隐藏,不再打开独立窗口。发现新版本后显示“可更新”标识,点击后在同一位置显示圆形下载进度,准备完成后变为“重新启动”。“夭夭 → 检查 App 更新…”也在当前窗口检查;尚未连接服务器时,状态显示在启动页内。连接旧版服务器时,客户端会补上原地更新入口,无需先升级服务器。客户端与服务器模式均可使用,不依赖账号的管理员权限、本机 Web 或 Hermes。此入口更新当前电脑的 App;远程服务器升级仍由服务器管理员管理。更新直接连接官方 GitHub,不受 Web 自定义源设置影响。
 
 正式安装的 App 使用 `electron-updater`,从 `app-update.yml` 配置的官方源读取稳定版本的 `latest-mac.yml`,下载当前架构的 ZIP 并验证 SHA-512,再交由 Squirrel.Mac 检查签名、准备安装。启动约 15 秒后检查一次,随后每小时检查;下载由用户点击开始。进入原生准备阶段后不可取消。准备成功后提供“重新启动”,由用户点击后先关闭 App 的设备通道和子进程,再安装和重新启动;独立 Web 后台服务不随客户端更新而停止。
 

+ 20 - 6
docs/npm-install.md

@@ -48,15 +48,29 @@ yaoyao --version          # 查看已安装的 npm 包版本
 
 LaunchAgent 安装时的环境会写入 plist;修改环境变量配置后,执行 `yaoyao service install` 重新写入配置并启动服务。`yaoyao service restart` 使用已保存的配置重启。
 
-## 升级
+## 从界面升级
+
+macOS 通过 `yaoyao service install` 注册的独立服务可进入「我的设置 → 更新与回滚」升级:
+
+1. 检查 npm 仓库的新版本,点击「下载更新」。
+2. 安装包通过 SHA-512 校验后,安装到独立版本目录并准备依赖;当前服务继续运行。
+3. 显示「重启服务器」后,由管理员点击生效。后台进程等待任务结束、备份数据、切换版本并验证服务;启动失败会恢复旧版本。
+4. 更新成功后页面自动刷新。旧程序目录保留,可以在数据库结构兼容时回滚上一版本;手动回滚保留更新后写入的数据。
+
+准备状态保存在服务器,即使关闭页面也不会自动重启,重新打开设置仍可继续。已准备更新的重启不依赖 npm 仓库在线。远程升级默认关闭,需要显式设置 `HERMES_YAOYAO_ALLOW_REMOTE_UPDATE=1`;接口仍要求管理员登录、Origin 和 CSRF 校验。
+
+程序保存在 `~/.local/share/hermes-yaoyao/releases/`,`current` 指向正在使用的包目录。npm 全局安装保留为启动器,`yaoyao --version` 显示全局包版本;界面的「当前 Web」显示实际服务版本。`yaoyao service restart` 使用已保存的服务入口,不会切回全局旧包。
+
+首次使用需要先安装包含界面更新功能的 npm 版本。旧版本可用命令升级:
 
 ```sh
-npm update -g @lsamien/yaoyao
-yaoyao service restart
-yaoyao --version
+npm install -g @lsamien/yaoyao@latest
+yaoyao service install
 ```
 
-Web 界面中的「系统更新」入口面向源码/发布包安装;npm 安装的服务请直接用上述 npm 命令升级,避免两套版本并存。
+已经迁移到版本目录的服务,重复执行全局包的 `service install` 会重新指定服务入口;日常重启使用 `service restart`,后续升级使用界面。
+
+默认 npm 安装自动使用 `npm:@lsamien/yaoyao` 发布源,检查遵循 npm 仓库配置;显式自定义的 Git 仓库继续使用 Git 更新流程。也可设置 `HERMES_YAOYAO_RELEASE_SOURCE=npm:@lsamien/yaoyao`。
 
 ## 与其他安装方式的关系
 
@@ -67,4 +81,4 @@ Web 界面中的「系统更新」入口面向源码/发布包安装;npm 安
 ## 已知限制
 
 - Linux 的 `service install`(systemd user unit)尚未实现,当前 CLI 在 Linux 上会提示不支持;临时做法:手动运行 `npm root -g` 定位包目录,用进程管理器守护 `node dist-server/server/index.js`(工作目录设为包根目录)。
-- 通过 npm 安装的服务端不支持在 Web 界面内自更新(版本由 npm 管理)。
+- Linux 的界面内切换与重启仍需部署管理器支持;当前 npm 界面升级适用于 macOS LaunchAgent 服务。

+ 77 - 0
scripts/verify-npm-update.mjs

@@ -0,0 +1,77 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { execFileSync } from 'node:child_process'
+import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createServer } from 'node:http'
+import { createHash, randomUUID } from 'node:crypto'
+import { stageNpmRelease, verifyPreparedRelease } from '../bin/hermes-yaoyao-updater.mjs'
+import { currentRelease, LaunchAgentService, localRequest, switchRelease, transitionService } from '../bin/lib/service-update.mjs'
+import { writeNpmFixture } from '../tests/fixtures/npm-release.mjs'
+
+test('real npm archives prepare alongside a live service; explicit switching and failed-start recovery preserve data', { skip: process.platform !== 'darwin', timeout: 90000 }, async () => {
+  const root = realpathSync(mkdtempSync(join(tmpdir(), 'yaoyao-npm-live-'))), home = join(root, 'data'), releaseRoot = join(root, 'programs')
+  mkdirSync(home); mkdirSync(join(home, 'updates')); mkdirSync(releaseRoot)
+  let archive
+  const registry = createServer((req, res) => {
+    if (req.url !== '/release.tgz') { res.statusCode = 404; res.end(); return }
+    res.setHeader('Content-Length', archive.length); res.end(archive)
+  })
+  await new Promise(done => registry.listen(0, '127.0.0.1', done))
+  const probe = createServer(); await new Promise(done => probe.listen(0, '127.0.0.1', done))
+  const port = probe.address().port; await new Promise(done => probe.close(done))
+  const label = `cn.samien.yaoyao.npm-test.${randomUUID()}`
+  class FixtureService extends LaunchAgentService {
+    verify(expected) { return super.verify(expected, expected?.version === '0.3.1' ? 1500 : 10000) }
+  }
+  const driver = new FixtureService({ home, releaseRoot, port, label, plistPath: join(home, 'updates/service.plist') })
+  const old = join(root, 'old'); writeNpmFixture(old, '0.2.0')
+  function packageJob(version, broken = false) {
+    const source = join(root, `package-${version}`), target = writeNpmFixture(source, version, broken)
+    const [packed] = JSON.parse(execFileSync('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', root], { cwd: source, encoding: 'utf8' }))
+    archive = readFileSync(join(root, packed.filename))
+    const npm = { version, integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}`, tarball: `http://127.0.0.1:${registry.address().port}/release.tgz` }
+    const id = randomUUID(), jobPath = join(home, 'updates', `${id}.json`)
+    const job = { id, operation: 'update', state: 'queued', target, plan: { home, releaseRoot, port, source: 'npm:@lsamien/yaoyao', npm, target } }
+    writeFileSync(jobPath, JSON.stringify(job))
+    return { job, jobPath }
+  }
+  try {
+    switchRelease(releaseRoot, old); await driver.start(old); await driver.verify()
+    const initialPID = driver.pid()
+    writeFileSync(join(home, 'user.txt'), 'original data')
+    const first = packageJob('0.3.0')
+    const prepared = await stageNpmRelease(first.job, first.jobPath)
+    assert.equal(JSON.parse(readFileSync(first.jobPath)).state, 'prepared')
+    assert.equal(driver.pid(), initialPID, 'preparation must leave the old service running')
+    assert.equal(currentRelease(releaseRoot), old)
+    assert.equal((await localRequest(`http://127.0.0.1:${port}/api/status`)).body.version, '0.2.0')
+    const finalRoot = verifyPreparedRelease({ ...first.job.plan, prepared }, first.job.target)
+    await transitionService({ home, releaseRoot, driver, finalRoot })
+    assert.notEqual(driver.pid(), initialPID)
+    assert.equal(currentRelease(releaseRoot), finalRoot)
+    assert.equal((await localRequest(`http://127.0.0.1:${port}/api/status`)).body.version, '0.3.0')
+    assert.equal(readFileSync(join(home, 'user.txt'), 'utf8'), 'original data')
+    assert.ok(readFileSync(join(old, 'release.json')), 'retain the old release')
+
+    const second = packageJob('0.3.1', true), healthyPID = driver.pid()
+    const bad = await stageNpmRelease(second.job, second.jobPath)
+    assert.equal(driver.pid(), healthyPID)
+    await assert.rejects(transitionService({ home, releaseRoot, driver, finalRoot: bad.root }), /核验失败/)
+    assert.equal(currentRelease(releaseRoot), finalRoot)
+    assert.equal((await localRequest(`http://127.0.0.1:${port}/api/status`)).body.version, '0.3.0')
+    assert.equal(readFileSync(join(home, 'user.txt'), 'utf8'), 'original data', 'failed startup must restore the pre-switch data snapshot')
+
+    const third = packageJob('0.3.2'), survivingPID = driver.pid()
+    third.job.plan.npm.integrity = `sha512-${Buffer.alloc(64).toString('base64')}`
+    await assert.rejects(stageNpmRelease(third.job, third.jobPath), /完整性校验失败/)
+    assert.equal(driver.pid(), survivingPID)
+    assert.equal(currentRelease(releaseRoot), finalRoot)
+    console.log('Verified npm preparation, explicit restart, data recovery and integrity rejection in an isolated LaunchAgent.')
+  } finally {
+    await driver.stop().catch(() => {})
+    await new Promise(done => { registry.close(done); registry.closeAllConnections() })
+    rmSync(root, { recursive: true, force: true })
+  }
+})

+ 9 - 1
src/client/api/systemUpdate.ts

@@ -12,6 +12,7 @@ export type UpdateJobState =
   | 'queued'
   | 'downloading'
   | 'building'
+  | 'prepared'
   | 'installing'
   | 'restarting'
   | 'verifying'
@@ -29,6 +30,8 @@ export interface UpdateJob {
   updatedAt: string
   target?: ReleaseManifest
   error?: string
+  received?: number
+  total?: number
 }
 
 export interface SystemUpdateStatus {
@@ -36,7 +39,8 @@ export interface SystemUpdateStatus {
   releasePageUrl?: string
   current: ReleaseManifest
   build?: { commit: string; buildNumber: number; dirty: boolean; artifactDigest?: string }
-  installationMode: 'source' | 'release' | 'desktop'
+  installationMode: 'source' | 'release' | 'npm' | 'desktop'
+  updateMethod?: 'git' | 'npm'
   latest?: ReleaseManifest
   updateAvailable: boolean
   supported: boolean
@@ -71,3 +75,7 @@ export function systemUpdateJob(jobID: string): Promise<UpdateJob> {
 export function rollbackSystemUpdate(): Promise<UpdateJob> {
   return apiRequest('/api/app/system/update/rollback', { method: 'POST', body: {} })
 }
+
+export function activateSystemUpdate(jobID: string): Promise<UpdateJob> {
+  return apiRequest('/api/app/system/update/activate', { method: 'POST', body: { jobID }, timeoutMs: 90000 })
+}

+ 44 - 11
src/client/components/app/SystemUpdatePanel.vue

@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import {
   applySystemUpdate,
+  activateSystemUpdate,
   checkSystemUpdate,
   rollbackSystemUpdate,
   systemUpdateJob,
@@ -25,10 +26,15 @@ const operationStarting = ref(false)
 let pollToken = 0
 let lifecycleToken = 0
 
-const terminal = computed(() => job.value && ['succeeded', 'failed', 'rolled_back'].includes(job.value.state))
+const terminalStates = ['prepared', 'succeeded', 'failed', 'rolled_back']
+const terminal = computed(() => job.value && terminalStates.includes(job.value.state))
+const prepared = computed(() => job.value?.state === 'prepared')
+const npmUpdate = computed(() => status.value?.updateMethod === 'npm')
 const jobRunning = computed(() => Boolean(job.value && !terminal.value))
 const locked = computed(() => operationStarting.value || (jobRunning.value && !trackingTimedOut.value))
-const canApply = computed(() => Boolean(status.value?.supported && status.value.updateAvailable && status.value.latest && !jobRunning.value && !busy.value))
+const canApply = computed(() => Boolean(status.value?.supported && status.value.updateAvailable && status.value.latest && !prepared.value && !jobRunning.value && !busy.value))
+const progress = computed(() => job.value?.state === 'downloading' && (job.value.total ?? 0) > 0
+  ? Math.max(0, Math.min(100, Math.floor((job.value.received ?? 0) / job.value.total! * 100))) : undefined)
 const displayedJob = computed(() => {
   const current = status.value?.current, value = job.value
   if (value?.state === 'succeeded' && value.target && current && (value.target.webVersion !== current.webVersion || value.target.releaseVersion !== current.releaseVersion)) return undefined
@@ -60,7 +66,7 @@ async function poll(next: UpdateJob) {
   trackingTimedOut.value = false
   const token = ++pollToken
   for (let attempt = 0; attempt < 300 && token === pollToken; attempt += 1) {
-    if (['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) break
+    if (terminalStates.includes(job.value.state)) break
     await new Promise(resolvePromise => window.setTimeout(resolvePromise, 1_000))
     try {
       job.value = await systemUpdateJob(next.id)
@@ -68,7 +74,7 @@ async function poll(next: UpdateJob) {
       // 15300 会在原子切换期间短暂不可用;继续等待新服务读取同一任务文件。
     }
   }
-  if (token === pollToken && job.value && !['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) {
+  if (token === pollToken && job.value && !terminalStates.includes(job.value.state)) {
     error.value = '升级仍在后台执行,请稍后重新打开此页面查看'
     trackingTimedOut.value = true
   }
@@ -78,18 +84,18 @@ async function poll(next: UpdateJob) {
 async function loadAndResume(token: number) {
   await refresh(false)
   if (token !== lifecycleToken || !props.active) return
-  if (job.value && !['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) {
+  if (job.value && !terminalStates.includes(job.value.state)) {
     await poll(job.value)
     return
   }
   await refresh(true)
   if (token !== lifecycleToken || !props.active) return
-  if (job.value && !['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) await poll(job.value)
+  if (job.value && !terminalStates.includes(job.value.state)) await poll(job.value)
 }
 
 async function applyUpdate() {
   const target = status.value?.latest
-  if (!target || !window.confirm(`将 Web 升级到 ${target.webVersion}?`)) return
+  if (!target || (!npmUpdate.value && !window.confirm(`将 Web 升级到 ${target.webVersion}?`))) return
   const token = lifecycleToken
   operationStarting.value = true
   busy.value = true
@@ -109,6 +115,27 @@ async function applyUpdate() {
   }
 }
 
+async function activateUpdate() {
+  if (!prepared.value || !job.value || busy.value) return
+  const token = lifecycleToken
+  operationStarting.value = true; busy.value = true; error.value = ''
+  try {
+    const next = await activateSystemUpdate(job.value.id)
+    if (token !== lifecycleToken || !props.active) return
+    operationStarting.value = false
+    await poll(next)
+    if (token === lifecycleToken && job.value?.state === 'succeeded') reloadPage()
+  } catch (cause) {
+    if (token === lifecycleToken) {
+      const message = cause instanceof Error ? cause.message : '无法启动服务器更新'
+      await refresh(false)
+      error.value = message
+    }
+  } finally {
+    if (token === lifecycleToken) { operationStarting.value = false; busy.value = false }
+  }
+}
+
 async function rollback() {
   if (!window.confirm('回滚到上一个 Web 版本?')) return
   const token = lifecycleToken
@@ -163,18 +190,21 @@ onBeforeUnmount(() => { lifecycleToken += 1; operationStarting.value = false; st
 
     <p v-if="status?.releaseSource" class="mode-note">发布源:{{ status.releaseSource }} <a v-if="status.releasePageUrl" :href="status.releasePageUrl" target="_blank" rel="noopener noreferrer">查看发布说明</a></p>
     <p v-if="status?.installationMode === 'source'" class="mode-note">首次升级会把运行服务迁移到可回滚的版本目录;Git 工作区不会被覆盖。</p>
+    <p v-if="npmUpdate" class="mode-note">下载完成后仍使用当前版本,点击“重启服务器”才会生效。重启期间所有连接会短暂断开,完成后页面自动刷新。</p>
     <p v-if="status && !status.supported" class="mode-note">{{ status.unsupportedReason }}</p>
 
     <section v-if="displayedJob" class="update-progress" :class="`update-progress--${displayedJob.state}`" aria-live="polite">
-      <span class="progress-icon"><AppIcon :name="displayedJob.state === 'succeeded' || displayedJob.state === 'rolled_back' ? 'check' : displayedJob.state === 'failed' ? 'alert' : 'refresh'" :size="18" /></span>
+      <span v-if="npmUpdate && jobRunning" class="progress-icon npm-progress" :class="{ 'npm-progress--spinning': progress === undefined }" role="progressbar" aria-label="服务器更新进度" :aria-valuemin="0" :aria-valuemax="100" :aria-valuenow="progress"><svg viewBox="0 0 24 24" aria-hidden="true"><circle class="npm-progress__track" cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="9" pathLength="100" :stroke-dasharray="`${progress ?? 25} 100`" /></svg></span>
+      <span v-else class="progress-icon"><AppIcon :name="displayedJob.state === 'prepared' || displayedJob.state === 'succeeded' || displayedJob.state === 'rolled_back' ? 'check' : displayedJob.state === 'failed' ? 'alert' : 'refresh'" :size="18" /></span>
       <span><strong>{{ displayedJob.message }}</strong><small v-if="displayedJob.error">{{ displayedJob.error }}</small></span>
     </section>
 
     <footer>
       <button v-if="displayedJob?.state === 'succeeded' || displayedJob?.state === 'rolled_back'" class="quiet-button" type="button" @click="reloadPage"><AppIcon name="refresh" :size="16" />刷新页面</button>
-      <button v-else-if="status?.canRollback && !jobRunning" class="quiet-button danger" type="button" :disabled="busy" @click="rollback">回滚上一版本</button>
+      <button v-else-if="status?.canRollback && !jobRunning && !prepared" class="quiet-button danger" type="button" :disabled="busy" @click="rollback">回滚上一版本</button>
       <button class="quiet-button" type="button" :disabled="checking || busy || operationStarting || jobRunning" @click="refresh(true)"><AppIcon name="refresh" :size="16" />{{ checking ? '检查中…' : '检查更新' }}</button>
-      <button class="solid-button" type="button" :disabled="!canApply || !!error || !remoteChecked" @click="applyUpdate"><AppIcon name="download" :size="16" />{{ jobRunning ? '升级中…' : error ? '暂无法检查' : !remoteChecked ? '待检查更新' : status?.updateAvailable ? '升级 Web' : '已是最新版本' }}</button>
+      <button v-if="prepared" class="solid-button restart-server" type="button" :disabled="busy || operationStarting || !status?.supported" @click="activateUpdate"><AppIcon name="refresh" :size="16" />重启服务器</button>
+      <button v-else class="solid-button" type="button" :disabled="!canApply || !!error || !remoteChecked" @click="applyUpdate"><AppIcon name="download" :size="16" />{{ jobRunning ? '升级中…' : error ? '暂无法检查' : !remoteChecked ? '待检查更新' : status?.updateAvailable ? npmUpdate ? '下载更新' : '升级 Web' : '已是最新版本' }}</button>
     </footer>
   </section>
 </template>
@@ -197,7 +227,10 @@ onBeforeUnmount(() => { lifecycleToken += 1; operationStarting.value = false; st
 .update-progress > span:last-child { display: grid; gap: 4px; min-width: 0; }
 .update-progress strong { font-size: 14px; }
 .update-progress small { color: var(--danger); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
-.update-progress:not(.update-progress--failed, .update-progress--succeeded, .update-progress--rolled_back) .progress-icon { animation: update-spin 1s linear infinite; }
+.update-progress:not(.update-progress--prepared, .update-progress--failed, .update-progress--succeeded, .update-progress--rolled_back) .progress-icon:not(.npm-progress) { animation: update-spin 1s linear infinite; }
+.npm-progress svg{width:22px;height:22px;transform:rotate(-90deg)}.npm-progress circle{fill:none;stroke:currentColor;stroke-width:2.5;stroke-linecap:round}.npm-progress__track{opacity:.18}.npm-progress--spinning svg{animation:update-spin 1s linear infinite}
+.update-progress--prepared .progress-icon{color:var(--success)}
+@media(prefers-reduced-motion:reduce){.npm-progress--spinning svg,.update-progress .progress-icon{animation:none!important}}
 .update-progress--succeeded .progress-icon,.update-progress--rolled_back .progress-icon { color: var(--success); }
 .update-progress--failed .progress-icon { color: var(--danger); }
 footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: 6px; padding-top: 18px; border-top: 1px solid var(--line); }

+ 1 - 0
src/server/config.ts

@@ -101,6 +101,7 @@ function parseUpstream(value: string | undefined): URL {
 
 function parseReleaseSource(value: string | undefined): string {
   const source = normalizeReleaseSource(value)
+  if (source === 'npm:@lsamien/yaoyao') return source
   if (source.length > 2_048 || /[\u0000-\u001f\u007f]/.test(source)) {
     throw new Error('HERMES_YAOYAO_RELEASE_SOURCE is invalid')
   }

+ 9 - 1
src/server/routes.ts

@@ -225,7 +225,7 @@ function requireLocalSystemUpdate(ctx: Koa.Context, dependencies: RouteDependenc
 
 function updateFailure(error: unknown): HttpError {
   const message = error instanceof Error ? error.message : String(error)
-  if (/最新版本|目标版本|正在执行|可回滚|不支持/.test(message)) {
+  if (/最新版本|目标版本|正在执行|可回滚|不支持|没有可生效|已准备更新/.test(message)) {
     return new HttpError(409, message, 'system_update_conflict')
   }
   return new HttpError(502, `无法访问系统发布源:${message}`, 'system_update_source_failed')
@@ -2118,6 +2118,14 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       throw updateFailure(error)
     }
   })
+  router.post('/api/app/system/update/activate', (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
+    requireLocalSystemUpdate(ctx, dependencies)
+    const jobID = body(ctx).jobID
+    if (typeof jobID !== 'string' || !/^[0-9a-f-]{36}$/.test(jobID)) throw new HttpError(400, 'jobID 无效', 'invalid_request')
+    try { json(ctx, 202, dependencies.updates.startActivation(jobID)) }
+    catch (error) { throw updateFailure(error) }
+  })
   router.get('/api/app/system/update/jobs/:jobID', async (ctx) => {
     dependencies.auth.requireAdmin(ctx)
     const job = dependencies.updates.job(ctx.params.jobID)

+ 48 - 11
src/server/updateManager.ts

@@ -9,6 +9,7 @@ import {
   mkdtempSync,
   openSync,
   readFileSync,
+  realpathSync,
   rmSync,
   statSync,
   writeFileSync,
@@ -19,6 +20,7 @@ import { spawn } from 'node:child_process'
 import type { ServerConfig } from './config.js'
 import { DEFAULT_YAOYAO_RELEASE_SOURCE } from './config.js'
 import { normalizeReleaseSource } from '../../bin/lib/release-source.mjs'
+import { inspectNpmRelease, isNpmInstallation, NPM_RELEASE_SOURCE, npmRuntimeDigest, type NpmRelease } from '../../bin/lib/npm-release.mjs'
 import { githubReleasePage, inspectGitHubRelease } from './githubReleases.js'
 import {
   compareReleaseVersions,
@@ -31,6 +33,7 @@ export type UpdateJobState =
   | 'queued'
   | 'downloading'
   | 'building'
+  | 'prepared'
   | 'installing'
   | 'restarting'
   | 'verifying'
@@ -48,6 +51,8 @@ export interface UpdateJob {
   updatedAt: string
   target?: ReleaseManifest
   error?: string
+  received?: number
+  total?: number
 }
 
 interface UpdatePlan {
@@ -58,6 +63,8 @@ interface UpdatePlan {
   previousServiceRoot: string
   target?: ReleaseManifest
   targetCommit?: string
+  npm?: NpmRelease
+  prepared?: { root: string; digest: string }
 }
 
 interface StoredUpdateJob extends UpdateJob {
@@ -69,7 +76,8 @@ export interface SystemUpdateStatus {
   releasePageUrl?: string
   current: ReleaseManifest
   build?: BuildIdentity
-  installationMode: 'source' | 'release' | 'desktop'
+  installationMode: 'source' | 'release' | 'npm' | 'desktop'
+  updateMethod?: 'git' | 'npm'
   latest?: ReleaseManifest
   updateAvailable: boolean
   supported: boolean
@@ -81,7 +89,8 @@ export interface SystemUpdateStatus {
 export interface RemoteRelease {
   releasePageUrl?: string
   manifest: ReleaseManifest
-  commit: string
+  commit?: string
+  npm?: NpmRelease
 }
 
 type InspectRemote = (source: string, current: ReleaseManifest) => Promise<RemoteRelease | undefined>
@@ -200,7 +209,7 @@ function publicJob(job: StoredUpdateJob | undefined): UpdateJob | undefined {
 }
 
 function terminal(state: UpdateJobState): boolean {
-  return ['succeeded', 'failed', 'rolled_back'].includes(state)
+  return ['prepared', 'succeeded', 'failed', 'rolled_back'].includes(state)
 }
 
 export class SystemUpdateManager {
@@ -221,8 +230,13 @@ export class SystemUpdateManager {
     this.updaterPath = resolve(options.updaterPath ?? join(this.projectRoot, 'bin', 'hermes-yaoyao-updater.mjs'))
     this.updateHome = join(config.home, 'updates')
     this.releaseRoot = resolve(config.releaseRoot ?? join(homedir(), '.local', 'share', 'hermes-yaoyao'))
-    this.releaseSource = normalizeReleaseSource(config.releaseSource ?? DEFAULT_YAOYAO_RELEASE_SOURCE)
-    this.inspectRemote = options.inspectRemote ?? inspectGitRemote
+    const source = normalizeReleaseSource(config.releaseSource ?? DEFAULT_YAOYAO_RELEASE_SOURCE)
+    this.releaseSource = source === DEFAULT_YAOYAO_RELEASE_SOURCE && isNpmInstallation(this.projectRoot) ? NPM_RELEASE_SOURCE : source
+    this.inspectRemote = options.inspectRemote ?? (this.releaseSource === NPM_RELEASE_SOURCE
+      ? async () => {
+        const npm = await inspectNpmRelease(this.projectRoot)
+        return { npm, manifest: { schemaVersion: 1, releaseVersion: npm.version, webVersion: npm.version, gitTag: `v${npm.version}` } }
+      } : inspectGitRemote)
     this.platform = options.platform ?? process.platform
     this.desktopOwned = options.desktopOwned ?? process.env.HERMES_YAOYAO_DESKTOP === '1'
     this.launchUpdater = options.launchUpdater ?? ((jobPath) => {
@@ -273,9 +287,10 @@ export class SystemUpdateManager {
     return {
       current,
       releaseSource: this.releaseSource,
-      releasePageUrl: githubReleasePage(this.releaseSource),
+      releasePageUrl: this.releaseSource === NPM_RELEASE_SOURCE ? 'https://www.npmjs.com/package/@lsamien/yaoyao' : githubReleasePage(this.releaseSource),
       build: readBuildIdentity(this.projectRoot),
-      installationMode: this.desktopOwned ? 'desktop' : this.projectRoot.startsWith(`${this.releaseRoot}/`) ? 'release' : 'source',
+      installationMode: this.desktopOwned ? 'desktop' : this.releaseSource === NPM_RELEASE_SOURCE ? 'npm' : this.projectRoot.startsWith(`${this.releaseRoot}/`) ? 'release' : 'source',
+      updateMethod: this.releaseSource === NPM_RELEASE_SOURCE ? 'npm' : 'git',
       latest,
       updateAvailable: Boolean(latest && compareReleaseVersions(latest.releaseVersion, current.releaseVersion) > 0),
       supported: !this.desktopOwned && this.platform === 'darwin',
@@ -289,7 +304,7 @@ export class SystemUpdateManager {
     const current = this.currentManifest()
     if (this.desktopOwned) return this.status()
     const latest = await this.inspectRemote(this.releaseSource, current)
-    return { ...this.status(latest?.manifest), releasePageUrl: latest?.releasePageUrl ?? githubReleasePage(this.releaseSource) }
+    return { ...this.status(latest?.manifest), ...(latest?.releasePageUrl ? { releasePageUrl: latest.releasePageUrl } : {}) }
   }
 
   private acquire(jobID: string): void {
@@ -338,7 +353,7 @@ export class SystemUpdateManager {
     }
   }
 
-  private createJob(operation: UpdateJob['operation'], target?: ReleaseManifest, targetCommit?: string): StoredUpdateJob {
+  private createJob(operation: UpdateJob['operation'], target?: ReleaseManifest, targetCommit?: string, npm?: NpmRelease, prepared?: UpdatePlan['prepared']): StoredUpdateJob {
     const id = randomUUID()
     this.acquire(id)
     const now = new Date().toISOString()
@@ -346,7 +361,7 @@ export class SystemUpdateManager {
       id,
       operation,
       state: 'queued',
-      message: operation === 'update' ? '升级任务已排队' : '回滚任务已排队',
+      message: prepared ? '服务器重启任务已排队' : operation === 'update' ? '升级任务已排队' : '回滚任务已排队',
       createdAt: now,
       updatedAt: now,
       target,
@@ -358,6 +373,8 @@ export class SystemUpdateManager {
         previousServiceRoot: this.projectRoot,
         target,
         targetCommit,
+        npm,
+        prepared,
       },
     }
     const jobPath = join(this.updateHome, `${id}.json`)
@@ -384,7 +401,27 @@ export class SystemUpdateManager {
       throw new Error('当前已经是最新版本')
     }
     if (targetVersion !== latest.manifest.releaseVersion) throw new Error('目标版本不是当前发布源的最新版本')
-    return publicJob(this.createJob('update', latest.manifest, latest.commit))!
+    if (this.releaseSource === NPM_RELEASE_SOURCE && !latest.npm) throw new Error('npm 发布信息不完整')
+    return publicJob(this.createJob('update', latest.manifest, latest.commit, latest.npm))!
+  }
+
+  startActivation(jobID: string): UpdateJob {
+    if (this.desktopOwned || this.platform !== 'darwin') throw new Error('当前环境不支持服务器重启更新')
+    const job = this.latestJob()
+    if (!job || job.id !== jobID || job.state !== 'prepared' || !job.plan.prepared || !job.plan.npm || !job.target)
+      throw new Error('没有可生效的已准备更新,请重新检查更新')
+    if (compareReleaseVersions(job.target.releaseVersion, this.currentManifest().releaseVersion) <= 0)
+      throw new Error('已准备更新不是比当前服务更新的版本')
+    const prepared = job.plan.prepared
+    try {
+      const root = realpathSync(prepared.root), releases = realpathSync(join(this.releaseRoot, 'releases'))
+      if (!root.startsWith(`${releases}/`) || npmRuntimeDigest(root, job.target.releaseVersion) !== prepared.digest) throw new Error('digest mismatch')
+    } catch {
+      const error = '已准备更新的文件已改变或不可用,请重新下载'
+      writeFileSync(join(this.updateHome, `${job.id}.json`), JSON.stringify({ ...job, state: 'failed', message: error, error, updatedAt: new Date().toISOString() }), { mode: 0o600 })
+      throw new Error(error)
+    }
+    return publicJob(this.createJob('update', job.target, undefined, job.plan.npm, prepared))!
   }
 
   startRollback(): UpdateJob {

+ 5 - 0
tests/client/systemUpdate.test.ts

@@ -1,6 +1,7 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import {
   applySystemUpdate,
+  activateSystemUpdate,
   checkSystemUpdate,
   rollbackSystemUpdate,
   systemUpdateJob,
@@ -32,6 +33,7 @@ describe('system update client protocol', () => {
     await applySystemUpdate('0.3.0')
     await systemUpdateJob('11111111-1111-4111-8111-111111111111')
     await rollbackSystemUpdate()
+    await activateSystemUpdate('11111111-1111-4111-8111-111111111111')
 
     expect(calls.map(call => call.path)).toEqual([
       '/api/app/system/update/status',
@@ -39,9 +41,12 @@ describe('system update client protocol', () => {
       '/api/app/system/update/apply',
       '/api/app/system/update/jobs/11111111-1111-4111-8111-111111111111',
       '/api/app/system/update/rollback',
+      '/api/app/system/update/activate',
     ])
     expect(new Headers(calls[1]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-system-update')
     expect(calls[2]!.init?.body).toBe(JSON.stringify({ targetVersion: '0.3.0' }))
     expect(new Headers(calls[4]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-system-update')
+    expect(calls[5]!.init?.body).toBe(JSON.stringify({ jobID: '11111111-1111-4111-8111-111111111111' }))
+    expect(new Headers(calls[5]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-system-update')
   })
 })

+ 34 - 0
tests/client/systemUpdatePanel.test.ts

@@ -4,6 +4,7 @@ import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
 
 const api = vi.hoisted(() => ({
   applySystemUpdate: vi.fn(),
+  activateSystemUpdate: vi.fn(),
   checkSystemUpdate: vi.fn(),
   rollbackSystemUpdate: vi.fn(),
   systemUpdateJob: vi.fn(),
@@ -46,6 +47,7 @@ beforeEach(() => {
   api.checkSystemUpdate.mockReset()
   api.systemUpdateJob.mockReset()
   api.applySystemUpdate.mockReset()
+  api.activateSystemUpdate.mockReset()
   api.rollbackSystemUpdate.mockReset()
   api.systemUpdateStatus.mockResolvedValue(readyStatus)
   api.checkSystemUpdate.mockResolvedValue(readyStatus)
@@ -60,6 +62,38 @@ afterEach(() => {
 })
 
 describe('SystemUpdatePanel', () => {
+  it('downloads npm updates without restarting and restores the restart action after reopening offline', async () => {
+    const target = { ...manifest, releaseVersion: '0.3.0', webVersion: '0.3.0', gitTag: 'v0.3.0' }
+    const npmStatus = { ...readyStatus, updateMethod: 'npm', installationMode: 'npm', latest: target, updateAvailable: true }
+    const preparedJob = { ...activeJob, state: 'prepared', target, message: '服务器更新已准备完成' }
+    api.systemUpdateStatus.mockResolvedValue(npmStatus)
+    api.checkSystemUpdate.mockResolvedValue(npmStatus)
+    api.applySystemUpdate.mockResolvedValue({ ...activeJob, state: 'downloading', received: 37, total: 100 })
+    api.systemUpdateJob.mockResolvedValue(preparedJob)
+    const confirm = vi.spyOn(window, 'confirm')
+    let wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
+    await flushPromises()
+    await wrapper.get('.solid-button').trigger('click'); await flushPromises()
+    expect(api.applySystemUpdate).toHaveBeenCalledWith('0.3.0')
+    expect(confirm).not.toHaveBeenCalled()
+    expect(wrapper.get('[role="progressbar"]').attributes('aria-valuenow')).toBe('37')
+    api.systemUpdateStatus.mockResolvedValue({ ...npmStatus, job: preparedJob })
+    await vi.advanceTimersByTimeAsync(1000); await flushPromises()
+    expect(wrapper.get('.restart-server').text()).toContain('重启服务器')
+    expect(wrapper.emitted('lock-change')?.at(-1)).toEqual([false])
+    expect(api.activateSystemUpdate).not.toHaveBeenCalled()
+    wrapper.unmount()
+    api.checkSystemUpdate.mockRejectedValue(new Error('npm 暂时离线'))
+    wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
+    await flushPromises()
+    expect(wrapper.get<HTMLButtonElement>('.restart-server').element.disabled).toBe(false)
+    let finish!: (job: typeof activeJob) => void
+    api.activateSystemUpdate.mockReturnValue(new Promise(resolve => { finish = resolve }))
+    await wrapper.get('.restart-server').trigger('click'); await wrapper.get('.restart-server').trigger('click')
+    expect(api.activateSystemUpdate).toHaveBeenCalledExactlyOnceWith('job-1')
+    await wrapper.setProps({ active: false }); finish(activeJob); await flushPromises()
+    wrapper.unmount()
+  })
   it('hides a previous successful update that does not match the running Web version', async () => {
     const old = { ...doneJob, target: { ...manifest, webVersion: '0.1.0', releaseVersion: '0.1.0' }, message: '已升级 Web 0.1.0' }
     api.systemUpdateStatus.mockResolvedValue({ ...readyStatus, job: old })

+ 92 - 0
tests/e2e/npmUpdate.spec.ts

@@ -0,0 +1,92 @@
+import { expect, test } from '@playwright/test'
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { once } from 'node:events'
+import type { AddressInfo } from 'node:net'
+import serve from 'koa-static'
+import send from 'koa-send'
+import { npmRuntimeDigest } from '../../bin/lib/npm-release.mjs'
+import { writeNpmFixture } from '../fixtures/npm-release.mjs'
+
+test('npm update downloads in place, survives reopening, and refreshes only after explicit server restart', async ({ page }, testInfo) => {
+  const root = mkdtempSync(join(tmpdir(), 'yaoyao-npm-ui-')), home = join(root, 'data'), projectRoot = join(root, 'initial')
+  const current = JSON.parse(readFileSync('release.json', 'utf8'))
+  const version = current.webVersion.split('.').map((value: string, index: number) => index === 2 ? String(Number(value) + 1) : value).join('.')
+  writeNpmFixture(projectRoot, current.webVersion)
+  const releaseRoot = join(root, 'programs'), packageRoot = join(releaseRoot, 'releases/prepared/node_modules/@lsamien/yaoyao')
+  const target = writeNpmFixture(packageRoot, version)
+  const prepared = { root: packageRoot, digest: npmRuntimeDigest(packageRoot, version) }
+  const { createApplication, createNodeServer } = await import(pathToFileURL(join(process.cwd(), 'dist-server/server/app.js')).href)
+  const { SystemUpdateManager } = await import(pathToFileURL(join(process.cwd(), 'dist-server/server/updateManager.js')).href)
+  const config = { host: '127.0.0.1', port: 0, upstream: new URL('http://127.0.0.1:1'), allowedHosts: new Set(['127.0.0.1']),
+    home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home, mediaOwner: 'test', allowInsecureLan: false, insecureLan: false, production: true,
+    releaseRoot, releaseSource: 'npm:@lsamien/yaoyao' }
+  let finishDownload!: () => void, downloads = 0, restarts = 0, confirmations = 0
+  const updates = new SystemUpdateManager(config, { projectRoot, platform: 'darwin',
+    inspectRemote: async () => ({ manifest: target, npm: { version, tarball: 'https://registry.npmjs.org/fixture.tgz', integrity: `sha512-${Buffer.alloc(64).toString('base64')}` } }),
+    // Native archive installation and actual process replacement are covered by
+    // verify-npm-update.mjs; this fixture exercises the real UI/API/state flow.
+    launchUpdater: (path: string) => {
+      const job = JSON.parse(readFileSync(path, 'utf8'))
+      if (job.plan.prepared) {
+        restarts++
+        writeFileSync(join(projectRoot, 'release.json'), JSON.stringify(target))
+        writeFileSync(path, JSON.stringify({ ...job, state: 'succeeded', message: '服务器更新完成' }))
+        rmSync(join(home, 'updates/active.lock'))
+      } else {
+        downloads++
+        writeFileSync(path, JSON.stringify({ ...job, state: 'downloading', received: 37, total: 100, message: '正在下载服务器更新 · 37%' }))
+        finishDownload = () => {
+          writeFileSync(path, JSON.stringify({ ...job, state: 'prepared', message: '新版本已准备完成,点击重启服务器生效', plan: { ...job.plan, prepared } }))
+          rmSync(join(home, 'updates/active.lock'))
+        }
+      }
+    },
+  })
+  const runtime = createApplication({ config, updates })
+  runtime.app.use(serve(join(process.cwd(), 'dist')))
+  runtime.app.use(async (ctx: Parameters<ReturnType<typeof serve>>[0]) => {
+    if (ctx.method === 'GET' && ctx.accepts('html')) await send(ctx, 'index.html', { root: join(process.cwd(), 'dist') })
+  })
+  const node = createNodeServer(runtime); node.server.listen(0, '127.0.0.1'); await once(node.server, 'listening')
+  const origin = `http://127.0.0.1:${(node.server.address() as AddressInfo).port}`
+  async function openUpdates() {
+    await page.getByRole('button', { name: /^(打开我的设置|设置与模式)$/ }).click()
+    await page.getByRole('menuitem', { name: '我的设置', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: '我的设置' })
+    await dialog.getByRole('button', { name: '更新与回滚', exact: true }).click()
+    return dialog
+  }
+  try {
+    const boot = await (await page.request.get(origin + '/api/app/bootstrap')).json()
+    const signedIn = await page.request.post(origin + '/api/app/setup', { headers: { Origin: origin, 'X-CSRF-Token': boot.csrfToken }, data: { username: 'admin', password: 'npm-e2e-password' } })
+    expect(signedIn.ok()).toBe(true)
+    page.on('dialog', dialog => { confirmations++; void dialog.dismiss() })
+    await page.goto(origin + '/chat')
+    let dialog = await openUpdates()
+    await dialog.getByRole('button', { name: '下载更新', exact: true }).click()
+    await expect(dialog.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '37')
+    expect(downloads).toBe(1); expect(restarts).toBe(0)
+    await page.screenshot({ path: testInfo.outputPath('npm-downloading.png') })
+    finishDownload()
+    await expect(dialog.getByRole('button', { name: '重启服务器', exact: true })).toBeEnabled()
+    await page.screenshot({ path: testInfo.outputPath('npm-ready.png') })
+    await page.reload()
+    dialog = await openUpdates()
+    await expect(dialog.getByRole('button', { name: '重启服务器', exact: true })).toBeEnabled()
+    expect(restarts).toBe(0)
+    const refreshed = page.waitForEvent('framenavigated', { predicate: frame => frame === page.mainFrame() })
+    await dialog.getByRole('button', { name: '重启服务器', exact: true }).click()
+    await refreshed
+    expect(restarts).toBe(1); expect(confirmations).toBe(0)
+    const status = await (await page.request.get(origin + '/api/app/system/update/status')).json()
+    expect(status.current.webVersion).toBe(version)
+    expect(status.job.state).toBe('succeeded')
+  } finally {
+    await page.goto('about:blank')
+    node.server.closeAllConnections(); await node.close()
+    rmSync(root, { recursive: true, force: true })
+  }
+})

+ 41 - 0
tests/fixtures/npm-release.mjs

@@ -0,0 +1,41 @@
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { createHash } from 'node:crypto'
+
+export function writeNpmFixture(root, version, broken = false) {
+  const release = { schemaVersion: 1, releaseVersion: version, webVersion: version, gitTag: `v${version}` }
+  const files = {
+    'package.json': JSON.stringify({ name: '@lsamien/yaoyao', version, type: 'module', engines: { node: '>=24' }, fixtureBroken: broken }),
+    'release.json': JSON.stringify(release),
+    'build-info.json': JSON.stringify({ commit: createHash('sha256').update(version).digest('hex').slice(0, 40), buildNumber: 1, dirty: false }),
+    'dist/index.html': '<html>npm fixture</html>',
+    'bin/hermes-yaoyao.mjs': '// fixture launcher\n',
+    'bin/hermes-yaoyao-updater.mjs': '// fixture updater\n',
+    'dist-server/server/index.js': `
+import {createServer} from 'node:http';
+import {DatabaseSync} from 'node:sqlite';
+import {createHash,randomUUID} from 'node:crypto';
+import {readFileSync,writeFileSync,realpathSync} from 'node:fs';
+import {join} from 'node:path';
+const root=process.cwd(),home=realpathSync(process.env.HERMES_YAOYAO_HOME),port=Number(process.env.HERMES_YAOYAO_PORT);
+const release=JSON.parse(readFileSync(join(root,'release.json'))),build=JSON.parse(readFileSync(join(root,'build-info.json'))),pkg=JSON.parse(readFileSync(join(root,'package.json')));
+const db=new DatabaseSync(join(home,'workspace.sqlite3'));db.exec('CREATE TABLE IF NOT EXISTS fixture_data (value TEXT)');
+if(pkg.fixtureBroken){db.exec("INSERT INTO fixture_data VALUES ('failed update')");writeFileSync(join(home,'user.txt'),'failed update');}
+const identity={protocol:1,pid:process.pid,instanceId:randomUUID(),dataKey:createHash('sha256').update(home).digest('hex'),version:release.webVersion,build,url:'http://127.0.0.1:'+port,maintenanceProtocol:1};
+writeFileSync(join(home,'service-instance.json'),JSON.stringify(identity));let quiesced=false;
+const server=createServer((req,res)=>{res.setHeader('Content-Type','application/json');
+ if(req.url==='/desktop/service')res.end(JSON.stringify({...identity,quiesced}));
+ else if(req.url==='/desktop/service/quiesce'){quiesced=req.method==='POST';res.end(JSON.stringify({quiesced}));}
+ else if(req.url==='/healthz')res.end(JSON.stringify({ok:!pkg.fixtureBroken}));
+ else if(req.url==='/api/status')res.end(JSON.stringify({server_kind:'yaoyao-web',version:release.webVersion}));
+ else{res.statusCode=404;res.end('{}');}});
+server.listen(port,'127.0.0.1');process.on('SIGTERM',()=>server.close(()=>{db.close();process.exit(0)}));
+`,
+  }
+  for (const [file, contents] of Object.entries(files)) {
+    const path = join(root, file)
+    mkdirSync(join(path, '..'), { recursive: true })
+    writeFileSync(path, contents)
+  }
+  return release
+}

+ 82 - 0
tests/server/npmUpdates.test.ts

@@ -0,0 +1,82 @@
+// @vitest-environment node
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { afterEach, expect, it, vi } from 'vitest'
+import { SystemUpdateManager } from '../../src/server/updateManager.js'
+import { loadServerConfig } from '../../src/server/config.js'
+import { NPM_RELEASE_SOURCE, npmRuntimeDigest, parseNpmRelease } from '../../bin/lib/npm-release.mjs'
+import { writeNpmFixture } from '../fixtures/npm-release.mjs'
+
+const roots: string[] = []
+afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
+const npm = { version: '0.3.0', integrity: `sha512-${Buffer.alloc(64, 1).toString('base64')}`, tarball: 'https://registry.npmjs.org/fixture.tgz' }
+const target = { schemaVersion: 1 as const, releaseVersion: npm.version, webVersion: npm.version, gitTag: `v${npm.version}` }
+
+function fixture() {
+  const root = mkdtempSync(join(tmpdir(), 'yaoyao-npm-manager-')); roots.push(root)
+  const projectRoot = join(root, 'global/node_modules/@lsamien/yaoyao')
+  writeNpmFixture(projectRoot, '0.2.0')
+  const config = loadServerConfig({ HERMES_YAOYAO_HOME: join(root, 'data'), HERMES_YAOYAO_RELEASE_ROOT: join(root, 'programs') })
+  const launchUpdater = vi.fn(), inspectRemote = vi.fn(async () => ({ npm, manifest: target }))
+  const options = { projectRoot, platform: 'darwin' as const, launchUpdater, inspectRemote }
+  const manager = new SystemUpdateManager(config, options)
+  return { root, manager, config, options, launchUpdater, inspectRemote }
+}
+
+it('detects npm installs and queues an integrity-pinned download without activating', async () => {
+  const f = fixture()
+  expect(await f.manager.check()).toMatchObject({ installationMode: 'npm', updateMethod: 'npm', releaseSource: NPM_RELEASE_SOURCE, updateAvailable: true })
+  const job = await f.manager.startUpdate(npm.version)
+  const stored = JSON.parse(readFileSync(f.launchUpdater.mock.calls[0][0], 'utf8'))
+  expect(stored.plan.npm).toEqual(npm)
+  expect(stored.plan.prepared).toBeUndefined()
+  expect(f.manager.job(job.id)).not.toHaveProperty('plan')
+  expect(() => f.manager.startActivation(job.id)).toThrow('没有可生效')
+  expect(f.launchUpdater).toHaveBeenCalledTimes(1)
+})
+
+it('restores a prepared update after manager recreation and activates offline only after an explicit request', async () => {
+  const f = fixture(), job = await f.manager.startUpdate(npm.version)
+  const path = f.launchUpdater.mock.calls[0][0], stored = JSON.parse(readFileSync(path, 'utf8'))
+  const packageRoot = join(f.config.releaseRoot!, 'releases/new/node_modules/@lsamien/yaoyao')
+  writeNpmFixture(packageRoot, npm.version)
+  const prepared = { root: packageRoot, digest: npmRuntimeDigest(packageRoot, npm.version) }
+  writeFileSync(path, JSON.stringify({ ...stored, state: 'prepared', plan: { ...stored.plan, prepared } }))
+  rmSync(join(f.config.home, 'updates/active.lock'))
+  f.inspectRemote.mockRejectedValue(new Error('npm offline'))
+  const resumed = new SystemUpdateManager(f.config, f.options)
+  expect(resumed.status().job?.state).toBe('prepared')
+  expect(() => resumed.startActivation('wrong-job')).toThrow('没有可生效')
+  const activate = resumed.startActivation(job.id)
+  expect(activate.state).toBe('queued')
+  const activationPlan = JSON.parse(readFileSync(f.launchUpdater.mock.calls[1][0], 'utf8')).plan
+  expect(activationPlan.prepared).toEqual(prepared)
+  expect(f.inspectRemote).toHaveBeenCalledTimes(1)
+  expect(() => resumed.startActivation(job.id)).toThrow('没有可生效')
+})
+
+it('rejects changed prepared files and retains explicit Git sources for npm installs', async () => {
+  const f = fixture(), job = await f.manager.startUpdate(npm.version)
+  const path = f.launchUpdater.mock.calls[0][0], stored = JSON.parse(readFileSync(path, 'utf8'))
+  const packageRoot = join(f.config.releaseRoot!, 'releases/new/node_modules/@lsamien/yaoyao')
+  writeNpmFixture(packageRoot, npm.version)
+  const prepared = { root: packageRoot, digest: npmRuntimeDigest(packageRoot, npm.version) }
+  writeFileSync(path, JSON.stringify({ ...stored, state: 'prepared', plan: { ...stored.plan, prepared } }))
+  rmSync(join(f.config.home, 'updates/active.lock'))
+  writeFileSync(join(packageRoot, 'dist-server/server/index.js'), '// changed after verification')
+  expect(() => f.manager.startActivation(job.id)).toThrow('文件已改变')
+  expect(f.manager.status().job?.state).toBe('failed')
+  expect(f.launchUpdater).toHaveBeenCalledTimes(1)
+  const git = new SystemUpdateManager({ ...f.config, releaseSource: 'https://example.test/private.git' }, f.options)
+  expect(git.status()).toMatchObject({ installationMode: 'source', updateMethod: 'git', releaseSource: 'https://example.test/private.git' })
+})
+
+it('validates npm metadata and restricts configured npm sources to the official package', () => {
+  const value = { name: '@lsamien/yaoyao', version: npm.version, dist: npm }
+  expect(parseNpmRelease(value)).toEqual(npm)
+  expect(() => parseNpmRelease({ ...value, name: 'other-package' })).toThrow('名称或版本')
+  expect(() => parseNpmRelease({ ...value, dist: { ...npm, integrity: 'sha1-weak' } })).toThrow('SHA-512')
+  expect(loadServerConfig({ HERMES_YAOYAO_RELEASE_SOURCE: NPM_RELEASE_SOURCE }).releaseSource).toBe(NPM_RELEASE_SOURCE)
+  expect(() => loadServerConfig({ HERMES_YAOYAO_RELEASE_SOURCE: 'npm:other-package' })).toThrow()
+})

+ 2 - 2
tests/server/offlineSystemUpdate.test.ts

@@ -94,7 +94,7 @@ describe('independent Web updates', () => {
     const boot = await anonymous.get('/api/app/bootstrap').set('Host', host).expect(200)
     const getPaths = ['/status', '/jobs/11111111-1111-4111-8111-111111111111']
     for (const path of getPaths) await anonymous.get(base + path).set('Host', host).expect(401)
-    for (const path of ['/check', '/apply', '/rollback']) {
+    for (const path of ['/check', '/apply', '/rollback', '/activate']) {
       await anonymous.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', boot.body.csrfToken)
         .send({ targetVersion: '0.3.0' }).expect(401)
     }
@@ -103,7 +103,7 @@ describe('independent Web updates', () => {
     const member = await login(f.runtime, 'member', 'temporary-password')
     f.fetchImpl.mockClear()
     for (const path of getPaths) await member.agent.get(base + path).set('Host', host).expect(403)
-    for (const path of ['/check', '/apply', '/rollback']) {
+    for (const path of ['/check', '/apply', '/rollback', '/activate']) {
       await member.agent.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', member.csrf)
         .send({ targetVersion: '0.3.0' }).expect(403)
       await admin.agent.post(base + path).set('Host', host).set('Origin', origin).send({}).expect(403)