From 66261f96d07e2ba3f6d103966b40881bf3f6e432 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 20 Jul 2026 16:53:13 +0800 Subject: [PATCH 01/19] fix(cli): update npm installs safely in background --- docs/design/npm-background-auto-update.md | 53 ++ packages/cli/src/cli.test.ts | 121 ++++ .../cli/src/startup/startup-prefetch.test.ts | 28 +- packages/cli/src/startup/startup-prefetch.ts | 4 + packages/cli/src/ui/utils/updateCheck.test.ts | 21 + packages/cli/src/ui/utils/updateCheck.ts | 2 + .../cli/src/utils/handleAutoUpdate.test.ts | 114 +++- packages/cli/src/utils/handleAutoUpdate.ts | 98 +++- .../cli/src/utils/managed-npm-update.test.ts | 310 +++++++++++ packages/cli/src/utils/managed-npm-update.ts | 519 ++++++++++++++++++ .../cli/src/utils/update-relaunch.test.ts | 13 +- packages/cli/src/utils/update-relaunch.ts | 7 +- scripts/cli-entry.js | 217 +++++++- 13 files changed, 1442 insertions(+), 65 deletions(-) create mode 100644 docs/design/npm-background-auto-update.md create mode 100644 packages/cli/src/utils/managed-npm-update.test.ts create mode 100644 packages/cli/src/utils/managed-npm-update.ts diff --git a/docs/design/npm-background-auto-update.md b/docs/design/npm-background-auto-update.md new file mode 100644 index 00000000000..1e65245a3f6 --- /dev/null +++ b/docs/design/npm-background-auto-update.md @@ -0,0 +1,53 @@ +# Background npm updates + +## Problem + +The published CLI is code-split into content-hashed JavaScript chunks. Running +`npm install -g` from an active session replaces those chunks in place, so a +later dynamic import in the old process can fail with `ERR_MODULE_NOT_FOUND`. +Deferring the install until session exit avoids corruption, but turns a +background update into an exit-time delay and gives users no benefit until they +leave the session. + +## Design + +For writable global npm installations, the post-render update check installs +the exact resolved version under a directory derived from the global launcher: + +```text +~/.qwen/updates/npm//versions// +``` + +The version check runs npm in its global context and the staged install uses an +empty prefix. Both bypass the current repository's `.npmrc` and inherit the +same environment and user/global npm configuration, so the version that was +checked is installed from the same configured registry. + +After npm exits successfully, Qwen Code verifies the package name, version, +bundle, and launcher, then atomically writes an `active.json` pointer beside +that launcher's versions. The global npm package is not modified. The +already-running process continues loading its original package and chunks. On +the next invocation, the stable launcher reads the pointer and starts the +verified version directory. + +Each global npm launcher has its own pointer and version payloads, so +installations under different npm or nvm prefixes can share `~/.qwen` without +overriding one another or sharing dependencies. A slower concurrent update +cannot replace a newer active version. + +An incomplete install never changes the active pointer. Before activation, the +new launcher must report the expected version. A missing, malformed, or +launcher-mismatched pointer is ignored and the original npm package remains the +fallback. The pointer also records the base package and launcher identity, so a +later explicit global npm install supersedes the managed version. + +The launcher creates a process lease before starting a managed version. +Activation keeps leased versions for running sessions and removes unleased +superseded versions. The active pointer, lease creation, and cleanup share a +short lock; package installation and validation remain asynchronous. + +## Scope + +This changes automatic updates for npm installations only. Other package +managers and standalone archives keep the existing exit-safe behavior until +they have an equivalent immutable-version installation layout. diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index d1b6dac2ff3..73c0515e087 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -12,10 +12,13 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, rmSync, + statSync, writeFileSync, } from 'node:fs'; import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { FatalError } from '@qwen-code/qwen-code-core'; @@ -502,6 +505,124 @@ describe('bootstrap import boundaries', () => { expect(output).toBe(`${expectedVersion}\n`); }); + it('launches the active managed npm version without replacing the running package', () => { + const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-managed-npm-')); + const entryDir = path.join(tempDir, 'bootstrap'); + const entryPath = path.join(entryDir, 'cli-entry.mjs'); + const qwenHome = path.join(tempDir, 'home'); + try { + mkdirSync(entryDir, { recursive: true }); + copyFileSync('../../scripts/cli-entry.js', entryPath); + const bootstrapId = createHash('sha256') + .update(realpathSync(entryPath)) + .digest('hex') + .slice(0, 16); + const launcherRoot = path.join(qwenHome, 'updates', 'npm', bootstrapId); + const packageRoot = path.join( + launcherRoot, + 'versions', + '2.0.0', + 'node_modules', + '@qwen-code', + 'qwen-code', + ); + mkdirSync(path.join(packageRoot, 'dist'), { recursive: true }); + writeFileSync( + path.join(entryDir, 'cli.js'), + "process.stdout.write('old version\\n');\n", + ); + writeFileSync( + path.join(entryDir, 'package.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + version: '1.0.0', + }), + ); + writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + version: '2.0.0', + }), + ); + writeFileSync( + path.join(packageRoot, 'dist', 'cli.js'), + 'process.stdout.write(JSON.stringify({ managed: process.env.QWEN_CODE_MANAGED_NPM_UPDATE, launcher: process.env.QWEN_CODE_CLI, args: process.argv.slice(2) }));\n', + ); + mkdirSync(launcherRoot, { recursive: true }); + const { dev, ino, size, mtimeMs } = statSync(entryPath); + writeFileSync( + path.join(launcherRoot, 'active.json'), + JSON.stringify({ + version: '2.0.0', + bootstrap: realpathSync(entryPath), + baseVersion: '1.0.0', + bootstrapFingerprint: { dev, ino, size, mtimeMs }, + }), + ); + + const output = execFileSync( + process.execPath, + [entryPath, '--prompt', 'hello'], + { + encoding: 'utf8', + env: { ...process.env, QWEN_HOME: qwenHome }, + }, + ); + + expect(JSON.parse(output)).toEqual({ + managed: 'true', + launcher: realpathSync(entryPath), + args: ['--prompt', 'hello'], + }); + + writeFileSync( + path.join(launcherRoot, 'active.json'), + JSON.stringify({ + version: '2.0.0', + bootstrap: path.join(tempDir, 'another-install', 'cli-entry.js'), + baseVersion: '1.0.0', + bootstrapFingerprint: { dev, ino, size, mtimeMs }, + }), + ); + expect( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: { ...process.env, QWEN_HOME: qwenHome }, + }), + ).toBe('old version\n'); + + writeFileSync( + path.join(launcherRoot, 'active.json'), + JSON.stringify({ + version: '2.0.0', + bootstrap: realpathSync(entryPath), + baseVersion: '1.0.0', + bootstrapFingerprint: { dev, ino, size, mtimeMs }, + }), + ); + writeFileSync( + path.join(entryDir, 'package.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + version: '3.0.0', + }), + ); + expect( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: { + ...process.env, + QWEN_HOME: qwenHome, + QWEN_CODE_MANAGED_NPM_UPDATE: 'true', + }, + }), + ).toBe('old version\n'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('falls through to cli.js when wrapper package.json lookup fails', () => { const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-entry-')); const entryDir = path.join(tempDir, 'bin'); diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index 5a278ff9581..51689fc5490 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -128,6 +128,7 @@ describe('startupPrefetch', () => { }); mockGetInstallationInfo.mockReturnValue({ updateCommand: 'npm install -g @qwen-code/qwen-code@latest', + packageManager: 'npm', isStandalone: false, }); mockRequestUpdateOnExit.mockReturnValue(true); @@ -220,8 +221,9 @@ describe('startupPrefetch', () => { ); }); - it('defers an available update until the session exits', async () => { + it('installs npm updates in the background after first render', async () => { const config = makeConfig(); + const settings = makeSettings(); mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'update', info: { @@ -230,19 +232,28 @@ describe('startupPrefetch', () => { }, }); - startPostRenderPrefetches(config, makeSettings()); + startPostRenderPrefetches(config, settings); await vi.dynamicImportSettled(); - expect(mockRequestUpdateOnExit).toHaveBeenCalledTimes(1); - expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-info', { - message: - 'Update available\nThe update will be installed after you exit this session.', - }); + expect(mockHandleAutoUpdate).toHaveBeenCalledWith( + { + message: 'Update available', + update: { latest: '2.0.0' }, + }, + settings, + '/repo', + ); + expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); }); - it('prompts for an explicit update when no parent supervisor is available', async () => { + it('prompts non-npm installs when no parent supervisor is available', async () => { mockRequestUpdateOnExit.mockReturnValue(false); + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'pnpm add -g @qwen-code/qwen-code@latest', + packageManager: 'pnpm', + isGlobal: true, + }); mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'update', info: { @@ -270,6 +281,7 @@ describe('startupPrefetch', () => { }); mockGetInstallationInfo.mockReturnValue({ updateCommand: 'standalone update', + packageManager: 'standalone', isStandalone: true, standaloneDir: '/tmp/qwen-code', }); diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index cf92bba8b33..5124d457e67 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -196,6 +196,10 @@ export function startPostRenderPrefetches( return; } const installationInfo = getInstallationInfo(projectRoot, true); + if (installationInfo.packageManager === 'npm') { + void handleAutoUpdate(result.info, settings, projectRoot); + return; + } if ( installationInfo.updateCommand || (installationInfo.isStandalone && installationInfo.standaloneDir) diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts index 1d9a98f2852..080289c7b9f 100644 --- a/packages/cli/src/ui/utils/updateCheck.test.ts +++ b/packages/cli/src/ui/utils/updateCheck.test.ts @@ -31,6 +31,7 @@ describe('checkForUpdates', () => { vi.resetAllMocks(); // Clear DEV environment variable before each test delete process.env['DEV']; + delete process.env['QWEN_CODE_MANAGED_NPM_UPDATE']; }); afterEach(() => { @@ -279,6 +280,26 @@ describe('checkForUpdates', () => { expect(run).not.toHaveBeenCalled(); }); + it('uses the global npm registry for managed background updates', async () => { + process.env['QWEN_CODE_MANAGED_NPM_UPDATE'] = 'true'; + const run = vi.fn(); + const canonicalize = vi.fn(); + + await expect( + isGlobalNpmInstallation( + '/home/user/.qwen/updates/npm/versions/2.0.0/dist/cli.js', + run as unknown as NonNullable< + Parameters[1] + >, + canonicalize as unknown as NonNullable< + Parameters[2] + >, + ), + ).resolves.toBe(true); + expect(run).not.toHaveBeenCalled(); + expect(canonicalize).not.toHaveBeenCalled(); + }); + it('resolves a bin symlink before matching the npm package path', async () => { const run = vi.fn().mockResolvedValue({ stdout: '/usr/local/lib/node_modules\n', diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index af12a056375..4e8ee735de2 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -15,6 +15,7 @@ import { getPackageJson } from '../../utils/package.js'; import { getNpmCliPath } from '../../utils/installationInfo.js'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; +import { MANAGED_NPM_UPDATE_ENV_VAR } from '../../utils/managed-npm-update.js'; const debugLogger = createDebugLogger('UPDATE_CHECK'); @@ -97,6 +98,7 @@ export async function isGlobalNpmInstallation( run: typeof execFileAsync = execFileAsync, canonicalize: typeof realpath = realpath, ): Promise { + if (process.env[MANAGED_NPM_UPDATE_ENV_VAR] === 'true') return true; if (!cliPath) return false; // Canonicalize before matching. The CLI can be launched through its global // bin symlink (e.g. `.../bin/qwen`), whose path carries no `node_modules` diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index f4c7fa06044..dd52c635c8e 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -11,11 +11,19 @@ import { updateEventEmitter } from './updateEventEmitter.js'; import type { UpdateObject } from '../ui/utils/updateCheck.js'; import type { LoadedSettings } from '../config/settings.js'; import EventEmitter from 'node:events'; -import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js'; +import { + handleAutoUpdate, + setUpdateHandler, + waitForAutoUpdate, +} from './handleAutoUpdate.js'; import { performStandaloneUpdate } from './standalone-update.js'; import { MessageType } from '../ui/types.js'; import os from 'node:os'; +const mockPrepareManagedNpmUpdate = vi.hoisted(() => vi.fn()); +const mockActivateManagedNpmUpdate = vi.hoisted(() => vi.fn()); +const mockCleanupManagedNpmUpdate = vi.hoisted(() => vi.fn()); + vi.mock('./installationInfo.js', async () => { const actual = await vi.importActual('./installationInfo.js'); return { @@ -28,6 +36,15 @@ vi.mock('./standalone-update.js', () => ({ performStandaloneUpdate: vi.fn(), })); +vi.mock('./managed-npm-update.js', () => ({ + prepareManagedNpmUpdate: (version: string) => + mockPrepareManagedNpmUpdate(version), + activateManagedNpmUpdate: (...args: unknown[]) => + mockActivateManagedNpmUpdate(...args), + cleanupManagedNpmUpdate: (...args: unknown[]) => + mockCleanupManagedNpmUpdate(...args), +})); + vi.mock('./updateEventEmitter.js', async () => { const { EventEmitter } = await import('node:events'); return { @@ -86,6 +103,18 @@ describe('handleAutoUpdate', () => { mockSpawn.mockReturnValue( mockChildProcess as unknown as ReturnType, ); + mockPrepareManagedNpmUpdate.mockImplementation((version: string) => ({ + stagingDir: `/tmp/${version}`, + versionDir: `/updates/${version}`, + installArgs: [ + 'install', + '--prefix', + `/tmp/${version}`, + '--no-save', + '--package-lock=false', + `@qwen-code/qwen-code@${version}`, + ], + })); }); afterEach(() => { @@ -163,11 +192,6 @@ describe('handleAutoUpdate', () => { packageManager: PackageManager.NPM, }); - // Simulate successful execution - setTimeout(() => { - mockChildProcess.emit('close', 0); - }, 0); - handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); expect(mockSpawn).toHaveBeenCalledOnce(); @@ -235,9 +259,12 @@ describe('handleAutoUpdate', () => { process.execPath, [ expect.stringMatching(/npm-cli\.js$/), - 'i', - '-g', - '@qwen-code/qwen-code@nightly', + 'install', + '--prefix', + '/tmp/2.0.0-nightly', + '--no-save', + '--package-lock=false', + '@qwen-code/qwen-code@2.0.0-nightly', ], { stdio: ['pipe', 'ignore', 'pipe'], @@ -260,7 +287,10 @@ describe('handleAutoUpdate', () => { [ expect.stringMatching(/npm-cli\.js$/), 'install', - '-g', + '--prefix', + '/tmp/2.0.0', + '--no-save', + '--package-lock=false', '@qwen-code/qwen-code@2.0.0', ], { @@ -289,27 +319,53 @@ describe('handleAutoUpdate', () => { }); it('should emit "update-success" when the update process succeeds', async () => { - await new Promise((resolve) => { - mockGetInstallationInfo.mockReturnValue({ - updateCommand: 'npm i -g @qwen-code/qwen-code@latest', - updateMessage: 'This is an additional message.', - isGlobal: false, - packageManager: PackageManager.NPM, - }); - - // Simulate successful execution - setTimeout(() => { - mockChildProcess.emit('close', 0); - resolve(); - }, 0); - - handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @qwen-code/qwen-code@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, }); + const updateProcess = handleAutoUpdate( + mockUpdateInfo, + mockSettings, + '/root', + mockSpawn, + )!; + mockChildProcess.emit('close', 0); + + await expect(waitForAutoUpdate(updateProcess)).resolves.toBe(true); expect(emitSpy).toHaveBeenCalledWith('update-success', { message: - 'Update successful! Please restart Qwen Code to use the new version. ' + - 'Switching model providers before restarting may not work correctly.', + 'Update successful! The new version will be used on your next run.', + }); + expect(mockActivateManagedNpmUpdate).toHaveBeenCalledWith( + expect.objectContaining({ versionDir: '/updates/2.0.0' }), + '2.0.0', + ); + }); + + it('reports activation failure after npm exits successfully', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @qwen-code/qwen-code@latest', + isGlobal: true, + packageManager: PackageManager.NPM, + }); + mockActivateManagedNpmUpdate.mockImplementation(() => { + throw new Error('pointer write failed'); + }); + + const updateProcess = handleAutoUpdate( + mockUpdateInfo, + mockSettings, + '/root', + mockSpawn, + )!; + mockChildProcess.emit('close', 0); + + await expect(waitForAutoUpdate(updateProcess)).resolves.toBe(false); + expect(emitSpy).toHaveBeenCalledWith('update-failed', { + message: 'Automatic update failed. Please try updating manually.', }); }); }); @@ -475,9 +531,7 @@ describe('setUpdateHandler', () => { expect(addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: - 'Update successful! Please restart Qwen Code to use the new version. ' + - 'Switching model providers before restarting may not work correctly.', + text: 'Update successful! The new version will be used on your next run.', }, expect.any(Number), ); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index c302b48acc9..08b169e6d62 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -21,15 +21,34 @@ import { t } from '../i18n/index.js'; import type { spawn } from 'node:child_process'; import os from 'node:os'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { + activateManagedNpmUpdate, + cleanupManagedNpmUpdate, + prepareManagedNpmUpdate, +} from './managed-npm-update.js'; const debugLogger = createDebugLogger('AUTO_UPDATE'); const UPDATE_SUCCESS_MESSAGE = - 'Update successful! Please restart Qwen Code to use the new version. ' + - 'Switching model providers before restarting may not work correctly.'; + 'Update successful! The new version will be used on your next run.'; const UPDATE_FAILED_MESSAGE = 'Automatic update failed. Please try updating manually.'; +type UpdateProcess = ReturnType; + +const updateCompletions = new WeakMap>(); + +export function waitForAutoUpdate( + updateProcess: UpdateProcess, +): Promise { + const completion = updateCompletions.get(updateProcess); + if (completion) return completion; + return new Promise((resolve) => { + updateProcess.once('close', (code) => resolve(code === 0)); + updateProcess.once('error', () => resolve(false)); + }); +} + export function handleAutoUpdate( info: UpdateObject | null, settings: LoadedSettings, @@ -97,6 +116,10 @@ export function handleAutoUpdate( ); const platform = os.platform(); const isWindows = platform === 'win32'; + const managedNpmUpdate = + installationInfo.packageManager === PackageManager.NPM + ? prepareManagedNpmUpdate(info.update.latest) + : undefined; const command = installationInfo.packageManager === PackageManager.NPM ? process.execPath @@ -107,7 +130,7 @@ export function handleAutoUpdate( installationInfo.packageManager === PackageManager.NPM ? [ getNpmCliPath(process.execPath, platform), - ...updateCommand.split(' ').slice(1), + ...managedNpmUpdate!.installArgs, ] : isWindows ? ['/c', updateCommand] @@ -115,31 +138,70 @@ export function handleAutoUpdate( const updateProcess = spawnFn(command, commandArgs, { stdio: ['pipe', 'ignore', 'pipe'], }); + let finishUpdate!: (success: boolean) => void; + let settled = false; + const completion = new Promise((resolve) => { + finishUpdate = resolve; + }); + updateCompletions.set(updateProcess, completion); let errorOutput = ''; updateProcess.stderr.on('data', (data) => { errorOutput += data.toString(); }); updateProcess.on('close', (code) => { - if (code === 0) { - updateEventEmitter.emit('update-success', { - message: t(UPDATE_SUCCESS_MESSAGE), - }); - } else { - debugLogger.warn( - `Automatic update command failed: ${updateCommand}; stderr: ${errorOutput.trim()}`, - ); - updateEventEmitter.emit('update-failed', { - message: t(UPDATE_FAILED_MESSAGE), - }); + if (settled) return; + settled = true; + if (code !== 0) { + void (async () => { + if (managedNpmUpdate) { + await cleanupManagedNpmUpdate(managedNpmUpdate); + } + debugLogger.warn( + `Automatic update command failed: ${updateCommand}; stderr: ${errorOutput.trim()}`, + ); + updateEventEmitter.emit('update-failed', { + message: t(UPDATE_FAILED_MESSAGE), + }); + finishUpdate(false); + })(); + return; } + void (async () => { + try { + if (managedNpmUpdate) { + await activateManagedNpmUpdate(managedNpmUpdate, info.update.latest); + } + updateEventEmitter.emit('update-success', { + message: t(UPDATE_SUCCESS_MESSAGE), + }); + finishUpdate(true); + } catch (error) { + debugLogger.warn('Automatic update activation failed:', error); + if (managedNpmUpdate) { + await cleanupManagedNpmUpdate(managedNpmUpdate); + } + updateEventEmitter.emit('update-failed', { + message: t(UPDATE_FAILED_MESSAGE), + }); + finishUpdate(false); + } + })(); }); updateProcess.on('error', (err) => { - debugLogger.warn('Automatic update command failed to start:', err); - updateEventEmitter.emit('update-failed', { - message: t(UPDATE_FAILED_MESSAGE), - }); + if (settled) return; + settled = true; + void (async () => { + if (managedNpmUpdate) { + await cleanupManagedNpmUpdate(managedNpmUpdate); + } + debugLogger.warn('Automatic update command failed to start:', err); + updateEventEmitter.emit('update-failed', { + message: t(UPDATE_FAILED_MESSAGE), + }); + finishUpdate(false); + })(); }); return updateProcess; } diff --git a/packages/cli/src/utils/managed-npm-update.test.ts b/packages/cli/src/utils/managed-npm-update.test.ts new file mode 100644 index 00000000000..69bec4035f7 --- /dev/null +++ b/packages/cli/src/utils/managed-npm-update.test.ts @@ -0,0 +1,310 @@ +/** + * @license + * Copyright 2026 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + activateManagedNpmUpdate, + cleanupManagedNpmUpdate, + prepareManagedNpmUpdate, +} from './managed-npm-update.js'; + +const temporaryDirectories: string[] = []; + +function makeTemporaryDirectory(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-npm-update-')); + temporaryDirectories.push(directory); + return directory; +} + +function writeInstallation(prefix: string, version: string): void { + const packageRoot = path.join( + prefix, + 'node_modules', + '@qwen-code', + 'qwen-code', + ); + fs.mkdirSync(path.join(packageRoot, 'dist'), { recursive: true }); + fs.mkdirSync(path.join(packageRoot, 'scripts'), { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ name: '@qwen-code/qwen-code', version }), + ); + fs.writeFileSync(path.join(packageRoot, 'dist', 'cli.js'), ''); + fs.writeFileSync( + path.join(packageRoot, 'scripts', 'cli-entry.js'), + `process.stdout.write('${version}\\n');\n`, + ); +} + +function writeBaseInstallation(root: string, version = '1.0.0'): string { + const packageRoot = path.join( + root, + 'global', + 'node_modules', + '@qwen-code', + 'qwen-code', + ); + const bootstrap = path.join(packageRoot, 'scripts', 'cli-entry.js'); + fs.mkdirSync(path.dirname(bootstrap), { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ name: '@qwen-code/qwen-code', version }), + ); + fs.writeFileSync(bootstrap, 'global launcher'); + return bootstrap; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('managed npm update', () => { + it('stages an exact package version for one global launcher', () => { + const root = makeTemporaryDirectory(); + const updateRoot = path.join(root, 'updates'); + const bootstrap = writeBaseInstallation(root); + const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + + expect(update.installArgs).toEqual([ + 'install', + '--prefix', + update.stagingDir, + '--no-save', + '--package-lock=false', + '@qwen-code/qwen-code@2.0.0', + ]); + expect(update.versionDir).toBe( + path.join(update.launcherRoot, 'versions', '2.0.0'), + ); + }); + + it('activates only a complete matching installation', async () => { + const root = makeTemporaryDirectory(); + const updateRoot = path.join(root, 'updates'); + const bootstrap = writeBaseInstallation(root); + const runningChunk = path.join(root, 'running', 'chunks', 'old.js'); + fs.mkdirSync(path.dirname(runningChunk), { recursive: true }); + fs.writeFileSync(runningChunk, 'old chunk'); + const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + writeInstallation(update.stagingDir, '2.0.0'); + + await activateManagedNpmUpdate(update, '2.0.0', bootstrap); + + expect(fs.existsSync(update.stagingDir)).toBe(false); + expect( + fs.existsSync( + path.join( + update.versionDir, + 'node_modules', + '@qwen-code', + 'qwen-code', + 'dist', + 'cli.js', + ), + ), + ).toBe(true); + expect( + JSON.parse( + fs.readFileSync(path.join(update.launcherRoot, 'active.json'), 'utf8'), + ), + ).toMatchObject({ + version: '2.0.0', + bootstrap: fs.realpathSync(bootstrap), + baseVersion: '1.0.0', + }); + expect(fs.readFileSync(bootstrap, 'utf8')).toBe('global launcher'); + expect(fs.readFileSync(runningChunk, 'utf8')).toBe('old chunk'); + }); + + it('does not activate a mismatched package', async () => { + const root = makeTemporaryDirectory(); + const updateRoot = path.join(root, 'updates'); + const bootstrap = writeBaseInstallation(root); + const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + writeInstallation(update.stagingDir, '2.0.1'); + + await expect( + activateManagedNpmUpdate(update, '2.0.0', bootstrap), + ).rejects.toThrow('Installed package did not match'); + expect(fs.existsSync(path.join(update.launcherRoot, 'active.json'))).toBe( + false, + ); + }); + + it('does not activate a package that fails its version smoke test', async () => { + const root = makeTemporaryDirectory(); + const updateRoot = path.join(root, 'updates'); + const bootstrap = writeBaseInstallation(root); + const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + writeInstallation(update.stagingDir, '2.0.0'); + fs.writeFileSync( + path.join( + update.stagingDir, + 'node_modules', + '@qwen-code', + 'qwen-code', + 'scripts', + 'cli-entry.js', + ), + "process.stdout.write('broken\\n');\n", + ); + + await expect( + activateManagedNpmUpdate(update, '2.0.0', bootstrap), + ).rejects.toThrow('Installed package reported version broken'); + expect(fs.existsSync(path.join(update.launcherRoot, 'active.json'))).toBe( + false, + ); + }); + + it('does not mask a global install that changes while staging', async () => { + const root = makeTemporaryDirectory(); + const updateRoot = path.join(root, 'updates'); + const bootstrap = writeBaseInstallation(root); + const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + writeInstallation(update.stagingDir, '2.0.0'); + fs.writeFileSync(bootstrap, 'manually reinstalled launcher'); + + await expect( + activateManagedNpmUpdate(update, '2.0.0', bootstrap), + ).rejects.toThrow('changed during update'); + expect(fs.existsSync(path.join(update.launcherRoot, 'active.json'))).toBe( + false, + ); + }); + + it('does not replace a newer active version from another session', async () => { + const root = makeTemporaryDirectory(); + const updateRoot = path.join(root, 'updates'); + const bootstrap = writeBaseInstallation(root); + const newer = prepareManagedNpmUpdate('3.0.0', bootstrap, updateRoot); + writeInstallation(newer.stagingDir, '3.0.0'); + await activateManagedNpmUpdate(newer, '3.0.0', bootstrap); + const older = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + writeInstallation(older.stagingDir, '2.0.0'); + + await activateManagedNpmUpdate(older, '2.0.0', bootstrap); + + expect( + JSON.parse( + fs.readFileSync(path.join(older.launcherRoot, 'active.json'), 'utf8'), + ), + ).toMatchObject({ version: '3.0.0' }); + expect(fs.existsSync(older.versionDir)).toBe(false); + }); + + it('publishes an existing payload during concurrent activation', async () => { + const root = makeTemporaryDirectory(); + const updateRoot = path.join(root, 'updates'); + const bootstrap = writeBaseInstallation(root); + const older = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + const newer = prepareManagedNpmUpdate('3.0.0', bootstrap, updateRoot); + writeInstallation(older.stagingDir, '2.0.0'); + writeInstallation(newer.stagingDir, '3.0.0'); + const staleLock = path.join(older.launcherRoot, 'activation.lock'); + fs.mkdirSync(staleLock); + fs.writeFileSync( + path.join(staleLock, 'owner.json'), + JSON.stringify({ pid: 999999, token: 'stale-owner' }), + ); + const staleTime = new Date(Date.now() - 2 * 60 * 1000); + fs.utimesSync(staleLock, staleTime, staleTime); + + await Promise.all([ + activateManagedNpmUpdate(older, '2.0.0', bootstrap), + activateManagedNpmUpdate(newer, '3.0.0', bootstrap), + ]); + + const active = JSON.parse( + fs.readFileSync(path.join(newer.launcherRoot, 'active.json'), 'utf8'), + ) as { version: string }; + expect(active.version).toBe('3.0.0'); + expect( + fs.existsSync(path.join(newer.launcherRoot, 'versions', active.version)), + ).toBe(true); + }); + + it('isolates version payloads for different global launchers', async () => { + const root = makeTemporaryDirectory(); + const updateRoot = path.join(root, 'updates'); + const firstBootstrap = writeBaseInstallation(path.join(root, 'node-22')); + const secondBootstrap = writeBaseInstallation(path.join(root, 'node-24')); + const launcherRoots = new Set(); + + for (const bootstrap of [firstBootstrap, secondBootstrap]) { + const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + writeInstallation(update.stagingDir, '2.0.0'); + await activateManagedNpmUpdate(update, '2.0.0', bootstrap); + launcherRoots.add(update.launcherRoot); + expect(fs.existsSync(update.versionDir)).toBe(true); + } + + expect(launcherRoots).toHaveLength(2); + }); + + it('keeps leased versions and removes them after their process exits', async () => { + const root = makeTemporaryDirectory(); + const updateRoot = path.join(root, 'updates'); + const bootstrap = writeBaseInstallation(root); + const first = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + writeInstallation(first.stagingDir, '2.0.0'); + await activateManagedNpmUpdate(first, '2.0.0', bootstrap); + const leasesDir = path.join(first.versionDir, '.leases'); + fs.mkdirSync(leasesDir); + fs.writeFileSync(path.join(leasesDir, String(process.pid)), ''); + + const second = prepareManagedNpmUpdate('3.0.0', bootstrap, updateRoot); + writeInstallation(second.stagingDir, '3.0.0'); + await activateManagedNpmUpdate(second, '3.0.0', bootstrap); + + expect(fs.existsSync(first.versionDir)).toBe(true); + fs.rmSync(path.join(leasesDir, String(process.pid))); + + const third = prepareManagedNpmUpdate('4.0.0', bootstrap, updateRoot); + writeInstallation(third.stagingDir, '4.0.0'); + await activateManagedNpmUpdate(third, '4.0.0', bootstrap); + + expect(fs.existsSync(first.versionDir)).toBe(false); + expect(fs.existsSync(second.versionDir)).toBe(false); + expect(fs.existsSync(third.versionDir)).toBe(true); + }); + + it('retries cleanup of abandoned staging and trash directories', async () => { + const root = makeTemporaryDirectory(); + const updateRoot = path.join(root, 'updates'); + const bootstrap = writeBaseInstallation(root); + const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + writeInstallation(update.stagingDir, '2.0.0'); + const abandoned = path.join(update.launcherRoot, 'versions', '.abandoned'); + fs.mkdirSync(abandoned); + fs.writeFileSync(path.join(abandoned, '.qwen-update-owner'), '999999'); + const old = new Date(Date.now() - 2 * 60 * 60 * 1000); + fs.utimesSync(abandoned, old, old); + const trash = path.join(update.launcherRoot, '.trash-old'); + fs.mkdirSync(trash); + + await activateManagedNpmUpdate(update, '2.0.0', bootstrap); + + expect(fs.existsSync(abandoned)).toBe(false); + expect(fs.existsSync(trash)).toBe(false); + }); + + it('removes a failed staging directory', async () => { + const root = makeTemporaryDirectory(); + const updateRoot = path.join(root, 'updates'); + const bootstrap = writeBaseInstallation(root); + const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + + await cleanupManagedNpmUpdate(update); + + expect(fs.existsSync(update.stagingDir)).toBe(false); + }); +}); diff --git a/packages/cli/src/utils/managed-npm-update.ts b/packages/cli/src/utils/managed-npm-update.ts new file mode 100644 index 00000000000..872e5137aa3 --- /dev/null +++ b/packages/cli/src/utils/managed-npm-update.ts @@ -0,0 +1,519 @@ +/** + * @license + * Copyright 2026 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Storage } from '@qwen-code/qwen-code-core'; +import { execFile } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as fsPromises from 'node:fs/promises'; +import * as path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { promisify } from 'node:util'; +import semver from 'semver'; + +const PACKAGE_NAME = '@qwen-code/qwen-code'; +const VERSION_PATTERN = + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const LOCK_TIMEOUT_MS = 5_000; +const STALE_LOCK_MS = 30_000; +const ABANDONED_STAGING_MS = 60 * 60 * 1000; +const MAX_LOCK_AGE_MS = 5 * 60 * 1000; +const MAX_STAGING_AGE_MS = 24 * 60 * 60 * 1000; +const STAGING_OWNER_FILE = '.qwen-update-owner'; +const execFileAsync = promisify(execFile); + +export const MANAGED_NPM_UPDATE_ENV_VAR = 'QWEN_CODE_MANAGED_NPM_UPDATE'; + +export interface ManagedNpmUpdate { + stagingDir: string; + versionDir: string; + launcherRoot: string; + baseVersion: string; + bootstrapFingerprint: Pick; + installArgs: string[]; +} + +interface ActiveInstallation { + version?: unknown; + bootstrap?: unknown; +} + +function assertVersion(version: string): void { + if (!VERSION_PATTERN.test(version)) { + throw new Error(`Invalid update version: ${version}`); + } +} + +function packageDir(prefix: string): string { + return path.join(prefix, 'node_modules', '@qwen-code', 'qwen-code'); +} + +function resolveBootstrapPath(bootstrapPath?: string): string { + if (!bootstrapPath) { + throw new Error('Unable to identify the Qwen Code npm launcher'); + } + return fs.realpathSync(bootstrapPath); +} + +function launcherId(bootstrapPath: string): string { + return createHash('sha256').update(bootstrapPath).digest('hex').slice(0, 16); +} + +async function validateInstallation( + prefix: string, + version: string, +): Promise { + const root = packageDir(prefix); + const manifest = JSON.parse( + await fsPromises.readFile(path.join(root, 'package.json'), 'utf8'), + ) as { name?: unknown; version?: unknown }; + if (manifest.name !== PACKAGE_NAME || manifest.version !== version) { + throw new Error( + `Installed package did not match ${PACKAGE_NAME}@${version}`, + ); + } + await Promise.all([ + fsPromises.access(path.join(root, 'dist', 'cli.js')), + fsPromises.access(path.join(root, 'scripts', 'cli-entry.js')), + ]); +} + +async function smokeTest(prefix: string, version: string): Promise { + const launcher = path.join(packageDir(prefix), 'scripts', 'cli-entry.js'); + const env = { ...process.env }; + delete env['CLI_VERSION']; + delete env['QWEN_CODE_RELAUNCH_ARGS']; + const { stdout } = await execFileAsync( + process.execPath, + [launcher, '--version'], + { + encoding: 'utf8', + env, + timeout: 10_000, + }, + ); + const output = stdout.trim(); + if (output !== version) { + throw new Error( + `Installed package reported version ${output || 'unknown'}`, + ); + } +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +async function releaseActivationLock( + lockPath: string, + token: string, +): Promise { + try { + const owner = JSON.parse( + await fsPromises.readFile(path.join(lockPath, 'owner.json'), 'utf8'), + ) as { token?: unknown }; + if (owner.token !== token) return; + const releasedPath = `${lockPath}.release-${token}`; + await fsPromises.rename(lockPath, releasedPath); + await fsPromises.rm(releasedPath, { recursive: true, force: true }); + } catch { + // A missing or replaced lock is no longer ours to release. + } +} + +async function acquireActivationLock(lockPath: string): Promise { + const deadline = Date.now() + LOCK_TIMEOUT_MS; + while (true) { + const token = randomUUID(); + try { + await fsPromises.mkdir(lockPath); + try { + await fsPromises.writeFile( + path.join(lockPath, 'owner.json'), + JSON.stringify({ pid: process.pid, token }), + ); + } catch (error) { + await fsPromises.rm(lockPath, { recursive: true, force: true }); + throw error; + } + return token; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + let lockStats: fs.Stats; + try { + lockStats = await fsPromises.stat(lockPath); + } catch { + continue; + } + let owner = Number.NaN; + let ownerToken: string | undefined; + try { + const metadata = JSON.parse( + await fsPromises.readFile(path.join(lockPath, 'owner.json'), 'utf8'), + ) as { pid?: unknown; token?: unknown }; + owner = typeof metadata.pid === 'number' ? metadata.pid : Number.NaN; + ownerToken = + typeof metadata.token === 'string' ? metadata.token : undefined; + } catch { + // The lock owner may still be creating its metadata. + } + const hasOwner = Number.isInteger(owner) && owner > 0; + const lockAge = Date.now() - lockStats.mtimeMs; + if ( + (!hasOwner && lockAge >= STALE_LOCK_MS) || + (hasOwner && !processIsAlive(owner)) || + lockAge >= MAX_LOCK_AGE_MS + ) { + const staleIdentity = + ownerToken ?? + `${lockStats.dev}-${lockStats.ino}-${lockStats.mtimeMs}`; + const staleId = createHash('sha256') + .update(staleIdentity) + .digest('hex') + .slice(0, 16); + const stalePath = `${lockPath}.stale-${staleId}`; + try { + await fsPromises.rename(lockPath, stalePath); + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code !== 'ENOENT' && + (error as NodeJS.ErrnoException).code !== 'EEXIST' && + (error as NodeJS.ErrnoException).code !== 'ENOTEMPTY' && + !( + ((error as NodeJS.ErrnoException).code === 'EPERM' || + (error as NodeJS.ErrnoException).code === 'EACCES') && + fs.existsSync(stalePath) + ) + ) { + throw error; + } + } + continue; + } + if (Date.now() >= deadline) { + throw new Error('Timed out waiting to activate the npm update'); + } + await delay(25); + } + } +} + +async function quarantineAbandonedWork( + launcherRoot: string, +): Promise { + const trash: string[] = []; + try { + const entries = await fsPromises.readdir(launcherRoot, { + withFileTypes: true, + }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith('.trash-')) { + trash.push(path.join(launcherRoot, entry.name)); + } else if ( + entry.isDirectory() && + entry.name.startsWith('activation.lock.release-') + ) { + const candidate = path.join(launcherRoot, entry.name); + try { + const age = Date.now() - (await fsPromises.stat(candidate)).mtimeMs; + if (age >= STALE_LOCK_MS * 2) trash.push(candidate); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + const versionsDir = path.join(launcherRoot, 'versions'); + let versions: fs.Dirent[]; + try { + versions = await fsPromises.readdir(versionsDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return trash; + throw error; + } + for (const entry of versions) { + if (!entry.isDirectory() || !entry.name.startsWith('.')) continue; + const stagingDir = path.join(versionsDir, entry.name); + let stagingAge: number; + try { + stagingAge = Date.now() - (await fsPromises.stat(stagingDir)).mtimeMs; + if (stagingAge < ABANDONED_STAGING_MS) continue; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + let owner = Number.NaN; + try { + owner = Number.parseInt( + await fsPromises.readFile( + path.join(stagingDir, STAGING_OWNER_FILE), + 'utf8', + ), + 10, + ); + } catch { + // The age grace is sufficient when owner metadata is missing. + } + if ( + stagingAge < MAX_STAGING_AGE_MS && + Number.isInteger(owner) && + owner > 0 && + processIsAlive(owner) + ) { + continue; + } + const trashDir = path.join( + launcherRoot, + `.trash-staging-${process.pid}-${randomUUID()}`, + ); + try { + await fsPromises.rename(stagingDir, trashDir); + trash.push(trashDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + return trash; +} + +async function hasLiveLease(versionDir: string): Promise { + const leasesDir = path.join(versionDir, '.leases'); + let leases: string[]; + try { + leases = await fsPromises.readdir(leasesDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + let live = false; + for (const lease of leases) { + const pid = Number.parseInt(lease, 10); + if (Number.isInteger(pid) && pid > 0 && processIsAlive(pid)) { + live = true; + } else { + await fsPromises.rm(path.join(leasesDir, lease), { force: true }); + } + } + return live; +} + +async function quarantineUnusedVersions( + launcherRoot: string, + activeVersion: string, +): Promise { + const versionsDir = path.join(launcherRoot, 'versions'); + const trash: string[] = []; + let entries: fs.Dirent[]; + try { + entries = await fsPromises.readdir(versionsDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return trash; + throw error; + } + for (const entry of entries) { + if ( + !entry.isDirectory() || + entry.name.startsWith('.') || + entry.name === activeVersion + ) { + continue; + } + const versionDir = path.join(versionsDir, entry.name); + if (await hasLiveLease(versionDir)) continue; + const trashDir = path.join( + launcherRoot, + `.trash-${entry.name}-${process.pid}-${Date.now()}`, + ); + await fsPromises.rename(versionDir, trashDir); + trash.push(trashDir); + } + return trash; +} + +function readBaseInstallation(bootstrapPath: string): { + version: string; + fingerprint: Pick; +} { + const packageJsonPath = path.join( + path.dirname(path.dirname(bootstrapPath)), + 'package.json', + ); + const manifest = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + name?: unknown; + version?: unknown; + }; + if (manifest.name !== PACKAGE_NAME || typeof manifest.version !== 'string') { + throw new Error('Unable to identify the base Qwen Code npm installation'); + } + const { dev, ino, size, mtimeMs } = fs.statSync(bootstrapPath); + return { + version: manifest.version, + fingerprint: { dev, ino, size, mtimeMs }, + }; +} + +export function prepareManagedNpmUpdate( + version: string, + bootstrapPath = process.env['QWEN_CODE_CLI'], + updateRoot = path.join(Storage.getGlobalQwenDir(), 'updates', 'npm'), +): ManagedNpmUpdate { + assertVersion(version); + const resolvedBootstrapPath = resolveBootstrapPath(bootstrapPath); + const base = readBaseInstallation(resolvedBootstrapPath); + const launcherRoot = path.join(updateRoot, launcherId(resolvedBootstrapPath)); + const versionsDir = path.join(launcherRoot, 'versions'); + fs.mkdirSync(versionsDir, { recursive: true }); + const stagingDir = fs.mkdtempSync( + path.join(versionsDir, `.${version}-${process.pid}-`), + ); + fs.writeFileSync( + path.join(stagingDir, STAGING_OWNER_FILE), + String(process.pid), + { mode: 0o600 }, + ); + return { + stagingDir, + versionDir: path.join(versionsDir, version), + launcherRoot, + baseVersion: base.version, + bootstrapFingerprint: base.fingerprint, + installArgs: [ + 'install', + '--prefix', + stagingDir, + '--no-save', + '--package-lock=false', + `${PACKAGE_NAME}@${version}`, + ], + }; +} + +export async function activateManagedNpmUpdate( + update: ManagedNpmUpdate, + version: string, + bootstrapPath = process.env['QWEN_CODE_CLI'], +): Promise { + assertVersion(version); + const resolvedBootstrapPath = resolveBootstrapPath(bootstrapPath); + const expectedLauncherRoot = path.join( + path.dirname(update.launcherRoot), + launcherId(resolvedBootstrapPath), + ); + if (expectedLauncherRoot !== update.launcherRoot) { + throw new Error('The npm update does not match the active launcher'); + } + + await validateInstallation(update.stagingDir, version); + await smokeTest(update.stagingDir, version); + const activeFile = path.join(update.launcherRoot, 'active.json'); + const lockPath = path.join(update.launcherRoot, 'activation.lock'); + const abandoned = await quarantineAbandonedWork(update.launcherRoot); + await Promise.allSettled( + abandoned.map((trashDir) => + fsPromises.rm(trashDir, { recursive: true, force: true }), + ), + ); + const trash: string[] = []; + const lockToken = await acquireActivationLock(lockPath); + try { + const base = readBaseInstallation(resolvedBootstrapPath); + if ( + base.version !== update.baseVersion || + base.fingerprint.dev !== update.bootstrapFingerprint.dev || + base.fingerprint.ino !== update.bootstrapFingerprint.ino || + base.fingerprint.size !== update.bootstrapFingerprint.size || + base.fingerprint.mtimeMs !== update.bootstrapFingerprint.mtimeMs + ) { + throw new Error( + 'The base Qwen Code npm installation changed during update', + ); + } + let active: ActiveInstallation | undefined; + try { + active = JSON.parse(await fsPromises.readFile(activeFile, 'utf8')) as { + version?: unknown; + bootstrap?: unknown; + }; + } catch { + active = undefined; + } + if ( + active?.bootstrap === resolvedBootstrapPath && + typeof active.version === 'string' && + semver.valid(active.version) !== null && + semver.gt(active.version, version) + ) { + await validateInstallation( + path.join(update.launcherRoot, 'versions', active.version), + active.version, + ); + trash.push( + ...(await quarantineUnusedVersions( + update.launcherRoot, + active.version, + )), + ); + trash.push(update.stagingDir); + return; + } + + try { + await fsPromises.rename(update.stagingDir, update.versionDir); + await fsPromises.rm(path.join(update.versionDir, STAGING_OWNER_FILE), { + force: true, + }); + } catch (error) { + if (!fs.existsSync(update.versionDir)) throw error; + await validateInstallation(update.versionDir, version); + trash.push(update.stagingDir); + } + + const temporaryActivePath = `${activeFile}.${process.pid}.${Date.now()}`; + try { + await fsPromises.writeFile( + temporaryActivePath, + JSON.stringify({ + version, + bootstrap: resolvedBootstrapPath, + baseVersion: base.version, + bootstrapFingerprint: base.fingerprint, + }), + { mode: 0o600 }, + ); + await fsPromises.rename(temporaryActivePath, activeFile); + } finally { + await fsPromises.rm(temporaryActivePath, { force: true }); + } + trash.push( + ...(await quarantineUnusedVersions(update.launcherRoot, version)), + ); + } finally { + await releaseActivationLock(lockPath, lockToken); + await Promise.allSettled( + trash.map((trashDir) => + fsPromises.rm(trashDir, { recursive: true, force: true }), + ), + ); + } +} + +export async function cleanupManagedNpmUpdate( + update: ManagedNpmUpdate, +): Promise { + try { + await fsPromises.rm(update.stagingDir, { recursive: true, force: true }); + } catch { + // A later update retries abandoned staging cleanup. + } +} diff --git a/packages/cli/src/utils/update-relaunch.test.ts b/packages/cli/src/utils/update-relaunch.test.ts index 557bdb9823c..8430e86f67b 100644 --- a/packages/cli/src/utils/update-relaunch.test.ts +++ b/packages/cli/src/utils/update-relaunch.test.ts @@ -10,12 +10,16 @@ import { EventEmitter } from 'node:events'; const checkForUpdatesDetailed = vi.hoisted(() => vi.fn()); const handleAutoUpdate = vi.hoisted(() => vi.fn()); +const waitForAutoUpdate = vi.hoisted(() => vi.fn()); const getInstallationInfo = vi.hoisted(() => vi.fn()); const performStandaloneUpdate = vi.hoisted(() => vi.fn()); const writeStderrLine = vi.hoisted(() => vi.fn()); vi.mock('../ui/utils/updateCheck.js', () => ({ checkForUpdatesDetailed })); -vi.mock('./handleAutoUpdate.js', () => ({ handleAutoUpdate })); +vi.mock('./handleAutoUpdate.js', () => ({ + handleAutoUpdate, + waitForAutoUpdate, +})); vi.mock('./installationInfo.js', () => ({ getInstallationInfo })); vi.mock('./standalone-update.js', () => ({ performStandaloneUpdate })); vi.mock('./stdioHelpers.js', () => ({ writeStderrLine })); @@ -30,6 +34,13 @@ describe('updateBeforeRelaunch', () => { beforeEach(() => { vi.clearAllMocks(); + waitForAutoUpdate.mockImplementation( + (updateProcess: EventEmitter) => + new Promise((resolve) => { + updateProcess.once('close', (code) => resolve(code === 0)); + updateProcess.once('error', () => resolve(false)); + }), + ); checkForUpdatesDetailed.mockResolvedValue({ status: 'update', info: { diff --git a/packages/cli/src/utils/update-relaunch.ts b/packages/cli/src/utils/update-relaunch.ts index d618888b8dd..8b007544014 100644 --- a/packages/cli/src/utils/update-relaunch.ts +++ b/packages/cli/src/utils/update-relaunch.ts @@ -21,7 +21,7 @@ export async function updateBeforeRelaunch( try { const [ { checkForUpdatesDetailed }, - { handleAutoUpdate }, + { handleAutoUpdate, waitForAutoUpdate }, { getInstallationInfo }, { performStandaloneUpdate }, { t }, @@ -65,10 +65,7 @@ export async function updateBeforeRelaunch( projectRoot, ); const success = updateProcess - ? await new Promise((resolve) => { - updateProcess.once('close', (code) => resolve(code === 0)); - updateProcess.once('error', () => resolve(false)); - }) + ? await waitForAutoUpdate(updateProcess) : false; writeStderrLine( t( diff --git a/scripts/cli-entry.js b/scripts/cli-entry.js index f4178b6b60c..54e8752ed54 100755 --- a/scripts/cli-entry.js +++ b/scripts/cli-entry.js @@ -60,13 +60,216 @@ if (isTopLevelVersion && process.env['CLI_VERSION']) { process.exit(0); } -const { existsSync, realpathSync } = await import('node:fs'); +const { + existsSync, + mkdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} = await import('node:fs'); +const { createHash, randomUUID } = await import('node:crypto'); +const { homedir } = await import('node:os'); const { fileURLToPath, pathToFileURL } = await import('node:url'); const { delimiter, dirname, join, parse, resolve, sep } = await import( 'node:path' ); const __dirname = dirname(fileURLToPath(import.meta.url)); +const currentEntryPath = realpathSync(fileURLToPath(import.meta.url)); +const lockWaiter = new Int32Array(new SharedArrayBuffer(4)); + +function resolveQwenHome() { + const configured = process.env['QWEN_HOME']; + if (!configured) return join(homedir(), '.qwen'); + if (configured === '~') return homedir(); + if (configured.startsWith('~/') || configured.startsWith('~\\')) { + return resolve(homedir(), configured.slice(2)); + } + return resolve(configured); +} + +function processIsAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +function acquireActivationLock(lockPath) { + const deadline = Date.now() + 1000; + while (true) { + const token = randomUUID(); + try { + mkdirSync(lockPath); + try { + writeFileSync( + join(lockPath, 'owner.json'), + JSON.stringify({ pid: process.pid, token }), + ); + } catch (error) { + rmSync(lockPath, { recursive: true, force: true }); + throw error; + } + return token; + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + let lockStats; + try { + lockStats = statSync(lockPath); + } catch { + continue; + } + let owner = Number.NaN; + let ownerToken; + try { + const metadata = JSON.parse( + readFileSync(join(lockPath, 'owner.json'), 'utf8'), + ); + owner = typeof metadata.pid === 'number' ? metadata.pid : Number.NaN; + ownerToken = + typeof metadata.token === 'string' ? metadata.token : undefined; + } catch { + // The lock owner may still be creating its metadata. + } + const hasOwner = Number.isInteger(owner) && owner > 0; + const lockAge = Date.now() - lockStats.mtimeMs; + if ( + (!hasOwner && lockAge >= 30000) || + (hasOwner && !processIsAlive(owner)) || + lockAge >= 300000 + ) { + const staleIdentity = + ownerToken ?? + `${lockStats.dev}-${lockStats.ino}-${lockStats.mtimeMs}`; + const staleId = createHash('sha256') + .update(staleIdentity) + .digest('hex') + .slice(0, 16); + const stalePath = `${lockPath}.stale-${staleId}`; + try { + renameSync(lockPath, stalePath); + } catch (error) { + if ( + error?.code !== 'ENOENT' && + error?.code !== 'EEXIST' && + error?.code !== 'ENOTEMPTY' && + !( + (error?.code === 'EPERM' || error?.code === 'EACCES') && + existsSync(stalePath) + ) + ) { + throw error; + } + } + continue; + } + if (Date.now() >= deadline) { + throw new Error('Timed out waiting for the npm update lock'); + } + Atomics.wait(lockWaiter, 0, 0, 25); + } + } +} + +function releaseActivationLock(lockPath, token) { + try { + const owner = JSON.parse( + readFileSync(join(lockPath, 'owner.json'), 'utf8'), + ); + if (owner.token !== token) return; + const releasedPath = `${lockPath}.release-${token}`; + renameSync(lockPath, releasedPath); + rmSync(releasedPath, { recursive: true, force: true }); + } catch { + // A missing or replaced lock is no longer ours to release. + } +} + +function matchesFingerprint(actual, expected) { + return ( + expected && + actual.dev === expected.dev && + actual.ino === expected.ino && + actual.size === expected.size && + actual.mtimeMs === expected.mtimeMs + ); +} + +delete process.env['QWEN_CODE_MANAGED_NPM_UPDATE']; + +function getManagedNpmInstallation() { + let lockPath; + let lockToken; + try { + const updateRoot = join(resolveQwenHome(), 'updates', 'npm'); + const id = createHash('sha256') + .update(currentEntryPath) + .digest('hex') + .slice(0, 16); + const launcherRoot = join(updateRoot, id); + lockPath = join(launcherRoot, 'activation.lock'); + lockToken = acquireActivationLock(lockPath); + const active = JSON.parse( + readFileSync(join(launcherRoot, 'active.json'), 'utf8'), + ); + const basePackageJsonPath = [ + join(__dirname, 'package.json'), + join(__dirname, '..', 'package.json'), + ].find((candidate) => existsSync(candidate)); + if (!basePackageJsonPath) return undefined; + const basePackage = JSON.parse(readFileSync(basePackageJsonPath, 'utf8')); + if ( + typeof active.version !== 'string' || + !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test( + active.version, + ) || + typeof active.bootstrap !== 'string' || + realpathSync(active.bootstrap) !== currentEntryPath || + active.baseVersion !== basePackage.version || + !matchesFingerprint( + statSync(currentEntryPath), + active.bootstrapFingerprint, + ) + ) { + return undefined; + } + const packageRoot = join( + launcherRoot, + 'versions', + active.version, + 'node_modules', + '@qwen-code', + 'qwen-code', + ); + const packageJsonPath = join(packageRoot, 'package.json'); + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + const cliPath = join(packageRoot, 'dist', 'cli.js'); + if ( + pkg.name !== '@qwen-code/qwen-code' || + pkg.version !== active.version || + !existsSync(cliPath) + ) { + return undefined; + } + const leasesDir = join(launcherRoot, 'versions', active.version, '.leases'); + mkdirSync(leasesDir, { recursive: true }); + const leasePath = join(leasesDir, String(process.pid)); + writeFileSync(leasePath, ''); + process.env['QWEN_CODE_MANAGED_NPM_UPDATE'] = 'true'; + return { cliPath, packageJsonPath, leasePath }; + } catch { + return undefined; + } finally { + if (lockToken) { + releaseActivationLock(lockPath, lockToken); + } + } +} // The entry a subprocess should call to reach THIS build. // @@ -103,14 +306,22 @@ process.env['QWEN_CODE_CLI'] = ? standaloneShim : fileURLToPath(import.meta.url); +const managedNpmInstallation = getManagedNpmInstallation(); +if (managedNpmInstallation?.leasePath) { + process.once('exit', () => { + rmSync(managedNpmInstallation.leasePath, { force: true }); + }); +} const cliPathCandidates = [ + managedNpmInstallation?.cliPath, join(__dirname, 'cli.js'), join(__dirname, '..', 'dist', 'cli.js'), -]; +].filter(Boolean); const packageJsonPathCandidates = [ + managedNpmInstallation?.packageJsonPath, join(__dirname, 'package.json'), join(__dirname, '..', 'package.json'), -]; +].filter(Boolean); const cliPath = cliPathCandidates.find((candidate) => existsSync(candidate)) ?? cliPathCandidates[0]; From 79d80e035a1753baa25805134e9dd1e77d469ffa Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 20 Jul 2026 17:19:37 +0800 Subject: [PATCH 02/19] refactor(cli): simplify managed npm updates --- docs/design/npm-background-auto-update.md | 7 +- packages/cli/src/cli.test.ts | 29 +- .../cli/src/startup/startup-prefetch.test.ts | 21 + packages/cli/src/startup/startup-prefetch.ts | 5 +- packages/cli/src/ui/utils/updateCheck.ts | 3 +- .../cli/src/utils/handleAutoUpdate.test.ts | 2 + packages/cli/src/utils/handleAutoUpdate.ts | 1 + .../cli/src/utils/managed-npm-update.test.ts | 198 +++----- packages/cli/src/utils/managed-npm-update.ts | 447 ++++-------------- scripts/cli-entry.js | 149 +----- 10 files changed, 199 insertions(+), 663 deletions(-) diff --git a/docs/design/npm-background-auto-update.md b/docs/design/npm-background-auto-update.md index 1e65245a3f6..a0166b76497 100644 --- a/docs/design/npm-background-auto-update.md +++ b/docs/design/npm-background-auto-update.md @@ -41,10 +41,9 @@ launcher-mismatched pointer is ignored and the original npm package remains the fallback. The pointer also records the base package and launcher identity, so a later explicit global npm install supersedes the managed version. -The launcher creates a process lease before starting a managed version. -Activation keeps leased versions for running sessions and removes unleased -superseded versions. The active pointer, lease creation, and cleanup share a -short lock; package installation and validation remain asynchronous. +Version directories are retained because an older live session may still load +from them. Cleanup is intentionally deferred until disk usage shows that a +lease-based collector is necessary. ## Scope diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 73c0515e087..dd890c5fdd1 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -550,14 +550,14 @@ describe('bootstrap import boundaries', () => { 'process.stdout.write(JSON.stringify({ managed: process.env.QWEN_CODE_MANAGED_NPM_UPDATE, launcher: process.env.QWEN_CODE_CLI, args: process.argv.slice(2) }));\n', ); mkdirSync(launcherRoot, { recursive: true }); - const { dev, ino, size, mtimeMs } = statSync(entryPath); + const { mtimeMs } = statSync(entryPath); writeFileSync( path.join(launcherRoot, 'active.json'), JSON.stringify({ version: '2.0.0', bootstrap: realpathSync(entryPath), baseVersion: '1.0.0', - bootstrapFingerprint: { dev, ino, size, mtimeMs }, + bootstrapMtimeMs: mtimeMs, }), ); @@ -576,31 +576,6 @@ describe('bootstrap import boundaries', () => { args: ['--prompt', 'hello'], }); - writeFileSync( - path.join(launcherRoot, 'active.json'), - JSON.stringify({ - version: '2.0.0', - bootstrap: path.join(tempDir, 'another-install', 'cli-entry.js'), - baseVersion: '1.0.0', - bootstrapFingerprint: { dev, ino, size, mtimeMs }, - }), - ); - expect( - execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { - encoding: 'utf8', - env: { ...process.env, QWEN_HOME: qwenHome }, - }), - ).toBe('old version\n'); - - writeFileSync( - path.join(launcherRoot, 'active.json'), - JSON.stringify({ - version: '2.0.0', - bootstrap: realpathSync(entryPath), - baseVersion: '1.0.0', - bootstrapFingerprint: { dev, ino, size, mtimeMs }, - }), - ); writeFileSync( path.join(entryDir, 'package.json'), JSON.stringify({ diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index 51689fc5490..21bf507dbce 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -247,6 +247,27 @@ describe('startupPrefetch', () => { expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); }); + it('keeps manual guidance for npm installs without an update command', async () => { + mockGetInstallationInfo.mockReturnValue({ + packageManager: 'npm', + isGlobal: true, + updateMessage: 'Update requires sudo.', + }); + mockCheckForUpdatesDetailed.mockResolvedValue({ + status: 'update', + info: { + message: 'Update available', + update: { latest: '2.0.0' }, + }, + }); + + startPostRenderPrefetches(makeConfig(), makeSettings()); + await vi.dynamicImportSettled(); + + expect(mockHandleAutoUpdate).toHaveBeenCalledOnce(); + expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); + }); + it('prompts non-npm installs when no parent supervisor is available', async () => { mockRequestUpdateOnExit.mockReturnValue(false); mockGetInstallationInfo.mockReturnValue({ diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index 5124d457e67..5a8721afd7b 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -196,7 +196,10 @@ export function startPostRenderPrefetches( return; } const installationInfo = getInstallationInfo(projectRoot, true); - if (installationInfo.packageManager === 'npm') { + if ( + installationInfo.packageManager === 'npm' && + installationInfo.updateCommand + ) { void handleAutoUpdate(result.info, settings, projectRoot); return; } diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index 4e8ee735de2..e218388c4ab 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -15,7 +15,6 @@ import { getPackageJson } from '../../utils/package.js'; import { getNpmCliPath } from '../../utils/installationInfo.js'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; -import { MANAGED_NPM_UPDATE_ENV_VAR } from '../../utils/managed-npm-update.js'; const debugLogger = createDebugLogger('UPDATE_CHECK'); @@ -98,7 +97,7 @@ export async function isGlobalNpmInstallation( run: typeof execFileAsync = execFileAsync, canonicalize: typeof realpath = realpath, ): Promise { - if (process.env[MANAGED_NPM_UPDATE_ENV_VAR] === 'true') return true; + if (process.env['QWEN_CODE_MANAGED_NPM_UPDATE'] === 'true') return true; if (!cliPath) return false; // Canonicalize before matching. The CLI can be launched through its global // bin symlink (e.g. `.../bin/qwen`), whose path carries no `node_modules` diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index dd52c635c8e..edd6a126ab3 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -268,6 +268,7 @@ describe('handleAutoUpdate', () => { ], { stdio: ['pipe', 'ignore', 'pipe'], + cwd: '/tmp/2.0.0-nightly', }, ); }); @@ -295,6 +296,7 @@ describe('handleAutoUpdate', () => { ], { stdio: ['pipe', 'ignore', 'pipe'], + cwd: '/tmp/2.0.0', }, ); }); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index 08b169e6d62..25e4493a655 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -137,6 +137,7 @@ export function handleAutoUpdate( : ['-c', updateCommand]; const updateProcess = spawnFn(command, commandArgs, { stdio: ['pipe', 'ignore', 'pipe'], + ...(managedNpmUpdate ? { cwd: managedNpmUpdate.stagingDir } : {}), }); let finishUpdate!: (success: boolean) => void; let settled = false; diff --git a/packages/cli/src/utils/managed-npm-update.test.ts b/packages/cli/src/utils/managed-npm-update.test.ts index 69bec4035f7..966e8182585 100644 --- a/packages/cli/src/utils/managed-npm-update.test.ts +++ b/packages/cli/src/utils/managed-npm-update.test.ts @@ -67,11 +67,13 @@ afterEach(() => { }); describe('managed npm update', () => { - it('stages an exact package version for one global launcher', () => { + it('stages an exact version for one launcher', () => { const root = makeTemporaryDirectory(); - const updateRoot = path.join(root, 'updates'); - const bootstrap = writeBaseInstallation(root); - const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + const update = prepareManagedNpmUpdate( + '2.0.0', + writeBaseInstallation(root), + path.join(root, 'updates'), + ); expect(update.installArgs).toEqual([ 'install', @@ -86,31 +88,22 @@ describe('managed npm update', () => { ); }); - it('activates only a complete matching installation', async () => { + it('activates a verified install without changing running files', async () => { const root = makeTemporaryDirectory(); - const updateRoot = path.join(root, 'updates'); const bootstrap = writeBaseInstallation(root); - const runningChunk = path.join(root, 'running', 'chunks', 'old.js'); - fs.mkdirSync(path.dirname(runningChunk), { recursive: true }); + const runningChunk = path.join(root, 'running', 'old.js'); + fs.mkdirSync(path.dirname(runningChunk)); fs.writeFileSync(runningChunk, 'old chunk'); - const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + const update = prepareManagedNpmUpdate( + '2.0.0', + bootstrap, + path.join(root, 'updates'), + ); writeInstallation(update.stagingDir, '2.0.0'); await activateManagedNpmUpdate(update, '2.0.0', bootstrap); - expect(fs.existsSync(update.stagingDir)).toBe(false); - expect( - fs.existsSync( - path.join( - update.versionDir, - 'node_modules', - '@qwen-code', - 'qwen-code', - 'dist', - 'cli.js', - ), - ), - ).toBe(true); + expect(fs.existsSync(update.versionDir)).toBe(true); expect( JSON.parse( fs.readFileSync(path.join(update.launcherRoot, 'active.json'), 'utf8'), @@ -124,42 +117,35 @@ describe('managed npm update', () => { expect(fs.readFileSync(runningChunk, 'utf8')).toBe('old chunk'); }); - it('does not activate a mismatched package', async () => { + it.each([ + ['a mismatched package', '2.0.1', undefined, 'did not match'], + ['a failed smoke test', '2.0.0', 'broken', 'reported version broken'], + ])('rejects %s', async (_name, installed, reported, error) => { const root = makeTemporaryDirectory(); - const updateRoot = path.join(root, 'updates'); const bootstrap = writeBaseInstallation(root); - const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); - writeInstallation(update.stagingDir, '2.0.1'); - - await expect( - activateManagedNpmUpdate(update, '2.0.0', bootstrap), - ).rejects.toThrow('Installed package did not match'); - expect(fs.existsSync(path.join(update.launcherRoot, 'active.json'))).toBe( - false, - ); - }); - - it('does not activate a package that fails its version smoke test', async () => { - const root = makeTemporaryDirectory(); - const updateRoot = path.join(root, 'updates'); - const bootstrap = writeBaseInstallation(root); - const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); - writeInstallation(update.stagingDir, '2.0.0'); - fs.writeFileSync( - path.join( - update.stagingDir, - 'node_modules', - '@qwen-code', - 'qwen-code', - 'scripts', - 'cli-entry.js', - ), - "process.stdout.write('broken\\n');\n", + const update = prepareManagedNpmUpdate( + '2.0.0', + bootstrap, + path.join(root, 'updates'), ); + writeInstallation(update.stagingDir, installed); + if (reported) { + fs.writeFileSync( + path.join( + update.stagingDir, + 'node_modules', + '@qwen-code', + 'qwen-code', + 'scripts', + 'cli-entry.js', + ), + `process.stdout.write('${reported}\\n');\n`, + ); + } await expect( activateManagedNpmUpdate(update, '2.0.0', bootstrap), - ).rejects.toThrow('Installed package reported version broken'); + ).rejects.toThrow(error); expect(fs.existsSync(path.join(update.launcherRoot, 'active.json'))).toBe( false, ); @@ -167,41 +153,44 @@ describe('managed npm update', () => { it('does not mask a global install that changes while staging', async () => { const root = makeTemporaryDirectory(); - const updateRoot = path.join(root, 'updates'); const bootstrap = writeBaseInstallation(root); - const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + const update = prepareManagedNpmUpdate( + '2.0.0', + bootstrap, + path.join(root, 'updates'), + ); writeInstallation(update.stagingDir, '2.0.0'); fs.writeFileSync(bootstrap, 'manually reinstalled launcher'); await expect( activateManagedNpmUpdate(update, '2.0.0', bootstrap), ).rejects.toThrow('changed during update'); - expect(fs.existsSync(path.join(update.launcherRoot, 'active.json'))).toBe( - false, - ); }); - it('does not replace a newer active version from another session', async () => { + it('replaces a higher pointer from an older global install', async () => { const root = makeTemporaryDirectory(); const updateRoot = path.join(root, 'updates'); const bootstrap = writeBaseInstallation(root); - const newer = prepareManagedNpmUpdate('3.0.0', bootstrap, updateRoot); - writeInstallation(newer.stagingDir, '3.0.0'); - await activateManagedNpmUpdate(newer, '3.0.0', bootstrap); - const older = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); - writeInstallation(older.stagingDir, '2.0.0'); + const oldUpdate = prepareManagedNpmUpdate('3.0.0', bootstrap, updateRoot); + writeInstallation(oldUpdate.stagingDir, '3.0.0'); + await activateManagedNpmUpdate(oldUpdate, '3.0.0', bootstrap); - await activateManagedNpmUpdate(older, '2.0.0', bootstrap); + writeBaseInstallation(root, '2.0.0'); + const newUpdate = prepareManagedNpmUpdate('2.1.0', bootstrap, updateRoot); + writeInstallation(newUpdate.stagingDir, '2.1.0'); + await activateManagedNpmUpdate(newUpdate, '2.1.0', bootstrap); expect( JSON.parse( - fs.readFileSync(path.join(older.launcherRoot, 'active.json'), 'utf8'), + fs.readFileSync( + path.join(newUpdate.launcherRoot, 'active.json'), + 'utf8', + ), ), - ).toMatchObject({ version: '3.0.0' }); - expect(fs.existsSync(older.versionDir)).toBe(false); + ).toMatchObject({ version: '2.1.0', baseVersion: '2.0.0' }); }); - it('publishes an existing payload during concurrent activation', async () => { + it('keeps the highest concurrently activated version', async () => { const root = makeTemporaryDirectory(); const updateRoot = path.join(root, 'updates'); const bootstrap = writeBaseInstallation(root); @@ -209,14 +198,6 @@ describe('managed npm update', () => { const newer = prepareManagedNpmUpdate('3.0.0', bootstrap, updateRoot); writeInstallation(older.stagingDir, '2.0.0'); writeInstallation(newer.stagingDir, '3.0.0'); - const staleLock = path.join(older.launcherRoot, 'activation.lock'); - fs.mkdirSync(staleLock); - fs.writeFileSync( - path.join(staleLock, 'owner.json'), - JSON.stringify({ pid: 999999, token: 'stale-owner' }), - ); - const staleTime = new Date(Date.now() - 2 * 60 * 1000); - fs.utimesSync(staleLock, staleTime, staleTime); await Promise.all([ activateManagedNpmUpdate(older, '2.0.0', bootstrap), @@ -232,76 +213,31 @@ describe('managed npm update', () => { ).toBe(true); }); - it('isolates version payloads for different global launchers', async () => { + it('isolates payloads for different launchers', async () => { const root = makeTemporaryDirectory(); const updateRoot = path.join(root, 'updates'); - const firstBootstrap = writeBaseInstallation(path.join(root, 'node-22')); - const secondBootstrap = writeBaseInstallation(path.join(root, 'node-24')); const launcherRoots = new Set(); - for (const bootstrap of [firstBootstrap, secondBootstrap]) { + for (const bootstrap of [ + writeBaseInstallation(path.join(root, 'node-22')), + writeBaseInstallation(path.join(root, 'node-24')), + ]) { const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); writeInstallation(update.stagingDir, '2.0.0'); await activateManagedNpmUpdate(update, '2.0.0', bootstrap); launcherRoots.add(update.launcherRoot); - expect(fs.existsSync(update.versionDir)).toBe(true); } expect(launcherRoots).toHaveLength(2); }); - it('keeps leased versions and removes them after their process exits', async () => { - const root = makeTemporaryDirectory(); - const updateRoot = path.join(root, 'updates'); - const bootstrap = writeBaseInstallation(root); - const first = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); - writeInstallation(first.stagingDir, '2.0.0'); - await activateManagedNpmUpdate(first, '2.0.0', bootstrap); - const leasesDir = path.join(first.versionDir, '.leases'); - fs.mkdirSync(leasesDir); - fs.writeFileSync(path.join(leasesDir, String(process.pid)), ''); - - const second = prepareManagedNpmUpdate('3.0.0', bootstrap, updateRoot); - writeInstallation(second.stagingDir, '3.0.0'); - await activateManagedNpmUpdate(second, '3.0.0', bootstrap); - - expect(fs.existsSync(first.versionDir)).toBe(true); - fs.rmSync(path.join(leasesDir, String(process.pid))); - - const third = prepareManagedNpmUpdate('4.0.0', bootstrap, updateRoot); - writeInstallation(third.stagingDir, '4.0.0'); - await activateManagedNpmUpdate(third, '4.0.0', bootstrap); - - expect(fs.existsSync(first.versionDir)).toBe(false); - expect(fs.existsSync(second.versionDir)).toBe(false); - expect(fs.existsSync(third.versionDir)).toBe(true); - }); - - it('retries cleanup of abandoned staging and trash directories', async () => { - const root = makeTemporaryDirectory(); - const updateRoot = path.join(root, 'updates'); - const bootstrap = writeBaseInstallation(root); - const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); - writeInstallation(update.stagingDir, '2.0.0'); - const abandoned = path.join(update.launcherRoot, 'versions', '.abandoned'); - fs.mkdirSync(abandoned); - fs.writeFileSync(path.join(abandoned, '.qwen-update-owner'), '999999'); - const old = new Date(Date.now() - 2 * 60 * 60 * 1000); - fs.utimesSync(abandoned, old, old); - const trash = path.join(update.launcherRoot, '.trash-old'); - fs.mkdirSync(trash); - - await activateManagedNpmUpdate(update, '2.0.0', bootstrap); - - expect(fs.existsSync(abandoned)).toBe(false); - expect(fs.existsSync(trash)).toBe(false); - }); - it('removes a failed staging directory', async () => { const root = makeTemporaryDirectory(); - const updateRoot = path.join(root, 'updates'); - const bootstrap = writeBaseInstallation(root); - const update = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + const update = prepareManagedNpmUpdate( + '2.0.0', + writeBaseInstallation(root), + path.join(root, 'updates'), + ); await cleanupManagedNpmUpdate(update); diff --git a/packages/cli/src/utils/managed-npm-update.ts b/packages/cli/src/utils/managed-npm-update.ts index 872e5137aa3..f7c2f36f606 100644 --- a/packages/cli/src/utils/managed-npm-update.ts +++ b/packages/cli/src/utils/managed-npm-update.ts @@ -6,41 +6,28 @@ import { Storage } from '@qwen-code/qwen-code-core'; import { execFile } from 'node:child_process'; -import { createHash, randomUUID } from 'node:crypto'; +import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as fsPromises from 'node:fs/promises'; import * as path from 'node:path'; -import { setTimeout as delay } from 'node:timers/promises'; import { promisify } from 'node:util'; +import lockfile from 'proper-lockfile'; import semver from 'semver'; const PACKAGE_NAME = '@qwen-code/qwen-code'; const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; -const LOCK_TIMEOUT_MS = 5_000; -const STALE_LOCK_MS = 30_000; -const ABANDONED_STAGING_MS = 60 * 60 * 1000; -const MAX_LOCK_AGE_MS = 5 * 60 * 1000; -const MAX_STAGING_AGE_MS = 24 * 60 * 60 * 1000; -const STAGING_OWNER_FILE = '.qwen-update-owner'; const execFileAsync = promisify(execFile); -export const MANAGED_NPM_UPDATE_ENV_VAR = 'QWEN_CODE_MANAGED_NPM_UPDATE'; - export interface ManagedNpmUpdate { stagingDir: string; versionDir: string; launcherRoot: string; baseVersion: string; - bootstrapFingerprint: Pick; + bootstrapMtimeMs: number; installArgs: string[]; } -interface ActiveInstallation { - version?: unknown; - bootstrap?: unknown; -} - function assertVersion(version: string): void { if (!VERSION_PATTERN.test(version)) { throw new Error(`Invalid update version: ${version}`); @@ -62,6 +49,25 @@ function launcherId(bootstrapPath: string): string { return createHash('sha256').update(bootstrapPath).digest('hex').slice(0, 16); } +function readBaseInstallation(bootstrapPath: string): { + version: string; + mtimeMs: number; +} { + const manifest = JSON.parse( + fs.readFileSync( + path.join(path.dirname(path.dirname(bootstrapPath)), 'package.json'), + 'utf8', + ), + ) as { name?: unknown; version?: unknown }; + if (manifest.name !== PACKAGE_NAME || typeof manifest.version !== 'string') { + throw new Error('Unable to identify the base Qwen Code npm installation'); + } + return { + version: manifest.version, + mtimeMs: fs.statSync(bootstrapPath).mtimeMs, + }; +} + async function validateInstallation( prefix: string, version: string, @@ -82,287 +88,50 @@ async function validateInstallation( } async function smokeTest(prefix: string, version: string): Promise { - const launcher = path.join(packageDir(prefix), 'scripts', 'cli-entry.js'); const env = { ...process.env }; delete env['CLI_VERSION']; delete env['QWEN_CODE_RELAUNCH_ARGS']; const { stdout } = await execFileAsync( process.execPath, - [launcher, '--version'], - { - encoding: 'utf8', - env, - timeout: 10_000, - }, + [path.join(packageDir(prefix), 'scripts', 'cli-entry.js'), '--version'], + { encoding: 'utf8', env, timeout: 10_000 }, ); - const output = stdout.trim(); - if (output !== version) { + if (stdout.trim() !== version) { throw new Error( - `Installed package reported version ${output || 'unknown'}`, + `Installed package reported version ${stdout.trim() || 'unknown'}`, ); } } -function processIsAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'EPERM'; - } -} - -async function releaseActivationLock( - lockPath: string, - token: string, -): Promise { +async function readActiveVersion( + activeFile: string, + expected: { + bootstrap: string; + baseVersion: string; + bootstrapMtimeMs: number; + }, +): Promise { try { - const owner = JSON.parse( - await fsPromises.readFile(path.join(lockPath, 'owner.json'), 'utf8'), - ) as { token?: unknown }; - if (owner.token !== token) return; - const releasedPath = `${lockPath}.release-${token}`; - await fsPromises.rename(lockPath, releasedPath); - await fsPromises.rm(releasedPath, { recursive: true, force: true }); + const active = JSON.parse( + await fsPromises.readFile(activeFile, 'utf8'), + ) as { + version?: unknown; + bootstrap?: unknown; + baseVersion?: unknown; + bootstrapMtimeMs?: unknown; + }; + return typeof active.version === 'string' && + semver.valid(active.version) !== null && + active.bootstrap === expected.bootstrap && + active.baseVersion === expected.baseVersion && + active.bootstrapMtimeMs === expected.bootstrapMtimeMs + ? active.version + : null; } catch { - // A missing or replaced lock is no longer ours to release. - } -} - -async function acquireActivationLock(lockPath: string): Promise { - const deadline = Date.now() + LOCK_TIMEOUT_MS; - while (true) { - const token = randomUUID(); - try { - await fsPromises.mkdir(lockPath); - try { - await fsPromises.writeFile( - path.join(lockPath, 'owner.json'), - JSON.stringify({ pid: process.pid, token }), - ); - } catch (error) { - await fsPromises.rm(lockPath, { recursive: true, force: true }); - throw error; - } - return token; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; - let lockStats: fs.Stats; - try { - lockStats = await fsPromises.stat(lockPath); - } catch { - continue; - } - let owner = Number.NaN; - let ownerToken: string | undefined; - try { - const metadata = JSON.parse( - await fsPromises.readFile(path.join(lockPath, 'owner.json'), 'utf8'), - ) as { pid?: unknown; token?: unknown }; - owner = typeof metadata.pid === 'number' ? metadata.pid : Number.NaN; - ownerToken = - typeof metadata.token === 'string' ? metadata.token : undefined; - } catch { - // The lock owner may still be creating its metadata. - } - const hasOwner = Number.isInteger(owner) && owner > 0; - const lockAge = Date.now() - lockStats.mtimeMs; - if ( - (!hasOwner && lockAge >= STALE_LOCK_MS) || - (hasOwner && !processIsAlive(owner)) || - lockAge >= MAX_LOCK_AGE_MS - ) { - const staleIdentity = - ownerToken ?? - `${lockStats.dev}-${lockStats.ino}-${lockStats.mtimeMs}`; - const staleId = createHash('sha256') - .update(staleIdentity) - .digest('hex') - .slice(0, 16); - const stalePath = `${lockPath}.stale-${staleId}`; - try { - await fsPromises.rename(lockPath, stalePath); - } catch (error) { - if ( - (error as NodeJS.ErrnoException).code !== 'ENOENT' && - (error as NodeJS.ErrnoException).code !== 'EEXIST' && - (error as NodeJS.ErrnoException).code !== 'ENOTEMPTY' && - !( - ((error as NodeJS.ErrnoException).code === 'EPERM' || - (error as NodeJS.ErrnoException).code === 'EACCES') && - fs.existsSync(stalePath) - ) - ) { - throw error; - } - } - continue; - } - if (Date.now() >= deadline) { - throw new Error('Timed out waiting to activate the npm update'); - } - await delay(25); - } + return null; } } -async function quarantineAbandonedWork( - launcherRoot: string, -): Promise { - const trash: string[] = []; - try { - const entries = await fsPromises.readdir(launcherRoot, { - withFileTypes: true, - }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith('.trash-')) { - trash.push(path.join(launcherRoot, entry.name)); - } else if ( - entry.isDirectory() && - entry.name.startsWith('activation.lock.release-') - ) { - const candidate = path.join(launcherRoot, entry.name); - try { - const age = Date.now() - (await fsPromises.stat(candidate)).mtimeMs; - if (age >= STALE_LOCK_MS * 2) trash.push(candidate); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - } - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - - const versionsDir = path.join(launcherRoot, 'versions'); - let versions: fs.Dirent[]; - try { - versions = await fsPromises.readdir(versionsDir, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return trash; - throw error; - } - for (const entry of versions) { - if (!entry.isDirectory() || !entry.name.startsWith('.')) continue; - const stagingDir = path.join(versionsDir, entry.name); - let stagingAge: number; - try { - stagingAge = Date.now() - (await fsPromises.stat(stagingDir)).mtimeMs; - if (stagingAge < ABANDONED_STAGING_MS) continue; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; - throw error; - } - let owner = Number.NaN; - try { - owner = Number.parseInt( - await fsPromises.readFile( - path.join(stagingDir, STAGING_OWNER_FILE), - 'utf8', - ), - 10, - ); - } catch { - // The age grace is sufficient when owner metadata is missing. - } - if ( - stagingAge < MAX_STAGING_AGE_MS && - Number.isInteger(owner) && - owner > 0 && - processIsAlive(owner) - ) { - continue; - } - const trashDir = path.join( - launcherRoot, - `.trash-staging-${process.pid}-${randomUUID()}`, - ); - try { - await fsPromises.rename(stagingDir, trashDir); - trash.push(trashDir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - } - return trash; -} - -async function hasLiveLease(versionDir: string): Promise { - const leasesDir = path.join(versionDir, '.leases'); - let leases: string[]; - try { - leases = await fsPromises.readdir(leasesDir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; - throw error; - } - let live = false; - for (const lease of leases) { - const pid = Number.parseInt(lease, 10); - if (Number.isInteger(pid) && pid > 0 && processIsAlive(pid)) { - live = true; - } else { - await fsPromises.rm(path.join(leasesDir, lease), { force: true }); - } - } - return live; -} - -async function quarantineUnusedVersions( - launcherRoot: string, - activeVersion: string, -): Promise { - const versionsDir = path.join(launcherRoot, 'versions'); - const trash: string[] = []; - let entries: fs.Dirent[]; - try { - entries = await fsPromises.readdir(versionsDir, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return trash; - throw error; - } - for (const entry of entries) { - if ( - !entry.isDirectory() || - entry.name.startsWith('.') || - entry.name === activeVersion - ) { - continue; - } - const versionDir = path.join(versionsDir, entry.name); - if (await hasLiveLease(versionDir)) continue; - const trashDir = path.join( - launcherRoot, - `.trash-${entry.name}-${process.pid}-${Date.now()}`, - ); - await fsPromises.rename(versionDir, trashDir); - trash.push(trashDir); - } - return trash; -} - -function readBaseInstallation(bootstrapPath: string): { - version: string; - fingerprint: Pick; -} { - const packageJsonPath = path.join( - path.dirname(path.dirname(bootstrapPath)), - 'package.json', - ); - const manifest = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { - name?: unknown; - version?: unknown; - }; - if (manifest.name !== PACKAGE_NAME || typeof manifest.version !== 'string') { - throw new Error('Unable to identify the base Qwen Code npm installation'); - } - const { dev, ino, size, mtimeMs } = fs.statSync(bootstrapPath); - return { - version: manifest.version, - fingerprint: { dev, ino, size, mtimeMs }, - }; -} - export function prepareManagedNpmUpdate( version: string, bootstrapPath = process.env['QWEN_CODE_CLI'], @@ -374,20 +143,13 @@ export function prepareManagedNpmUpdate( const launcherRoot = path.join(updateRoot, launcherId(resolvedBootstrapPath)); const versionsDir = path.join(launcherRoot, 'versions'); fs.mkdirSync(versionsDir, { recursive: true }); - const stagingDir = fs.mkdtempSync( - path.join(versionsDir, `.${version}-${process.pid}-`), - ); - fs.writeFileSync( - path.join(stagingDir, STAGING_OWNER_FILE), - String(process.pid), - { mode: 0o600 }, - ); + const stagingDir = fs.mkdtempSync(path.join(versionsDir, `.${version}-`)); return { stagingDir, versionDir: path.join(versionsDir, version), launcherRoot, baseVersion: base.version, - bootstrapFingerprint: base.fingerprint, + bootstrapMtimeMs: base.mtimeMs, installArgs: [ 'install', '--prefix', @@ -406,80 +168,62 @@ export async function activateManagedNpmUpdate( ): Promise { assertVersion(version); const resolvedBootstrapPath = resolveBootstrapPath(bootstrapPath); - const expectedLauncherRoot = path.join( - path.dirname(update.launcherRoot), - launcherId(resolvedBootstrapPath), - ); - if (expectedLauncherRoot !== update.launcherRoot) { + if ( + path.join( + path.dirname(update.launcherRoot), + launcherId(resolvedBootstrapPath), + ) !== update.launcherRoot + ) { throw new Error('The npm update does not match the active launcher'); } await validateInstallation(update.stagingDir, version); await smokeTest(update.stagingDir, version); + const activeFile = path.join(update.launcherRoot, 'active.json'); - const lockPath = path.join(update.launcherRoot, 'activation.lock'); - const abandoned = await quarantineAbandonedWork(update.launcherRoot); - await Promise.allSettled( - abandoned.map((trashDir) => - fsPromises.rm(trashDir, { recursive: true, force: true }), - ), - ); - const trash: string[] = []; - const lockToken = await acquireActivationLock(lockPath); + const release = await lockfile.lock(activeFile, { + realpath: false, + stale: 30_000, + retries: { retries: 50, minTimeout: 20, maxTimeout: 100 }, + }); try { const base = readBaseInstallation(resolvedBootstrapPath); if ( base.version !== update.baseVersion || - base.fingerprint.dev !== update.bootstrapFingerprint.dev || - base.fingerprint.ino !== update.bootstrapFingerprint.ino || - base.fingerprint.size !== update.bootstrapFingerprint.size || - base.fingerprint.mtimeMs !== update.bootstrapFingerprint.mtimeMs + base.mtimeMs !== update.bootstrapMtimeMs ) { throw new Error( 'The base Qwen Code npm installation changed during update', ); } - let active: ActiveInstallation | undefined; - try { - active = JSON.parse(await fsPromises.readFile(activeFile, 'utf8')) as { - version?: unknown; - bootstrap?: unknown; - }; - } catch { - active = undefined; - } - if ( - active?.bootstrap === resolvedBootstrapPath && - typeof active.version === 'string' && - semver.valid(active.version) !== null && - semver.gt(active.version, version) - ) { - await validateInstallation( - path.join(update.launcherRoot, 'versions', active.version), - active.version, - ); - trash.push( - ...(await quarantineUnusedVersions( - update.launcherRoot, - active.version, - )), - ); - trash.push(update.stagingDir); - return; + + const activeVersion = await readActiveVersion(activeFile, { + bootstrap: resolvedBootstrapPath, + baseVersion: base.version, + bootstrapMtimeMs: base.mtimeMs, + }); + if (activeVersion && semver.gt(activeVersion, version)) { + try { + await validateInstallation( + path.join(update.launcherRoot, 'versions', activeVersion), + activeVersion, + ); + await cleanupManagedNpmUpdate(update); + return; + } catch { + // Replace a pointer whose payload is missing or incomplete. + } } try { await fsPromises.rename(update.stagingDir, update.versionDir); - await fsPromises.rm(path.join(update.versionDir, STAGING_OWNER_FILE), { - force: true, - }); } catch (error) { if (!fs.existsSync(update.versionDir)) throw error; await validateInstallation(update.versionDir, version); - trash.push(update.stagingDir); + await cleanupManagedNpmUpdate(update); } - const temporaryActivePath = `${activeFile}.${process.pid}.${Date.now()}`; + const temporaryActivePath = `${activeFile}.${process.pid}`; try { await fsPromises.writeFile( temporaryActivePath, @@ -487,7 +231,7 @@ export async function activateManagedNpmUpdate( version, bootstrap: resolvedBootstrapPath, baseVersion: base.version, - bootstrapFingerprint: base.fingerprint, + bootstrapMtimeMs: base.mtimeMs, }), { mode: 0o600 }, ); @@ -495,25 +239,18 @@ export async function activateManagedNpmUpdate( } finally { await fsPromises.rm(temporaryActivePath, { force: true }); } - trash.push( - ...(await quarantineUnusedVersions(update.launcherRoot, version)), - ); } finally { - await releaseActivationLock(lockPath, lockToken); - await Promise.allSettled( - trash.map((trashDir) => - fsPromises.rm(trashDir, { recursive: true, force: true }), - ), - ); + await release(); } } export async function cleanupManagedNpmUpdate( update: ManagedNpmUpdate, ): Promise { - try { - await fsPromises.rm(update.stagingDir, { recursive: true, force: true }); - } catch { - // A later update retries abandoned staging cleanup. - } + await fsPromises + .rm(update.stagingDir, { recursive: true, force: true }) + .catch(() => {}); } + +// ponytail: immutable versions are retained because a live older session may +// still import them; add measured, lease-based GC only if disk use warrants it. diff --git a/scripts/cli-entry.js b/scripts/cli-entry.js index 54e8752ed54..9b215bf29d3 100755 --- a/scripts/cli-entry.js +++ b/scripts/cli-entry.js @@ -60,17 +60,10 @@ if (isTopLevelVersion && process.env['CLI_VERSION']) { process.exit(0); } -const { - existsSync, - mkdirSync, - readFileSync, - realpathSync, - renameSync, - rmSync, - statSync, - writeFileSync, -} = await import('node:fs'); -const { createHash, randomUUID } = await import('node:crypto'); +const { existsSync, readFileSync, realpathSync, statSync } = await import( + 'node:fs' +); +const { createHash } = await import('node:crypto'); const { homedir } = await import('node:os'); const { fileURLToPath, pathToFileURL } = await import('node:url'); const { delimiter, dirname, join, parse, resolve, sep } = await import( @@ -79,7 +72,6 @@ const { delimiter, dirname, join, parse, resolve, sep } = await import( const __dirname = dirname(fileURLToPath(import.meta.url)); const currentEntryPath = realpathSync(fileURLToPath(import.meta.url)); -const lockWaiter = new Int32Array(new SharedArrayBuffer(4)); function resolveQwenHome() { const configured = process.env['QWEN_HOME']; @@ -91,120 +83,9 @@ function resolveQwenHome() { return resolve(configured); } -function processIsAlive(pid) { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error?.code === 'EPERM'; - } -} - -function acquireActivationLock(lockPath) { - const deadline = Date.now() + 1000; - while (true) { - const token = randomUUID(); - try { - mkdirSync(lockPath); - try { - writeFileSync( - join(lockPath, 'owner.json'), - JSON.stringify({ pid: process.pid, token }), - ); - } catch (error) { - rmSync(lockPath, { recursive: true, force: true }); - throw error; - } - return token; - } catch (error) { - if (error?.code !== 'EEXIST') throw error; - let lockStats; - try { - lockStats = statSync(lockPath); - } catch { - continue; - } - let owner = Number.NaN; - let ownerToken; - try { - const metadata = JSON.parse( - readFileSync(join(lockPath, 'owner.json'), 'utf8'), - ); - owner = typeof metadata.pid === 'number' ? metadata.pid : Number.NaN; - ownerToken = - typeof metadata.token === 'string' ? metadata.token : undefined; - } catch { - // The lock owner may still be creating its metadata. - } - const hasOwner = Number.isInteger(owner) && owner > 0; - const lockAge = Date.now() - lockStats.mtimeMs; - if ( - (!hasOwner && lockAge >= 30000) || - (hasOwner && !processIsAlive(owner)) || - lockAge >= 300000 - ) { - const staleIdentity = - ownerToken ?? - `${lockStats.dev}-${lockStats.ino}-${lockStats.mtimeMs}`; - const staleId = createHash('sha256') - .update(staleIdentity) - .digest('hex') - .slice(0, 16); - const stalePath = `${lockPath}.stale-${staleId}`; - try { - renameSync(lockPath, stalePath); - } catch (error) { - if ( - error?.code !== 'ENOENT' && - error?.code !== 'EEXIST' && - error?.code !== 'ENOTEMPTY' && - !( - (error?.code === 'EPERM' || error?.code === 'EACCES') && - existsSync(stalePath) - ) - ) { - throw error; - } - } - continue; - } - if (Date.now() >= deadline) { - throw new Error('Timed out waiting for the npm update lock'); - } - Atomics.wait(lockWaiter, 0, 0, 25); - } - } -} - -function releaseActivationLock(lockPath, token) { - try { - const owner = JSON.parse( - readFileSync(join(lockPath, 'owner.json'), 'utf8'), - ); - if (owner.token !== token) return; - const releasedPath = `${lockPath}.release-${token}`; - renameSync(lockPath, releasedPath); - rmSync(releasedPath, { recursive: true, force: true }); - } catch { - // A missing or replaced lock is no longer ours to release. - } -} - -function matchesFingerprint(actual, expected) { - return ( - expected && - actual.dev === expected.dev && - actual.ino === expected.ino && - actual.size === expected.size && - actual.mtimeMs === expected.mtimeMs - ); -} - delete process.env['QWEN_CODE_MANAGED_NPM_UPDATE']; function getManagedNpmInstallation() { - let lockPath; - let lockToken; try { const updateRoot = join(resolveQwenHome(), 'updates', 'npm'); const id = createHash('sha256') @@ -212,8 +93,6 @@ function getManagedNpmInstallation() { .digest('hex') .slice(0, 16); const launcherRoot = join(updateRoot, id); - lockPath = join(launcherRoot, 'activation.lock'); - lockToken = acquireActivationLock(lockPath); const active = JSON.parse( readFileSync(join(launcherRoot, 'active.json'), 'utf8'), ); @@ -231,10 +110,7 @@ function getManagedNpmInstallation() { typeof active.bootstrap !== 'string' || realpathSync(active.bootstrap) !== currentEntryPath || active.baseVersion !== basePackage.version || - !matchesFingerprint( - statSync(currentEntryPath), - active.bootstrapFingerprint, - ) + active.bootstrapMtimeMs !== statSync(currentEntryPath).mtimeMs ) { return undefined; } @@ -256,18 +132,10 @@ function getManagedNpmInstallation() { ) { return undefined; } - const leasesDir = join(launcherRoot, 'versions', active.version, '.leases'); - mkdirSync(leasesDir, { recursive: true }); - const leasePath = join(leasesDir, String(process.pid)); - writeFileSync(leasePath, ''); process.env['QWEN_CODE_MANAGED_NPM_UPDATE'] = 'true'; - return { cliPath, packageJsonPath, leasePath }; + return { cliPath, packageJsonPath }; } catch { return undefined; - } finally { - if (lockToken) { - releaseActivationLock(lockPath, lockToken); - } } } @@ -307,11 +175,6 @@ process.env['QWEN_CODE_CLI'] = : fileURLToPath(import.meta.url); const managedNpmInstallation = getManagedNpmInstallation(); -if (managedNpmInstallation?.leasePath) { - process.once('exit', () => { - rmSync(managedNpmInstallation.leasePath, { force: true }); - }); -} const cliPathCandidates = [ managedNpmInstallation?.cliPath, join(__dirname, 'cli.js'), From 8cdb9c6cc2b670363a427c1e0d89261a393071bb Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 20 Jul 2026 20:20:35 +0800 Subject: [PATCH 03/19] refactor(cli): simplify background update completion --- packages/cli/src/startup/startup-prefetch.ts | 5 +- .../cli/src/utils/handleAutoUpdate.test.ts | 87 +++++++++------- packages/cli/src/utils/handleAutoUpdate.ts | 99 +++++++------------ packages/cli/src/utils/managed-npm-update.ts | 11 +-- .../cli/src/utils/update-relaunch.test.ts | 29 ++---- packages/cli/src/utils/update-relaunch.ts | 7 +- 6 files changed, 99 insertions(+), 139 deletions(-) diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index 5a8721afd7b..5124d457e67 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -196,10 +196,7 @@ export function startPostRenderPrefetches( return; } const installationInfo = getInstallationInfo(projectRoot, true); - if ( - installationInfo.packageManager === 'npm' && - installationInfo.updateCommand - ) { + if (installationInfo.packageManager === 'npm') { void handleAutoUpdate(result.info, settings, projectRoot); return; } diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index edd6a126ab3..8d16422b775 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -11,11 +11,7 @@ import { updateEventEmitter } from './updateEventEmitter.js'; import type { UpdateObject } from '../ui/utils/updateCheck.js'; import type { LoadedSettings } from '../config/settings.js'; import EventEmitter from 'node:events'; -import { - handleAutoUpdate, - setUpdateHandler, - waitForAutoUpdate, -} from './handleAutoUpdate.js'; +import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js'; import { performStandaloneUpdate } from './standalone-update.js'; import { MessageType } from '../ui/types.js'; import os from 'node:os'; @@ -198,47 +194,62 @@ describe('handleAutoUpdate', () => { }); it('should emit "update-failed" when the update process fails', async () => { - await new Promise((resolve) => { - mockGetInstallationInfo.mockReturnValue({ - updateCommand: 'npm i -g @qwen-code/qwen-code@latest', - updateMessage: 'This is an additional message.', - isGlobal: false, - packageManager: PackageManager.NPM, - }); - - // Simulate failed execution - setTimeout(() => { - mockChildProcess.stderr.emit('data', 'An error occurred'); - mockChildProcess.emit('close', 1); - resolve(); - }, 0); - - handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @qwen-code/qwen-code@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, }); + const update = handleAutoUpdate( + mockUpdateInfo, + mockSettings, + '/root', + mockSpawn, + )!; + mockChildProcess.stderr.emit('data', 'An error occurred'); + mockChildProcess.emit('close', 1); + await expect(update).resolves.toBe(false); expect(emitSpy).toHaveBeenCalledWith('update-failed', { message: 'Automatic update failed. Please try updating manually.', }); }); it('should emit "update-failed" when the spawn function throws an error', async () => { - await new Promise((resolve) => { - mockGetInstallationInfo.mockReturnValue({ - updateCommand: 'npm i -g @qwen-code/qwen-code@latest', - updateMessage: 'This is an additional message.', - isGlobal: false, - packageManager: PackageManager.NPM, - }); + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @qwen-code/qwen-code@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + const update = handleAutoUpdate( + mockUpdateInfo, + mockSettings, + '/root', + mockSpawn, + )!; + mockChildProcess.emit('error', new Error('Spawn error')); - // Simulate an error event - setTimeout(() => { - mockChildProcess.emit('error', new Error('Spawn error')); - resolve(); - }, 0); + await expect(update).resolves.toBe(false); + expect(emitSpy).toHaveBeenCalledWith('update-failed', { + message: 'Automatic update failed. Please try updating manually.', + }); + }); - handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + it('reports managed update preparation failures', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @qwen-code/qwen-code@latest', + isGlobal: true, + packageManager: PackageManager.NPM, + }); + mockPrepareManagedNpmUpdate.mockImplementation(() => { + throw new Error('update directory is not writable'); }); + await expect( + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn), + ).resolves.toBe(false); + expect(mockSpawn).not.toHaveBeenCalled(); expect(emitSpy).toHaveBeenCalledWith('update-failed', { message: 'Automatic update failed. Please try updating manually.', }); @@ -327,7 +338,7 @@ describe('handleAutoUpdate', () => { isGlobal: false, packageManager: PackageManager.NPM, }); - const updateProcess = handleAutoUpdate( + const update = handleAutoUpdate( mockUpdateInfo, mockSettings, '/root', @@ -335,7 +346,7 @@ describe('handleAutoUpdate', () => { )!; mockChildProcess.emit('close', 0); - await expect(waitForAutoUpdate(updateProcess)).resolves.toBe(true); + await expect(update).resolves.toBe(true); expect(emitSpy).toHaveBeenCalledWith('update-success', { message: @@ -357,7 +368,7 @@ describe('handleAutoUpdate', () => { throw new Error('pointer write failed'); }); - const updateProcess = handleAutoUpdate( + const update = handleAutoUpdate( mockUpdateInfo, mockSettings, '/root', @@ -365,7 +376,7 @@ describe('handleAutoUpdate', () => { )!; mockChildProcess.emit('close', 0); - await expect(waitForAutoUpdate(updateProcess)).resolves.toBe(false); + await expect(update).resolves.toBe(false); expect(emitSpy).toHaveBeenCalledWith('update-failed', { message: 'Automatic update failed. Please try updating manually.', }); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index 25e4493a655..38bba77ca69 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -34,21 +34,6 @@ const UPDATE_SUCCESS_MESSAGE = const UPDATE_FAILED_MESSAGE = 'Automatic update failed. Please try updating manually.'; -type UpdateProcess = ReturnType; - -const updateCompletions = new WeakMap>(); - -export function waitForAutoUpdate( - updateProcess: UpdateProcess, -): Promise { - const completion = updateCompletions.get(updateProcess); - if (completion) return completion; - return new Promise((resolve) => { - updateProcess.once('close', (code) => resolve(code === 0)); - updateProcess.once('error', () => resolve(false)); - }); -} - export function handleAutoUpdate( info: UpdateObject | null, settings: LoadedSettings, @@ -116,10 +101,19 @@ export function handleAutoUpdate( ); const platform = os.platform(); const isWindows = platform === 'win32'; - const managedNpmUpdate = - installationInfo.packageManager === PackageManager.NPM - ? prepareManagedNpmUpdate(info.update.latest) - : undefined; + let managedNpmUpdate: ReturnType | undefined; + try { + managedNpmUpdate = + installationInfo.packageManager === PackageManager.NPM + ? prepareManagedNpmUpdate(info.update.latest) + : undefined; + } catch (error) { + debugLogger.warn('Automatic update preparation failed:', error); + updateEventEmitter.emit('update-failed', { + message: t(UPDATE_FAILED_MESSAGE), + }); + return Promise.resolve(false); + } const command = installationInfo.packageManager === PackageManager.NPM ? process.execPath @@ -139,72 +133,49 @@ export function handleAutoUpdate( stdio: ['pipe', 'ignore', 'pipe'], ...(managedNpmUpdate ? { cwd: managedNpmUpdate.stagingDir } : {}), }); - let finishUpdate!: (success: boolean) => void; - let settled = false; - const completion = new Promise((resolve) => { - finishUpdate = resolve; - }); - updateCompletions.set(updateProcess, completion); let errorOutput = ''; updateProcess.stderr.on('data', (data) => { errorOutput += data.toString(); }); - updateProcess.on('close', (code) => { - if (settled) return; - settled = true; - if (code !== 0) { - void (async () => { - if (managedNpmUpdate) { - await cleanupManagedNpmUpdate(managedNpmUpdate); - } - debugLogger.warn( - `Automatic update command failed: ${updateCommand}; stderr: ${errorOutput.trim()}`, - ); - updateEventEmitter.emit('update-failed', { - message: t(UPDATE_FAILED_MESSAGE), - }); - finishUpdate(false); - })(); - return; - } - void (async () => { + return new Promise((resolve) => { + let settled = false; + const finish = async (error?: unknown) => { + if (settled) return; + settled = true; try { + if (error) throw error; if (managedNpmUpdate) { await activateManagedNpmUpdate(managedNpmUpdate, info.update.latest); } updateEventEmitter.emit('update-success', { message: t(UPDATE_SUCCESS_MESSAGE), }); - finishUpdate(true); + resolve(true); } catch (error) { - debugLogger.warn('Automatic update activation failed:', error); if (managedNpmUpdate) { await cleanupManagedNpmUpdate(managedNpmUpdate); } + debugLogger.warn('Automatic update failed:', error); updateEventEmitter.emit('update-failed', { message: t(UPDATE_FAILED_MESSAGE), }); - finishUpdate(false); + resolve(false); } - })(); - }); - - updateProcess.on('error', (err) => { - if (settled) return; - settled = true; - void (async () => { - if (managedNpmUpdate) { - await cleanupManagedNpmUpdate(managedNpmUpdate); - } - debugLogger.warn('Automatic update command failed to start:', err); - updateEventEmitter.emit('update-failed', { - message: t(UPDATE_FAILED_MESSAGE), - }); - finishUpdate(false); - })(); + }; + updateProcess.once('close', (code) => { + void finish( + code === 0 + ? undefined + : new Error( + `Command failed: ${updateCommand}; stderr: ${errorOutput.trim()}`, + ), + ); + }); + updateProcess.once('error', (error) => { + void finish(error); + }); }); - return updateProcess; } export function setUpdateHandler( diff --git a/packages/cli/src/utils/managed-npm-update.ts b/packages/cli/src/utils/managed-npm-update.ts index f7c2f36f606..143c0384b51 100644 --- a/packages/cli/src/utils/managed-npm-update.ts +++ b/packages/cli/src/utils/managed-npm-update.ts @@ -15,11 +15,9 @@ import lockfile from 'proper-lockfile'; import semver from 'semver'; const PACKAGE_NAME = '@qwen-code/qwen-code'; -const VERSION_PATTERN = - /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const execFileAsync = promisify(execFile); -export interface ManagedNpmUpdate { +interface ManagedNpmUpdate { stagingDir: string; versionDir: string; launcherRoot: string; @@ -29,7 +27,7 @@ export interface ManagedNpmUpdate { } function assertVersion(version: string): void { - if (!VERSION_PATTERN.test(version)) { + if (semver.valid(version) !== version) { throw new Error(`Invalid update version: ${version}`); } } @@ -81,10 +79,7 @@ async function validateInstallation( `Installed package did not match ${PACKAGE_NAME}@${version}`, ); } - await Promise.all([ - fsPromises.access(path.join(root, 'dist', 'cli.js')), - fsPromises.access(path.join(root, 'scripts', 'cli-entry.js')), - ]); + await fsPromises.access(path.join(root, 'dist', 'cli.js')); } async function smokeTest(prefix: string, version: string): Promise { diff --git a/packages/cli/src/utils/update-relaunch.test.ts b/packages/cli/src/utils/update-relaunch.test.ts index 8430e86f67b..9cb9de772cd 100644 --- a/packages/cli/src/utils/update-relaunch.test.ts +++ b/packages/cli/src/utils/update-relaunch.test.ts @@ -6,20 +6,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { LoadedSettings } from '../config/settings.js'; -import { EventEmitter } from 'node:events'; const checkForUpdatesDetailed = vi.hoisted(() => vi.fn()); const handleAutoUpdate = vi.hoisted(() => vi.fn()); -const waitForAutoUpdate = vi.hoisted(() => vi.fn()); const getInstallationInfo = vi.hoisted(() => vi.fn()); const performStandaloneUpdate = vi.hoisted(() => vi.fn()); const writeStderrLine = vi.hoisted(() => vi.fn()); vi.mock('../ui/utils/updateCheck.js', () => ({ checkForUpdatesDetailed })); -vi.mock('./handleAutoUpdate.js', () => ({ - handleAutoUpdate, - waitForAutoUpdate, -})); +vi.mock('./handleAutoUpdate.js', () => ({ handleAutoUpdate })); vi.mock('./installationInfo.js', () => ({ getInstallationInfo })); vi.mock('./standalone-update.js', () => ({ performStandaloneUpdate })); vi.mock('./stdioHelpers.js', () => ({ writeStderrLine })); @@ -34,13 +29,6 @@ describe('updateBeforeRelaunch', () => { beforeEach(() => { vi.clearAllMocks(); - waitForAutoUpdate.mockImplementation( - (updateProcess: EventEmitter) => - new Promise((resolve) => { - updateProcess.once('close', (code) => resolve(code === 0)); - updateProcess.once('error', () => resolve(false)); - }), - ); checkForUpdatesDetailed.mockResolvedValue({ status: 'update', info: { @@ -54,8 +42,12 @@ describe('updateBeforeRelaunch', () => { }); it('waits for the package-manager update before reporting success', async () => { - const updateProcess = new EventEmitter(); - handleAutoUpdate.mockReturnValue(updateProcess); + let finishUpdate!: (success: boolean) => void; + handleAutoUpdate.mockReturnValue( + new Promise((resolve) => { + finishUpdate = resolve; + }), + ); const update = updateBeforeRelaunch(settings, '/repo', false); await vi.waitFor(() => expect(handleAutoUpdate).toHaveBeenCalledTimes(1)); @@ -64,7 +56,7 @@ describe('updateBeforeRelaunch', () => { 'Update successful! The new version will be used on your next run.', ); - updateProcess.emit('close', 0); + finishUpdate(true); await expect(update).resolves.toBe(true); expect(writeStderrLine).toHaveBeenCalledWith( @@ -78,12 +70,9 @@ describe('updateBeforeRelaunch', () => { ] as const)( 'reports %s failure and returns %s', async (_source, relaunchOnFailure, expected) => { - const updateProcess = new EventEmitter(); - handleAutoUpdate.mockReturnValue(updateProcess); + handleAutoUpdate.mockResolvedValue(false); const update = updateBeforeRelaunch(settings, '/repo', relaunchOnFailure); - await vi.waitFor(() => expect(handleAutoUpdate).toHaveBeenCalledTimes(1)); - updateProcess.emit('close', 1); await expect(update).resolves.toBe(expected); expect(writeStderrLine).toHaveBeenCalledWith( diff --git a/packages/cli/src/utils/update-relaunch.ts b/packages/cli/src/utils/update-relaunch.ts index 8b007544014..77d1bfcd979 100644 --- a/packages/cli/src/utils/update-relaunch.ts +++ b/packages/cli/src/utils/update-relaunch.ts @@ -21,7 +21,7 @@ export async function updateBeforeRelaunch( try { const [ { checkForUpdatesDetailed }, - { handleAutoUpdate, waitForAutoUpdate }, + { handleAutoUpdate }, { getInstallationInfo }, { performStandaloneUpdate }, { t }, @@ -59,14 +59,11 @@ export async function updateBeforeRelaunch( ); return relaunchOnFailure; } - const updateProcess = handleAutoUpdate( + const success = await handleAutoUpdate( result.info, settings, projectRoot, ); - const success = updateProcess - ? await waitForAutoUpdate(updateProcess) - : false; writeStderrLine( t( success From e14f95c4ac038197f564c8219cd331275a18a653 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 20 Jul 2026 20:42:25 +0800 Subject: [PATCH 04/19] fix(cli): align managed updates with npm package layout --- packages/cli/src/cli.test.ts | 24 +++++++++++++------ .../cli/src/utils/managed-npm-update.test.ts | 10 ++++---- packages/cli/src/utils/managed-npm-update.ts | 6 ++--- scripts/cli-entry.js | 13 ++++++---- 4 files changed, 32 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index dd890c5fdd1..c199e3a534a 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -505,11 +505,11 @@ describe('bootstrap import boundaries', () => { expect(output).toBe(`${expectedVersion}\n`); }); - it('launches the active managed npm version without replacing the running package', () => { + it('launches the active managed npm version with an empty home directory', () => { const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-managed-npm-')); const entryDir = path.join(tempDir, 'bootstrap'); const entryPath = path.join(entryDir, 'cli-entry.mjs'); - const qwenHome = path.join(tempDir, 'home'); + const qwenHome = path.join(tempDir, '.qwen'); try { mkdirSync(entryDir, { recursive: true }); copyFileSync('../../scripts/cli-entry.js', entryPath); @@ -526,7 +526,7 @@ describe('bootstrap import boundaries', () => { '@qwen-code', 'qwen-code', ); - mkdirSync(path.join(packageRoot, 'dist'), { recursive: true }); + mkdirSync(packageRoot, { recursive: true }); writeFileSync( path.join(entryDir, 'cli.js'), "process.stdout.write('old version\\n');\n", @@ -546,7 +546,7 @@ describe('bootstrap import boundaries', () => { }), ); writeFileSync( - path.join(packageRoot, 'dist', 'cli.js'), + path.join(packageRoot, 'cli.js'), 'process.stdout.write(JSON.stringify({ managed: process.env.QWEN_CODE_MANAGED_NPM_UPDATE, launcher: process.env.QWEN_CODE_CLI, args: process.argv.slice(2) }));\n', ); mkdirSync(launcherRoot, { recursive: true }); @@ -561,12 +561,23 @@ describe('bootstrap import boundaries', () => { }), ); + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + HOME: '', + USERPROFILE: '', + HOMEDRIVE: '', + HOMEPATH: '', + TMPDIR: tempDir, + TEMP: tempDir, + TMP: tempDir, + }; + delete childEnv['QWEN_HOME']; const output = execFileSync( process.execPath, [entryPath, '--prompt', 'hello'], { encoding: 'utf8', - env: { ...process.env, QWEN_HOME: qwenHome }, + env: childEnv, }, ); @@ -587,8 +598,7 @@ describe('bootstrap import boundaries', () => { execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { encoding: 'utf8', env: { - ...process.env, - QWEN_HOME: qwenHome, + ...childEnv, QWEN_CODE_MANAGED_NPM_UPDATE: 'true', }, }), diff --git a/packages/cli/src/utils/managed-npm-update.test.ts b/packages/cli/src/utils/managed-npm-update.test.ts index 966e8182585..a7132705be3 100644 --- a/packages/cli/src/utils/managed-npm-update.test.ts +++ b/packages/cli/src/utils/managed-npm-update.test.ts @@ -29,15 +29,14 @@ function writeInstallation(prefix: string, version: string): void { '@qwen-code', 'qwen-code', ); - fs.mkdirSync(path.join(packageRoot, 'dist'), { recursive: true }); - fs.mkdirSync(path.join(packageRoot, 'scripts'), { recursive: true }); + fs.mkdirSync(packageRoot, { recursive: true }); fs.writeFileSync( path.join(packageRoot, 'package.json'), JSON.stringify({ name: '@qwen-code/qwen-code', version }), ); - fs.writeFileSync(path.join(packageRoot, 'dist', 'cli.js'), ''); + fs.writeFileSync(path.join(packageRoot, 'cli.js'), ''); fs.writeFileSync( - path.join(packageRoot, 'scripts', 'cli-entry.js'), + path.join(packageRoot, 'cli-entry.js'), `process.stdout.write('${version}\\n');\n`, ); } @@ -50,7 +49,7 @@ function writeBaseInstallation(root: string, version = '1.0.0'): string { '@qwen-code', 'qwen-code', ); - const bootstrap = path.join(packageRoot, 'scripts', 'cli-entry.js'); + const bootstrap = path.join(packageRoot, 'cli-entry.js'); fs.mkdirSync(path.dirname(bootstrap), { recursive: true }); fs.writeFileSync( path.join(packageRoot, 'package.json'), @@ -136,7 +135,6 @@ describe('managed npm update', () => { 'node_modules', '@qwen-code', 'qwen-code', - 'scripts', 'cli-entry.js', ), `process.stdout.write('${reported}\\n');\n`, diff --git a/packages/cli/src/utils/managed-npm-update.ts b/packages/cli/src/utils/managed-npm-update.ts index 143c0384b51..80d310dc4fa 100644 --- a/packages/cli/src/utils/managed-npm-update.ts +++ b/packages/cli/src/utils/managed-npm-update.ts @@ -53,7 +53,7 @@ function readBaseInstallation(bootstrapPath: string): { } { const manifest = JSON.parse( fs.readFileSync( - path.join(path.dirname(path.dirname(bootstrapPath)), 'package.json'), + path.join(path.dirname(bootstrapPath), 'package.json'), 'utf8', ), ) as { name?: unknown; version?: unknown }; @@ -79,7 +79,7 @@ async function validateInstallation( `Installed package did not match ${PACKAGE_NAME}@${version}`, ); } - await fsPromises.access(path.join(root, 'dist', 'cli.js')); + await fsPromises.access(path.join(root, 'cli.js')); } async function smokeTest(prefix: string, version: string): Promise { @@ -88,7 +88,7 @@ async function smokeTest(prefix: string, version: string): Promise { delete env['QWEN_CODE_RELAUNCH_ARGS']; const { stdout } = await execFileAsync( process.execPath, - [path.join(packageDir(prefix), 'scripts', 'cli-entry.js'), '--version'], + [path.join(packageDir(prefix), 'cli-entry.js'), '--version'], { encoding: 'utf8', env, timeout: 10_000 }, ); if (stdout.trim() !== version) { diff --git a/scripts/cli-entry.js b/scripts/cli-entry.js index 9b215bf29d3..a12dfe79ecf 100755 --- a/scripts/cli-entry.js +++ b/scripts/cli-entry.js @@ -9,7 +9,7 @@ /** * Production bin entry wrapper. * - * For most commands: launches dist/cli.js with --expose-gc so that + * For most commands: launches the bundled CLI with --expose-gc so that * global.gc() is available for the memory-pressure monitor's critical-tier * cleanup. * @@ -64,7 +64,7 @@ const { existsSync, readFileSync, realpathSync, statSync } = await import( 'node:fs' ); const { createHash } = await import('node:crypto'); -const { homedir } = await import('node:os'); +const { homedir, tmpdir } = await import('node:os'); const { fileURLToPath, pathToFileURL } = await import('node:url'); const { delimiter, dirname, join, parse, resolve, sep } = await import( 'node:path' @@ -75,7 +75,10 @@ const currentEntryPath = realpathSync(fileURLToPath(import.meta.url)); function resolveQwenHome() { const configured = process.env['QWEN_HOME']; - if (!configured) return join(homedir(), '.qwen'); + if (!configured) { + const home = homedir(); + return join(home || tmpdir(), '.qwen'); + } if (configured === '~') return homedir(); if (configured.startsWith('~/') || configured.startsWith('~\\')) { return resolve(homedir(), configured.slice(2)); @@ -124,7 +127,7 @@ function getManagedNpmInstallation() { ); const packageJsonPath = join(packageRoot, 'package.json'); const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')); - const cliPath = join(packageRoot, 'dist', 'cli.js'); + const cliPath = join(packageRoot, 'cli.js'); if ( pkg.name !== '@qwen-code/qwen-code' || pkg.version !== active.version || @@ -152,7 +155,7 @@ function getManagedNpmInstallation() { // Assignment, not `||=`: an inherited value is another session's CLI — an outer // qwen shelling out to this one — and honouring it re-creates the exact skew above, // one level up. Each entry stamps itself, so nested sessions each call their own -// build. Nothing downstream overwrites this: the spawn below runs dist/cli.js, +// build. Nothing downstream overwrites this: the spawn below runs the bundled CLI, // which never re-executes this wrapper, and the post-update relaunch re-enters // through the launcher's own wrapper — which stamps the updated entry, as it must. // From 94f37cb5f2b85007bb92e9978f3ee802b77cbe10 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 20 Jul 2026 20:55:21 +0800 Subject: [PATCH 05/19] fix(cli): harden managed update lifecycle --- docs/design/npm-background-auto-update.md | 4 ++ packages/cli/src/cli.test.ts | 48 ++++++++++++++++--- .../cli/src/utils/managed-npm-update.test.ts | 16 +++++++ packages/cli/src/utils/managed-npm-update.ts | 43 ++++++++++++++++- scripts/cli-entry.js | 24 ++++++++-- 5 files changed, 123 insertions(+), 12 deletions(-) diff --git a/docs/design/npm-background-auto-update.md b/docs/design/npm-background-auto-update.md index a0166b76497..1a831f076de 100644 --- a/docs/design/npm-background-auto-update.md +++ b/docs/design/npm-background-auto-update.md @@ -23,6 +23,10 @@ empty prefix. Both bypass the current repository's `.npmrc` and inherit the same environment and user/global npm configuration, so the version that was checked is installed from the same configured registry. +The launcher resolves `QWEN_HOME` from the same home-scoped `.env` files before +selecting a version. This keeps the bootstrap path aligned with CLI storage even +though the full environment loader runs later. + After npm exits successfully, Qwen Code verifies the package name, version, bundle, and launcher, then atomically writes an `active.json` pointer beside that launcher's versions. The global npm package is not modified. The diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index c199e3a534a..8350a62d0a9 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -13,6 +13,7 @@ import { mkdtempSync, readFileSync, realpathSync, + renameSync, rmSync, statSync, writeFileSync, @@ -505,11 +506,11 @@ describe('bootstrap import boundaries', () => { expect(output).toBe(`${expectedVersion}\n`); }); - it('launches the active managed npm version with an empty home directory', () => { + it('resolves managed updates from home env and an empty home', () => { const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-managed-npm-')); const entryDir = path.join(tempDir, 'bootstrap'); const entryPath = path.join(entryDir, 'cli-entry.mjs'); - const qwenHome = path.join(tempDir, '.qwen'); + const qwenHome = path.join(tempDir, 'custom-home'); try { mkdirSync(entryDir, { recursive: true }); copyFileSync('../../scripts/cli-entry.js', entryPath); @@ -561,12 +562,15 @@ describe('bootstrap import boundaries', () => { }), ); + mkdirSync(path.join(tempDir, '.qwen'), { recursive: true }); + writeFileSync( + path.join(tempDir, '.qwen', '.env'), + `QWEN_HOME=${JSON.stringify(qwenHome)}\n`, + ); const childEnv: NodeJS.ProcessEnv = { ...process.env, - HOME: '', - USERPROFILE: '', - HOMEDRIVE: '', - HOMEPATH: '', + HOME: tempDir, + USERPROFILE: tempDir, TMPDIR: tempDir, TEMP: tempDir, TMP: tempDir, @@ -587,6 +591,36 @@ describe('bootstrap import boundaries', () => { args: ['--prompt', 'hello'], }); + const emptyHomeRoot = path.join(tempDir, 'empty-home'); + const emptyQwenHome = path.join(emptyHomeRoot, '.qwen'); + mkdirSync(emptyQwenHome, { recursive: true }); + renameSync( + path.join(qwenHome, 'updates'), + path.join(emptyQwenHome, 'updates'), + ); + const emptyHomeEnv = { + ...childEnv, + HOME: '', + USERPROFILE: '', + HOMEDRIVE: '', + HOMEPATH: '', + TMPDIR: emptyHomeRoot, + TEMP: emptyHomeRoot, + TMP: emptyHomeRoot, + }; + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: emptyHomeEnv, + }), + ), + ).toEqual({ + managed: 'true', + launcher: realpathSync(entryPath), + args: ['--prompt', 'hello'], + }); + writeFileSync( path.join(entryDir, 'package.json'), JSON.stringify({ @@ -598,7 +632,7 @@ describe('bootstrap import boundaries', () => { execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { encoding: 'utf8', env: { - ...childEnv, + ...emptyHomeEnv, QWEN_CODE_MANAGED_NPM_UPDATE: 'true', }, }), diff --git a/packages/cli/src/utils/managed-npm-update.test.ts b/packages/cli/src/utils/managed-npm-update.test.ts index a7132705be3..758b0dbe988 100644 --- a/packages/cli/src/utils/managed-npm-update.test.ts +++ b/packages/cli/src/utils/managed-npm-update.test.ts @@ -87,6 +87,22 @@ describe('managed npm update', () => { ); }); + it('reclaims staging owned by a terminated process', () => { + const root = makeTemporaryDirectory(); + const updateRoot = path.join(root, 'updates'); + const bootstrap = writeBaseInstallation(root); + const first = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + const abandoned = path.join( + path.dirname(first.stagingDir), + '.2.0.0-999999999-abandoned', + ); + fs.renameSync(first.stagingDir, abandoned); + + prepareManagedNpmUpdate('2.0.1', bootstrap, updateRoot); + + expect(fs.existsSync(abandoned)).toBe(false); + }); + it('activates a verified install without changing running files', async () => { const root = makeTemporaryDirectory(); const bootstrap = writeBaseInstallation(root); diff --git a/packages/cli/src/utils/managed-npm-update.ts b/packages/cli/src/utils/managed-npm-update.ts index 80d310dc4fa..abdb8f94eb4 100644 --- a/packages/cli/src/utils/managed-npm-update.ts +++ b/packages/cli/src/utils/managed-npm-update.ts @@ -15,6 +15,7 @@ import lockfile from 'proper-lockfile'; import semver from 'semver'; const PACKAGE_NAME = '@qwen-code/qwen-code'; +const OWNERLESS_STAGING_MAX_AGE_MS = 60 * 60 * 1000; const execFileAsync = promisify(execFile); interface ManagedNpmUpdate { @@ -36,6 +37,43 @@ function packageDir(prefix: string): string { return path.join(prefix, 'node_modules', '@qwen-code', 'qwen-code'); } +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +function reclaimStaleStaging(versionsDir: string): void { + for (const entry of fs.readdirSync(versionsDir, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('.')) continue; + const stagingDir = path.join(versionsDir, entry.name); + const owner = Number.parseInt( + entry.name.match(/-(\d+)-[^-]+$/)?.[1] ?? '', + 10, + ); + if (Number.isInteger(owner) && owner > 0) { + if (processIsAlive(owner)) { + continue; + } + } else { + let age: number; + try { + age = Date.now() - fs.statSync(stagingDir).mtimeMs; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + if (age < OWNERLESS_STAGING_MAX_AGE_MS) { + continue; + } + } + fs.rmSync(stagingDir, { recursive: true, force: true }); + } +} + function resolveBootstrapPath(bootstrapPath?: string): string { if (!bootstrapPath) { throw new Error('Unable to identify the Qwen Code npm launcher'); @@ -138,7 +176,10 @@ export function prepareManagedNpmUpdate( const launcherRoot = path.join(updateRoot, launcherId(resolvedBootstrapPath)); const versionsDir = path.join(launcherRoot, 'versions'); fs.mkdirSync(versionsDir, { recursive: true }); - const stagingDir = fs.mkdtempSync(path.join(versionsDir, `.${version}-`)); + reclaimStaleStaging(versionsDir); + const stagingDir = fs.mkdtempSync( + path.join(versionsDir, `.${version}-${process.pid}-`), + ); return { stagingDir, versionDir: path.join(versionsDir, version), diff --git a/scripts/cli-entry.js b/scripts/cli-entry.js index a12dfe79ecf..7db173bb6f9 100755 --- a/scripts/cli-entry.js +++ b/scripts/cli-entry.js @@ -65,6 +65,7 @@ const { existsSync, readFileSync, realpathSync, statSync } = await import( ); const { createHash } = await import('node:crypto'); const { homedir, tmpdir } = await import('node:os'); +const { parseEnv } = await import('node:util'); const { fileURLToPath, pathToFileURL } = await import('node:url'); const { delimiter, dirname, join, parse, resolve, sep } = await import( 'node:path' @@ -75,10 +76,7 @@ const currentEntryPath = realpathSync(fileURLToPath(import.meta.url)); function resolveQwenHome() { const configured = process.env['QWEN_HOME']; - if (!configured) { - const home = homedir(); - return join(home || tmpdir(), '.qwen'); - } + if (!configured) return join(homedir() || tmpdir(), '.qwen'); if (configured === '~') return homedir(); if (configured.startsWith('~/') || configured.startsWith('~\\')) { return resolve(homedir(), configured.slice(2)); @@ -86,7 +84,25 @@ function resolveQwenHome() { return resolve(configured); } +function preResolveQwenHome() { + if (process.env['QWEN_HOME']) return; + const home = homedir(); + if (!home) return; + for (const candidate of [join(home, '.qwen', '.env'), join(home, '.env')]) { + try { + const configured = parseEnv(readFileSync(candidate, 'utf8'))['QWEN_HOME']; + if (configured) { + process.env['QWEN_HOME'] = configured; + return; + } + } catch { + // Match the CLI's quiet handling of missing or invalid home env files. + } + } +} + delete process.env['QWEN_CODE_MANAGED_NPM_UPDATE']; +preResolveQwenHome(); function getManagedNpmInstallation() { try { From c564f78e012830262c29d9f6083e0c731f123bda Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 20 Jul 2026 21:18:13 +0800 Subject: [PATCH 06/19] fix(cli): invalidate managed updates after reinstall --- packages/cli/src/cli.test.ts | 17 +++++++++++++++-- packages/cli/src/utils/managed-npm-update.ts | 20 ++++++++++---------- scripts/cli-entry.js | 2 +- scripts/tests/cli-entry.test.js | 3 ++- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 8350a62d0a9..98a48e60dbf 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -16,6 +16,7 @@ import { renameSync, rmSync, statSync, + utimesSync, writeFileSync, } from 'node:fs'; import { execFileSync } from 'node:child_process'; @@ -551,14 +552,14 @@ describe('bootstrap import boundaries', () => { 'process.stdout.write(JSON.stringify({ managed: process.env.QWEN_CODE_MANAGED_NPM_UPDATE, launcher: process.env.QWEN_CODE_CLI, args: process.argv.slice(2) }));\n', ); mkdirSync(launcherRoot, { recursive: true }); - const { mtimeMs } = statSync(entryPath); + const bootstrapStat = statSync(entryPath); writeFileSync( path.join(launcherRoot, 'active.json'), JSON.stringify({ version: '2.0.0', bootstrap: realpathSync(entryPath), baseVersion: '1.0.0', - bootstrapMtimeMs: mtimeMs, + bootstrapCtimeMs: bootstrapStat.ctimeMs, }), ); @@ -621,6 +622,18 @@ describe('bootstrap import boundaries', () => { args: ['--prompt', 'hello'], }); + const replacement = `${entryPath}.replacement`; + copyFileSync(entryPath, replacement); + renameSync(replacement, entryPath); + utimesSync(entryPath, bootstrapStat.atime, bootstrapStat.mtime); + expect(statSync(entryPath).ctimeMs).not.toBe(bootstrapStat.ctimeMs); + expect( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: emptyHomeEnv, + }), + ).toBe('old version\n'); + writeFileSync( path.join(entryDir, 'package.json'), JSON.stringify({ diff --git a/packages/cli/src/utils/managed-npm-update.ts b/packages/cli/src/utils/managed-npm-update.ts index abdb8f94eb4..70a58727a37 100644 --- a/packages/cli/src/utils/managed-npm-update.ts +++ b/packages/cli/src/utils/managed-npm-update.ts @@ -23,7 +23,7 @@ interface ManagedNpmUpdate { versionDir: string; launcherRoot: string; baseVersion: string; - bootstrapMtimeMs: number; + bootstrapCtimeMs: number; installArgs: string[]; } @@ -87,7 +87,7 @@ function launcherId(bootstrapPath: string): string { function readBaseInstallation(bootstrapPath: string): { version: string; - mtimeMs: number; + ctimeMs: number; } { const manifest = JSON.parse( fs.readFileSync( @@ -100,7 +100,7 @@ function readBaseInstallation(bootstrapPath: string): { } return { version: manifest.version, - mtimeMs: fs.statSync(bootstrapPath).mtimeMs, + ctimeMs: fs.statSync(bootstrapPath).ctimeMs, }; } @@ -141,7 +141,7 @@ async function readActiveVersion( expected: { bootstrap: string; baseVersion: string; - bootstrapMtimeMs: number; + bootstrapCtimeMs: number; }, ): Promise { try { @@ -151,13 +151,13 @@ async function readActiveVersion( version?: unknown; bootstrap?: unknown; baseVersion?: unknown; - bootstrapMtimeMs?: unknown; + bootstrapCtimeMs?: unknown; }; return typeof active.version === 'string' && semver.valid(active.version) !== null && active.bootstrap === expected.bootstrap && active.baseVersion === expected.baseVersion && - active.bootstrapMtimeMs === expected.bootstrapMtimeMs + active.bootstrapCtimeMs === expected.bootstrapCtimeMs ? active.version : null; } catch { @@ -185,7 +185,7 @@ export function prepareManagedNpmUpdate( versionDir: path.join(versionsDir, version), launcherRoot, baseVersion: base.version, - bootstrapMtimeMs: base.mtimeMs, + bootstrapCtimeMs: base.ctimeMs, installArgs: [ 'install', '--prefix', @@ -226,7 +226,7 @@ export async function activateManagedNpmUpdate( const base = readBaseInstallation(resolvedBootstrapPath); if ( base.version !== update.baseVersion || - base.mtimeMs !== update.bootstrapMtimeMs + base.ctimeMs !== update.bootstrapCtimeMs ) { throw new Error( 'The base Qwen Code npm installation changed during update', @@ -236,7 +236,7 @@ export async function activateManagedNpmUpdate( const activeVersion = await readActiveVersion(activeFile, { bootstrap: resolvedBootstrapPath, baseVersion: base.version, - bootstrapMtimeMs: base.mtimeMs, + bootstrapCtimeMs: base.ctimeMs, }); if (activeVersion && semver.gt(activeVersion, version)) { try { @@ -267,7 +267,7 @@ export async function activateManagedNpmUpdate( version, bootstrap: resolvedBootstrapPath, baseVersion: base.version, - bootstrapMtimeMs: base.mtimeMs, + bootstrapCtimeMs: base.ctimeMs, }), { mode: 0o600 }, ); diff --git a/scripts/cli-entry.js b/scripts/cli-entry.js index 7db173bb6f9..624ea1583a4 100755 --- a/scripts/cli-entry.js +++ b/scripts/cli-entry.js @@ -129,7 +129,7 @@ function getManagedNpmInstallation() { typeof active.bootstrap !== 'string' || realpathSync(active.bootstrap) !== currentEntryPath || active.baseVersion !== basePackage.version || - active.bootstrapMtimeMs !== statSync(currentEntryPath).mtimeMs + active.bootstrapCtimeMs !== statSync(currentEntryPath).ctimeMs ) { return undefined; } diff --git a/scripts/tests/cli-entry.test.js b/scripts/tests/cli-entry.test.js index 2dbfe1abe8f..8ab5b7d8e75 100644 --- a/scripts/tests/cli-entry.test.js +++ b/scripts/tests/cli-entry.test.js @@ -15,7 +15,8 @@ vi.mock('node:child_process', () => ({ spawnSync: spawnSyncMock, })); -vi.mock('node:fs', () => ({ +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), existsSync: existsSyncMock, realpathSync: vi.fn((p) => p), readFileSync: vi.fn(() => JSON.stringify({ version: '0.0.0-test' })), From b0b5d02835354eed54f0d09c48a09e250dd432a2 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 20 Jul 2026 21:30:52 +0800 Subject: [PATCH 07/19] fix(cli): validate staged npm updates --- docs/design/npm-background-auto-update.md | 7 ++++--- packages/cli/src/utils/handleAutoUpdate.test.ts | 3 +++ packages/cli/src/utils/managed-npm-update.test.ts | 15 ++++++++------- packages/cli/src/utils/managed-npm-update.ts | 14 +++++--------- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/docs/design/npm-background-auto-update.md b/docs/design/npm-background-auto-update.md index 1a831f076de..8c62d06bd75 100644 --- a/docs/design/npm-background-auto-update.md +++ b/docs/design/npm-background-auto-update.md @@ -19,9 +19,10 @@ the exact resolved version under a directory derived from the global launcher: ``` The version check runs npm in its global context and the staged install uses an -empty prefix. Both bypass the current repository's `.npmrc` and inherit the -same environment and user/global npm configuration, so the version that was -checked is installed from the same configured registry. +isolated prefix. The staged command forces npm's project layout, but otherwise +both bypass the current repository's `.npmrc` and inherit the same environment +and user/global npm configuration, so the version that was checked is installed +from the same configured registry. The launcher resolves `QWEN_HOME` from the same home-scoped `.env` files before selecting a version. This keeps the bootstrap path aligned with CLI storage even diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index 8d16422b775..c59bebe4197 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -106,6 +106,7 @@ describe('handleAutoUpdate', () => { 'install', '--prefix', `/tmp/${version}`, + '--global=false', '--no-save', '--package-lock=false', `@qwen-code/qwen-code@${version}`, @@ -273,6 +274,7 @@ describe('handleAutoUpdate', () => { 'install', '--prefix', '/tmp/2.0.0-nightly', + '--global=false', '--no-save', '--package-lock=false', '@qwen-code/qwen-code@2.0.0-nightly', @@ -301,6 +303,7 @@ describe('handleAutoUpdate', () => { 'install', '--prefix', '/tmp/2.0.0', + '--global=false', '--no-save', '--package-lock=false', '@qwen-code/qwen-code@2.0.0', diff --git a/packages/cli/src/utils/managed-npm-update.test.ts b/packages/cli/src/utils/managed-npm-update.test.ts index 758b0dbe988..08b6652719a 100644 --- a/packages/cli/src/utils/managed-npm-update.test.ts +++ b/packages/cli/src/utils/managed-npm-update.test.ts @@ -37,7 +37,7 @@ function writeInstallation(prefix: string, version: string): void { fs.writeFileSync(path.join(packageRoot, 'cli.js'), ''); fs.writeFileSync( path.join(packageRoot, 'cli-entry.js'), - `process.stdout.write('${version}\\n');\n`, + `if (process.argv.includes('--version')) process.stdout.write('${version}\\n'); else await import('./cli.js');\n`, ); } @@ -78,6 +78,7 @@ describe('managed npm update', () => { 'install', '--prefix', update.stagingDir, + '--global=false', '--no-save', '--package-lock=false', '@qwen-code/qwen-code@2.0.0', @@ -133,9 +134,9 @@ describe('managed npm update', () => { }); it.each([ - ['a mismatched package', '2.0.1', undefined, 'did not match'], - ['a failed smoke test', '2.0.0', 'broken', 'reported version broken'], - ])('rejects %s', async (_name, installed, reported, error) => { + ['a mismatched package', '2.0.1', false, 'did not match'], + ['a failed smoke test', '2.0.0', true, 'Command failed'], + ])('rejects %s', async (_name, installed, brokenBundle, error) => { const root = makeTemporaryDirectory(); const bootstrap = writeBaseInstallation(root); const update = prepareManagedNpmUpdate( @@ -144,16 +145,16 @@ describe('managed npm update', () => { path.join(root, 'updates'), ); writeInstallation(update.stagingDir, installed); - if (reported) { + if (brokenBundle) { fs.writeFileSync( path.join( update.stagingDir, 'node_modules', '@qwen-code', 'qwen-code', - 'cli-entry.js', + 'cli.js', ), - `process.stdout.write('${reported}\\n');\n`, + 'this is not valid JavaScript !!!\n', ); } diff --git a/packages/cli/src/utils/managed-npm-update.ts b/packages/cli/src/utils/managed-npm-update.ts index 70a58727a37..0d16912c412 100644 --- a/packages/cli/src/utils/managed-npm-update.ts +++ b/packages/cli/src/utils/managed-npm-update.ts @@ -120,20 +120,15 @@ async function validateInstallation( await fsPromises.access(path.join(root, 'cli.js')); } -async function smokeTest(prefix: string, version: string): Promise { +async function smokeTest(prefix: string): Promise { const env = { ...process.env }; delete env['CLI_VERSION']; delete env['QWEN_CODE_RELAUNCH_ARGS']; - const { stdout } = await execFileAsync( + await execFileAsync( process.execPath, - [path.join(packageDir(prefix), 'cli-entry.js'), '--version'], + [path.join(packageDir(prefix), 'cli-entry.js'), '--help'], { encoding: 'utf8', env, timeout: 10_000 }, ); - if (stdout.trim() !== version) { - throw new Error( - `Installed package reported version ${stdout.trim() || 'unknown'}`, - ); - } } async function readActiveVersion( @@ -190,6 +185,7 @@ export function prepareManagedNpmUpdate( 'install', '--prefix', stagingDir, + '--global=false', '--no-save', '--package-lock=false', `${PACKAGE_NAME}@${version}`, @@ -214,7 +210,7 @@ export async function activateManagedNpmUpdate( } await validateInstallation(update.stagingDir, version); - await smokeTest(update.stagingDir, version); + await smokeTest(update.stagingDir); const activeFile = path.join(update.launcherRoot, 'active.json'); const release = await lockfile.lock(activeFile, { From 10ddba00fdffc71a3cb6a78ea399ca998e6dd2b1 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 20 Jul 2026 21:41:26 +0800 Subject: [PATCH 08/19] fix(cli): match home env parsing in launcher --- packages/cli/src/cli.test.ts | 2 +- scripts/cli-entry.js | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 98a48e60dbf..f5ed6e09710 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -566,7 +566,7 @@ describe('bootstrap import boundaries', () => { mkdirSync(path.join(tempDir, '.qwen'), { recursive: true }); writeFileSync( path.join(tempDir, '.qwen', '.env'), - `QWEN_HOME=${JSON.stringify(qwenHome)}\n`, + `QWEN_HOME: ${JSON.stringify(qwenHome)}\n`, ); const childEnv: NodeJS.ProcessEnv = { ...process.env, diff --git a/scripts/cli-entry.js b/scripts/cli-entry.js index 624ea1583a4..972a04b4e39 100755 --- a/scripts/cli-entry.js +++ b/scripts/cli-entry.js @@ -90,7 +90,11 @@ function preResolveQwenHome() { if (!home) return; for (const candidate of [join(home, '.qwen', '.env'), join(home, '.env')]) { try { - const configured = parseEnv(readFileSync(candidate, 'utf8'))['QWEN_HOME']; + const source = readFileSync(candidate, 'utf8').replace( + /^(\s*(?:export\s+)?QWEN_HOME)\s*:\s*/gm, + '$1=', + ); + const configured = parseEnv(source)['QWEN_HOME']; if (configured) { process.env['QWEN_HOME'] = configured; return; From 6e3a59e50127abeb3ac7d20cf0fb454e801a204a Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 21 Jul 2026 00:00:35 +0800 Subject: [PATCH 09/19] fix(cli): harden managed background updates --- docs/design/npm-background-auto-update.md | 29 +-- packages/cli/src/cli.test.ts | 183 +++++++++++++++--- packages/cli/src/cli.ts | 11 ++ .../cli/src/utils/handleAutoUpdate.test.ts | 111 ++--------- packages/cli/src/utils/handleAutoUpdate.ts | 73 +++---- .../cli/src/utils/managed-npm-update.test.ts | 94 +++++++-- packages/cli/src/utils/managed-npm-update.ts | 109 +++++++---- scripts/cli-entry.js | 133 +++++++++---- 8 files changed, 455 insertions(+), 288 deletions(-) diff --git a/docs/design/npm-background-auto-update.md b/docs/design/npm-background-auto-update.md index 8c62d06bd75..078487f5f2e 100644 --- a/docs/design/npm-background-auto-update.md +++ b/docs/design/npm-background-auto-update.md @@ -19,21 +19,21 @@ the exact resolved version under a directory derived from the global launcher: ``` The version check runs npm in its global context and the staged install uses an -isolated prefix. The staged command forces npm's project layout, but otherwise -both bypass the current repository's `.npmrc` and inherit the same environment -and user/global npm configuration, so the version that was checked is installed -from the same configured registry. +isolated prefix. The staged command explicitly preserves the original global +npm configuration, so changing the prefix does not switch registry or +authentication settings between discovery and installation. The launcher resolves `QWEN_HOME` from the same home-scoped `.env` files before selecting a version. This keeps the bootstrap path aligned with CLI storage even though the full environment loader runs later. -After npm exits successfully, Qwen Code verifies the package name, version, -bundle, and launcher, then atomically writes an `active.json` pointer beside -that launcher's versions. The global npm package is not modified. The -already-running process continues loading its original package and chunks. On -the next invocation, the stable launcher reads the pointer and starts the -verified version directory. +The install and activation run in a detached worker, so quitting the TUI does +not interrupt an update already in progress. After npm exits successfully, the +worker verifies the package name, version, bundle, and launcher, then atomically +writes an `active.json` pointer beside that launcher's versions. The global npm +package is not modified. The already-running process and any child commands it +starts remain pinned to their original build. On the next invocation, the +stable launcher reads the pointer and starts the verified version directory. Each global npm launcher has its own pointer and version payloads, so installations under different npm or nvm prefixes can share `~/.qwen` without @@ -41,10 +41,11 @@ overriding one another or sharing dependencies. A slower concurrent update cannot replace a newer active version. An incomplete install never changes the active pointer. Before activation, the -new launcher must report the expected version. A missing, malformed, or -launcher-mismatched pointer is ignored and the original npm package remains the -fallback. The pointer also records the base package and launcher identity, so a -later explicit global npm install supersedes the managed version. +worker validates the installed manifest and runs a launcher smoke test. A +missing, malformed, or launcher-mismatched pointer is ignored and the original +npm package remains the fallback. The pointer also records the base package and +launcher identity, so a later explicit global npm install supersedes the +managed version. Version directories are retained because an older live session may still load from them. Cleanup is intentionally deferred until disk usage shows that a diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index f5ed6e09710..af20ba5db5d 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -47,6 +47,7 @@ const mocks = vi.hoisted(() => ({ mcpListHandler: vi.fn(), mcpAddHandler: vi.fn(), getCliVersion: vi.fn(), + installManagedNpmUpdate: vi.fn(), })); vi.mock('./gemini.js', () => ({ @@ -74,6 +75,10 @@ vi.mock('./utils/version.js', () => ({ getCliVersion: mocks.getCliVersion, })); +vi.mock('./utils/managed-npm-update.js', () => ({ + installManagedNpmUpdate: mocks.installManagedNpmUpdate, +})); + vi.mock('./commands/mcp.js', () => ({ mcpCommand: { command: 'mcp', @@ -138,6 +143,8 @@ describe('resolveBootstrapRoute', () => { describe('runCliEntry', () => { const savedEnv = { CLI_VERSION: process.env['CLI_VERSION'], + QWEN_CODE_MANAGED_NPM_UPDATE_VERSION: + process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION'], }; let stdout: string[]; @@ -170,6 +177,12 @@ describe('runCliEntry', () => { } else { process.env['CLI_VERSION'] = savedEnv.CLI_VERSION; } + if (savedEnv.QWEN_CODE_MANAGED_NPM_UPDATE_VERSION === undefined) { + delete process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION']; + } else { + process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION'] = + savedEnv.QWEN_CODE_MANAGED_NPM_UPDATE_VERSION; + } vi.restoreAllMocks(); }); @@ -183,6 +196,16 @@ describe('runCliEntry', () => { expect(mocks.initCpuProfiler).not.toHaveBeenCalled(); }); + it('runs a managed update worker without starting the CLI', async () => { + process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION'] = '2.0.0'; + + await runCliEntry([]); + + expect(mocks.installManagedNpmUpdate).toHaveBeenCalledWith('2.0.0'); + expect(process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION']).toBeUndefined(); + expect(mocks.main).not.toHaveBeenCalled(); + }); + it('falls back to getCliVersion when CLI_VERSION is unset', async () => { delete process.env['CLI_VERSION']; @@ -507,11 +530,11 @@ describe('bootstrap import boundaries', () => { expect(output).toBe(`${expectedVersion}\n`); }); - it('resolves managed updates from home env and an empty home', () => { + it('resolves and pins managed updates from the configured home', () => { const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-managed-npm-')); const entryDir = path.join(tempDir, 'bootstrap'); const entryPath = path.join(entryDir, 'cli-entry.mjs'); - const qwenHome = path.join(tempDir, 'custom-home'); + const qwenHome = path.join(tempDir, 'custom', 'qwen'); try { mkdirSync(entryDir, { recursive: true }); copyFileSync('../../scripts/cli-entry.js', entryPath); @@ -531,7 +554,7 @@ describe('bootstrap import boundaries', () => { mkdirSync(packageRoot, { recursive: true }); writeFileSync( path.join(entryDir, 'cli.js'), - "process.stdout.write('old version\\n');\n", + "process.stdout.write(JSON.stringify({ build: 'base', pin: process.env.QWEN_CODE_MANAGED_NPM_PIN }));\n", ); writeFileSync( path.join(entryDir, 'package.json'), @@ -549,24 +572,15 @@ describe('bootstrap import boundaries', () => { ); writeFileSync( path.join(packageRoot, 'cli.js'), - 'process.stdout.write(JSON.stringify({ managed: process.env.QWEN_CODE_MANAGED_NPM_UPDATE, launcher: process.env.QWEN_CODE_CLI, args: process.argv.slice(2) }));\n', + "process.stdout.write(JSON.stringify({ build: 'managed-2', managed: process.env.QWEN_CODE_MANAGED_NPM_UPDATE, launcher: process.env.QWEN_CODE_CLI, pin: process.env.QWEN_CODE_MANAGED_NPM_PIN, args: process.argv.slice(2) }));\n", ); mkdirSync(launcherRoot, { recursive: true }); const bootstrapStat = statSync(entryPath); - writeFileSync( - path.join(launcherRoot, 'active.json'), - JSON.stringify({ - version: '2.0.0', - bootstrap: realpathSync(entryPath), - baseVersion: '1.0.0', - bootstrapCtimeMs: bootstrapStat.ctimeMs, - }), - ); mkdirSync(path.join(tempDir, '.qwen'), { recursive: true }); writeFileSync( path.join(tempDir, '.qwen', '.env'), - `QWEN_HOME: ${JSON.stringify(qwenHome)}\n`, + '\uFEFFQWEN_HOME: ~\\custom\\qwen\n', ); const childEnv: NodeJS.ProcessEnv = { ...process.env, @@ -577,6 +591,72 @@ describe('bootstrap import boundaries', () => { TMP: tempDir, }; delete childEnv['QWEN_HOME']; + delete childEnv['QWEN_CODE_MANAGED_NPM_PIN']; + const baseSession = JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: childEnv, + }), + ) as { build: string; pin: string }; + expect(baseSession.build).toBe('base'); + + writeFileSync( + path.join(launcherRoot, 'active.json'), + JSON.stringify({ + version: '2.0.0', + bootstrap: realpathSync(entryPath), + baseVersion: '1.0.0', + bootstrapCtimeMs: bootstrapStat.ctimeMs, + }), + ); + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: { + ...childEnv, + QWEN_CODE_MANAGED_NPM_PIN: baseSession.pin, + }, + }), + ), + ).toMatchObject({ build: 'base' }); + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: { ...childEnv, QWEN_HOME: '' }, + }), + ), + ).toMatchObject({ build: 'base' }); + + writeFileSync( + path.join(tempDir, '.qwen', '.env'), + `QWEN_HOME:${qwenHome}\n`, + ); + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: childEnv, + }), + ), + ).toMatchObject({ build: 'base' }); + writeFileSync( + path.join(tempDir, '.qwen', '.env'), + `QWEN_HOME: \nOTHER=${qwenHome}\n`, + ); + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: childEnv, + }), + ), + ).toMatchObject({ build: 'base' }); + writeFileSync( + path.join(tempDir, '.qwen', '.env'), + '\uFEFFQWEN_HOME: ~\\custom\\qwen\n', + ); const output = execFileSync( process.execPath, [entryPath, '--prompt', 'hello'], @@ -586,11 +666,49 @@ describe('bootstrap import boundaries', () => { }, ); - expect(JSON.parse(output)).toEqual({ + const managedSession = JSON.parse(output) as { + build: string; + managed: string; + launcher: string; + pin: string; + args: string[]; + }; + expect(managedSession).toMatchObject({ + build: 'managed-2', managed: 'true', launcher: realpathSync(entryPath), args: ['--prompt', 'hello'], }); + writeFileSync( + path.join(launcherRoot, 'active.json'), + JSON.stringify({ + version: '3.0.0', + bootstrap: realpathSync(entryPath), + baseVersion: '1.0.0', + bootstrapCtimeMs: bootstrapStat.ctimeMs, + }), + ); + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: { + ...childEnv, + QWEN_HOME: 'different-relative-home', + QWEN_CODE_MANAGED_NPM_PIN: managedSession.pin, + }, + }), + ), + ).toMatchObject({ build: 'managed-2' }); + writeFileSync( + path.join(launcherRoot, 'active.json'), + JSON.stringify({ + version: '2.0.0', + bootstrap: realpathSync(entryPath), + baseVersion: '1.0.0', + bootstrapCtimeMs: bootstrapStat.ctimeMs, + }), + ); const emptyHomeRoot = path.join(tempDir, 'empty-home'); const emptyQwenHome = path.join(emptyHomeRoot, '.qwen'); @@ -616,7 +734,8 @@ describe('bootstrap import boundaries', () => { env: emptyHomeEnv, }), ), - ).toEqual({ + ).toMatchObject({ + build: 'managed-2', managed: 'true', launcher: realpathSync(entryPath), args: ['--prompt', 'hello'], @@ -628,11 +747,13 @@ describe('bootstrap import boundaries', () => { utimesSync(entryPath, bootstrapStat.atime, bootstrapStat.mtime); expect(statSync(entryPath).ctimeMs).not.toBe(bootstrapStat.ctimeMs); expect( - execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { - encoding: 'utf8', - env: emptyHomeEnv, - }), - ).toBe('old version\n'); + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: emptyHomeEnv, + }), + ), + ).toMatchObject({ build: 'base' }); writeFileSync( path.join(entryDir, 'package.json'), @@ -642,18 +763,20 @@ describe('bootstrap import boundaries', () => { }), ); expect( - execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { - encoding: 'utf8', - env: { - ...emptyHomeEnv, - QWEN_CODE_MANAGED_NPM_UPDATE: 'true', - }, - }), - ).toBe('old version\n'); + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: { + ...emptyHomeEnv, + QWEN_CODE_MANAGED_NPM_UPDATE: 'true', + }, + }), + ), + ).toMatchObject({ build: 'base' }); } finally { rmSync(tempDir, { recursive: true, force: true }); } - }); + }, 60_000); it('falls through to cli.js when wrapper package.json lookup fails', () => { const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-entry-')); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 56592777c72..e9743bbf0ea 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -339,6 +339,17 @@ async function parseYargsCommand( export async function runCliEntry( rawArgv: readonly string[] = process.argv.slice(2), ): Promise { + const managedUpdateVersion = + process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION']; + if (managedUpdateVersion) { + delete process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION']; + const { installManagedNpmUpdate } = await import( + './utils/managed-npm-update.js' + ); + await installManagedNpmUpdate(managedUpdateVersion); + return; + } + const argv = normalizeServeFastPathArgv(rawArgv); const route = resolveBootstrapRoute(argv); diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index c59bebe4197..00cb740edd7 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -16,10 +16,6 @@ import { performStandaloneUpdate } from './standalone-update.js'; import { MessageType } from '../ui/types.js'; import os from 'node:os'; -const mockPrepareManagedNpmUpdate = vi.hoisted(() => vi.fn()); -const mockActivateManagedNpmUpdate = vi.hoisted(() => vi.fn()); -const mockCleanupManagedNpmUpdate = vi.hoisted(() => vi.fn()); - vi.mock('./installationInfo.js', async () => { const actual = await vi.importActual('./installationInfo.js'); return { @@ -32,15 +28,6 @@ vi.mock('./standalone-update.js', () => ({ performStandaloneUpdate: vi.fn(), })); -vi.mock('./managed-npm-update.js', () => ({ - prepareManagedNpmUpdate: (version: string) => - mockPrepareManagedNpmUpdate(version), - activateManagedNpmUpdate: (...args: unknown[]) => - mockActivateManagedNpmUpdate(...args), - cleanupManagedNpmUpdate: (...args: unknown[]) => - mockCleanupManagedNpmUpdate(...args), -})); - vi.mock('./updateEventEmitter.js', async () => { const { EventEmitter } = await import('node:events'); return { @@ -99,19 +86,6 @@ describe('handleAutoUpdate', () => { mockSpawn.mockReturnValue( mockChildProcess as unknown as ReturnType, ); - mockPrepareManagedNpmUpdate.mockImplementation((version: string) => ({ - stagingDir: `/tmp/${version}`, - versionDir: `/updates/${version}`, - installArgs: [ - 'install', - '--prefix', - `/tmp/${version}`, - '--global=false', - '--no-save', - '--package-lock=false', - `@qwen-code/qwen-code@${version}`, - ], - })); }); afterEach(() => { @@ -237,25 +211,6 @@ describe('handleAutoUpdate', () => { }); }); - it('reports managed update preparation failures', async () => { - mockGetInstallationInfo.mockReturnValue({ - updateCommand: 'npm i -g @qwen-code/qwen-code@latest', - isGlobal: true, - packageManager: PackageManager.NPM, - }); - mockPrepareManagedNpmUpdate.mockImplementation(() => { - throw new Error('update directory is not writable'); - }); - - await expect( - handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn), - ).resolves.toBe(false); - expect(mockSpawn).not.toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith('update-failed', { - message: 'Automatic update failed. Please try updating manually.', - }); - }); - it('should use the "@nightly" tag for nightly updates', async () => { mockUpdateInfo.update.latest = '2.0.0-nightly'; mockGetInstallationInfo.mockReturnValue({ @@ -269,19 +224,14 @@ describe('handleAutoUpdate', () => { expect(mockSpawn).toHaveBeenCalledWith( process.execPath, - [ - expect.stringMatching(/npm-cli\.js$/), - 'install', - '--prefix', - '/tmp/2.0.0-nightly', - '--global=false', - '--no-save', - '--package-lock=false', - '@qwen-code/qwen-code@2.0.0-nightly', - ], + [process.argv[1]], { - stdio: ['pipe', 'ignore', 'pipe'], - cwd: '/tmp/2.0.0-nightly', + detached: true, + env: expect.objectContaining({ + QWEN_CODE_MANAGED_NPM_UPDATE_VERSION: '2.0.0-nightly', + }), + stdio: 'ignore', + windowsHide: true, }, ); }); @@ -298,19 +248,14 @@ describe('handleAutoUpdate', () => { expect(mockSpawn).toHaveBeenCalledWith( process.execPath, - [ - expect.stringMatching(/npm-cli\.js$/), - 'install', - '--prefix', - '/tmp/2.0.0', - '--global=false', - '--no-save', - '--package-lock=false', - '@qwen-code/qwen-code@2.0.0', - ], + [process.argv[1]], { - stdio: ['pipe', 'ignore', 'pipe'], - cwd: '/tmp/2.0.0', + detached: true, + env: expect.objectContaining({ + QWEN_CODE_MANAGED_NPM_UPDATE_VERSION: '2.0.0', + }), + stdio: 'ignore', + windowsHide: true, }, ); }); @@ -355,34 +300,6 @@ describe('handleAutoUpdate', () => { message: 'Update successful! The new version will be used on your next run.', }); - expect(mockActivateManagedNpmUpdate).toHaveBeenCalledWith( - expect.objectContaining({ versionDir: '/updates/2.0.0' }), - '2.0.0', - ); - }); - - it('reports activation failure after npm exits successfully', async () => { - mockGetInstallationInfo.mockReturnValue({ - updateCommand: 'npm i -g @qwen-code/qwen-code@latest', - isGlobal: true, - packageManager: PackageManager.NPM, - }); - mockActivateManagedNpmUpdate.mockImplementation(() => { - throw new Error('pointer write failed'); - }); - - const update = handleAutoUpdate( - mockUpdateInfo, - mockSettings, - '/root', - mockSpawn, - )!; - mockChildProcess.emit('close', 0); - - await expect(update).resolves.toBe(false); - expect(emitSpy).toHaveBeenCalledWith('update-failed', { - message: 'Automatic update failed. Please try updating manually.', - }); }); }); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index 38bba77ca69..7324aa77e52 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -7,7 +7,6 @@ import type { UpdateObject } from '../ui/utils/updateCheck.js'; import type { LoadedSettings } from '../config/settings.js'; import { - getNpmCliPath, getInstallationInfo, PackageManager, resolveUpdateCommand, @@ -21,11 +20,6 @@ import { t } from '../i18n/index.js'; import type { spawn } from 'node:child_process'; import os from 'node:os'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; -import { - activateManagedNpmUpdate, - cleanupManagedNpmUpdate, - prepareManagedNpmUpdate, -} from './managed-npm-update.js'; const debugLogger = createDebugLogger('AUTO_UPDATE'); @@ -101,61 +95,48 @@ export function handleAutoUpdate( ); const platform = os.platform(); const isWindows = platform === 'win32'; - let managedNpmUpdate: ReturnType | undefined; - try { - managedNpmUpdate = - installationInfo.packageManager === PackageManager.NPM - ? prepareManagedNpmUpdate(info.update.latest) - : undefined; - } catch (error) { - debugLogger.warn('Automatic update preparation failed:', error); - updateEventEmitter.emit('update-failed', { - message: t(UPDATE_FAILED_MESSAGE), - }); - return Promise.resolve(false); - } - const command = - installationInfo.packageManager === PackageManager.NPM - ? process.execPath - : isWindows - ? 'cmd.exe' - : 'bash'; - const commandArgs = - installationInfo.packageManager === PackageManager.NPM - ? [ - getNpmCliPath(process.execPath, platform), - ...managedNpmUpdate!.installArgs, - ] - : isWindows - ? ['/c', updateCommand] - : ['-c', updateCommand]; + const isManagedNpmUpdate = + installationInfo.packageManager === PackageManager.NPM; + const command = isManagedNpmUpdate + ? process.execPath + : isWindows + ? 'cmd.exe' + : 'bash'; + const commandArgs = isManagedNpmUpdate + ? [process.argv[1]!] + : isWindows + ? ['/c', updateCommand] + : ['-c', updateCommand]; const updateProcess = spawnFn(command, commandArgs, { - stdio: ['pipe', 'ignore', 'pipe'], - ...(managedNpmUpdate ? { cwd: managedNpmUpdate.stagingDir } : {}), + ...(isManagedNpmUpdate + ? { + detached: true, + env: { + ...process.env, + QWEN_CODE_MANAGED_NPM_UPDATE_VERSION: info.update.latest, + }, + stdio: 'ignore' as const, + windowsHide: true, + } + : { stdio: ['pipe', 'ignore', 'pipe'] as const }), }); let errorOutput = ''; - updateProcess.stderr.on('data', (data) => { + updateProcess.stderr?.on('data', (data) => { errorOutput += data.toString(); }); return new Promise((resolve) => { let settled = false; - const finish = async (error?: unknown) => { + const finish = (error?: unknown) => { if (settled) return; settled = true; try { if (error) throw error; - if (managedNpmUpdate) { - await activateManagedNpmUpdate(managedNpmUpdate, info.update.latest); - } updateEventEmitter.emit('update-success', { message: t(UPDATE_SUCCESS_MESSAGE), }); resolve(true); } catch (error) { - if (managedNpmUpdate) { - await cleanupManagedNpmUpdate(managedNpmUpdate); - } debugLogger.warn('Automatic update failed:', error); updateEventEmitter.emit('update-failed', { message: t(UPDATE_FAILED_MESSAGE), @@ -164,7 +145,7 @@ export function handleAutoUpdate( } }; updateProcess.once('close', (code) => { - void finish( + finish( code === 0 ? undefined : new Error( @@ -173,7 +154,7 @@ export function handleAutoUpdate( ); }); updateProcess.once('error', (error) => { - void finish(error); + finish(error); }); }); } diff --git a/packages/cli/src/utils/managed-npm-update.test.ts b/packages/cli/src/utils/managed-npm-update.test.ts index 08b6652719a..f9429401b4b 100644 --- a/packages/cli/src/utils/managed-npm-update.test.ts +++ b/packages/cli/src/utils/managed-npm-update.test.ts @@ -4,15 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { activateManagedNpmUpdate, cleanupManagedNpmUpdate, + installManagedNpmUpdate, prepareManagedNpmUpdate, } from './managed-npm-update.js'; +import { EventEmitter } from 'node:events'; +import type { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; const temporaryDirectories: string[] = []; @@ -59,7 +63,12 @@ function writeBaseInstallation(root: string, version = '1.0.0'): string { return bootstrap; } +beforeEach(() => { + vi.stubEnv('NPM_CONFIG_GLOBALCONFIG', '/global/npmrc'); +}); + afterEach(() => { + vi.unstubAllEnvs(); for (const directory of temporaryDirectories.splice(0)) { fs.rmSync(directory, { recursive: true, force: true }); } @@ -76,6 +85,8 @@ describe('managed npm update', () => { expect(update.installArgs).toEqual([ 'install', + '--globalconfig', + '/global/npmrc', '--prefix', update.stagingDir, '--global=false', @@ -88,22 +99,6 @@ describe('managed npm update', () => { ); }); - it('reclaims staging owned by a terminated process', () => { - const root = makeTemporaryDirectory(); - const updateRoot = path.join(root, 'updates'); - const bootstrap = writeBaseInstallation(root); - const first = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); - const abandoned = path.join( - path.dirname(first.stagingDir), - '.2.0.0-999999999-abandoned', - ); - fs.renameSync(first.stagingDir, abandoned); - - prepareManagedNpmUpdate('2.0.1', bootstrap, updateRoot); - - expect(fs.existsSync(abandoned)).toBe(false); - }); - it('activates a verified install without changing running files', async () => { const root = makeTemporaryDirectory(); const bootstrap = writeBaseInstallation(root); @@ -133,6 +128,71 @@ describe('managed npm update', () => { expect(fs.readFileSync(runningChunk, 'utf8')).toBe('old chunk'); }); + it('installs and activates inside the managed worker', async () => { + const root = makeTemporaryDirectory(); + const bootstrap = writeBaseInstallation(root); + const updateRoot = path.join(root, 'updates'); + vi.stubEnv('NPM_CONFIG_USERCONFIG', 'config/npmrc'); + const spawnFn = vi.fn( + ( + _command: string, + args: readonly string[], + _options: object, + ): ReturnType => { + const prefix = args[args.indexOf('--prefix') + 1]!; + writeInstallation(prefix, '2.0.0'); + const child = new EventEmitter(); + queueMicrotask(() => child.emit('close', 0)); + return child as ReturnType; + }, + ); + + await installManagedNpmUpdate( + '2.0.0', + bootstrap, + updateRoot, + spawnFn as unknown as typeof spawn, + ); + + expect(spawnFn).toHaveBeenCalledWith( + process.execPath, + [ + expect.stringMatching(/npm-cli\.js$/), + 'install', + '--globalconfig', + '/global/npmrc', + '--prefix', + expect.any(String), + '--global=false', + '--no-save', + '--package-lock=false', + '@qwen-code/qwen-code@2.0.0', + ], + expect.objectContaining({ + env: expect.objectContaining({ + NPM_CONFIG_USERCONFIG: path.resolve('config/npmrc'), + }), + stdio: 'ignore', + windowsHide: true, + }), + ); + expect( + JSON.parse( + fs.readFileSync( + path.join( + updateRoot, + createHash('sha256') + .update(fs.realpathSync(bootstrap)) + .digest('hex') + .slice(0, 16), + 'active.json', + ), + 'utf8', + ), + ), + ).toMatchObject({ version: '2.0.0' }); + }); + it.each([ ['a mismatched package', '2.0.1', false, 'did not match'], ['a failed smoke test', '2.0.0', true, 'Command failed'], diff --git a/packages/cli/src/utils/managed-npm-update.ts b/packages/cli/src/utils/managed-npm-update.ts index 0d16912c412..be55e4b412b 100644 --- a/packages/cli/src/utils/managed-npm-update.ts +++ b/packages/cli/src/utils/managed-npm-update.ts @@ -5,7 +5,7 @@ */ import { Storage } from '@qwen-code/qwen-code-core'; -import { execFile } from 'node:child_process'; +import { execFile, execFileSync, spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as fsPromises from 'node:fs/promises'; @@ -13,9 +13,9 @@ import * as path from 'node:path'; import { promisify } from 'node:util'; import lockfile from 'proper-lockfile'; import semver from 'semver'; +import { getNpmCliPath } from './installationInfo.js'; const PACKAGE_NAME = '@qwen-code/qwen-code'; -const OWNERLESS_STAGING_MAX_AGE_MS = 60 * 60 * 1000; const execFileAsync = promisify(execFile); interface ManagedNpmUpdate { @@ -37,43 +37,6 @@ function packageDir(prefix: string): string { return path.join(prefix, 'node_modules', '@qwen-code', 'qwen-code'); } -function processIsAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'EPERM'; - } -} - -function reclaimStaleStaging(versionsDir: string): void { - for (const entry of fs.readdirSync(versionsDir, { withFileTypes: true })) { - if (!entry.isDirectory() || !entry.name.startsWith('.')) continue; - const stagingDir = path.join(versionsDir, entry.name); - const owner = Number.parseInt( - entry.name.match(/-(\d+)-[^-]+$/)?.[1] ?? '', - 10, - ); - if (Number.isInteger(owner) && owner > 0) { - if (processIsAlive(owner)) { - continue; - } - } else { - let age: number; - try { - age = Date.now() - fs.statSync(stagingDir).mtimeMs; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; - throw error; - } - if (age < OWNERLESS_STAGING_MAX_AGE_MS) { - continue; - } - } - fs.rmSync(stagingDir, { recursive: true, force: true }); - } -} - function resolveBootstrapPath(bootstrapPath?: string): string { if (!bootstrapPath) { throw new Error('Unable to identify the Qwen Code npm launcher'); @@ -85,6 +48,26 @@ function launcherId(bootstrapPath: string): string { return createHash('sha256').update(bootstrapPath).digest('hex').slice(0, 16); } +function resolveNpmGlobalConfigPath(): string { + const configured = process.env['NPM_CONFIG_GLOBALCONFIG']; + if (configured) return path.resolve(configured); + const output = execFileSync( + process.execPath, + [ + getNpmCliPath(process.execPath, process.platform), + 'config', + 'get', + 'globalconfig', + '--global', + ], + { encoding: 'utf8', timeout: 2000 }, + ).trim(); + if (!output || output === 'null' || output === 'undefined') { + throw new Error('Unable to resolve the global npm configuration'); + } + return output; +} + function readBaseInstallation(bootstrapPath: string): { version: string; ctimeMs: number; @@ -163,15 +146,16 @@ async function readActiveVersion( export function prepareManagedNpmUpdate( version: string, bootstrapPath = process.env['QWEN_CODE_CLI'], - updateRoot = path.join(Storage.getGlobalQwenDir(), 'updates', 'npm'), + updateRoot = process.env['QWEN_CODE_MANAGED_NPM_ROOT'] ?? + path.join(Storage.getGlobalQwenDir(), 'updates', 'npm'), ): ManagedNpmUpdate { assertVersion(version); const resolvedBootstrapPath = resolveBootstrapPath(bootstrapPath); const base = readBaseInstallation(resolvedBootstrapPath); + const npmGlobalConfigPath = resolveNpmGlobalConfigPath(); const launcherRoot = path.join(updateRoot, launcherId(resolvedBootstrapPath)); const versionsDir = path.join(launcherRoot, 'versions'); fs.mkdirSync(versionsDir, { recursive: true }); - reclaimStaleStaging(versionsDir); const stagingDir = fs.mkdtempSync( path.join(versionsDir, `.${version}-${process.pid}-`), ); @@ -183,6 +167,8 @@ export function prepareManagedNpmUpdate( bootstrapCtimeMs: base.ctimeMs, installArgs: [ 'install', + '--globalconfig', + npmGlobalConfigPath, '--prefix', stagingDir, '--global=false', @@ -193,6 +179,47 @@ export function prepareManagedNpmUpdate( }; } +export async function installManagedNpmUpdate( + version: string, + bootstrapPath = process.env['QWEN_CODE_CLI'], + updateRoot = process.env['QWEN_CODE_MANAGED_NPM_ROOT'] ?? + path.join(Storage.getGlobalQwenDir(), 'updates', 'npm'), + spawnFn: typeof spawn = spawn, +): Promise { + const update = prepareManagedNpmUpdate(version, bootstrapPath, updateRoot); + const env = { ...process.env }; + for (const key of ['NPM_CONFIG_USERCONFIG', 'npm_config_userconfig']) { + const configured = env[key]; + if (configured) env[key] = path.resolve(configured); + } + try { + await new Promise((resolve, reject) => { + const child = spawnFn( + process.execPath, + [ + getNpmCliPath(process.execPath, process.platform), + ...update.installArgs, + ], + { + cwd: update.stagingDir, + env, + stdio: 'ignore', + windowsHide: true, + }, + ); + child.once('error', reject); + child.once('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`npm install exited with code ${code}`)); + }); + }); + await activateManagedNpmUpdate(update, version, bootstrapPath); + } catch (error) { + await cleanupManagedNpmUpdate(update); + throw error; + } +} + export async function activateManagedNpmUpdate( update: ManagedNpmUpdate, version: string, diff --git a/scripts/cli-entry.js b/scripts/cli-entry.js index 972a04b4e39..881b9101867 100755 --- a/scripts/cli-entry.js +++ b/scripts/cli-entry.js @@ -79,21 +79,25 @@ function resolveQwenHome() { if (!configured) return join(homedir() || tmpdir(), '.qwen'); if (configured === '~') return homedir(); if (configured.startsWith('~/') || configured.startsWith('~\\')) { - return resolve(homedir(), configured.slice(2)); + return join( + homedir(), + ...configured + .slice(2) + .split(/[/\\]+/) + .filter(Boolean), + ); } return resolve(configured); } function preResolveQwenHome() { - if (process.env['QWEN_HOME']) return; - const home = homedir(); - if (!home) return; + if (Object.hasOwn(process.env, 'QWEN_HOME')) return; + const home = homedir() || tmpdir(); for (const candidate of [join(home, '.qwen', '.env'), join(home, '.env')]) { try { - const source = readFileSync(candidate, 'utf8').replace( - /^(\s*(?:export\s+)?QWEN_HOME)\s*:\s*/gm, - '$1=', - ); + const source = readFileSync(candidate, 'utf8') + .replace(/^\uFEFF/, '') + .replace(/^(\s*(?:export\s+)?QWEN_HOME):[^\S\r\n]+/gm, '$1='); const configured = parseEnv(source)['QWEN_HOME']; if (configured) { process.env['QWEN_HOME'] = configured; @@ -108,39 +112,70 @@ function preResolveQwenHome() { delete process.env['QWEN_CODE_MANAGED_NPM_UPDATE']; preResolveQwenHome(); -function getManagedNpmInstallation() { +function getManagedNpmPin() { try { - const updateRoot = join(resolveQwenHome(), 'updates', 'npm'); - const id = createHash('sha256') - .update(currentEntryPath) - .digest('hex') - .slice(0, 16); - const launcherRoot = join(updateRoot, id); - const active = JSON.parse( - readFileSync(join(launcherRoot, 'active.json'), 'utf8'), - ); - const basePackageJsonPath = [ - join(__dirname, 'package.json'), - join(__dirname, '..', 'package.json'), - ].find((candidate) => existsSync(candidate)); - if (!basePackageJsonPath) return undefined; - const basePackage = JSON.parse(readFileSync(basePackageJsonPath, 'utf8')); + const pin = JSON.parse(process.env['QWEN_CODE_MANAGED_NPM_PIN'] ?? ''); + if (pin.bootstrap !== currentEntryPath) return undefined; if ( - typeof active.version !== 'string' || - !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test( - active.version, - ) || - typeof active.bootstrap !== 'string' || - realpathSync(active.bootstrap) !== currentEntryPath || - active.baseVersion !== basePackage.version || - active.bootstrapCtimeMs !== statSync(currentEntryPath).ctimeMs + typeof pin.updateRoot !== 'string' || + resolve(pin.updateRoot) !== pin.updateRoot ) { return undefined; } + if (pin.version === null) { + return { version: null, updateRoot: pin.updateRoot }; + } + return typeof pin.version === 'string' && + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test( + pin.version, + ) + ? { version: pin.version, updateRoot: pin.updateRoot } + : undefined; + } catch { + return undefined; + } +} + +const managedNpmPin = getManagedNpmPin(); +const managedNpmUpdateRoot = + managedNpmPin?.updateRoot ?? join(resolveQwenHome(), 'updates', 'npm'); + +function getManagedNpmInstallation() { + try { + if (managedNpmPin?.version === null) return undefined; + const launcherRoot = join( + managedNpmUpdateRoot, + createHash('sha256').update(currentEntryPath).digest('hex').slice(0, 16), + ); + let version = managedNpmPin?.version; + if (version === undefined) { + const active = JSON.parse( + readFileSync(join(launcherRoot, 'active.json'), 'utf8'), + ); + const basePackageJsonPath = [ + join(__dirname, 'package.json'), + join(__dirname, '..', 'package.json'), + ].find((candidate) => existsSync(candidate)); + if (!basePackageJsonPath) return undefined; + const basePackage = JSON.parse(readFileSync(basePackageJsonPath, 'utf8')); + if ( + typeof active.version !== 'string' || + !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test( + active.version, + ) || + typeof active.bootstrap !== 'string' || + realpathSync(active.bootstrap) !== currentEntryPath || + active.baseVersion !== basePackage.version || + active.bootstrapCtimeMs !== statSync(currentEntryPath).ctimeMs + ) { + return undefined; + } + version = active.version; + } const packageRoot = join( launcherRoot, 'versions', - active.version, + version, 'node_modules', '@qwen-code', 'qwen-code', @@ -150,13 +185,13 @@ function getManagedNpmInstallation() { const cliPath = join(packageRoot, 'cli.js'); if ( pkg.name !== '@qwen-code/qwen-code' || - pkg.version !== active.version || + pkg.version !== version || !existsSync(cliPath) ) { return undefined; } process.env['QWEN_CODE_MANAGED_NPM_UPDATE'] = 'true'; - return { cliPath, packageJsonPath }; + return { cliPath, packageJsonPath, version }; } catch { return undefined; } @@ -175,9 +210,9 @@ function getManagedNpmInstallation() { // Assignment, not `||=`: an inherited value is another session's CLI — an outer // qwen shelling out to this one — and honouring it re-creates the exact skew above, // one level up. Each entry stamps itself, so nested sessions each call their own -// build. Nothing downstream overwrites this: the spawn below runs the bundled CLI, -// which never re-executes this wrapper, and the post-update relaunch re-enters -// through the launcher's own wrapper — which stamps the updated entry, as it must. +// build. The managed-version pin keeps nested calls on the running session's build +// even if a background update changes the active pointer. A post-update relaunch +// clears that pin so the launcher's wrapper selects the newly active build. // // One exception, and it points the SAME way: the standalone package launches this // file through a shim (`bin/qwen`) that selects the BUNDLED Node — the host may @@ -192,12 +227,23 @@ function getManagedNpmInstallation() { // wearing this one's stamp. const standaloneShim = process.env['QWEN_CODE_LAUNCHER_PATH']; delete process.env['QWEN_CODE_LAUNCHER_PATH']; -process.env['QWEN_CODE_CLI'] = - standaloneShim && existsSync(standaloneShim) - ? standaloneShim - : fileURLToPath(import.meta.url); const managedNpmInstallation = getManagedNpmInstallation(); +const validStandaloneShim = + standaloneShim && existsSync(standaloneShim) ? standaloneShim : undefined; +process.env['QWEN_CODE_CLI'] = + validStandaloneShim ?? fileURLToPath(import.meta.url); +if (validStandaloneShim) { + delete process.env['QWEN_CODE_MANAGED_NPM_PIN']; + delete process.env['QWEN_CODE_MANAGED_NPM_ROOT']; +} else { + process.env['QWEN_CODE_MANAGED_NPM_ROOT'] = managedNpmUpdateRoot; + process.env['QWEN_CODE_MANAGED_NPM_PIN'] = JSON.stringify({ + bootstrap: currentEntryPath, + version: managedNpmInstallation?.version ?? null, + updateRoot: managedNpmUpdateRoot, + }); +} const cliPathCandidates = [ managedNpmInstallation?.cliPath, join(__dirname, 'cli.js'), @@ -290,7 +336,7 @@ if (isInProcessFastPath()) { } else { if (!launcher) { process.stderr.write( - 'Update installed. Restart Qwen Code to use the new version.\n', + 'Update successful! The new version will be used on your next run.\n', ); process.exit(0); } @@ -299,6 +345,7 @@ if (isInProcessFastPath()) { QWEN_CODE_RELAUNCH_ARGS: JSON.stringify(cliArgs), QWEN_CODE_SKIP_UPDATE_CHECK_ONCE: 'true', }; + delete relaunchEnv['QWEN_CODE_MANAGED_NPM_PIN']; const relaunchResult = process.platform === 'win32' && launcher.endsWith('.cmd') ? spawnSync( From 76c8e7caf0c3296e89321a8fab084764327b77bf Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 21 Jul 2026 00:30:56 +0800 Subject: [PATCH 10/19] fix(cli): bound managed npm updates --- .../cli/src/utils/managed-npm-update.test.ts | 29 +++++++++++++++++++ packages/cli/src/utils/managed-npm-update.ts | 3 ++ 2 files changed, 32 insertions(+) diff --git a/packages/cli/src/utils/managed-npm-update.test.ts b/packages/cli/src/utils/managed-npm-update.test.ts index f9429401b4b..b4800071314 100644 --- a/packages/cli/src/utils/managed-npm-update.test.ts +++ b/packages/cli/src/utils/managed-npm-update.test.ts @@ -92,6 +92,8 @@ describe('managed npm update', () => { '--global=false', '--no-save', '--package-lock=false', + '--no-audit', + '--no-fund', '@qwen-code/qwen-code@2.0.0', ]); expect(update.versionDir).toBe( @@ -166,6 +168,8 @@ describe('managed npm update', () => { '--global=false', '--no-save', '--package-lock=false', + '--no-audit', + '--no-fund', '@qwen-code/qwen-code@2.0.0', ], expect.objectContaining({ @@ -173,6 +177,7 @@ describe('managed npm update', () => { NPM_CONFIG_USERCONFIG: path.resolve('config/npmrc'), }), stdio: 'ignore', + timeout: 10 * 60_000, windowsHide: true, }), ); @@ -288,6 +293,30 @@ describe('managed npm update', () => { ).toBe(true); }); + it('reuses a valid payload activated concurrently for the same version', async () => { + const root = makeTemporaryDirectory(); + const updateRoot = path.join(root, 'updates'); + const bootstrap = writeBaseInstallation(root); + const first = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + const second = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + writeInstallation(first.stagingDir, '2.0.0'); + writeInstallation(second.stagingDir, '2.0.0'); + + await Promise.all([ + activateManagedNpmUpdate(first, '2.0.0', bootstrap), + activateManagedNpmUpdate(second, '2.0.0', bootstrap), + ]); + + expect( + JSON.parse( + fs.readFileSync(path.join(first.launcherRoot, 'active.json'), 'utf8'), + ), + ).toMatchObject({ version: '2.0.0' }); + expect(fs.existsSync(first.versionDir)).toBe(true); + expect(fs.existsSync(first.stagingDir)).toBe(false); + expect(fs.existsSync(second.stagingDir)).toBe(false); + }); + it('isolates payloads for different launchers', async () => { const root = makeTemporaryDirectory(); const updateRoot = path.join(root, 'updates'); diff --git a/packages/cli/src/utils/managed-npm-update.ts b/packages/cli/src/utils/managed-npm-update.ts index be55e4b412b..931211a9152 100644 --- a/packages/cli/src/utils/managed-npm-update.ts +++ b/packages/cli/src/utils/managed-npm-update.ts @@ -174,6 +174,8 @@ export function prepareManagedNpmUpdate( '--global=false', '--no-save', '--package-lock=false', + '--no-audit', + '--no-fund', `${PACKAGE_NAME}@${version}`, ], }; @@ -204,6 +206,7 @@ export async function installManagedNpmUpdate( cwd: update.stagingDir, env, stdio: 'ignore', + timeout: 10 * 60_000, windowsHide: true, }, ); From a4a13ed0e6a9d53ac98bce07229acbdad611aa2b Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 21 Jul 2026 01:14:24 +0800 Subject: [PATCH 11/19] fix(cli): keep update notices live --- packages/cli/src/ui/AppContainer.tsx | 7 ++-- .../cli/src/ui/components/Notifications.tsx | 4 +-- .../src/ui/layouts/DefaultAppLayout.test.tsx | 32 +++++++++++++++++++ .../cli/src/ui/layouts/DefaultAppLayout.tsx | 4 +++ .../src/ui/layouts/ScreenReaderAppLayout.tsx | 4 +++ 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 293dd4cd028..6a4574df78c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -3040,10 +3040,13 @@ export const AppContainer = (props: AppContainerProps) => { dialogsVisible, stickyTodosLayoutKey, liveAgentPanelLayoutKey, - // Composer height also shifts with these; without them the footer isn't - // re-measured during a streaming turn and the VP viewport bottom clips. + // Composer and update notification height also shift with these; without + // them the footer isn't re-measured during a streaming turn and the VP + // viewport bottom clips. // (elapsedTime/currentLoadingPhrase excluded: they tick without changing rows.) streamingState, + updateInfo, + agentViewState.activeView, embeddedShellFocused, messageQueue.length, isInputActive, diff --git a/packages/cli/src/ui/components/Notifications.tsx b/packages/cli/src/ui/components/Notifications.tsx index 59dec3cf894..c7e75c8fa1e 100644 --- a/packages/cli/src/ui/components/Notifications.tsx +++ b/packages/cli/src/ui/components/Notifications.tsx @@ -9,11 +9,10 @@ import { useAppContext } from '../contexts/AppContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { theme } from '../semantic-colors.js'; import { StreamingState } from '../types.js'; -import { UpdateNotification } from './UpdateNotification.js'; export const Notifications = () => { const { startupWarnings } = useAppContext(); - const { initError, streamingState, updateInfo } = useUIState(); + const { initError, streamingState } = useUIState(); const showStartupWarnings = startupWarnings.length > 0; const showInitError = @@ -21,7 +20,6 @@ export const Notifications = () => { return ( <> - {updateInfo && } {showStartupWarnings && ( ({ MainContent: () => MainContent, })); +vi.mock('../components/UpdateNotification.js', () => ({ + UpdateNotification: ({ message }: { message: string }) => ( + {`UpdateNotification: ${message}`} + ), +})); + vi.mock('../components/DialogManager.js', () => ({ DialogManager: () => ( @@ -140,6 +146,32 @@ describe('DefaultAppLayout', () => { ); }); + it('renders an update that arrives after startup above the composer', () => { + mockedUseAgentViewState.mockReturnValue({ + activeView: 'main', + agents: new Map(), + }); + + const { lastFrame } = renderLayout({ + ...baseUIState, + updateInfo: { + message: 'Update successful!', + update: { + latest: '0.20.0', + current: '0.19.12', + type: 'latest', + name: '@qwen-code/qwen-code', + }, + }, + }); + const output = lastFrame() ?? ''; + + expect(output).toContain('UpdateNotification: Update successful!'); + expect(output.indexOf('UpdateNotification')).toBeLessThan( + output.indexOf('Composer'), + ); + }); + it('does not render sticky todo list when dialogs are visible', () => { mockedUseAgentViewState.mockReturnValue({ activeView: 'main', diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 76159ca1e3e..e2a728f1a7a 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { useEffect, useRef } from 'react'; import { Box } from 'ink'; import { MainContent } from '../components/MainContent.js'; +import { UpdateNotification } from '../components/UpdateNotification.js'; import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; import { ExitWarning } from '../components/ExitWarning.js'; @@ -77,6 +78,9 @@ export const DefaultAppLayout: React.FC = () => { {/* Main view: conversation history + main composer / dialogs */} + {!uiState.dialogsVisible && uiState.updateInfo && ( + + )} {uiState.dialogsVisible ? ( { return ( + {uiState.updateInfo && ( + + )}