From 2669f251ac4998dae4743095c3b1baaae79a1d5c Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 19 Oct 2026 15:41:42 +0800 Subject: [PATCH] fix: preserve npm environment during package replacement --- docs/development/platform-upgrade-bundles.md | 2 +- package.json | 2 +- scripts/verify-panel-package.cjs | 9 +- shell/install-package.cjs | 46 +++++++++- test/back/npm-package.test.cjs | 96 ++++++++++++++++++++ 5 files changed, 146 insertions(+), 9 deletions(-) diff --git a/docs/development/platform-upgrade-bundles.md b/docs/development/platform-upgrade-bundles.md index 6adf7d6a..58355334 100644 --- a/docs/development/platform-upgrade-bundles.md +++ b/docs/development/platform-upgrade-bundles.md @@ -74,7 +74,7 @@ npm 会排除根目录的 `pnpm-lock.yaml`。主包的原生 `npm pack/publish` 服务集成测试只能在专用容器中运行:设置 `QL_PANEL_PACKAGE_TEST_ISOLATED=1`、指向隔离 PM2 的 `QL_PANEL_TEST_PM2` 和指向正式构建脚本输出目录的 `QL_PANEL_TEST_BUNDLE_DIR`,然后执行 `node scripts/verify-panel-package.cjs verify /tmp/qinglong-npm /tmp/qinglong-npm-results`。容器需已有 Bash、curl、OpenSSL、Python、jq、nginx 和 procps。旧正式包会重置 PATH,因此验收容器将 PM2 6.0.14 安装到 `/usr/local/bin/pm2`,并以此设置 `QL_PANEL_TEST_PM2`;隔离通过专属容器和独立 `PM2_HOME` 保证。测试创建独立的 npm prefix、HOME、PM2_HOME 和数据目录,结束后清理。 -通过 npm 覆盖升级前,应先停止服务,备份安装目录中的自定义 `.env`,执行新包安装,再恢复 `.env` 并启动服务;持久数据必须放在包目录以外的 `QL_DATA_DIR`。npm 会替换整个包目录,不负责保留包内用户文件。验收按这一流程备份和恢复自定义 `.env`,并直接校验外置用户数据。在线和离线平台包升级仍由事务保留现有 `.env`。 +通过 npm 覆盖升级前,应先停止服务,执行新包安装,再启动服务。正常 npm 安装的 `postinstall` 会从当前路径对应的 npm 旧包临时目录中自动保留 `.env`,逐字节恢复为权限 `0600` 的普通文件,不需要手工备份和恢复;新目录中已有的 `.env` 不会被覆盖。只读取包名匹配的旧包,不扫描其他安装或备份目录;异常配置(如符号链接)会使安装失败,由 npm 回退旧包,避免静默丢失配置。此机制需要启用 npm 安装脚本,不能使用 `--ignore-scripts`。持久数据仍必须放在包目录以外的 `QL_DATA_DIR`。验收直接检查真实旧 npm 包覆盖安装后配置自动保留,并校验外置用户数据。在线和离线平台包升级仍由事务保留现有 `.env`。 master 发布任务等待上述测试通过,直接发布已验证的 `.tgz`,不重新构建或打包。develop 只生成测试归档与验收记录,不发布 npm。 diff --git a/package.json b/package.json index 987de9d9..7cd57bcf 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'", "prepack": "node scripts/prepare-npm-package.cjs", "postpack": "node scripts/prepare-npm-package.cjs --clean", - "postinstall": "node -e \"const fs = require('fs'); if (fs.existsSync('shell/install-package.cjs')) require('./shell/install-package.cjs').restorePackageLock(); else fs.accessSync('pnpm-lock.yaml')\" && (max setup 2>/dev/null || true)", + "postinstall": "node -e \"const fs = require('fs'); if (fs.existsSync('shell/install-package.cjs')) require('./shell/install-package.cjs').installPackage(); else fs.accessSync('pnpm-lock.yaml')\" && (max setup 2>/dev/null || true)", "start:simple": "bash shell/start-simplify.sh" }, "gitHooks": { diff --git a/scripts/verify-panel-package.cjs b/scripts/verify-panel-package.cjs index bf7dbd97..40155f78 100644 --- a/scripts/verify-panel-package.cjs +++ b/scripts/verify-panel-package.cjs @@ -231,14 +231,13 @@ async function verifyPanel(input, output) { assert.equal(await fs.readFile(script, 'utf8'), `console.log(${JSON.stringify(nonce)});\n`); }; await preservedData(); - console.log('Stopping legacy service, backing up its .env and overwriting with the new npm archive'); + console.log('Stopping legacy service and verifying npm automatically preserves its .env during overwrite'); await stop(); - // npm replaces its entire package directory. Back up a custom root .env - // explicitly, as required by the documented package-manager upgrade flow. await fs.appendFile(path.join(root, '.env'), `\n# retained custom npm environment ${nonce}\n`); const savedEnv = await fs.readFile(path.join(root, '.env')); await install(input); - await fs.writeFile(path.join(root, '.env'), savedEnv); + assert.deepEqual(await fs.readFile(path.join(root, '.env')), savedEnv); + assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600); const configSha = hash(savedEnv); const local = createRequire(path.join(root, 'package.json')); const artifacts = local('./shell/upgrade.cjs'); @@ -322,7 +321,7 @@ async function verifyPanel(input, output) { installedProductionDependencies: true, restoredLockVerified: true, legacyNpmVersion: legacy.version, legacyNpmIntegrity: legacy.integrity, legacyNpmOverwriteInstalled: true, legacyExternalDataPreserved: true, - customRootEnvironmentRestoredFromExplicitBackup: true, + customRootEnvironmentPreservedAutomatically: true, officialPlatformBundle: { baseImage: fixture.baseImage, sourceCommit: fixture.sourceCommit, sha256: fixture.sha256 }, migratedFromNpmToOfficialPnpmLayout: true, existingNodePreserved: true, diff --git a/shell/install-package.cjs b/shell/install-package.cjs index 32fc5460..895455ff 100644 --- a/shell/install-package.cjs +++ b/shell/install-package.cjs @@ -28,5 +28,47 @@ function restorePackageLock(root = path.resolve(__dirname, '..')) { } } -module.exports = { restorePackageLock }; -if (require.main === module) restorePackageLock(); +function restoreEnvironment(root = path.resolve(__dirname, '..')) { + // npm's Arborist keeps this exact retirement directory until lifecycle scripts + // succeed, then deletes it. Never search other installations or sibling backups. + const suffix = createHash('sha1').update(root).digest('base64') + .replace(/[^a-zA-Z0-9]+/g, '').slice(0, 8); + const retired = path.join(path.dirname(root), `.${path.basename(root)}-${suffix}`); + const destination = path.join(root, '.env'); + const exists = (file) => { + try { return fs.lstatSync(file); } + catch (error) { if (error.code === 'ENOENT') return; throw error; } + }; + if (exists(destination)) return; + const directory = exists(retired); + if (!directory) return; + if (!directory.isDirectory()) throw new Error('Invalid npm retirement directory'); + const metadata = path.join(retired, 'package.json'); + if (!exists(metadata)?.isFile()) throw new Error('Invalid retired npm package'); + if (JSON.parse(fs.readFileSync(metadata, 'utf8')).name !== '@whyour/qinglong') + throw new Error('Unexpected retired npm package'); + const source = path.join(retired, '.env'); + const stat = exists(source); + if (!stat) return; + if (!stat.isFile()) throw new Error('Retired npm .env must be a regular file'); + const descriptor = fs.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + const temporary = path.join(root, `.upgrade-env-${randomUUID()}`); + try { + if (!fs.fstatSync(descriptor).isFile()) throw new Error('Invalid retired npm .env'); + fs.writeFileSync(temporary, fs.readFileSync(descriptor), { flag: 'wx', mode: 0o600 }); + // Linking publishes a complete file without replacing a concurrently created .env. + try { fs.linkSync(temporary, destination); } + catch (error) { if (error.code !== 'EEXIST') throw error; } + } finally { + fs.closeSync(descriptor); + fs.rmSync(temporary, { force: true }); + } +} + +function installPackage(root = path.resolve(__dirname, '..')) { + restorePackageLock(root); + restoreEnvironment(root); +} + +module.exports = { restorePackageLock, restoreEnvironment, installPackage }; +if (require.main === module) installPackage(); diff --git a/test/back/npm-package.test.cjs b/test/back/npm-package.test.cjs index ac4ef36c..26b1a7dd 100644 --- a/test/back/npm-package.test.cjs +++ b/test/back/npm-package.test.cjs @@ -9,6 +9,7 @@ const { promisify } = require('node:util'); const execute = promisify(execFile); const { checkPackedFiles } = require('../../scripts/verify-panel-package.cjs'); const { restorePackageLock } = require('../../shell/install-package.cjs'); +const { restoreEnvironment } = require('../../shell/install-package.cjs'); async function packageFixture(t) { const temp = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-contract-'))); @@ -70,6 +71,7 @@ test('real npm pack excludes local state and its installed lifecycle restores ve const prefix = path.join(temp, 'installed'); await execute('npm', ['install', '--offline', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), '--prefix', prefix, path.join(output, archive.filename)], { cwd: temp, env, timeout: 60000 }); const installed = path.join(prefix, 'node_modules/@whyour/qinglong'); + await assert.rejects(fs.access(path.join(installed, '.env')), { code: 'ENOENT' }); assert.equal(await fs.readFile(path.join(installed, 'pnpm-lock.yaml'), 'utf8'), lock); assert.equal(typeof require(path.join(installed, 'shell/upgrade.cjs')).stageUpgrade, 'function'); assert.ok((await fs.stat(path.join(installed, 'shell/update.sh'))).mode & 0o111); @@ -95,6 +97,100 @@ test('a missing npm runtime lockfile fails instead of claiming an installed pack assert.throws(() => restorePackageLock(temp), /Missing npm package upgrade lockfile/); }); +test('real npm overwrites preserve .env automatically and failed installs restore the old package', async (t) => { + const { root, output, temp, env, pack } = await packageFixture(t); + const [packed] = JSON.parse((await pack()).stdout); + const archive = path.join(output, packed.filename); + const legacy = path.join(temp, 'legacy'); + await fs.mkdir(legacy); + await fs.writeFile(path.join(legacy, 'package.json'), JSON.stringify({ name: '@whyour/qinglong', version: '2.22.0' })); + await fs.writeFile(path.join(legacy, 'old-package-marker'), 'legacy'); + const [old] = JSON.parse((await execute('npm', ['pack', '--ignore-scripts', '--json', '--cache', path.join(temp, 'cache')], { cwd: legacy, env })).stdout); + const broken = path.join(temp, 'broken'); + await fs.mkdir(broken); + await fs.cp(root, broken, { recursive: true }); + await fs.writeFile(path.join(broken, 'shell/upgrade-lock.yaml'), 'invalid lock'); + const [bad] = JSON.parse((await execute('npm', ['pack', '--ignore-scripts', '--json', '--cache', path.join(temp, 'cache')], { cwd: broken, env })).stdout); + + for (const global of [false, true]) { + await t.test(global ? 'global npm install' : 'local npm install', async () => { + const prefix = path.join(temp, global ? 'global' : 'local'); + const linkedPrefix = prefix + '-link'; + await fs.mkdir(prefix); + await fs.symlink(prefix, linkedPrefix); + const installed = path.join(prefix, global ? 'lib/node_modules/@whyour/qinglong' : 'node_modules/@whyour/qinglong'); + const install = (file) => execute('npm', ['install', ...(global ? ['--global'] : []), '--prefix', linkedPrefix, '--force', '--offline', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), file], { cwd: temp, env, timeout: 60000 }); + await install(path.join(legacy, old.filename)); + const bytes = Buffer.from('QL_DATA_DIR=/external/data\r\nSECRET="配置 test"\r\n# no trailing newline'); + const external = path.join(prefix, 'external.env'); + await fs.writeFile(external, bytes); + await fs.symlink(external, path.join(installed, '.env')); + await assert.rejects(install(archive), (error) => { + assert.match(error.stderr, /must be a regular file/); + return true; + }); + assert.equal(JSON.parse(await fs.readFile(path.join(installed, 'package.json'))).version, '2.22.0'); + assert.equal(await fs.readlink(path.join(installed, '.env')), external); + assert.deepEqual(await fs.readFile(external), bytes); + await fs.rm(path.join(installed, '.env')); + await fs.writeFile(path.join(installed, '.env'), bytes, { mode: 0o644 }); + await assert.rejects(install(path.join(broken, bad.filename)), (error) => { + assert.match(error.stderr, /does not match its build manifest/); + return true; + }); + assert.equal(JSON.parse(await fs.readFile(path.join(installed, 'package.json'))).version, '2.22.0'); + assert.equal(await fs.readFile(path.join(installed, 'old-package-marker'), 'utf8'), 'legacy'); + assert.deepEqual(await fs.readFile(path.join(installed, '.env')), bytes); + await install(archive); + assert.deepEqual(await fs.readFile(path.join(installed, '.env')), bytes); + assert.equal((await fs.stat(path.join(installed, '.env'))).mode & 0o777, 0o600); + await assert.rejects(fs.access(path.join(installed, 'old-package-marker')), { code: 'ENOENT' }); + // Same-version forced reinstall must also retain custom configuration. + await install(archive); + assert.deepEqual(await fs.readFile(path.join(installed, '.env')), bytes); + assert.ok(!(await fs.readdir(installed)).some((name) => name.startsWith('.upgrade-env-'))); + }); + } +}); + +test('environment recovery uses only the exact retired package and never overwrites existing configuration', async (t) => { + const temp = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-env-'))); + t.after(() => fs.rm(temp, { recursive: true, force: true })); + const root = path.join(temp, 'node_modules/@whyour/qinglong'); + await fs.mkdir(root, { recursive: true }); + const suffix = createHash('sha1').update(root).digest('base64').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 8); + const retired = path.join(path.dirname(root), `.qinglong-${suffix}`); + const destination = path.join(root, '.env'); + const other = path.join(path.dirname(root), '.qinglong-unrelated'); + await fs.mkdir(other); + await fs.writeFile(path.join(other, '.env'), 'unrelated'); + restoreEnvironment(root); + await assert.rejects(fs.access(destination), { code: 'ENOENT' }); + await fs.mkdir(retired); + await fs.writeFile(path.join(retired, 'package.json'), JSON.stringify({ name: 'another-package' })); + await fs.writeFile(path.join(retired, '.env'), 'private'); + assert.throws(() => restoreEnvironment(root), /Unexpected retired npm package/); + await fs.writeFile(path.join(retired, 'package.json'), JSON.stringify({ name: '@whyour/qinglong' })); + await fs.rm(path.join(retired, '.env')); + restoreEnvironment(root); + await assert.rejects(fs.access(destination), { code: 'ENOENT' }); + await fs.symlink(path.join(other, '.env'), path.join(retired, '.env')); + assert.throws(() => restoreEnvironment(root), /must be a regular file/); + await fs.rm(path.join(retired, '.env')); + await fs.mkdir(path.join(retired, '.env')); + assert.throws(() => restoreEnvironment(root), /must be a regular file/); + await fs.rm(path.join(retired, '.env'), { recursive: true }); + await fs.writeFile(path.join(retired, '.env'), ''); + restoreEnvironment(root); + assert.equal(await fs.readFile(destination, 'utf8'), ''); + assert.equal((await fs.stat(destination)).mode & 0o777, 0o600); + await fs.writeFile(destination, 'new custom configuration'); + await fs.writeFile(path.join(retired, '.env'), 'old configuration'); + restoreEnvironment(root); + assert.equal(await fs.readFile(destination, 'utf8'), 'new custom configuration'); + assert.ok(!(await fs.readdir(root)).some((name) => name.startsWith('.upgrade-env-'))); +}); + test('npm publication consumes the exact archive whose isolated installation passed', () => { const yaml = require('js-yaml'); const sync = require('node:fs');