Skip to content
142 changes: 142 additions & 0 deletions packages/cli/src/ui/utils/updateCheck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
import {
checkForUpdates,
checkForUpdatesDetailed,
fetchGlobalNpmUpdateInfo,
FETCH_TIMEOUT_MS,
isGlobalNpmInstallation,
runGlobalNpm,
UpdateCheckTimeoutError,
} from './updateCheck.js';

Expand Down Expand Up @@ -203,6 +206,145 @@ describe('checkForUpdates', () => {
});
});

it('checks npm updates in the global npm context', async () => {
const run = vi.fn().mockResolvedValue({
stdout: '"1.1.0"\n',
stderr: '',
});

await expect(
fetchGlobalNpmUpdateInfo(
'@qwen-code/qwen-code',
'1.0.0',
'latest',
run as unknown as NonNullable<
Parameters<typeof fetchGlobalNpmUpdateInfo>[3]
>,
),
).resolves.toMatchObject({ current: '1.0.0', latest: '1.1.0' });
const npmArgs = [
'view',
'@qwen-code/qwen-code',
'dist-tags.latest',
'--json',
'--global',
];
expect(run).toHaveBeenCalledWith(
process.platform === 'win32' ? process.execPath : 'npm',
process.platform === 'win32'
? [expect.stringMatching(/npm-cli\.js$/), ...npmArgs]
: npmArgs,
expect.objectContaining({
timeout: FETCH_TIMEOUT_MS,
}),
);
});

it('does not treat pnpm installs as global npm installs', async () => {
const run = vi.fn();

await expect(
isGlobalNpmInstallation(
'/home/user/.pnpm/@qwen-code+qwen-code/node_modules/@qwen-code/qwen-code/dist/index.js',
run as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[1]
>,
),
).resolves.toBe(false);
expect(run).not.toHaveBeenCalled();
});

it('runs the Windows npm CLI through Node without a shell', async () => {
const run = vi.fn().mockResolvedValue({ stdout: '"1.1.0"', stderr: '' });
const resolveNpmCliPath = vi
.fn()
.mockReturnValue(
'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js',
);

await runGlobalNpm(
['view', '@qwen-code/qwen-code'],
run as unknown as NonNullable<Parameters<typeof runGlobalNpm>[1]>,
'win32',
'C:\\Program Files\\nodejs\\node.exe',
resolveNpmCliPath,
);

expect(run).toHaveBeenCalledWith(
'C:\\Program Files\\nodejs\\node.exe',
[
'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js',
'view',
'@qwen-code/qwen-code',
],
expect.not.objectContaining({ shell: true }),
);
});

it('canonicalizes the global npm root before comparing paths', async () => {
const run = vi.fn().mockResolvedValue({
stdout: '/linked/node_modules\n',
stderr: '',
});
const canonicalize = vi.fn(async (candidate: string) =>
candidate === '/linked/node_modules'
? '/real/node_modules'
: '/real/node_modules/@qwen-code/qwen-code/cli.js',
);

await expect(
isGlobalNpmInstallation(
'/linked/node_modules/@qwen-code/qwen-code/cli.js',
run as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[1]
>,
canonicalize as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[2]
>,
),
).resolves.toBe(true);
});

it('does not treat a missing global npm root as a global install', async () => {
const run = vi.fn().mockResolvedValue({
stdout: '/missing/node_modules\n',
stderr: '',
});
const canonicalize = vi.fn(async (candidate: string) => {
if (candidate === '/missing/node_modules') {
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
}
return '/local/node_modules/@qwen-code/qwen-code/cli.js';
});

await expect(
isGlobalNpmInstallation(
'/local/node_modules/@qwen-code/qwen-code/cli.js',
run as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[1]
>,
canonicalize as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[2]
>,
),
).resolves.toBe(false);
});

it('does not fall back when the global npm query fails', async () => {
const run = vi.fn().mockRejectedValue(new Error('npm view failed'));

await expect(
fetchGlobalNpmUpdateInfo(
'@qwen-code/qwen-code',
'1.0.0',
'latest',
run as unknown as NonNullable<
Parameters<typeof fetchGlobalNpmUpdateInfo>[3]
>,
),
).rejects.toThrow('npm view failed');
});

