Переглянути джерело

fix: overwrite npm installations without rollback copies

Samien 4 тижнів тому
батько
коміт
4f6f2bb4a3

+ 2 - 2
README.md

@@ -100,11 +100,11 @@ iOS 统一连接 Web 地址,通过设置中的手机登录二维码或账号
 
 ## 数据与升级
 
-npm 安装的 macOS 服务支持在「更新与回滚」中下载 npm 新版本,准备完成后点击「重启服务器」生效,保留上一版本用于回滚。参见 [npm 安装说明](docs/npm-install.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 数据目录,客户端版本要求见对应发布说明。
+发布版本由 `release.json` 记录。macOS 源码/发布包服务支持在系统设置中升级 Web 或回滚到上一个版本;其他源码部署更新代码、重新构建并重启服务,Docker 部署通过拉取远程镜像(或自行构建)并重建容器更新。升级前备份 Web 数据目录,客户端版本要求见对应发布说明。
 
 后续发布同时交付本地构建和 Docker Hub 远程镜像,验证固定版本后更新 `latest`,保留旧版本标签。发布者按 [发布流程](docs/release-process.md) 执行,并将镜像地址、架构和验证结果写入发布说明。
 

+ 59 - 28
bin/hermes-yaoyao-updater.mjs

@@ -10,6 +10,7 @@ import {
   openSync,
   readFileSync,
   readlinkSync,
+  readdirSync,
   renameSync,
   rmSync,
   symlinkSync,
@@ -21,9 +22,10 @@ import { dirname, join, relative, resolve, sep } from 'node:path'
 import { fileURLToPath } from 'node:url'
 
 const label = 'com.samien.hermes-yaoyao'
-import { LaunchAgentService, updateMutex, recoverTransition, transitionService } from './lib/service-update.mjs'
+import { LaunchAgentService, updateMutex, recoverTransition, switchRelease, transitionService } from './lib/service-update.mjs'
+import { readRuntime } from './lib/runtime-release.mjs'
 import { assertRollbackCompatible } from './lib/service-data.mjs'
-import { NPM_PACKAGE, npmRuntimeDigest, parseNpmRelease } from './lib/npm-release.mjs'
+import { NPM_PACKAGE, npmInstallationLocation, npmRuntimeDigest, parseNpmRelease, verifyPreparedNpmArchive } from './lib/npm-release.mjs'
 
 const uid = process.getuid?.() ?? 501
 const domain = `gui/${uid}`
@@ -258,16 +260,19 @@ 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 }) } = {}) {
+export async function stageNpmRelease(job, jobPath, { fetchImpl = fetch } = {}) {
   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 downloads = join(plan.home, 'updates', 'npm'), staging = join(downloads, id)
+  mkdirSync(downloads, { recursive: true, mode: 0o700 })
+  // Only one update owns the mutex. A replacement download supersedes any
+  // previous prepared archive, so temporary packages cannot accumulate.
+  for (const name of readdirSync(downloads)) {
+    if (/^[0-9a-f-]{36}$/.test(name)) removeInside(join(downloads, name), downloads)
+  }
+  mkdirSync(staging, { mode: 0o700 })
   const archive = join(staging, 'release.tgz')
   try {
     updateJob(jobPath, { state: 'downloading', message: `正在下载服务器 ${npm.version}…`, received: 0, total: 0 })
@@ -290,27 +295,52 @@ export async function stageNpmRelease(job, jobPath, { fetchImpl = fetch, install
       }
     } 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 } })
