qwen-code/packages/cli/src/utils/standalone-update.test.ts
Ziqiang Li e250d6e314
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
feat: add qwen update and /update commands with auto-update support (#5780)
* fix: align standalone-update RC markers with install script, add version to update output

## Changes

### Compatibility fixes (standalone-update.ts)
- ensurePathInShellRc: use install script's begin/end block markers (# Qwen Code PATH block begin/end) instead of single-line marker, preventing duplicate PATH entries
- ensurePathInShellRc: fish shell uses set -gx PATH (matching install script)
- ensurePathInShellRc: use single-quoted paths with shell_quote-style escaping
- ensureBinWrapper: use #!/usr/bin/env sh shebang (matching install script)

### Version output
- qwen update: show current version in 'up to date' message (Qwen Code X.Y.Z is up to date!)
- /update slash command: same version display

### Build fix
- esbuild.config.js: add ink/dom and ink/components/CursorContext aliases for ink 7.x compatibility

### i18n
- en.js: add 9 update-related translation keys

Co-Authored-By: Claude <noreply@anthropic.com>

* i18n: add update command translations for all 8 non-English locales

- zh: Simplified Chinese
- zh-TW: Traditional Chinese
- ja: Japanese
- ru: Russian
- de: German
- pt: Portuguese (Brazil)
- fr: French
- ca: Catalan

Co-Authored-By: Claude <noreply@anthropic.com>

* fix update command review feedback

* address update command review followups

* fix update command test args type

* address update review hardlink and fallback feedback

* fix update tar filter typing and sdk bundle guard

* fix(cli): address update review feedback

* address update review followups

* restore windows archive traversal scan

* address update review observations

* address latest update review findings

* fix latest update review regressions

* fix(cli): localize update command output

* fix(cli): localize update install guidance

* fix(cli): harden update review follow-ups

* fix(cli): address latest update review comments

* fix(cli): honor explicit update requests

* fix(cli): address update command review feedback

* fix(cli): address update slash command review feedback

* fix(cli): address latest update review feedback

* fix(cli): address update review follow-ups

* add code

* fix(cli): add .deferred marker to prevent Windows deferred update race

On Windows, when atomicReplace returns 'deferred', a bat script runs
detached to complete the swap after the Node process exits. The lock
file alone was insufficient because acquireLock falls through when the
Node PID is dead (process.kill check), allowing a second `qwen update`
to steal the lock and interfere with the in-flight bat script.

Add a .deferred marker file containing the bat script's PID. acquireLock
now checks this marker via isProcessAlive(batPid) before allowing lock
theft, blocking concurrent updates while the swap is still in progress.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(cli): harden update review edge cases

* fix(cli): address PR #5780 review feedback on update engine

* test(cli): fix update check test import

* fix(cli): avoid duplicate startup update checks

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: 易良 <1204183885@qq.com>
Co-authored-by: liziwl <23000702+liziwl@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: yiliang114 <effortyiliang@gmail.com>
2026-07-09 15:11:45 +00:00

864 lines
30 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
acquireLock,
rollbackStandaloneUpdate,
ensureBinWrapper,
ensurePathInShellRc,
cleanupFirstTimeMigrationArtifacts,
performStandaloneUpdate,
isSafeTarEntryPath,
isSafeTarEntry,
isSafeTarLinkTarget,
} from './standalone-update.js';
describe('standalone-update', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-update-test-'));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('rollbackStandaloneUpdate', () => {
it('returns no-old when .old directory does not exist', () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
fs.mkdirSync(standaloneDir);
fs.writeFileSync(
path.join(standaloneDir, 'manifest.json'),
JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'darwin-arm64',
}),
);
const result = rollbackStandaloneUpdate(standaloneDir);
expect(result.ok).toBe(false);
expect(result).toHaveProperty('reason', 'no-old');
});
it('returns no-manifest when .old directory has no manifest.json', () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
const oldDir = `${standaloneDir}.old`;
fs.mkdirSync(standaloneDir);
fs.mkdirSync(oldDir);
fs.writeFileSync(
path.join(standaloneDir, 'manifest.json'),
JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'darwin-arm64',
}),
);
const result = rollbackStandaloneUpdate(standaloneDir);
expect(result.ok).toBe(false);
expect(result).toHaveProperty('reason', 'no-manifest');
});
it('swaps current with .old directory on valid rollback', () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
const oldDir = `${standaloneDir}.old`;
fs.mkdirSync(standaloneDir);
fs.mkdirSync(oldDir);
fs.writeFileSync(
path.join(standaloneDir, 'manifest.json'),
JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'darwin-arm64',
version: '0.17.0',
}),
);
fs.writeFileSync(path.join(standaloneDir, 'marker.txt'), 'new');
fs.writeFileSync(
path.join(oldDir, 'manifest.json'),
JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'darwin-arm64',
version: '0.16.2',
}),
);
fs.writeFileSync(path.join(oldDir, 'marker.txt'), 'old');
const result = rollbackStandaloneUpdate(standaloneDir);
expect(result.ok).toBe(true);
const manifest = JSON.parse(
fs.readFileSync(path.join(standaloneDir, 'manifest.json'), 'utf-8'),
);
expect(manifest.version).toBe('0.16.2');
expect(
fs.readFileSync(path.join(standaloneDir, 'marker.txt'), 'utf-8'),
).toBe('old');
expect(fs.existsSync(oldDir)).toBe(false);
});
it('succeeds even with minimal manifest in .old', () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
const oldDir = `${standaloneDir}.old`;
fs.mkdirSync(standaloneDir);
fs.mkdirSync(oldDir);
fs.writeFileSync(
path.join(standaloneDir, 'manifest.json'),
JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.17.0' }),
);
fs.writeFileSync(path.join(oldDir, 'manifest.json'), '{}');
const result = rollbackStandaloneUpdate(standaloneDir);
expect(result.ok).toBe(true);
});
});
describe('ensureBinWrapper', () => {
// Unix wrapper test relies on POSIX file permissions (mode bits) and
// the SHELL env var, neither of which behave consistently on Windows.
it.skipIf(process.platform === 'win32')(
'creates a Unix shell wrapper script',
() => {
const libDir = path.join(tempDir, '.local', 'lib');
const standaloneDir = path.join(libDir, 'qwen-code');
fs.mkdirSync(standaloneDir, { recursive: true });
// Isolate HOME so ensurePathInShellRc doesn't touch real shell rc
const origHome = process.env['HOME'];
const origShell = process.env['SHELL'];
process.env['HOME'] = tempDir;
process.env['SHELL'] = '/bin/zsh';
try {
ensureBinWrapper(standaloneDir, 'darwin-arm64');
} finally {
process.env['HOME'] = origHome;
process.env['SHELL'] = origShell;
}
const wrapperPath = path.join(tempDir, '.local', 'bin', 'qwen');
expect(fs.existsSync(wrapperPath)).toBe(true);
const content = fs.readFileSync(wrapperPath, 'utf-8');
expect(content).toContain('#!/usr/bin/env sh');
expect(content).toContain(standaloneDir);
const mode = fs.statSync(wrapperPath).mode;
expect(mode & 0o111).toBeGreaterThan(0);
},
);
it('creates a Windows cmd wrapper', () => {
const libDir = path.join(tempDir, '.local', 'lib');
const standaloneDir = path.join(libDir, 'qwen-code');
fs.mkdirSync(standaloneDir, { recursive: true });
ensureBinWrapper(standaloneDir, 'win-x64');
const wrapperPath = path.join(tempDir, '.local', 'bin', 'qwen.cmd');
expect(fs.existsSync(wrapperPath)).toBe(true);
const content = fs.readFileSync(wrapperPath, 'utf-8');
expect(content).toContain('@echo off');
});
it.skipIf(process.platform === 'win32')(
'escapes single quotes in Unix wrapper paths',
() => {
const libDir = path.join(tempDir, "o'brien", '.local', 'lib');
const standaloneDir = path.join(libDir, 'qwen-code');
fs.mkdirSync(standaloneDir, { recursive: true });
const origHome = process.env['HOME'];
const origShell = process.env['SHELL'];
process.env['HOME'] = tempDir;
process.env['SHELL'] = '/bin/zsh';
try {
ensureBinWrapper(standaloneDir, 'linux-x64');
} finally {
process.env['HOME'] = origHome;
process.env['SHELL'] = origShell;
}
const wrapperPath = path.join(
tempDir,
"o'brien",
'.local',
'bin',
'qwen',
);
const content = fs.readFileSync(wrapperPath, 'utf-8');
expect(content).toContain("o'\\''brien");
},
);
it.skipIf(process.platform === 'win32')(
'allows shell metacharacters in single-quoted Unix wrapper paths',
() => {
const libDir = path.join(tempDir, 'with$dollar', '.local', 'lib');
const standaloneDir = path.join(libDir, 'qwen-code');
fs.mkdirSync(standaloneDir, { recursive: true });
const origHome = process.env['HOME'];
const origShell = process.env['SHELL'];
process.env['HOME'] = tempDir;
process.env['SHELL'] = '/bin/zsh';
try {
ensureBinWrapper(standaloneDir, 'linux-x64');
} finally {
process.env['HOME'] = origHome;
process.env['SHELL'] = origShell;
}
const wrapperPath = path.join(
tempDir,
'with$dollar',
'.local',
'bin',
'qwen',
);
const content = fs.readFileSync(wrapperPath, 'utf-8');
expect(content).toContain('with$dollar');
},
);
it.skipIf(process.platform === 'win32')(
'does not overwrite existing wrapper',
() => {
const libDir = path.join(tempDir, '.local', 'lib');
const standaloneDir = path.join(libDir, 'qwen-code');
const binDir = path.join(tempDir, '.local', 'bin');
fs.mkdirSync(standaloneDir, { recursive: true });
fs.mkdirSync(binDir, { recursive: true });
const origHome = process.env['HOME'];
const origShell = process.env['SHELL'];
process.env['HOME'] = tempDir;
process.env['SHELL'] = '/bin/zsh';
const wrapperPath = path.join(binDir, 'qwen');
fs.writeFileSync(wrapperPath, 'existing-content', { mode: 0o755 });
try {
ensureBinWrapper(standaloneDir, 'linux-x64');
expect(fs.readFileSync(wrapperPath, 'utf-8')).toBe(
'existing-content',
);
} finally {
process.env['HOME'] = origHome;
process.env['SHELL'] = origShell;
}
},
);
it.skipIf(process.platform === 'win32')(
'throws when wrapper creation fails safety validation',
() => {
const libDir = path.join(tempDir, 'bad\npath', '.local', 'lib');
const standaloneDir = path.join(libDir, 'qwen-code');
fs.mkdirSync(standaloneDir, { recursive: true });
expect(() => ensureBinWrapper(standaloneDir, 'linux-x64')).toThrow(
'Failed to create bin wrapper',
);
},
);
});
describe('performStandaloneUpdate', () => {
it('rejects invalid version format', async () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
fs.mkdirSync(standaloneDir);
fs.writeFileSync(
path.join(standaloneDir, 'manifest.json'),
JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'darwin-arm64',
}),
);
await expect(
performStandaloneUpdate(standaloneDir, 'not-a-version'),
).rejects.toThrow('Invalid version format');
});
it('rejects directory without manifest as non-managed install', async () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
fs.mkdirSync(standaloneDir);
// No manifest.json — could be user data
await expect(
performStandaloneUpdate(standaloneDir, '1.0.0'),
).rejects.toThrow('not a Qwen Code standalone install');
});
it('rejects unknown target in manifest', async () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
fs.mkdirSync(standaloneDir);
fs.writeFileSync(
path.join(standaloneDir, 'manifest.json'),
JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'freebsd-mips',
}),
);
await expect(
performStandaloneUpdate(standaloneDir, '1.0.0'),
).rejects.toThrow('Unknown target');
});
it('fails gracefully when another update is in progress', async () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
const parentDir = path.dirname(standaloneDir);
fs.mkdirSync(standaloneDir, { recursive: true });
fs.writeFileSync(
path.join(standaloneDir, 'manifest.json'),
JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'darwin-arm64',
}),
);
// Simulate held lock from a live process (current PID)
const lockPath = path.join(parentDir, '.qwen-update.lock');
fs.writeFileSync(lockPath, String(process.pid));
await expect(
performStandaloneUpdate(standaloneDir, '1.0.0'),
).rejects.toThrow('Another update is already in progress');
// Clean up lock
fs.unlinkSync(lockPath);
});
it('rejects a stale lock when a Windows deferred swap is pending', async () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
const parentDir = path.dirname(standaloneDir);
fs.mkdirSync(standaloneDir, { recursive: true });
fs.mkdirSync(`${standaloneDir}.new`);
fs.writeFileSync(
path.join(standaloneDir, 'manifest.json'),
JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'win-x64',
}),
);
const lockPath = path.join(parentDir, '.qwen-update.lock');
fs.writeFileSync(lockPath, '999999999');
await expect(
performStandaloneUpdate(standaloneDir, '1.0.0'),
).rejects.toThrow('A previous update left a pending swap');
expect(fs.existsSync(lockPath)).toBe(true);
expect(fs.existsSync(`${standaloneDir}.new`)).toBe(true);
});
});
describe('isSafeTarEntryPath', () => {
it('allows double dots inside a filename segment', () => {
expect(isSafeTarEntryPath('qwen-code/release..notes.md')).toBe(true);
expect(isSafeTarEntryPath('qwen-code/node/lib/foo..bar')).toBe(true);
expect(isSafeTarEntryPath('qwen-code/.../file.txt')).toBe(true);
expect(isSafeTarEntryPath('./qwen-code/bin/qwen')).toBe(true);
});
it('rejects parent-directory segments and absolute paths', () => {
expect(isSafeTarEntryPath('../qwen-code/manifest.json')).toBe(false);
expect(isSafeTarEntryPath('qwen-code/../manifest.json')).toBe(false);
expect(isSafeTarEntryPath('qwen-code\\..\\manifest.json')).toBe(false);
expect(isSafeTarEntryPath('/tmp/qwen-code/manifest.json')).toBe(false);
expect(isSafeTarEntryPath('C:\\tmp\\qwen-code\\manifest.json')).toBe(
false,
);
expect(isSafeTarEntryPath('')).toBe(false);
});
});
describe('isSafeTarLinkTarget', () => {
it('allows relative link targets inside the extraction directory', () => {
const dest = path.join(tempDir, 'extract');
expect(
isSafeTarLinkTarget('qwen-code/bin/qwen', '../lib/cli.js', dest),
).toBe(true);
expect(isSafeTarLinkTarget('qwen-code/bin/qwen', './qwen', dest)).toBe(
true,
);
});
it('allows symlink targets in child directories starting with two dots', () => {
const dest = path.join(tempDir, 'extract');
expect(
isSafeTarLinkTarget('qwen-code/bin/qwen', '../..hidden/tool', dest),
).toBe(true);
});
it('rejects symlink targets outside the extraction directory', () => {
const dest = path.join(tempDir, 'extract');
expect(
isSafeTarLinkTarget('qwen-code/bin/qwen', '../../../etc/passwd', dest),
).toBe(false);
expect(
isSafeTarLinkTarget('qwen-code/bin/qwen', '/etc/passwd', dest),
).toBe(false);
expect(
isSafeTarLinkTarget(
'qwen-code/bin/qwen',
'C:\\Windows\\System32',
dest,
),
).toBe(false);
});
it('rejects symlink targets outside the archive root that will be installed', () => {
const dest = path.join(tempDir, 'extract');
expect(
isSafeTarLinkTarget('qwen-code/bin/qwen', '../../shared/node', dest),
).toBe(false);
expect(
isSafeTarLinkTarget('./qwen-code/bin/qwen', '../../shared/node', dest),
).toBe(false);
});
});
describe('isSafeTarEntry', () => {
it('rejects hardlinks outright', () => {
const dest = path.join(tempDir, 'extract');
expect(
isSafeTarEntry(
'qwen-code/bin/qwen',
{ type: 'Link', linkpath: '../poc.txt' },
dest,
),
).toBe(false);
});
it('allows safe regular entries and safe symlinks', () => {
const dest = path.join(tempDir, 'extract');
expect(isSafeTarEntry('qwen-code/bin/qwen', { type: 'File' }, dest)).toBe(
true,
);
expect(isSafeTarEntry('qwen-code/lib', { type: 'Directory' }, dest)).toBe(
true,
);
expect(
isSafeTarEntry(
'qwen-code/bin/qwen',
{ type: 'SymbolicLink', linkpath: '../lib/cli.js' },
dest,
),
).toBe(true);
});
it('accepts fs stats entries from tar filter typing', () => {
const dest = path.join(tempDir, 'extract');
const filePath = path.join(tempDir, 'entry.txt');
fs.writeFileSync(filePath, 'entry');
expect(
isSafeTarEntry('qwen-code/bin/qwen', fs.statSync(filePath), dest),
).toBe(true);
});
it('rejects special archive entry types', () => {
const dest = path.join(tempDir, 'extract');
for (const type of [
'BlockDevice',
'CharacterDevice',
'FIFO',
'ContiguousFile',
]) {
expect(isSafeTarEntry('qwen-code/bin/qwen', { type }, dest)).toBe(
false,
);
}
});
});
describe('cleanupFirstTimeMigrationArtifacts', () => {
it.skipIf(process.platform === 'win32')(
'removes the wrapper and PATH block created during a failed migration',
() => {
const originalHome = process.env['HOME'];
const originalShell = process.env['SHELL'];
const home = path.join(tempDir, 'home');
process.env['HOME'] = home;
process.env['SHELL'] = '/bin/bash';
try {
const standaloneDir = path.join(home, '.local', 'lib', 'qwen-code');
const artifacts = ensureBinWrapper(standaloneDir, 'linux-x64');
const wrapperPath = path.join(home, '.local', 'bin', 'qwen');
const bashrc = path.join(home, '.bashrc');
expect(fs.existsSync(wrapperPath)).toBe(true);
expect(fs.readFileSync(bashrc, 'utf-8')).toContain(
'# Qwen Code PATH block begin',
);
cleanupFirstTimeMigrationArtifacts(artifacts);
expect(fs.existsSync(wrapperPath)).toBe(false);
expect(fs.readFileSync(bashrc, 'utf-8')).not.toContain(
'# Qwen Code PATH block begin',
);
} finally {
process.env['HOME'] = originalHome;
process.env['SHELL'] = originalShell;
}
},
);
});
describe('rollbackStandaloneUpdate — concurrent lock protection', () => {
it('returns error when an active update holds the lock', () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
const oldDir = `${standaloneDir}.old`;
const lockPath = path.join(tempDir, '.qwen-update.lock');
fs.mkdirSync(standaloneDir);
fs.mkdirSync(oldDir);
fs.writeFileSync(path.join(standaloneDir, 'manifest.json'), '{}');
fs.writeFileSync(path.join(oldDir, 'manifest.json'), '{}');
fs.writeFileSync(lockPath, String(process.pid));
const result = rollbackStandaloneUpdate(standaloneDir);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.detail).toContain('auto-update is currently in progress');
}
fs.unlinkSync(lockPath);
});
it('proceeds when lock has dead PID', () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
const oldDir = `${standaloneDir}.old`;
const lockPath = path.join(tempDir, '.qwen-update.lock');
fs.mkdirSync(standaloneDir);
fs.mkdirSync(oldDir);
fs.writeFileSync(
path.join(standaloneDir, 'manifest.json'),
JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.17.0' }),
);
fs.writeFileSync(
path.join(oldDir, 'manifest.json'),
JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.16.0' }),
);
fs.writeFileSync(lockPath, '999999999');
const result = rollbackStandaloneUpdate(standaloneDir);
expect(result.ok).toBe(true);
});
});
describe('acquireLock deferred marker handling', () => {
it('rejects lock takeover while a deferred bat process is alive', () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
const lockPath = path.join(tempDir, '.qwen-update.lock');
fs.writeFileSync(lockPath, '999999999');
fs.writeFileSync(`${standaloneDir}.deferred`, String(process.pid));
expect(() => acquireLock(lockPath, standaloneDir)).toThrow(
'A previous update is still being applied',
);
expect(fs.existsSync(lockPath)).toBe(true);
expect(fs.existsSync(`${standaloneDir}.deferred`)).toBe(true);
});
it('cleans a stale deferred marker before taking over a dead lock', () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
const lockPath = path.join(tempDir, '.qwen-update.lock');
fs.writeFileSync(lockPath, '999999999');
fs.writeFileSync(`${standaloneDir}.deferred`, '999999998');
expect(acquireLock(lockPath, standaloneDir)).toBe(true);
expect(fs.existsSync(`${standaloneDir}.deferred`)).toBe(false);
expect(fs.readFileSync(lockPath, 'utf-8')).toBe(String(process.pid));
});
it('cleans an unparseable deferred marker before taking over a dead lock', () => {
const standaloneDir = path.join(tempDir, 'qwen-code');
const lockPath = path.join(tempDir, '.qwen-update.lock');
fs.writeFileSync(lockPath, '999999999');
fs.writeFileSync(`${standaloneDir}.deferred`, 'not-a-pid');
expect(acquireLock(lockPath, standaloneDir)).toBe(true);
expect(fs.existsSync(`${standaloneDir}.deferred`)).toBe(false);
expect(fs.readFileSync(lockPath, 'utf-8')).toBe(String(process.pid));
});
});
describe.skipIf(process.platform === 'win32')('ensurePathInShellRc', () => {
it('appends PATH export to zshrc when SHELL is zsh', () => {
const binDir = path.join(tempDir, 'bin');
const zshrc = path.join(tempDir, '.zshrc');
fs.writeFileSync(zshrc, '# existing config\n');
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/bin/zsh';
process.env['HOME'] = tempDir;
try {
ensurePathInShellRc(binDir);
const content = fs.readFileSync(zshrc, 'utf-8');
expect(content).toContain('# Qwen Code PATH block begin');
expect(content).toContain('# Qwen Code PATH block end');
// Uses single-quoted paths matching install-qwen-standalone.sh shell_quote
expect(content).toContain(`export PATH='${binDir}':$PATH`);
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
it('skips if block markers already in rc file', () => {
const binDir = path.join(tempDir, 'bin');
const zshrc = path.join(tempDir, '.zshrc');
fs.writeFileSync(
zshrc,
`# Qwen Code PATH block begin\nexport PATH='${binDir}':$PATH\n# Qwen Code PATH block end\n`,
);
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/bin/zsh';
process.env['HOME'] = tempDir;
try {
ensurePathInShellRc(binDir);
const content = fs.readFileSync(zshrc, 'utf-8');
const matches = content.match(/# Qwen Code PATH block begin/g);
expect(matches).toHaveLength(1);
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
it('skips if legacy marker already in rc file', () => {
const binDir = path.join(tempDir, 'bin');
const zshrc = path.join(tempDir, '.zshrc');
fs.writeFileSync(
zshrc,
`# Added by Qwen Code standalone installer\nexport PATH="${binDir}:$PATH"\n`,
);
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/bin/zsh';
process.env['HOME'] = tempDir;
try {
ensurePathInShellRc(binDir);
const content = fs.readFileSync(zshrc, 'utf-8');
const matches = content.match(/export PATH/g);
expect(matches).toHaveLength(1);
expect(content).not.toContain('# Qwen Code PATH block begin');
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
it('uses .bashrc before .bash_profile for bash shells', () => {
const binDir = path.join(tempDir, 'bin');
const bashrc = path.join(tempDir, '.bashrc');
const profile = path.join(tempDir, '.bash_profile');
fs.writeFileSync(bashrc, '# bashrc\n');
fs.writeFileSync(profile, '# profile\n');
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/bin/bash';
process.env['HOME'] = tempDir;
try {
ensurePathInShellRc(binDir);
expect(fs.readFileSync(bashrc, 'utf-8')).toContain(
'# Qwen Code PATH block begin',
);
expect(fs.readFileSync(profile, 'utf-8')).toBe('# profile\n');
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
it('falls back to .bash_profile for bash when .bashrc is absent', () => {
const binDir = path.join(tempDir, 'bin');
const profile = path.join(tempDir, '.bash_profile');
fs.writeFileSync(profile, '# profile\n');
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/bin/bash';
process.env['HOME'] = tempDir;
try {
ensurePathInShellRc(binDir);
expect(fs.readFileSync(profile, 'utf-8')).toContain(
'# Qwen Code PATH block begin',
);
expect(fs.existsSync(path.join(tempDir, '.bashrc'))).toBe(false);
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
it('appends set -gx PATH for fish shell (matching install script)', () => {
const binDir = path.join(tempDir, 'bin');
const fishDir = path.join(tempDir, '.config', 'fish');
const fishConfig = path.join(fishDir, 'config.fish');
fs.mkdirSync(fishDir, { recursive: true });
fs.writeFileSync(fishConfig, '# existing config\n');
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/usr/bin/fish';
process.env['HOME'] = tempDir;
try {
ensurePathInShellRc(binDir);
const content = fs.readFileSync(fishConfig, 'utf-8');
// Matches install-qwen-standalone.sh's maybe_update_shell_path fish branch
expect(content).toContain('set -gx PATH');
expect(content).toContain('# Qwen Code PATH block begin');
expect(content).toContain('# Qwen Code PATH block end');
expect(content).toContain(binDir);
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
it('escapes single quotes in fish PATH entries', () => {
const binDir = path.join(tempDir, "o'brien", 'bin');
const fishDir = path.join(tempDir, '.config', 'fish');
const fishConfig = path.join(fishDir, 'config.fish');
fs.mkdirSync(fishDir, { recursive: true });
fs.writeFileSync(fishConfig, '# existing config\n');
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/usr/bin/fish';
process.env['HOME'] = tempDir;
try {
ensurePathInShellRc(binDir);
const content = fs.readFileSync(fishConfig, 'utf-8');
expect(content).toContain("set -gx PATH '");
expect(content).toContain("o'\\''brien");
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
it('creates fish config parent directories before appending PATH', () => {
const binDir = path.join(tempDir, 'bin');
const fishConfig = path.join(tempDir, '.config', 'fish', 'config.fish');
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/usr/bin/fish';
process.env['HOME'] = tempDir;
try {
expect(fs.existsSync(path.dirname(fishConfig))).toBe(false);
ensurePathInShellRc(binDir);
const content = fs.readFileSync(fishConfig, 'utf-8');
expect(content).toContain('set -gx PATH');
expect(content).toContain(binDir);
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
it('allows shell metacharacters in single-quoted PATH entries', () => {
const binDir = path.join(tempDir, 'bin$(evil)');
const zshrc = path.join(tempDir, '.zshrc');
fs.writeFileSync(zshrc, '# existing config\n');
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/bin/zsh';
process.env['HOME'] = tempDir;
try {
ensurePathInShellRc(binDir);
expect(fs.readFileSync(zshrc, 'utf-8')).toContain(
`export PATH='${binDir}':$PATH`,
);
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
it('rejects binDir with newlines', () => {
const binDir = path.join(tempDir, 'bin\nevil');
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/bin/zsh';
process.env['HOME'] = tempDir;
try {
expect(() => ensurePathInShellRc(binDir)).toThrow(
'unsafe for shell embedding',
);
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
it('rejects binDir with null bytes', () => {
const binDir = path.join(tempDir, 'bin\0evil');
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/bin/zsh';
process.env['HOME'] = tempDir;
try {
expect(() => ensurePathInShellRc(binDir)).toThrow(
'unsafe for shell embedding',
);
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
it('rejects binDir with carriage returns', () => {
const binDir = path.join(tempDir, 'bin\revil');
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/bin/zsh';
process.env['HOME'] = tempDir;
try {
expect(() => ensurePathInShellRc(binDir)).toThrow(
'unsafe for shell embedding',
);
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
it('does nothing for unknown shells', () => {
const binDir = path.join(tempDir, 'bin');
const origShell = process.env['SHELL'];
const origHome = process.env['HOME'];
process.env['SHELL'] = '/bin/csh';
process.env['HOME'] = tempDir;
try {
ensurePathInShellRc(binDir);
// No rc file should be created
expect(
fs.readdirSync(tempDir).filter((f) => f.startsWith('.')),
).toHaveLength(0);
} finally {
process.env['SHELL'] = origShell;
process.env['HOME'] = origHome;
}
});
});
});