it('should pass a non-optional package version to update-notifier', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
Expand Down
103 changes: 94 additions & 9 deletions packages/cli/src/ui/utils/updateCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
import type { UpdateInfo } from 'update-notifier';
import updateNotifier from 'update-notifier';
import semver from 'semver';
import { execFile } from 'node:child_process';
import { realpath } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { getPackageJson } from '../../utils/package.js';
import { getWindowsNpmCliPath } from '../../utils/installationInfo.js';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';

Expand Down Expand Up @@ -59,6 +64,81 @@ async function fetchInfoWithTimeout(
}
}

const execFileAsync = promisify(execFile);

export async function runGlobalNpm(
args: string[],
run: typeof execFileAsync = execFileAsync,
platform = process.platform,
nodePath = process.execPath,
resolveNpmCliPath = getWindowsNpmCliPath,
): Promise<string> {
const isWindows = platform === 'win32';
const command = isWindows ? nodePath : 'npm';
const commandArgs = isWindows ? [resolveNpmCliPath(nodePath), ...args] : args;
const { stdout } = await run(command, commandArgs, {
encoding: 'utf8',
timeout: FETCH_TIMEOUT_MS,
});
return String(stdout).trim();
}

function looksLikeNpmPackagePath(cliPath: string): boolean {
const normalized = cliPath.replace(/\\/g, '/');
return (
normalized.includes('/node_modules/@qwen-code/qwen-code/') &&
!normalized.includes('/.pnpm/')
);
}

export async function isGlobalNpmInstallation(
cliPath = process.argv[1],
run: typeof execFileAsync = execFileAsync,
canonicalize: typeof realpath = realpath,
): Promise<boolean> {
if (!cliPath || !looksLikeNpmPackagePath(cliPath)) return false;
const [resolvedCliPath, unresolvedGlobalRoot] = await Promise.all([
canonicalize(cliPath),
runGlobalNpm(['root', '--global'], run),
]);
Comment on lines +101 to +104

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] isGlobalNpmInstallation does not guard the Promise.all against ENOENT from runGlobalNpm → getNpmCliPath, even though it already guards the subsequent canonicalize(unresolvedGlobalRoot) call against the same error class. — Failure scenario: on a non-Windows host where the CLI path passes looksLikeNpmPackagePath but no npm symlink exists next to process.execPath (minimal Node.js install, broken symlink), getNpmCliPath throws ENOENT. The error propagates uncaught into checkForUpdatesDetailed's outer catch, returning { status: 'error' } — the user sees "Failed to check for updates" on every startup instead of falling back to update-notifier.

Suggested change
const [resolvedCliPath, unresolvedGlobalRoot] = await Promise.all([
canonicalize(cliPath),
runGlobalNpm(['root', '--global'], run),
]);
let resolvedCliPath: string;
let unresolvedGlobalRoot: string;
try {
[resolvedCliPath, unresolvedGlobalRoot] = await Promise.all([
canonicalize(cliPath),
runGlobalNpm(['root', '--global'], run),
]);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw error;
}

— qwen3.8-max-preview via Qwen Code /review

let globalRoot: string;
try {
globalRoot = await canonicalize(unresolvedGlobalRoot);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw error;
}
const relative = path.relative(globalRoot, resolvedCliPath);
return (
relative !== '' &&
relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
}

export async function fetchGlobalNpmUpdateInfo(
packageName: string,
currentVersion: string,
distTag: 'latest' | 'nightly',
run: typeof execFileAsync = execFileAsync,
): Promise<UpdateInfo> {
const output = await runGlobalNpm(
['view', packageName, `dist-tags.${distTag}`, '--json', '--global'],
run,
);
const latest: unknown = JSON.parse(output);
if (typeof latest !== 'string') {
throw new Error(`Invalid npm ${distTag} version response`);
}
return {
latest,
current: currentVersion,
type: 'latest',
name: packageName,
};
}