+    const prepared = { archive }
+    updateJob(jobPath, { state: 'prepared', message: `服务器 ${npm.version} 已下载完成,点击“重启服务器”覆盖更新`, received, total, plan: { ...plan, prepared } })
     return prepared
   } catch (error) {
-    removeInside(staging, releases)
+    removeInside(staging, downloads)
     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
+export async function overwriteNpmRelease(job, jobPath, { driver = serviceOptions(jobPath, job).driver, install = (archive, location) => run('npm', [
+  'install', ...(location.global ? ['--global'] : []), '--prefix', location.prefix, '--no-save', '--package-lock=false', '--omit=dev', '--no-audit', '--no-fund', '--engine-strict', '--allow-file=all', archive,
+], { cwd: location.prefix, timeout: 600000 }) } = {}) {
+  const { plan, target } = job
+  validateManifest(target)
+  const npm = parseNpmRelease({ name: NPM_PACKAGE, version: plan.npm?.version, dist: plan.npm })
+  if (npm.version !== target.releaseVersion) fail('npm 发布信息与目标版本不一致')
+  const archive = verifyPreparedNpmArchive(plan.home, plan.prepared, npm)
+  let stopped = false, quiescing = false
+  try {
+    const previous = driver.snapshot(), root = realpathSync(plan.previousServiceRoot)
+    if (!previous || realpathSync(previous.root) !== root) fail('当前服务与 npm 安装目录不一致,已取消覆盖更新')
+    const location = npmInstallationLocation(root), home = realpathSync(plan.home), modules = dirname(dirname(root))
+    if (home === modules || pathInside(home, modules)) fail('用户数据目录位于 npm 程序目录内,已取消覆盖更新')
+    if (existsSync(join(plan.home, 'updates', 'transition.json'))) fail('存在未完成的服务切换,请先处理后再覆盖更新')
+    if (previous.wasRunning) await driver.verify(undefined, 5000)
+    quiescing = true
+    await driver.quiesce(message => updateJob(jobPath, { message }))
+    updateJob(jobPath, { state: 'installing', message: '正在停止服务器并覆盖当前 npm 安装…' })
+    await driver.stop(); stopped = true
+    await install(archive, location)
+    npmRuntimeDigest(root, npm.version)
+    // Keep the same program path. No version copy, data snapshot or rollback
+    // record is created for an npm overwrite.
+    rmSync(join(plan.home, 'updates', 'last-success.json'), { force: true })
+    switchRelease(plan.releaseRoot, root)
+    updateJob(jobPath, { state: 'restarting', message: '正在启动更新后的服务器…' })
+    await driver.start(root, previous)
+    updateJob(jobPath, { state: 'verifying', message: '正在核对新版本和服务状态…' })
+    await driver.verify(readRuntime(root))
+    updateJob(jobPath, { state: 'succeeded', message: `服务器已覆盖更新到 ${target.webVersion}` })
+  } catch (error) {
+    if (quiescing && !stopped) await driver.resume()
+    throw new Error(`${error.message}${stopped ? ';未保留旧版本,请修复当前安装后重启服务器' : ''}`)
+  } finally {
+    removeInside(dirname(archive), join(plan.home, 'updates', 'npm'))
+  }
 }
 
 function serviceOptions(jobPath, job) {
@@ -320,13 +350,14 @@ function serviceOptions(jobPath, job) {
 }
 
 async function runUpdate(jobPath, job) {
-  const options = serviceOptions(jobPath, job)
-  await recoverTransition(options)
-  if (job.plan.npm && !job.plan.prepared) {
-    await stageNpmRelease(job, jobPath)
+  if (job.plan.npm) {
+    if (job.plan.prepared) await overwriteNpmRelease(job, jobPath)
+    else await stageNpmRelease(job, jobPath)
     return
   }
-  const finalRoot = job.plan.prepared ? verifyPreparedRelease(job.plan, job.target) : stageRelease(job, jobPath).finalRoot
+  const options = serviceOptions(jobPath, job)
+  await recoverTransition(options)
+  const { finalRoot } = stageRelease(job, jobPath)
   updateJob(jobPath, { state: 'installing', message: '正在准备切换 Web 服务' })
   await transitionService({ ...options, finalRoot })
   updateJob(jobPath, { state: 'succeeded', message: `已升级 Web ${job.target.webVersion}` })

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

@@ -1,7 +1,10 @@
 export interface NpmRelease { version: string; integrity: string; tarball: string }
+export interface PreparedNpmUpdate { archive: 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 npmInstallationLocation(root: string): { prefix: string; global: boolean }
+export function verifyPreparedNpmArchive(home: string, prepared: PreparedNpmUpdate, npm: NpmRelease): string
 export function npmRuntimeDigest(root: string, version: string): string

+ 31 - 4
bin/lib/npm-release.mjs

@@ -1,7 +1,7 @@
 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'
+import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, readdirSync, realpathSync } from 'node:fs'
+import { basename, dirname, join, sep } from 'node:path'
 
 export const NPM_PACKAGE = '@lsamien/yaoyao'
 export const NPM_RELEASE_SOURCE = `npm:${NPM_PACKAGE}`
@@ -37,8 +37,35 @@ export function inspectNpmRelease(cwd) {
   })
 }
 
-/** Hash the published application's files. npm installs dependencies separately;
- * these files must remain identical between preparation and activation. */
+export function npmInstallationLocation(root) {
+  root = realpathSync(root)
+  const modules = dirname(dirname(root)), prefix = dirname(modules)
+  if (basename(modules) !== 'node_modules' || root !== join(modules, NPM_PACKAGE) || !isNpmInstallation(root))
+    throw new Error('直接覆盖更新仅支持已核对的 npm 安装目录')
+  // Unix global installs use <prefix>/lib/node_modules; managed local installs
+  // use <prefix>/node_modules. Preserve the layout and unrelated global tools.
+  return basename(prefix) === 'lib' ? { prefix: dirname(prefix), global: true } : { prefix, global: false }
+}
+
+export function verifyPreparedNpmArchive(home, prepared, npm) {
+  const downloads = realpathSync(join(home, 'updates', 'npm'))
+  const archive = realpathSync(prepared.archive), directory = basename(dirname(archive))
+  if (!/^[0-9a-f-]{36}$/.test(directory) || archive !== join(downloads, directory, 'release.tgz') || !lstatSync(archive).isFile())
+    throw new Error('已下载更新的文件位置无效')
+  const hash = createHash('sha512'), descriptor = openSync(archive, 'r'), buffer = Buffer.alloc(1024 * 1024)
+  try {
+    let bytes, received = 0
+    while ((bytes = readSync(descriptor, buffer, 0, buffer.length, null)) > 0) {
+      received += bytes
+      if (received > 512 * 1024 * 1024) throw new Error('npm 安装包超过下载大小限制')
+      hash.update(buffer.subarray(0, bytes))
+    }
+  } finally { closeSync(descriptor) }
+  if (`sha512-${hash.digest('base64')}` !== npm.integrity) throw new Error('已下载更新的安装包完整性校验失败')
+  return archive
+}
+
+/** Validate the installed application's version, build and published files. */
 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'))

+ 8 - 6
docs/npm-install.md

@@ -53,13 +53,15 @@ LaunchAgent 安装时的环境会写入 plist;修改环境变量配置后,
 macOS 通过 `yaoyao service install` 注册的独立服务可进入「我的设置 → 更新与回滚」升级:
 
 1. 检查 npm 仓库的新版本,点击「下载更新」。
-2. 安装包通过 SHA-512 校验后,安装到独立版本目录并准备依赖;当前服务继续运行。
-3. 显示「重启服务器」后,由管理员点击生效。后台进程等待任务结束、备份数据、切换版本并验证服务;启动失败会恢复旧版本。
-4. 更新成功后页面自动刷新。旧程序目录保留,可以在数据库结构兼容时回滚上一版本;手动回滚保留更新后写入的数据。
+2. 安装包通过 SHA-512 校验后暂存;当前服务继续运行。
+3. 显示「重启服务器」后,由管理员点击生效。后台进程等待任务结束、停止服务,在当前 npm 安装位置覆盖程序及依赖,再启动并验证新版本。
+4. 更新成功后页面自动刷新。临时安装包自动清理,不新增版本目录或回滚备份,不保留旧程序;用户数据目录保持原位。
 
-准备状态保存在服务器,即使关闭页面也不会自动重启,重新打开设置仍可继续。已准备更新的重启不依赖 npm 仓库在线。远程升级默认关闭,需要显式设置 `HERMES_YAOYAO_ALLOW_REMOTE_UPDATE=1`;接口仍要求管理员登录、Origin 和 CSRF 校验。
+下载状态保存在服务器,即使关闭页面也不会自动重启,重新打开设置仍可继续。重启时使用已经校验的安装包,但 npm 安装依赖仍可能需要仓库连接。远程升级默认关闭,需要显式设置 `HERMES_YAOYAO_ALLOW_REMOTE_UPDATE=1`;接口仍要求管理员登录、Origin 和 CSRF 校验。
 
-程序保存在 `~/.local/share/hermes-yaoyao/releases/`,`current` 指向正在使用的包目录。npm 全局安装保留为启动器,`yaoyao --version` 显示全局包版本;界面的「当前 Web」显示实际服务版本。`yaoyao service restart` 使用已保存的服务入口,不会切回全局旧包。
+全局 npm 安装在原来的包目录更新,也支持之前迁移到版本目录的 npm 服务就地更新;后续更新复用同一路径。`~/.local/share/hermes-yaoyao/current` 指向实际服务的包目录,界面的「当前 Web」显示运行版本。全局安装就地更新后,`yaoyao --version` 也会显示新版本;历史迁移的服务可能与全局启动器版本不同。
+
+npm 覆盖更新不提供回滚入口,覆盖或新版本启动失败时需要修复当前安装再重启,不会自动恢复旧程序或旧数据。源码和 App 的更新机制保持各自原有规则。
 
 首次使用需要先安装包含界面更新功能的 npm 版本。旧版本可用命令升级:
 
@@ -68,7 +70,7 @@ npm install -g @lsamien/yaoyao@latest
 yaoyao service install
 ```
 
-已经迁移到版本目录的服务,重复执行全局包的 `service install` 会重新指定服务入口;日常重启使用 `service restart`,后续升级使用界面。
+重复执行全局包的 `service install` 会重新指定服务入口;日常重启使用 `service restart`,后续升级使用界面。
 
 默认 npm 安装自动使用 `npm:@lsamien/yaoyao` 发布源,检查遵循 npm 仓库配置;显式自定义的 Git 仓库继续使用 Git 更新流程。也可设置 `HERMES_YAOYAO_RELEASE_SOURCE=npm:@lsamien/yaoyao`。
 

+ 92 - 66
scripts/verify-npm-update.mjs

@@ -1,77 +1,103 @@
 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 { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, 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 { stageNpmRelease, overwriteNpmRelease } from '../bin/hermes-yaoyao-updater.mjs'
+import { currentRelease, LaunchAgentService, localRequest, switchRelease } 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')
+for (const global of [true, false]) {
+  test(`real npm ${global ? 'global' : 'managed local'} updates overwrite the same installation without rollback or retained copies`, { 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)
+    const prefix = global ? join(root, 'global') : join(releaseRoot, 'releases', `0.2.0-npm-${randomUUID()}`)
+    const packageRoot = join(prefix, ...(global ? ['lib'] : []), 'node_modules/@lsamien/yaoyao')
+    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.2' ? 1500 : 10000) }
+    }
+    const driver = new FixtureService({ home, releaseRoot, port, label, plistPath: join(home, 'updates/service.plist') })
+    function packageJob(version, broken = false) {
+      const source = join(root, `package-${version}`), target = writeNpmFixture(source, version, broken)
+      if (version === '0.2.0') writeFileSync(join(source, 'old-only.txt'), 'must disappear on overwrite')
+      const [packed] = JSON.parse(execFileSync('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', root], { cwd: source, encoding: 'utf8' }))
+      const packedPath = join(root, packed.filename)
+      archive = readFileSync(packedPath)
+      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, previousServiceRoot: packageRoot, source: 'npm:@lsamien/yaoyao', npm, target } }
+      writeFileSync(jobPath, JSON.stringify(job))
+      return { job, jobPath, packedPath }
+    }
+    const readVersion = () => JSON.parse(readFileSync(join(packageRoot, 'package.json'))).version
+    const apply = (next, prepared) => overwriteNpmRelease({ ...next.job, plan: { ...next.job.plan, prepared } }, next.jobPath, { driver })
+    try {
+      const initial = packageJob('0.2.0')
+      mkdirSync(prefix, { recursive: true })
+      execFileSync('npm', ['install', ...(global ? ['--global'] : []), '--prefix', prefix, '--no-save', '--package-lock=false', '--no-audit', '--no-fund', '--allow-file=all', initial.packedPath], { cwd: prefix, stdio: 'pipe' })
+      const unrelated = join(prefix, ...(global ? ['lib'] : []), 'node_modules/other-tool')
+      if (global) {
+        mkdirSync(unrelated); writeFileSync(join(unrelated, 'package.json'), '{"name":"other-tool","version":"1.0.0"}')
+        writeFileSync(join(unrelated, 'keep.txt'), 'unrelated tool')
+      }
+      switchRelease(releaseRoot, packageRoot); await driver.start(packageRoot); await driver.verify()
+      writeFileSync(join(home, 'user.txt'), 'original data')
+      const oldDownload = join(home, 'updates/npm', randomUUID()); mkdirSync(oldDownload, { recursive: true }); writeFileSync(join(oldDownload, 'release.tgz'), 'obsolete download')
+      for (const version of ['0.3.0', '0.3.1']) {
+        const next = packageJob(version), previousVersion = readVersion(), previousPID = driver.pid()
+        const prepared = await stageNpmRelease(next.job, next.jobPath)
+        assert.equal(JSON.parse(readFileSync(next.jobPath)).state, 'prepared')
+        assert.equal(driver.pid(), previousPID, 'download must leave the old service running')
+        assert.equal(readVersion(), previousVersion)
+        assert.equal(readdirSync(join(home, 'updates/npm')).length, 1, 'keep only the current temporary archive')
+        await apply(next, prepared)
+        assert.notEqual(driver.pid(), previousPID)
+        assert.equal(currentRelease(releaseRoot), packageRoot, 'reuse the same installation path')
+        assert.equal((await localRequest(`http://127.0.0.1:${port}/api/status`)).body.version, version)
+        assert.equal(readFileSync(join(home, 'user.txt'), 'utf8'), 'original data')
+        assert.equal(existsSync(join(packageRoot, 'old-only.txt')), false, 'remove obsolete program files')
+        assert.deepEqual(readdirSync(join(home, 'updates/npm')), [], 'clean the downloaded archive')
+        assert.equal(existsSync(join(home, 'updates/backups')), false)
+        assert.equal(existsSync(join(home, 'updates/last-success.json')), false)
+        assert.equal(existsSync(join(home, 'updates/transition.json')), false)
+        if (global) assert.equal(readFileSync(join(unrelated, 'keep.txt'), 'utf8'), 'unrelated tool')
+        assert.equal(global ? existsSync(join(releaseRoot, 'releases')) : readdirSync(join(releaseRoot, 'releases')).length > 1, false, 'do not create additional release directories')
+      }
 
-    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 broken = packageJob('0.3.2', true), healthyPID = driver.pid()
+      const bad = await stageNpmRelease(broken.job, broken.jobPath)
+      assert.equal(driver.pid(), healthyPID)
+      await assert.rejects(apply(broken, bad), /未保留旧版本/)
+      assert.equal(currentRelease(releaseRoot), packageRoot)
+      assert.equal(readVersion(), '0.3.2', 'a failed health check must not restore old code')
+      assert.equal((await localRequest(`http://127.0.0.1:${port}/api/status`)).body.version, '0.3.2')
+      assert.equal(readFileSync(join(home, 'user.txt'), 'utf8'), 'failed update', 'do not restore an old data snapshot')
+      assert.equal(existsSync(join(home, 'updates/backups')), false)
+      assert.deepEqual(readdirSync(join(home, 'updates/npm')), [])
 
-    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 })
-  }
-})
+      const invalid = packageJob('0.3.3'), survivingPID = driver.pid()
+      invalid.job.plan.npm.integrity = `sha512-${Buffer.alloc(64).toString('base64')}`
+      await assert.rejects(stageNpmRelease(invalid.job, invalid.jobPath), /完整性校验失败/)
+      assert.equal(driver.pid(), survivingPID)
+      assert.equal(readVersion(), '0.3.2')
+      assert.deepEqual(readdirSync(join(home, 'updates/npm')), [])
+      console.log(`Verified ${global ? 'global' : 'managed local'} npm overwrite, repeat updates, archive cleanup and failed startup without rollback in an isolated LaunchAgent.`)
+    } finally {
+      await driver.stop().catch(() => {})
+      await new Promise(done => { registry.close(done); registry.closeAllConnections() })
+      rmSync(root, { recursive: true, force: true })
+    }
+  })
+}

