Skip to content
8 changes: 6 additions & 2 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -967,9 +967,13 @@ describe('gemini.tsx main function', () => {
const { relaunchOnExitCode } = await import('./utils/relaunch.js');
const [, options] = vi.mocked(relaunchOnExitCode).mock.calls[0]!;

await expect(options?.onUpdateRelaunch?.()).resolves.toBe(44);
await expect(options?.onUpdateRelaunch?.(true)).resolves.toBe(44);

expect(mockUpdateBeforeRelaunch).toHaveBeenCalledTimes(1);
expect(mockUpdateBeforeRelaunch).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
true,
);
});

it('passes host update capability into a container sandbox', async () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ export async function main() {
? getNodeMemoryArgs(isDebugMode)
: [];
const updateProjectRoot = process.cwd();
const onUpdateRelaunch = async () => {
const onUpdateRelaunch = async (relaunchOnFailure: boolean) => {
await initializeI18n(
resolveLanguageSetting(settings.merged.general?.language as string),
);
Expand All @@ -410,6 +410,7 @@ export async function main() {
const shouldRelaunch = await updateBeforeRelaunch(
settings,
updateProjectRoot,
relaunchOnFailure,
);
return shouldRelaunch ? UPDATE_COMPLETE_EXIT_CODE : 0;
};
Expand Down
176 changes: 176 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,179 @@ 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.execPath,
[expect.stringMatching(/npm-cli\.js$/), ...npmArgs],
expect.objectContaining({
timeout: FETCH_TIMEOUT_MS,
}),
);
});

it('selects the global npm registry for global npm installs', async () => {
getPackageJson.mockResolvedValue({
name: '@qwen-code/qwen-code',
version: '1.0.0',
});
const detectGlobalNpm = vi.fn().mockResolvedValue(true);
const fetchGlobalNpm = vi.fn().mockResolvedValue({
current: '1.0.0',
latest: '1.1.0',
});

await expect(
checkForUpdatesDetailed(detectGlobalNpm, fetchGlobalNpm),
).resolves.toMatchObject({
status: 'update',
info: { update: { current: '1.0.0', latest: '1.1.0' } },
});

expect(detectGlobalNpm).toHaveBeenCalledOnce();
expect(fetchGlobalNpm).toHaveBeenCalledWith(
'@qwen-code/qwen-code',
'1.0.0',
'latest',
);
expect(updateNotifier).not.toHaveBeenCalled();
});

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 }),
);
expect(resolveNpmCliPath).toHaveBeenCalledWith(
'C:\\Program Files\\nodejs\\node.exe',
'win32',
);
});

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);
expect(run).toHaveBeenCalledWith(
process.execPath,
[expect.stringMatching(/npm-cli\.js$/), 'root', '--global'],
expect.objectContaining({ timeout: FETCH_TIMEOUT_MS }),
);
});

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
109 changes: 99 additions & 10 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 { getNpmCliPath } 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,82 @@ async function fetchInfoWithTimeout(
}
}

const execFileAsync = promisify(execFile);

export async function runGlobalNpm(
args: string[],
run: typeof execFileAsync = execFileAsync,
platform = process.platform,
nodePath = process.execPath,
resolveNpmCliPath = getNpmCliPath,
): Promise<string> {
const { stdout } = await run(
nodePath,
[resolveNpmCliPath(nodePath, platform), ...args],
{
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.
}

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 @@ -93,7 +174,10 @@ function getBestAvailableUpdate(
return semver.gt(stableVer, nightlyVer) ? stable : nightly;
}

export async function checkForUpdatesDetailed(): Promise<UpdateCheckResult> {
export async function checkForUpdatesDetailed(
detectGlobalNpm = isGlobalNpmInstallation,
fetchGlobalNpm = fetchGlobalNpmUpdateInfo,
): Promise<UpdateCheckResult> {
let currentVersion: string | undefined;
try {
// Skip update check when running from source (development mode)
Expand All @@ -106,18 +190,23 @@ export async function checkForUpdatesDetailed(): Promise<UpdateCheckResult> {
}

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

if (isNightly) {
const [nightlyUpdateInfo, latestUpdateInfo] = await Promise.all([
Expand Down
Loading
Loading