export interface UpdateObject {
message: string;
update: UpdateInfo;
Expand Down Expand Up @@ -106,18 +186,23 @@ export async function checkForUpdatesDetailed(): Promise<UpdateCheckResult> {
}

const { name, version } = packageJson;
const isGlobalNpm = await isGlobalNpmInstallation();
currentVersion = version;
const isNightly = version.includes('nightly');
const createNotifier = (distTag: 'latest' | 'nightly') =>
updateNotifier({
pkg: {
name,
version,
},
updateCheckInterval: 0,
shouldNotifyInNpmScript: true,
distTag,
});
isGlobalNpm
? {
fetchInfo: () => fetchGlobalNpmUpdateInfo(name, version, distTag),
}
: updateNotifier({
pkg: {
name,
version,
},
updateCheckInterval: 0,
shouldNotifyInNpmScript: true,
distTag,
});

if (isNightly) {
const [nightlyUpdateInfo, latestUpdateInfo] = await Promise.all([
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/utils/handleAutoUpdate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import EventEmitter from 'node:events';
import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js';
import { performStandaloneUpdate } from './standalone-update.js';
import { MessageType } from '../ui/types.js';
import os from 'node:os';

vi.mock('./installationInfo.js', async () => {
const actual = await vi.importActual('./installationInfo.js');
Expand Down Expand Up @@ -242,6 +243,30 @@ describe('handleAutoUpdate', () => {
);
});

it('runs npm through the active Node.js runtime on Windows', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
mockGetInstallationInfo.mockReturnValue({
updateCommand: 'npm install -g @qwen-code/qwen-code@latest',
isGlobal: true,
packageManager: PackageManager.NPM,
});

handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);

expect(mockSpawn).toHaveBeenCalledWith(
process.execPath,
[
expect.stringMatching(/npm-cli\.js$/),
'install',
'-g',
'@qwen-code/qwen-code@2.0.0',
],
{
stdio: ['pipe', 'ignore', 'pipe'],
},
);
});

it('should emit "update-success" when the update process succeeds', async () => {
await new Promise<void>((resolve) => {
mockGetInstallationInfo.mockReturnValue({
Expand Down
18 changes: 15 additions & 3 deletions packages/cli/src/utils/handleAutoUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import type { UpdateObject } from '../ui/utils/updateCheck.js';
import type { LoadedSettings } from '../config/settings.js';
import {
getWindowsNpmCliPath,
getInstallationInfo,
PackageManager,
resolveUpdateCommand,
} from './installationInfo.js';
import { updateEventEmitter } from './updateEventEmitter.js';
Expand Down Expand Up @@ -94,9 +96,19 @@ export function handleAutoUpdate(
info.update.latest,
);
const isWindows = os.platform() === 'win32';
const shell = isWindows ? 'cmd.exe' : 'bash';
const shellArgs = isWindows ? ['/c', updateCommand] : ['-c', updateCommand];
const updateProcess = spawnFn(shell, shellArgs, {
const command =
isWindows && installationInfo.packageManager === PackageManager.NPM
? process.execPath
: isWindows
? 'cmd.exe'
: 'bash';
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
const commandArgs =
isWindows && installationInfo.packageManager === PackageManager.NPM
? [getWindowsNpmCliPath(), ...updateCommand.split(' ').slice(1)]
: isWindows
? ['/c', updateCommand]
: ['-c', updateCommand];
const updateProcess = spawnFn(command, commandArgs, {
stdio: ['pipe', 'ignore', 'pipe'],
});
let errorOutput = '';
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/utils/installationInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ export enum PackageManager {
UNKNOWN = 'unknown',
}

export function getWindowsNpmCliPath(nodePath = process.execPath): string {
return path.win32.join(
path.win32.dirname(nodePath),
'node_modules',
'npm',
'bin',
'npm-cli.js',
);
}

const debugLogger = createDebugLogger('INSTALLATION_INFO');
const STANDALONE_UNIX_INSTALLER =
'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh';
Expand Down
Loading
Loading