+ 4 - 4
src/client/components/app/SystemUpdatePanel.vue

@@ -180,17 +180,17 @@ onBeforeUnmount(() => { lifecycleToken += 1; operationStarting.value = false; st
 
 <template>
   <section class="system-update-panel" aria-label="更新与回滚">
-    <p class="system-update-intro">查看 Web 版本、检查更新或回滚到上一个版本。更新完成后刷新页面即可使用新版本。</p>
+    <p class="system-update-intro">{{ npmUpdate ? '检查并下载服务器新版本,点击重启服务器后覆盖当前安装。' : '查看 Web 版本、检查更新或回滚到上一个版本。更新完成后刷新页面即可使用新版本。' }}</p>
     <p v-if="error" class="system-update-error" role="alert"><AppIcon name="alert" :size="16" />{{ error }}</p>
 
     <section v-if="status" class="version-grid" aria-label="版本信息">
       <article><small>当前 Web</small><strong>{{ status.current.webVersion }}</strong></article>
-      <article><small>最新 Web</small><strong>{{ error ? '检查失败' : !remoteChecked ? '待检查' : status.latest?.webVersion || status.current.webVersion }}</strong></article>
+      <article><small>最新 Web</small><strong>{{ error ? '检查失败' : !remoteChecked ? '待检查' : status.latest?.webVersion || (prepared ? job?.target?.webVersion : undefined) || status.current.webVersion }}</strong></article>
     </section>
 
     <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="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">
@@ -201,7 +201,7 @@ onBeforeUnmount(() => { lifecycleToken += 1; operationStarting.value = false; st
 
     <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 && !prepared" class="quiet-button danger" type="button" :disabled="busy" @click="rollback">回滚上一版本</button>
+      <button v-else-if="status?.canRollback && !npmUpdate && !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 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>

+ 5 - 6
src/server/updateManager.ts

@@ -9,7 +9,6 @@ import {
   mkdtempSync,
   openSync,
   readFileSync,
-  realpathSync,
   rmSync,
   statSync,
   writeFileSync,
@@ -20,7 +19,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 { inspectNpmRelease, isNpmInstallation, NPM_RELEASE_SOURCE, verifyPreparedNpmArchive, type NpmRelease, type PreparedNpmUpdate } from '../../bin/lib/npm-release.mjs'
 import { githubReleasePage, inspectGitHubRelease } from './githubReleases.js'
 import {
   compareReleaseVersions,
@@ -64,7 +63,7 @@ interface UpdatePlan {
   target?: ReleaseManifest
   targetCommit?: string
   npm?: NpmRelease
-  prepared?: { root: string; digest: string }
+  prepared?: PreparedNpmUpdate
 }
 
 interface StoredUpdateJob extends UpdateJob {
@@ -295,7 +294,7 @@ export class SystemUpdateManager {
       updateAvailable: Boolean(latest && compareReleaseVersions(latest.releaseVersion, current.releaseVersion) > 0),
       supported: !this.desktopOwned && this.platform === 'darwin',
       unsupportedReason: this.desktopOwned ? 'App 内置服务随 App 一起更新,请使用夭夭菜单中的 App 更新与回退。' : this.platform === 'darwin' ? undefined : '容器和非 macOS 环境请通过替换部署镜像升级',
-      canRollback: !this.desktopOwned && existsSync(join(this.updateHome, 'last-success.json')),
+      canRollback: !this.desktopOwned && this.releaseSource !== NPM_RELEASE_SOURCE && existsSync(join(this.updateHome, 'last-success.json')),
       job: job?.state === 'succeeded' && job.target && (job.target.webVersion !== current.webVersion || job.target.releaseVersion !== current.releaseVersion) ? undefined : publicJob(job),
     }
   }
@@ -414,8 +413,7 @@ export class SystemUpdateManager {
       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')
+      verifyPreparedNpmArchive(this.config.home, prepared, job.plan.npm)
     } catch {
       const error = '已准备更新的文件已改变或不可用,请重新下载'
       writeFileSync(join(this.updateHome, `${job.id}.json`), JSON.stringify({ ...job, state: 'failed', message: error, error, updatedAt: new Date().toISOString() }), { mode: 0o600 })
@@ -426,6 +424,7 @@ export class SystemUpdateManager {
 
   startRollback(): UpdateJob {
     if (this.desktopOwned) throw new Error('App 内置服务随 App 一起更新或回退')
+    if (this.releaseSource === NPM_RELEASE_SOURCE) throw new Error('npm 覆盖更新不保留旧版本,不支持回滚')
     if (this.platform !== 'darwin') throw new Error('当前环境不支持服务内回滚')
     if (!existsSync(join(this.updateHome, 'last-success.json'))) throw new Error('没有可回滚的上一版本')
     return publicJob(this.createJob('rollback'))!

+ 4 - 1
tests/client/systemUpdatePanel.test.ts

@@ -73,13 +73,16 @@ describe('SystemUpdatePanel', () => {
     const confirm = vi.spyOn(window, 'confirm')
     let wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
     await flushPromises()
+    expect(wrapper.text()).toContain('不保留旧版本')
+    expect(wrapper.text()).not.toContain('回滚上一版本')
     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 })
+    api.systemUpdateStatus.mockResolvedValue({ ...npmStatus, latest: undefined, job: preparedJob })
     await vi.advanceTimersByTimeAsync(1000); await flushPromises()
     expect(wrapper.get('.restart-server').text()).toContain('重启服务器')
+    expect(wrapper.get('.version-grid').text()).toContain('0.3.0')
     expect(wrapper.emitted('lock-change')?.at(-1)).toEqual([false])
     expect(api.activateSystemUpdate).not.toHaveBeenCalled()
     wrapper.unmount()

+ 12 - 7
tests/e2e/npmUpdate.spec.ts

@@ -1,5 +1,6 @@
 import { expect, test } from '@playwright/test'
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { createHash } from 'node:crypto'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -7,7 +8,6 @@ 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) => {
@@ -15,9 +15,9 @@ test('npm update downloads in place, survives reopening, and refreshes only afte
   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 releaseRoot = join(root, 'programs')
+  const target = { ...current, releaseVersion: version, webVersion: version, gitTag: `v${version}` }
+  const archive = Buffer.from('fixture npm archive')
   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']),
@@ -25,7 +25,7 @@ test('npm update downloads in place, survives reopening, and refreshes only afte
     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')}` } }),
+    inspectRemote: async () => ({ manifest: target, npm: { version, tarball: 'https://registry.npmjs.org/fixture.tgz', integrity: `sha512-${createHash('sha512').update(archive).digest('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) => {
@@ -39,7 +39,9 @@ test('npm update downloads in place, survives reopening, and refreshes only afte
         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 } }))
+          const prepared = { archive: join(home, 'updates/npm', job.id, 'release.tgz') }
+          mkdirSync(join(prepared.archive, '..'), { recursive: true }); writeFileSync(prepared.archive, archive)
+          writeFileSync(path, JSON.stringify({ ...job, state: 'prepared', message: '新版本已下载完成,点击重启服务器覆盖更新', plan: { ...job.plan, prepared } }))
           rmSync(join(home, 'updates/active.lock'))
         }
       }
@@ -66,12 +68,15 @@ test('npm update downloads in place, survives reopening, and refreshes only afte
     page.on('dialog', dialog => { confirmations++; void dialog.dismiss() })
     await page.goto(origin + '/chat')
     let dialog = await openUpdates()
+    await expect(dialog).toContainText('不保留旧版本')
+    await expect(dialog.getByRole('button', { name: '回滚上一版本', exact: true })).toHaveCount(0)
     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 expect(dialog.locator('.version-grid')).toContainText(version)
     await page.screenshot({ path: testInfo.outputPath('npm-ready.png') })
     await page.reload()
     dialog = await openUpdates()

+ 27 - 13
tests/server/npmUpdates.test.ts

@@ -1,21 +1,23 @@
 // @vitest-environment node
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { createHash } from 'node:crypto'
 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 { NPM_RELEASE_SOURCE, npmInstallationLocation, 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 archive = Buffer.from('fixture npm archive')
+const npm = { version: '0.3.0', integrity: `sha512-${createHash('sha512').update(archive).digest('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')
+  const root = realpathSync(mkdtempSync(join(tmpdir(), 'yaoyao-npm-manager-'))); roots.push(root)
+  const projectRoot = join(root, 'global/lib/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 }))
@@ -39,9 +41,8 @@ it('detects npm installs and queues an integrity-pinned download without activat
 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) }
+  const prepared = { archive: join(f.config.home, 'updates/npm', job.id, 'release.tgz') }
+  mkdirSync(join(prepared.archive, '..'), { recursive: true }); writeFileSync(prepared.archive, archive)
   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'))
@@ -56,15 +57,14 @@ it('restores a prepared update after manager recreation and activates offline on
   expect(() => resumed.startActivation(job.id)).toThrow('没有可生效')
 })
 
-it('rejects changed prepared files and retains explicit Git sources for npm installs', async () => {
+it('rejects a changed downloaded archive 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) }
+  const prepared = { archive: join(f.config.home, 'updates/npm', job.id, 'release.tgz') }
+  mkdirSync(join(prepared.archive, '..'), { recursive: true }); writeFileSync(prepared.archive, archive)
   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')
+  writeFileSync(prepared.archive, 'changed after verification')
   expect(() => f.manager.startActivation(job.id)).toThrow('文件已改变')
   expect(f.manager.status().job?.state).toBe('failed')
   expect(f.launchUpdater).toHaveBeenCalledTimes(1)
@@ -72,6 +72,20 @@ it('rejects changed prepared files and retains explicit Git sources for npm inst
   expect(git.status()).toMatchObject({ installationMode: 'source', updateMethod: 'git', releaseSource: 'https://example.test/private.git' })
 })
 
+it('disables npm rollback even with a legacy record and limits overwrites to npm installation layouts', () => {
+  const f = fixture()
+  mkdirSync(join(f.config.home, 'updates'), { recursive: true })
+  writeFileSync(join(f.config.home, 'updates/last-success.json'), '{}')
+  expect(f.manager.status().canRollback).toBe(false)
+  expect(() => f.manager.startRollback()).toThrow('不支持回滚')
+  expect(npmInstallationLocation(f.options.projectRoot)).toEqual({ prefix: join(f.root, 'global'), global: true })
+  const local = join(f.root, 'local/node_modules/@lsamien/yaoyao')
+  writeNpmFixture(local, '0.2.0')
+  expect(npmInstallationLocation(local)).toEqual({ prefix: join(f.root, 'local'), global: false })
+  const source = join(f.root, 'source'); writeNpmFixture(source, '0.2.0')
+  expect(() => npmInstallationLocation(source)).toThrow('npm 安装目录')
+})
+
 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)

+ 26 - 1
tests/updater.test.ts

@@ -1,4 +1,5 @@
-import { lstatSync, mkdtempSync, rmSync } from 'node:fs'
+import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { createHash, randomUUID } from 'node:crypto'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -6,12 +7,14 @@ import { launchAgentPlist } from '../bin/hermes-yaoyao.mjs'
 import {
   currentTarget,
   formatCommandFailure,
+  overwriteNpmRelease,
   restorePreviousService,
   serviceInstallInvocation,
   switchCurrent,
   validateManifest,
   verifyRuntime,
 } from '../bin/hermes-yaoyao-updater.mjs'
+import { writeNpmFixture } from './fixtures/npm-release.mjs'
 
 const roots: string[] = []
 afterEach(() => {
@@ -19,6 +22,28 @@ afterEach(() => {
 })
 
 describe('standalone updater primitives', () => {
+  it.each([true, false])('protects data inside the npm installation and never restarts an old version after an overwrite failure (nested data: %s)', async (nestedData) => {
+    const root = realpathSync(mkdtempSync(join(tmpdir(), 'npm-overwrite-'))); roots.push(root)
+    const packageRoot = join(root, 'node_modules/@lsamien/yaoyao')
+    writeNpmFixture(packageRoot, '0.2.0')
+    const home = nestedData ? join(packageRoot, 'data') : join(root, 'data'), id = randomUUID()
+    const archive = join(home, 'updates/npm', id, 'release.tgz'), contents = Buffer.from('verified archive')
+    mkdirSync(join(archive, '..'), { recursive: true }); writeFileSync(archive, contents)
+    writeFileSync(join(home, 'user.txt'), 'user data')
+    const target = { schemaVersion: 1, releaseVersion: '0.3.0', webVersion: '0.3.0', gitTag: 'v0.3.0' }
+    const job = { id, target, plan: { home, releaseRoot: join(root, 'releases'), previousServiceRoot: packageRoot, prepared: { archive },
+      npm: { version: '0.3.0', integrity: `sha512-${createHash('sha512').update(contents).digest('base64')}`, tarball: 'https://example.test/release.tgz' } } }
+    const jobPath = join(home, 'updates', `${id}.json`); writeFileSync(jobPath, JSON.stringify(job))
+    const driver = { snapshot: () => ({ root: packageRoot, wasRunning: false }), quiesce: vi.fn(), stop: vi.fn(), resume: vi.fn(), start: vi.fn(), restore: vi.fn() }
+    const install = vi.fn(async () => { writeFileSync(join(packageRoot, 'dist/index.html'), 'partly overwritten'); throw new Error('installation failed') })
+    await expect(overwriteNpmRelease(job, jobPath, { driver, install })).rejects.toThrow(nestedData ? '用户数据目录' : '未保留旧版本')
+    expect(readFileSync(join(home, 'user.txt'), 'utf8')).toBe('user data')
+    expect(driver.start).not.toHaveBeenCalled(); expect(driver.restore).not.toHaveBeenCalled(); expect(driver.resume).not.toHaveBeenCalled()
+    expect(install).toHaveBeenCalledTimes(nestedData ? 0 : 1)
+    expect(driver.stop).toHaveBeenCalledTimes(nestedData ? 0 : 1)
+    expect(existsSync(archive)).toBe(false)
+  })
+
   it('accepts a healthy Web without probing 9119 or upstream-dependent readiness', async () => {
     const fetchImpl = vi.fn(async (url: string) => {
       if (url !== 'http://127.0.0.1:15300/healthz') throw new Error('9119 offline')