Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
66261f9
fix(cli): update npm installs safely in background
yiliang114 Jul 20, 2026
79d80e0
refactor(cli): simplify managed npm updates
yiliang114 Jul 20, 2026
7a2d2b2
Merge remote-tracking branch 'origin/main' into cx/fix-background-aut…
yiliang114 Jul 20, 2026
8cdb9c6
refactor(cli): simplify background update completion
yiliang114 Jul 20, 2026
e14f95c
fix(cli): align managed updates with npm package layout
yiliang114 Jul 20, 2026
94f37cb
fix(cli): harden managed update lifecycle
yiliang114 Jul 20, 2026
c564f78
fix(cli): invalidate managed updates after reinstall
yiliang114 Jul 20, 2026
3857427
Merge remote-tracking branch 'origin/main' into cx/fix-background-aut…
yiliang114 Jul 20, 2026
b0b5d02
fix(cli): validate staged npm updates
yiliang114 Jul 20, 2026
10ddba0
fix(cli): match home env parsing in launcher
yiliang114 Jul 20, 2026
3ad7a39
Merge remote-tracking branch 'origin/main' into cx/fix-background-aut…
yiliang114 Jul 20, 2026
6e3a59e
fix(cli): harden managed background updates
yiliang114 Jul 20, 2026
76c8e7c
fix(cli): bound managed npm updates
yiliang114 Jul 20, 2026
a4a13ed
fix(cli): keep update notices live
yiliang114 Jul 20, 2026
efe6000
test(cli): avoid ctime race in update test
yiliang114 Jul 20, 2026
8cbc9d5
fix(cli): gate screen reader update notice
yiliang114 Jul 20, 2026
dbdb969
fix(cli): show update notice on agent tabs
yiliang114 Jul 20, 2026
b81c15e
fix(cli): preserve managed update stderr
yiliang114 Jul 21, 2026
71d9c5c
fix(cli): address managed update review
yiliang114 Jul 21, 2026
e740c45
fix(cli): handle tilde qwen home without homedir
yiliang114 Jul 21, 2026
64b46e4
test(cli): make managed update paths portable
yiliang114 Jul 21, 2026
b5966e5
fix(cli): tolerate unavailable home directory
yiliang114 Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/design/npm-background-auto-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 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/<launcher-id>/versions/<version>/
```

The version check runs npm in its global context and the staged install uses an
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.

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
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
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
lease-based collector is necessary.

## 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.
276 changes: 276 additions & 0 deletions packages/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync,
utimesSync,
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';
Expand All @@ -42,6 +47,7 @@ const mocks = vi.hoisted(() => ({
mcpListHandler: vi.fn(),
mcpAddHandler: vi.fn(),
getCliVersion: vi.fn(),
installManagedNpmUpdate: vi.fn(),
}));

vi.mock('./gemini.js', () => ({
Expand Down Expand Up @@ -69,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',
Expand Down Expand Up @@ -133,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[];
Expand Down Expand Up @@ -165,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();
});

Expand All @@ -178,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'];

Expand Down Expand Up @@ -502,6 +530,254 @@ describe('bootstrap import boundaries', () => {
expect(output).toBe(`${expectedVersion}\n`);
});

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', 'qwen');
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(packageRoot, { recursive: true });
writeFileSync(
path.join(entryDir, 'cli.js'),
"process.stdout.write(JSON.stringify({ build: 'base', pin: process.env.QWEN_CODE_MANAGED_NPM_PIN }));\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, 'cli.js'),
"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);

mkdirSync(path.join(tempDir, '.qwen'), { recursive: true });
writeFileSync(
path.join(tempDir, '.qwen', '.env'),
'\uFEFFQWEN_HOME: ~\\custom\\qwen\n',
);
const childEnv: NodeJS.ProcessEnv = {
...process.env,
HOME: tempDir,
USERPROFILE: tempDir,
TMPDIR: tempDir,
TEMP: tempDir,
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'],
{
encoding: 'utf8',
env: childEnv,
},
);

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');
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,
}),
),
).toMatchObject({
build: 'managed-2',
managed: 'true',
launcher: realpathSync(entryPath),
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(
JSON.parse(
execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], {
encoding: 'utf8',
env: emptyHomeEnv,
}),
),
).toMatchObject({ build: 'base' });

writeFileSync(
path.join(entryDir, 'package.json'),
JSON.stringify({
name: '@qwen-code/qwen-code',
version: '3.0.0',
}),
);
expect(
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-'));
const entryDir = path.join(tempDir, 'bin');
Expand Down
Loading
Loading