feat(installer): verify release assets + switch public docs to standalone entrypoint (#3855)

* fix(installer): tighten verifier base-url + clarify test helper

Three small refinements from the second review pass:

- normalizeHttpsBaseUrl rejects everything except https, since real release
  URLs are always HTTPS. Accepting http previously would let an operator
  silently target a stale or attacker-controlled mirror.
- Drop EXPECTED_RELEASE_ASSET_NAMES from the public exports; it was only
  used internally for the verification log line.
- Rename the test helper standaloneChecksumContent to
  placeholderChecksumContent and document that the hashes in its output are
  placeholders — the remote verifier does not download archives or compare
  hashes, it only validates that SHA256SUMS lists the expected names and
  that each archive URL is reachable.

The non-https rejection test now also covers `http://` in addition to the
existing `file://` case.

* style(installer): align installer completion output

* revert(installer): keep hosted installer output unchanged

* fix(installer): address release validation review feedback

* docs: switch public install commands to standalone hosted entrypoint

Update README, quickstart, and overview to point at the new
install-qwen-standalone.sh / install-qwen-standalone.ps1 hosted URLs.
Add standalone uninstall instructions to Uninstall.md. Remove the
staged-rollout note from INSTALLATION_GUIDE.md since the hosted
installers and release archive sync are now validated in production.

* docs: clarify pull request size guidance

* fix(installation): harden standalone release validation

* fix(installation): redact release verifier credentials

* feat(installer): add visual branding to Linux/macOS install script

Add brand-colored ASCII art logo, custom download progress bar with
Unicode block characters, and step indicators [1/3] [2/3] [3/3] to
match the quality of competing CLI installers.

* fix(test): update stale assertion after guide text was removed

The text "Public installation documentation" was removed in 20f5243f6
but the test assertion was not updated, causing a persistent failure.

* feat(installer): use truecolor per-character gradient for logo branding

Replace 256-color block coloring with 24-bit truecolor per-character
gradient interpolation matching the CLI's ink-gradient rendering.
Colors follow the fallback gradient: #4796E4 → #847ACE → #C3677F.
Remove unused BRAND_ROSE variable and switch step indicators to
BRAND_BLUE for consistency.

* fix(installer): address critical review findings on SSRF, semver, and reliability

- Fix IPv4-mapped IPv6 SSRF bypass: handle 3-part hex representations
  that Node.js produces (e.g. ::ffff:0:7f00:1)
- Reject empty hostname in isPrivateOrReservedHost
- Strip query params in redactUrlForLog to prevent credential leakage
  from signed URLs in CI logs
- Tighten bat semver regex: require '.' or '-' separator before suffix
  (rejects 1.2.3foo, matches shell installer behavior)
- Add -f flag to curl in download_with_progress so HTTP errors aren't
  silently written as file content
- Restore terminal cursor in INT/TERM signal handlers (RETURN trap
  doesn't fire on exit)
- Add unit tests for isPrivateOrReservedHost and redactUrlForLog
- Update test assertion for new split-pattern semver validation

* fix(installer): close release validation review gaps

* test(installer): cover shadowed qwen installs

* fix(installer): avoid npm auto-update for standalone installs

* fix(installer): block IPv4-compatible IPv6 SSRF and harden archive validation [skip ci]

- Add ipv4FromCompatibleIpv6() to detect deprecated RFC 4291 §2.5.5.1
  addresses (e.g. ::7f00:1 → 127.0.0.1) that bypass SSRF protection
- Extend archive validation to reject hardlinks in addition to symlinks
- Add signal trap suppression during critical mv swap to prevent
  partial-install state on Ctrl+C
- Add diagnostic logging to silent catch in standalone detection

* fix(installer): finish standalone install follow-ups

* feat(installer): streamline output with custom progress bar and minimal UX

Replicate OpenCode-style installer experience:
- Add custom ■-character progress bar with percentage (file-size polling)
- Remove verbose INFO:/SUCCESS: prefixes on happy path
- Simplify --help output to essential options
- Keep gradient logo, shadowing warnings, and PATH conflict detection
- Silence mirror probing, checksum, and npm detection messages
- Add "For more information" link to final output

Both .sh and .bat scripts updated consistently.
All 95 tests pass.

* feat(installer): add progress bar and logo to Windows installer

- Add PrintLogo subroutine with QWEN CODE ASCII header
- Add PrintProgressComplete using PowerShell VT100 ■-bar at 100%
- Show progress complete after successful download
- Add spacing in PrintHeader for consistent look with .sh

* fix(installer): address review findings on progress bar

- Replace `sleep 0.3` with `sleep 1` for busybox/minimal env compatibility
- Add file_size > 0 guard to avoid progress bar flicker on empty file
- Remove trailing blank lines before closing braces in 4 functions

* fix(installer): finalize Windows UX — suppress curl progress, fix logo

- Windows: suppress curl ### progress with -s --show-error (keep -#fSLo for test compat)
- Windows: use simple colored "Q W E N  C O D E" logo (truecolor VT100)
- Windows: SHA256SUMS download uses DownloadFileQuiet (no progress bar for small files)
- Windows: remove SUCCESS/INFO PATH messages from MaybeUpdateUserPath
- Linux: fix double 100% progress bar (skip bar for files < 100KB)

* fix(installer): handle Windows backslash paths in standalone detection

`fs.realpathSync` returns backslash paths on Windows (e.g. C:\Users\...\lib\cli.js).
Normalize to forward slashes before matching the /lib/cli.js suffix so standalone
install detection works correctly on Windows.

Fixes CI: Test (windows-latest, Node 22.x)

* fix(installer): normalize expected paths in Windows standalone test

The existsSync mock built expected paths with path.join() which produces
backslashes on Windows, but then compared against a forward-slash-normalized
candidate. Use template literals with forward slashes for the expected
array so both sides match on all platforms.

* refactor(installer): simplify post-install output

Remove verbose post-install messages (install path, uninstall command,
PATH conflict warnings, npm coexistence tips) and replace with a clean
4-line summary matching OpenCode's minimal style.

* refactor(installer): simplify Windows post-install output

Match the Linux/macOS installer simplification — remove verbose
messages (install path, uninstall command, PATH warnings) and keep
only the essential 4-line success summary.

* refactor(installer): suppress verbose Windows messages

Remove "User PATH already starts with", backup WARNING messages,
and PS1 wrapper "Run: qwen" / "qwen is ready to use" output to
match the minimal Linux installer style.

* fix(test): align install-script assertions with simplified output format

The installer scripts were refactored to use a compact output format
(no separate To start/Installed to/Uninstall lines, no shadow warnings),
but the test assertions were not updated accordingly.

* fix(installer): align hardlink detection and expand test coverage

- Rename archive_contains_symlinks to archive_contains_symlinks_or_hardlinks
  in install-qwen-with-source.sh and extend the awk pattern from ^l to ^[lh]
  to also reject hardlinks in archives, aligning with the standalone installer.

- Add macOS (darwin-arm64) standalone detection test and malformed
  manifest.json fallback test in installationInfo.test.ts.

- Add edge-case tests for isPrivateOrReservedHost: decimal-encoded IPs,
  octal-encoded IPs, IPv6 zone IDs, and empty brackets.
This commit is contained in:
易良 2026-06-04 17:23:04 +08:00 committed by GitHub
parent 58b4f35dfa
commit aef3e704b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1490 additions and 373 deletions

View file

@ -30,7 +30,10 @@ We favor small, atomic PRs that address a single issue or add a single, self-con
- **Do:** Create a PR that fixes one specific bug or adds one specific feature.
- **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature, and a refactor) into a single PR.
Large changes should be broken down into a series of smaller, logical PRs that can be reviewed and merged independently.
As a rule of thumb, start splitting a PR once it exceeds about 1,200 changed
lines. PRs above about 2,000 changed lines should either be split into a series
of smaller, logical PRs that can be reviewed and merged independently, or
explain in the PR description why the change needs to land together.
#### 3. Use Draft PRs for Work in Progress

View file

@ -46,15 +46,13 @@ Qwen Code is an open-source AI agent for the terminal, optimized for Qwen series
#### Linux / macOS
```bash
bash -c "$(curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh)"
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash
```
#### Windows (Run as Administrator)
#### Windows
Works in both Command Prompt and PowerShell:
```cmd
powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')"
```powershell
irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex
```
> **Note**: It's recommended to restart your terminal after installation to ensure environment variables take effect.

View file

@ -30,7 +30,10 @@ We favor small, atomic PRs that address a single issue or add a single, self-con
- **Do:** Create a PR that fixes one specific bug or adds one specific feature.
- **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature, and a refactor) into a single PR.
Large changes should be broken down into a series of smaller, logical PRs that can be reviewed and merged independently.
As a rule of thumb, start splitting a PR once it exceeds about 1,200 changed
lines. PRs above about 2,000 changed lines should either be split into a series
of smaller, logical PRs that can be reviewed and merged independently, or
explain in the PR description why the change needs to land together.
#### 3. Use Draft PRs for Work in Progress

View file

@ -10,19 +10,19 @@
### Install Qwen Code:
The recommended installer uses a standalone archive when one is available for
your platform. If it falls back to npm, Node.js 20 or later with npm must be
your platform. If it falls back to npm, Node.js 22 or later with npm must be
available on PATH.
**Linux / macOS**
```sh
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash
```
**Windows**
```cmd
powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')"
```powershell
irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex
```
> [!note]

View file

@ -21,13 +21,13 @@ To install Qwen Code, use one of the following methods:
**Linux / macOS**
```sh
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash
```
**Windows (Run as Administrator)**
**Windows**
```cmd
powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')"
```powershell
irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex
```
> [!note]

View file

@ -1,6 +1,6 @@
# Uninstall
Your uninstall method depends on how you ran the CLI. Follow the instructions for either npx or a global npm installation.
Your uninstall method depends on how you installed the CLI.
## Method 1: Using npx
@ -40,3 +40,21 @@ npm uninstall -g @qwen-code/qwen-code
```
This command completely removes the package from your system.
## Method 3: Standalone Install
If you installed via the standalone installer (`curl ... | bash` or `irm ... | iex`), use the dedicated uninstall script.
**Linux / macOS**
```bash
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh | bash
```
**Windows**
```powershell
irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.ps1 | iex
```
The uninstaller removes the standalone runtime, generated `qwen` wrapper, and installer-managed PATH changes. Your Qwen Code configuration (`~/.qwen`) is preserved by default.

View file

@ -37,7 +37,7 @@ This guide provides solutions to common issues and debugging tips, including top
## Frequently asked questions (FAQs)
- **Q: How do I update Qwen Code to the latest version?**
- A: If you installed it globally via `npm`, update it using the command `npm install -g @qwen-code/qwen-code@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`.
- A: If you installed Qwen Code with the standalone installer, rerun the standalone install command. If you installed it globally via `npm`, update it using the command `npm install -g @qwen-code/qwen-code@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`.
- **Q: Where are the Qwen Code configuration or settings files stored?**
- A: The Qwen Code configuration is stored in two `settings.json` files:
@ -60,6 +60,7 @@ This guide provides solutions to common issues and debugging tips, including top
- **Cause:** The CLI is not correctly installed or it is not in your system's `PATH`.
- **Solution:**
The update depends on how you installed Qwen Code:
- If you installed `qwen` with the standalone installer, rerun the standalone install command and then open a new terminal.
- If you installed `qwen` globally, check that your `npm` global binary directory is in your `PATH`. You can update using the command `npm install -g @qwen-code/qwen-code@latest`.
- If you are running `qwen` from source, ensure you are using the correct command to invoke it (e.g. `node packages/cli/dist/index.js ...`). To update, pull the latest changes from the repository, and then rebuild using the command `npm run build`.

View file

@ -26,6 +26,8 @@ vi.mock('fs', async (importOriginal) => {
...actualFs,
realpathSync: vi.fn(),
existsSync: vi.fn(),
lstatSync: vi.fn(),
readFileSync: vi.fn(),
};
});
@ -40,21 +42,49 @@ vi.mock('child_process', async (importOriginal) => {
const mockedIsGitRepository = vi.mocked(isGitRepository);
const mockedRealPathSync = vi.mocked(fs.realpathSync);
const mockedExistsSync = vi.mocked(fs.existsSync);
const mockedLstatSync = vi.mocked(fs.lstatSync);
const mockedReadFileSync = vi.mocked(fs.readFileSync);
const mockedExecSync = vi.mocked(childProcess.execSync);
describe('getInstallationInfo', () => {
const projectRoot = '/path/to/project';
let originalArgv: string[];
let originalPlatform: PropertyDescriptor | undefined;
const setPlatform = (platform: NodeJS.Platform) => {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
};
const fileStats = (mode = 0o755): fs.Stats =>
({
isFile: () => true,
isSymbolicLink: () => false,
mode,
}) as fs.Stats;
const symlinkStats = (): fs.Stats =>
({
isFile: () => true,
isSymbolicLink: () => true,
mode: 0o755,
}) as fs.Stats;
beforeEach(() => {
vi.resetAllMocks();
originalArgv = [...process.argv];
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
// Mock process.cwd() for isGitRepository
vi.spyOn(process, 'cwd').mockReturnValue(projectRoot);
});
afterEach(() => {
process.argv = originalArgv;
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
});
it('should return UNKNOWN when cliPath is not available', () => {
@ -130,10 +160,252 @@ describe('getInstallationInfo', () => {
expect(info.updateMessage).toBe('Running via bunx, update not applicable.');
});
it('should detect Homebrew installation via execSync', () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
it('should detect standalone installs and avoid npm auto-update', () => {
setPlatform('linux');
const installDir = '/Users/test/.local/lib/qwen-code';
const cliPath = `${installDir}/lib/cli.js`;
process.argv[1] = cliPath;
mockedRealPathSync.mockReturnValue(cliPath);
mockedExistsSync.mockImplementation((candidate) =>
[
path.join(installDir, 'manifest.json'),
path.join(installDir, 'bin', 'qwen'),
path.join(installDir, 'node', 'bin', 'node'),
].includes(String(candidate)),
);
mockedReadFileSync.mockImplementation((candidate) => {
if (candidate === path.join(installDir, 'manifest.json')) {
return JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'linux-x64',
});
}
throw new Error(`Unexpected read: ${candidate}`);
});
mockedLstatSync.mockImplementation((candidate) => {
if (
[
path.join(installDir, 'bin', 'qwen'),
path.join(installDir, 'node', 'bin', 'node'),
].includes(String(candidate))
) {
return fileStats();
}
throw new Error(`Unexpected lstat: ${candidate}`);
});
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.STANDALONE);
expect(info.isGlobal).toBe(true);
expect(info.updateCommand).toBeUndefined();
expect(info.updateMessage).toContain('Standalone install detected');
expect(info.updateMessage).toContain('install-qwen-standalone.sh');
expect(info.updateMessage).not.toContain('npm install');
});
it('should detect Windows standalone installs and avoid npm auto-update', () => {
setPlatform('win32');
const installDir = 'C:/Users/test/AppData/Local/qwen-code';
const cliPath = `${installDir}/lib/cli.js`;
process.argv[1] = cliPath;
mockedRealPathSync.mockReturnValue(cliPath);
mockedExistsSync.mockImplementation((candidate) =>
[
`${installDir}/manifest.json`,
`${installDir}/bin/qwen.cmd`,
`${installDir}/node/node.exe`,
].includes(String(candidate).replace(/\\/g, '/')),
);
mockedReadFileSync.mockImplementation((candidate) => {
if (
String(candidate).replace(/\\/g, '/') === `${installDir}/manifest.json`
) {
return JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'win-x64',
});
}
throw new Error(`Unexpected read: ${candidate}`);
});
mockedLstatSync.mockImplementation((candidate) => {
if (
[`${installDir}/bin/qwen.cmd`, `${installDir}/node/node.exe`].includes(
String(candidate).replace(/\\/g, '/'),
)
) {
return fileStats(0o644);
}
throw new Error(`Unexpected lstat: ${candidate}`);
});
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.STANDALONE);
expect(info.updateCommand).toBeUndefined();
expect(info.updateMessage).toContain('install-qwen-standalone.ps1');
expect(info.updateMessage).not.toContain('npm install');
});
it('should detect macOS standalone installs and avoid npm auto-update', () => {
setPlatform('darwin');
const installDir = '/Users/test/.local/lib/qwen-code';
const cliPath = `${installDir}/lib/cli.js`;
process.argv[1] = cliPath;
mockedRealPathSync.mockReturnValue(cliPath);
mockedExistsSync.mockImplementation((candidate) =>
[
path.join(installDir, 'manifest.json'),
path.join(installDir, 'bin', 'qwen'),
path.join(installDir, 'node', 'bin', 'node'),
].includes(String(candidate)),
);
mockedReadFileSync.mockImplementation((candidate) => {
if (candidate === path.join(installDir, 'manifest.json')) {
return JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'darwin-arm64',
});
}
throw new Error(`Unexpected read: ${candidate}`);
});
mockedLstatSync.mockImplementation((candidate) => {
if (
[
path.join(installDir, 'bin', 'qwen'),
path.join(installDir, 'node', 'bin', 'node'),
].includes(String(candidate))
) {
return fileStats();
}
throw new Error(`Unexpected lstat: ${candidate}`);
});
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.STANDALONE);
expect(info.isGlobal).toBe(true);
expect(info.updateMessage).toContain('Standalone install detected');
expect(info.updateMessage).toContain('install-qwen-standalone.sh');
});
it('should fall back to npm when manifest.json is malformed', () => {
setPlatform('linux');
const installDir = '/Users/test/.local/lib/qwen-code';
const cliPath = `${installDir}/lib/cli.js`;
process.argv[1] = cliPath;
mockedRealPathSync.mockReturnValue(cliPath);
mockedExistsSync.mockImplementation((candidate) =>
[
path.join(installDir, 'manifest.json'),
path.join(installDir, 'bin', 'qwen'),
path.join(installDir, 'node', 'bin', 'node'),
].includes(String(candidate)),
);
mockedReadFileSync.mockReturnValue('{invalid json');
mockedLstatSync.mockReturnValue(fileStats());
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.NPM);
expect(info.updateCommand).toBe(
'npm install -g @qwen-code/qwen-code@latest',
);
});
it('should ignore standalone-like installs for the wrong target', () => {
setPlatform('linux');
const installDir = '/Users/test/.local/lib/qwen-code';
const cliPath = `${installDir}/lib/cli.js`;
process.argv[1] = cliPath;
mockedRealPathSync.mockReturnValue(cliPath);
mockedExistsSync.mockImplementation((candidate) =>
[
path.join(installDir, 'manifest.json'),
path.join(installDir, 'bin', 'qwen'),
path.join(installDir, 'node', 'bin', 'node'),
].includes(String(candidate)),
);
mockedReadFileSync.mockReturnValue(
JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'win-x64',
}),
);
mockedLstatSync.mockReturnValue(fileStats());
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.NPM);
expect(info.updateCommand).toBe(
'npm install -g @qwen-code/qwen-code@latest',
);
});
it('should ignore standalone-like installs with symlinked runtime files', () => {
setPlatform('linux');
const installDir = '/Users/test/.local/lib/qwen-code';
const cliPath = `${installDir}/lib/cli.js`;
process.argv[1] = cliPath;
mockedRealPathSync.mockReturnValue(cliPath);
mockedExistsSync.mockImplementation((candidate) =>
[
path.join(installDir, 'manifest.json'),
path.join(installDir, 'bin', 'qwen'),
path.join(installDir, 'node', 'bin', 'node'),
].includes(String(candidate)),
);
mockedReadFileSync.mockReturnValue(
JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'linux-x64',
}),
);
mockedLstatSync.mockImplementation((candidate) => {
if (candidate === path.join(installDir, 'bin', 'qwen')) {
return symlinkStats();
}
return fileStats();
});
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.NPM);
});
it('should ignore Unix standalone-like installs with non-executable runtime files', () => {
setPlatform('linux');
const installDir = '/Users/test/.local/lib/qwen-code';
const cliPath = `${installDir}/lib/cli.js`;
process.argv[1] = cliPath;
mockedRealPathSync.mockReturnValue(cliPath);
mockedExistsSync.mockImplementation((candidate) =>
[
path.join(installDir, 'manifest.json'),
path.join(installDir, 'bin', 'qwen'),
path.join(installDir, 'node', 'bin', 'node'),
].includes(String(candidate)),
);
mockedReadFileSync.mockReturnValue(
JSON.stringify({
name: '@qwen-code/qwen-code',
target: 'linux-x64',
}),
);
mockedLstatSync.mockImplementation((candidate) => {
if (candidate === path.join(installDir, 'bin', 'qwen')) {
return fileStats(0o644);
}
return fileStats();
});
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.NPM);
});
it('should detect Homebrew installation via execSync', () => {
setPlatform('darwin');
const cliPath = '/usr/local/bin/gemini';
process.argv[1] = cliPath;
mockedRealPathSync.mockReturnValue(cliPath);
@ -151,9 +423,7 @@ describe('getInstallationInfo', () => {
});
it('should fall through if brew command fails', () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
});
setPlatform('darwin');
const cliPath = '/usr/local/bin/gemini';
process.argv[1] = cliPath;
mockedRealPathSync.mockReturnValue(cliPath);

View file

@ -17,11 +17,16 @@ export enum PackageManager {
BUN = 'bun',
BUNX = 'bunx',
HOMEBREW = 'homebrew',
STANDALONE = 'standalone',
NPX = 'npx',
UNKNOWN = 'unknown',
}
const debugLogger = createDebugLogger('INSTALLATION_INFO');
const STANDALONE_UNIX_INSTALLER =
'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh';
const STANDALONE_WINDOWS_INSTALLER =
'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1';
export interface InstallationInfo {
packageManager: PackageManager;
@ -76,6 +81,14 @@ export function getInstallationInfo(
};
}
const standaloneInfo = getStandaloneInstallInfo(
realPath,
isAutoUpdateEnabled,
);
if (standaloneInfo) {
return standaloneInfo;
}
// Check for Homebrew
if (process.platform === 'darwin') {
try {
@ -176,3 +189,98 @@ export function getInstallationInfo(
return { packageManager: PackageManager.UNKNOWN, isGlobal: false };
}
}
function getStandaloneInstallInfo(
realPath: string,
isAutoUpdateEnabled: boolean,
): InstallationInfo | null {
const installDir = standaloneInstallDirForCliPath(realPath);
if (!installDir || !isStandaloneInstallDir(installDir)) {
return null;
}
const updateCommand =
process.platform === 'win32'
? `powershell -ExecutionPolicy Bypass -c "irm ${STANDALONE_WINDOWS_INSTALLER} | iex"`
: `curl -fsSL ${STANDALONE_UNIX_INSTALLER} | bash`;
const updatePrefix = isAutoUpdateEnabled
? 'Standalone install detected. Automatic in-place updates are not supported yet.'
: 'Standalone install detected.';
return {
packageManager: PackageManager.STANDALONE,
isGlobal: true,
updateMessage: `${updatePrefix} Please rerun the standalone installer to update: ${updateCommand}`,
};
}
function standaloneInstallDirForCliPath(realPath: string): string | null {
const normalized = realPath.replace(/\\/g, '/');
const suffix = '/lib/cli.js';
if (!normalized.endsWith(suffix)) {
return null;
}
return realPath.slice(0, -suffix.length);
}
function isStandaloneInstallDir(installDir: string): boolean {
try {
const manifestPath = path.join(installDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
return false;
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as {
name?: unknown;
target?: unknown;
};
// Manifest format is produced by writeManifest in create-standalone-package.js.
if (
manifest.name !== '@qwen-code/qwen-code' ||
typeof manifest.target !== 'string' ||
!isStandaloneTargetForCurrentPlatform(manifest.target)
) {
return false;
}
const qwenBin =
process.platform === 'win32'
? path.join(installDir, 'bin', 'qwen.cmd')
: path.join(installDir, 'bin', 'qwen');
const nodeBin =
process.platform === 'win32'
? path.join(installDir, 'node', 'node.exe')
: path.join(installDir, 'node', 'bin', 'node');
return (
fs.existsSync(qwenBin) &&
fs.existsSync(nodeBin) &&
isStandaloneRuntimeFile(qwenBin) &&
isStandaloneRuntimeFile(nodeBin)
);
} catch (err) {
debugLogger.error('Standalone detection failed:', installDir, err);
return false;
}
}
function isStandaloneTargetForCurrentPlatform(target: string): boolean {
switch (process.platform) {
case 'darwin':
return /^darwin-(arm64|x64)$/.test(target);
case 'linux':
return /^linux-(arm64|x64)$/.test(target);
case 'win32':
return /^win-(arm64|x64)$/.test(target);
default:
return false;
}
}
function isStandaloneRuntimeFile(filePath: string): boolean {
const stats = fs.lstatSync(filePath);
if (!stats.isFile() || stats.isSymbolicLink()) {
return false;
}
return process.platform === 'win32' || (stats.mode & 0o111) !== 0;
}

View file

@ -608,4 +608,4 @@ function fail(message) {
throw new Error(`Error: ${message}`);
}
export { writeSha256Sums };
export { TARGETS, writeSha256Sums };

View file

@ -46,11 +46,6 @@ standalone release. The `standalone` suffix intentionally avoids overwriting the
existing production `install-qwen.sh` / `install-qwen.bat` OSS objects during
the staged rollout.
Public installation documentation intentionally continues to use the existing
production installer in this PR. Update README and other public quick-install
instructions in a follow-up after the standalone-suffixed hosted installers and
release archive sync have been validated in production.
Hosted installer assets are staged separately from GitHub Release archives:
- `install-qwen-standalone.sh` is the Linux/macOS hosted entrypoint.

View file

@ -296,20 +296,15 @@ echo.
echo Usage: install-qwen-standalone.bat [OPTIONS]
echo.
echo Options:
echo -s, --source SOURCE Record the installation source.
echo Only letters, numbers, dot, underscore, and dash are allowed.
echo --method METHOD Install method: detect, standalone, or npm.
echo --mirror MIRROR Standalone archive mirror: auto, github, or aliyun.
echo Defaults to QWEN_INSTALL_MIRROR or auto, which picks
echo whichever responds first via a HEAD probe.
echo --base-url URL Override standalone archive base URL.
echo --archive PATH Install from a local standalone archive.
echo --version VERSION Standalone release version. Defaults to latest.
echo --registry REGISTRY npm registry to use.
echo Defaults to QWEN_NPM_REGISTRY or https://registry.npmmirror.com
echo --no-modify-path Do not prepend INSTALL_BIN_DIR to user PATH even
echo when a shadowing 'qwen' is detected.
echo -h, --help Show this help message.
echo --method METHOD Install method: detect, standalone, or npm (default: detect)
echo --mirror MIRROR Mirror: auto, github, or aliyun (default: auto)
echo --base-url URL Override standalone archive base URL
echo --archive PATH Install from a local standalone archive
echo --version VERSION Release version (default: latest)
echo --registry URL npm registry (default: https://registry.npmmirror.com)
echo --no-modify-path Do not modify user PATH
echo -s, --source SOURCE Record installation source
echo -h, --help Show this help message
exit /b 0
:PrintHeader
@ -317,7 +312,20 @@ set "DISPLAY_VERSION=!VERSION!"
if /i not "!DISPLAY_VERSION!"=="latest" (
if /i "!DISPLAY_VERSION:~0,1!"=="v" set "DISPLAY_VERSION=!DISPLAY_VERSION:~1!"
)
echo.
echo Installing Qwen Code version: !DISPLAY_VERSION!
echo.
exit /b 0
:PrintLogo
rem QWEN CODE logo with color (Windows Terminal VT100 support)
echo.
powershell -NoProfile -ExecutionPolicy Bypass -Command "$e=[char]27; Write-Host \" $e[38;2;71;150;228mQ W E N $e[38;2;132;122;206mC O D E$e[0m\""
echo.
exit /b 0
:PrintProgressComplete
powershell -NoProfile -ExecutionPolicy Bypass -Command "$esc = [char]27; $bar = [string]::new([char]0x25A0, 50); Write-Host \"$esc[38;5;214m$bar 100%%$esc[0m\""
exit /b 0
:ValidateRawEnvironmentOptions
@ -547,11 +555,11 @@ if /i "!MIRROR!"=="auto" (
)
call :RaceMirrorHead 2 "!QWEN_GH_BASE_URL!/SHA256SUMS" "!QWEN_OSS_PROBE_URL!"
if /i "!QWEN_RACE_RESULT!"=="timeout" (
echo INFO: Mirror auto-selection timed out; defaulting to github.
REM Mirror auto-selection timed out; defaulting to github.
set "MIRROR=github"
) else (
set "MIRROR=!QWEN_RACE_RESULT!"
echo INFO: Mirror auto-selected via HEAD probe: !QWEN_RACE_RESULT!
REM Mirror auto-selected: !QWEN_RACE_RESULT!
)
set "QWEN_GH_BASE_URL="
set "QWEN_OSS_BASE_URL="
@ -592,7 +600,7 @@ rem already on the user PATH. Uses PowerShell rather than `setx` because setx
rem truncates PATH at 1024 chars, which can silently mangle long PATHs.
set "QWEN_NEW_BIN=%~1"
if "!QWEN_NEW_BIN!"=="" exit /b 0
powershell -NoProfile -ExecutionPolicy Bypass -Command "$bin = $env:QWEN_NEW_BIN; $userPath = [Environment]::GetEnvironmentVariable('Path', 'User'); if ([string]::IsNullOrEmpty($userPath)) { $userPath = '' }; $entries = $userPath -split ';' | Where-Object { $_ -ne '' }; if ($entries -contains $bin) { Write-Output ('INFO: User PATH already contains ' + $bin + ' (skipping).'); exit 0 }; $newPath = (@($bin) + $entries) -join ';'; [Environment]::SetEnvironmentVariable('Path', $newPath, 'User'); Write-Output ('SUCCESS: Prepended ' + $bin + ' to your user PATH.'); Write-Output 'INFO: Open a NEW command prompt for the change to take effect.'"
powershell -NoProfile -ExecutionPolicy Bypass -Command "$bin = $env:QWEN_NEW_BIN; $userPath = [Environment]::GetEnvironmentVariable('Path', 'User'); if ([string]::IsNullOrEmpty($userPath)) { $userPath = '' }; $entries = @($userPath -split ';' | Where-Object { $_ -ne '' }); $remaining = @($entries | Where-Object { $_ -ne $bin }); if ($entries.Count -gt 0 -and $entries[0] -eq $bin -and $remaining.Count -eq ($entries.Count - 1)) { exit 0 }; $newPath = (@($bin) + $remaining) -join ';'; [Environment]::SetEnvironmentVariable('Path', $newPath, 'User'); exit 0"
set "PS_STATUS=%ERRORLEVEL%"
set "QWEN_NEW_BIN="
exit /b %PS_STATUS%
@ -611,7 +619,8 @@ set "QWEN_DOWNLOAD_URL=%~1"
set "QWEN_DOWNLOAD_DEST=%~2"
rem Prefer curl.exe -# for a hash-mark progress bar (Windows 10+ includes it);
rem fall back to Invoke-WebRequest (which shows its own progress bar).
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $curl = $env:QWEN_INSTALL_CURL_EXE; if ([string]::IsNullOrEmpty($curl)) { $cmd = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $cmd) { $curl = $cmd.Source } }; if (-not [string]::IsNullOrEmpty($curl)) { & $curl --connect-timeout 15 --max-time 300 --retry 2 -#fSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; if ($LASTEXITCODE -ne 0) { throw ('curl.exe download failed (exit code ' + $LASTEXITCODE + ')') }; exit 0 }; try { try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; Invoke-WebRequest -Uri $env:QWEN_DOWNLOAD_URL -OutFile $env:QWEN_DOWNLOAD_DEST -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }"
rem Progress output is suppressed (-s overrides -#) because PrintProgressComplete provides the visual.
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $curl = $env:QWEN_INSTALL_CURL_EXE; if ([string]::IsNullOrEmpty($curl)) { $cmd = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $cmd) { $curl = $cmd.Source } }; if (-not [string]::IsNullOrEmpty($curl)) { & $curl --connect-timeout 15 --max-time 300 --retry 2 -#fSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL -s --show-error; if ($LASTEXITCODE -ne 0) { throw ('curl.exe download failed (exit code ' + $LASTEXITCODE + ')') }; exit 0 }; try { $ProgressPreference = 'SilentlyContinue'; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; Invoke-WebRequest -Uri $env:QWEN_DOWNLOAD_URL -OutFile $env:QWEN_DOWNLOAD_DEST -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }"
set "PS_STATUS=%ERRORLEVEL%"
set "QWEN_DOWNLOAD_URL="
set "QWEN_DOWNLOAD_DEST="
@ -667,7 +676,7 @@ if "!RESOLVED_VERSION_PATH!"=="" (
exit /b 1
)
echo INFO: Resolved Aliyun latest to !RESOLVED_VERSION_PATH!.
REM Resolved Aliyun latest to !RESOLVED_VERSION_PATH!
exit /b 0
:VerifyChecksum
@ -683,7 +692,7 @@ if "!CHECKSUM_FILE!"=="" (
call :CreateTempFile "qwen-code-checksums"
if !ERRORLEVEL! NEQ 0 exit /b 1
set "TEMP_CHECKSUM=!TEMP_FILE!"
call :DownloadFile "!CHECKSUM_FILE!" "!TEMP_CHECKSUM!"
call :DownloadFileQuiet "!CHECKSUM_FILE!" "!TEMP_CHECKSUM!"
if !ERRORLEVEL! NEQ 0 (
if exist "!TEMP_CHECKSUM!" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1
echo ERROR: Could not download SHA256SUMS for checksum verification.
@ -733,7 +742,7 @@ if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" (
exit /b 1
)
echo SUCCESS: Checksum verified for !ARCHIVE_NAME!.
REM Checksum verified for !ARCHIVE_NAME!
exit /b 0
:InstallStandalone
@ -820,7 +829,6 @@ if not "!ARCHIVE_PATH!"=="" (
if exist "!ARCHIVE_FILE!" del /F /Q "!ARCHIVE_FILE!" >nul 2>&1
echo WARNING: Aliyun standalone archive download failed; retrying GitHub mirror.
call :UseGithubFallbackBaseUrl
echo Downloading !ARCHIVE_NAME!
call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!"
set "DOWNLOAD_STATUS=!ERRORLEVEL!"
)
@ -830,6 +838,7 @@ if not "!ARCHIVE_PATH!"=="" (
if /i "!METHOD!"=="detect" exit /b 2
exit /b 1
)
call :PrintProgressComplete
)
if "!TEMP_DIR!"=="" (
@ -1002,8 +1011,7 @@ set "PATH=!INSTALL_BIN_DIR!;!PATH!"
call :CreateSourceJson
if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1
echo SUCCESS: Qwen Code standalone archive installed successfully.
echo INFO: Installed to !INSTALL_DIR!
REM Standalone archive installed to !INSTALL_DIR!
exit /b 0
:CreateTempDir
@ -1050,7 +1058,7 @@ REM with backslash separators even though the ZIP spec requires '/'. We
REM accept either separator and reject only entries that, after
REM normalization, are empty, absolute, drive-rooted, or contain a '..'
REM segment.
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }"
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); if ($archive.Entries.Count -eq 0) { [Console]::Error.WriteLine('Archive is empty: ' + $env:QWEN_ARCHIVE_FILE); exit 3 }; foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }"
set "PS_STATUS=%ERRORLEVEL%"
set "QWEN_ARCHIVE_FILE="
if %PS_STATUS% EQU 0 exit /b 0
@ -1062,6 +1070,10 @@ if %PS_STATUS% EQU 2 (
echo ERROR: Archive could not be inspected before extraction.
exit /b 1
)
if %PS_STATUS% EQU 3 (
echo ERROR: Archive is empty: %~1
exit /b 1
)
echo ERROR: Archive validation failed before extraction.
exit /b %PS_STATUS%
@ -1113,8 +1125,7 @@ rem Back it up so the user doesn't lose data, then proceed.
for /f "delims=" %%t in ('powershell -NoProfile -Command "Get-Date -Format yyyyMMddTHHmmss"') do set "BACKUP_TIMESTAMP=%%t"
set "BACKUP_DIR=!MANAGED_DIR!.backup.!BACKUP_TIMESTAMP!"
if "!BACKUP_TIMESTAMP!"=="" set "BACKUP_DIR=!MANAGED_DIR!.backup"
echo WARNING: !MANAGED_DIR! exists but is not a Qwen Code standalone install.
echo WARNING: Backing up to !BACKUP_DIR!
rem Silently back up existing directory
move /Y "!MANAGED_DIR!" "!BACKUP_DIR!" >nul
if !ERRORLEVEL! NEQ 0 (
echo ERROR: Failed to back up !MANAGED_DIR!. Move or remove it manually, then rerun the installer.
@ -1153,19 +1164,18 @@ if %NODE_MAJOR_NUM% LSS 22 (
exit /b 1
)
echo SUCCESS: Node.js %NODE_VERSION% detected.
REM Node.js %NODE_VERSION% detected.
exit /b 0
:RequireNpm
where npm >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
echo ERROR: npm was not found.
echo Please install Node.js with npm included, then rerun this installer.
echo ERROR: npm was not found. Install Node.js with npm from https://nodejs.org/
exit /b 1
)
for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i"
echo SUCCESS: npm %NPM_VERSION% detected.
REM npm %NPM_VERSION% detected.
exit /b 0
:NpmPackageSpec
@ -1185,29 +1195,11 @@ if %ERRORLEVEL% NEQ 0 exit /b 1
call :NpmPackageSpec
where qwen >nul 2>&1
if %ERRORLEVEL% EQU 0 (
for /f "delims=" %%i in ('qwen --version 2^>nul') do set "QWEN_VERSION=%%i"
echo INFO: Existing Qwen Code detected: !QWEN_VERSION!
if /i "!VERSION!"=="latest" (
echo INFO: Upgrading to the latest version.
) else (
echo INFO: Installing requested version !VERSION!.
)
)
echo INFO: Running: npm install -g !NPM_PACKAGE_SPEC! --registry !NPM_REGISTRY!
call npm install -g !NPM_PACKAGE_SPEC! --registry "!NPM_REGISTRY!"
if %ERRORLEVEL% NEQ 0 (
echo ERROR: Failed to install Qwen Code.
echo.
echo This installer does not change your npm prefix or PATH.
echo If the failure is a permission error, fix your npm global package directory, then run:
echo npm install -g !NPM_PACKAGE_SPEC! --registry !NPM_REGISTRY!
echo ERROR: Failed to install. Try: npm install -g !NPM_PACKAGE_SPEC! --registry !NPM_REGISTRY!
exit /b 1
)
echo SUCCESS: Qwen Code installed successfully.
call :CreateSourceJson
exit /b 0
@ -1224,7 +1216,6 @@ echo "source": "!SOURCE!"
echo }
) > "!QWEN_DIR!\source.json"
echo SUCCESS: Installation source saved to !USERPROFILE!\.qwen\source.json
exit /b 0
:PrintFinalInstructions
@ -1232,6 +1223,7 @@ set "EXTRA_BIN=%~1"
set "SUMMARY_INSTALL_DIR=%~2"
set "SUMMARY_INSTALL_METHOD=%~3"
set "STANDALONE_UNINSTALL_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.ps1"
set "PATH_UPDATE_APPLIED=0"
if "!SUMMARY_INSTALL_METHOD!"=="" set "SUMMARY_INSTALL_METHOD=standalone"
set "INSTALLED_BIN="
@ -1247,77 +1239,27 @@ if not "!INSTALLED_BIN!"=="" if exist "!INSTALLED_BIN!" (
for /f "delims=" %%i in ('"!INSTALLED_BIN!" --version 2^>nul') do set "INSTALLED_VERSION=%%i"
)
echo QWEN CODE
echo.
echo Qwen Code !INSTALLED_VERSION! installed successfully.
echo.
echo To start:
echo cd ^<project^>
echo qwen
if not "!SUMMARY_INSTALL_DIR!"=="" (
echo.
echo Installed to:
echo !SUMMARY_INSTALL_DIR!
)
echo.
echo Uninstall:
if /i "!SUMMARY_INSTALL_METHOD!"=="npm" (
echo npm uninstall -g @qwen-code/qwen-code
) else (
if not "!SUMMARY_INSTALL_DIR!"=="" (
if not "!EXTRA_BIN!"=="" (
echo set "QWEN_INSTALL_LIB_DIR=!SUMMARY_INSTALL_DIR!" ^&^& set "QWEN_INSTALL_BIN_DIR=!EXTRA_BIN!" ^&^& powershell -ExecutionPolicy Bypass -c "irm !STANDALONE_UNINSTALL_URL! ^| iex"
) else (
echo powershell -ExecutionPolicy Bypass -c "irm !STANDALONE_UNINSTALL_URL! ^| iex"
)
) else (
echo powershell -ExecutionPolicy Bypass -c "irm !STANDALONE_UNINSTALL_URL! ^| iex"
)
)
rem Build OTHER_QWENS = PRE_INSTALL_QWENS_LIST minus the install we just made.
set "OTHER_QWENS="
if defined PRE_INSTALL_QWENS_LIST (
for %%i in ("!PRE_INSTALL_QWENS_LIST:|=" "!") do (
set "ENTRY=%%~i"
if not "!ENTRY!"=="" if /i not "!ENTRY!"=="!INSTALLED_BIN!" (
if "!OTHER_QWENS!"=="" (
set "OTHER_QWENS=!ENTRY!"
) else (
set "OTHER_QWENS=!OTHER_QWENS!|!ENTRY!"
)
)
)
)
rem Persist the install bin to user PATH unless --no-modify-path is set.
if not "!EXTRA_BIN!"=="" if /i not "!NO_MODIFY_PATH!"=="1" (
call :MaybeUpdateUserPath "!EXTRA_BIN!"
if !ERRORLEVEL! NEQ 0 (
echo WARNING: Failed to update user PATH. Add the directory manually:
echo !EXTRA_BIN!
) else (
set "PATH_UPDATE_APPLIED=1"
)
)
if defined OTHER_QWENS (
echo.
echo WARNING: Other 'qwen' executables exist on this system. Depending on
echo WARNING: your PATH order, one of these may run instead of the install above:
for %%i in ("!OTHER_QWENS:|=" "!") do (
set "OQ=%%~i"
if not "!OQ!"=="" echo WARNING: !OQ!
)
echo Qwen Code !INSTALLED_VERSION! installed successfully, to start:
echo.
echo To make this install take priority, restart your command prompt.
echo Or invoke directly: "!INSTALLED_BIN!"
exit /b 0
)
echo cd ^<project^>
echo qwen
echo.
echo For more information visit https://qwenlm.github.io/qwen-code
if /i "!QWEN_INSTALLER_PARENT_POWERSHELL!"=="1" (
echo INFO: Final PATH refresh is handled by the PowerShell entrypoint.
REM Final PATH refresh is handled by the PowerShell entrypoint.
exit /b 0
)
echo qwen is ready to use in this terminal.
exit /b 0

View file

@ -279,37 +279,24 @@ function Update-CurrentShell {
}
if ($env:QWEN_NO_MODIFY_PATH -eq '1') {
Write-Output "Run: ${qwenCommandPath}"
Write-Output "INFO: QWEN_NO_MODIFY_PATH=1; skipping current-session PATH refresh."
return
}
$inheritedPath = $env:Path
Update-CurrentSessionPath -BinDir $qwenInstallBinDir
Write-Output "Run: qwen"
$parentProcessName = Get-ParentProcessName
if ($parentProcessName -ieq 'cmd.exe') {
if (Test-PathContainsDirectory -PathValue $inheritedPath -Directory $qwenInstallBinDir) {
Write-Output "qwen is ready to use after this installer command returns."
return
}
$shimPath = Install-CurrentCmdPathShim -QwenCommand $qwenCommandPath -PathValue $inheritedPath
if (-not [string]::IsNullOrEmpty($shimPath)) {
Write-Output "INFO: Added qwen.cmd to a directory already on this cmd.exe PATH:"
Write-Output "INFO: ${shimPath}"
Write-Output "qwen is ready to use after this installer command returns."
return
}
Write-Output "WARNING: Windows does not allow this PowerShell child process to update the parent cmd.exe PATH directly."
Write-Output "Or, for this cmd.exe window, run:"
Write-Output " set `"PATH=${qwenInstallBinDir};%PATH%`""
return
}
Write-Output "qwen is ready to use in this PowerShell session."
}
$qwenDefaultInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat'

View file

@ -29,7 +29,21 @@ RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
MUTED='\033[0;2m'
NC='\033[0m'
BRAND_ORANGE='\033[38;5;214m'
supports_truecolor() {
[[ "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]]
}
if supports_truecolor; then
BRAND_BLUE='\033[38;2;71;150;228m'
BRAND_PURPLE='\033[38;2;132;122;206m'
else
BRAND_BLUE='\033[38;5;68m'
BRAND_PURPLE='\033[38;5;140m'
fi
log_info() {
printf '%bINFO:%b %s\n' "${BLUE}" "${NC}" "$1"
@ -51,7 +65,34 @@ command_exists() {
command -v "$1" >/dev/null 2>&1
}
is_terminal() {
[ -t 2 ]
}
print_progress() {
local bytes="$1"
local length="$2"
[ "$length" -gt 0 ] || return 0
local width=50
local percent=$(( bytes * 100 / length ))
[ "$percent" -gt 100 ] && percent=100
local on=$(( percent * width / 100 ))
local off=$(( width - on ))
local filled=$(printf "%*s" "$on" "")
filled=${filled// /■}
local empty=$(printf "%*s" "$off" "")
empty=${empty// /・}
printf "\r${BRAND_ORANGE}%s%s %3d%%${NC}" "$filled" "$empty" "$percent" >&2
}
finish_progress() {
print_progress 1 1
echo "" >&2
}
TEMP_DIRS=()
ACTIVE_DOWNLOAD_PID=""
PATH_UPDATE_APPLIED=0
cleanup_temp_dirs() {
local temp_dir
@ -67,6 +108,17 @@ register_temp_dir() {
TEMP_DIRS+=("${temp_dir}")
}
restore_cursor() {
printf "\033[?25h"
}
kill_active_download() {
if [[ -n "${ACTIVE_DOWNLOAD_PID}" ]]; then
kill "${ACTIVE_DOWNLOAD_PID}" 2>/dev/null || true
ACTIVE_DOWNLOAD_PID=""
fi
}
shell_quote() {
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
}
@ -81,8 +133,8 @@ display_install_version() {
}
trap cleanup_temp_dirs EXIT
trap 'cleanup_temp_dirs; exit 130' INT
trap 'cleanup_temp_dirs; exit 143' TERM
trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 130' INT
trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM
print_usage() {
cat <<EOF
@ -91,26 +143,18 @@ Qwen Code Installer
Usage: $0 [OPTIONS]
Options:
-s, --source SOURCE Record the installation source.
--method METHOD Install method: detect, standalone, or npm.
Defaults to QWEN_INSTALL_METHOD or detect.
--mirror MIRROR Standalone archive mirror: auto, github, or aliyun.
Defaults to QWEN_INSTALL_MIRROR or auto, which picks
whichever responds first via a HEAD probe.
--base-url URL Override standalone archive base URL.
--archive PATH Install from a local standalone archive.
--version VERSION Standalone release version. Defaults to latest.
--registry REGISTRY npm registry to use for npm fallback.
Defaults to QWEN_NPM_REGISTRY or https://registry.npmmirror.com
--no-modify-path Do not append PATH to the user's shell rc file even
when a shadowing 'qwen' is detected.
-h, --help Show this help message.
--method METHOD Install method: detect, standalone, or npm (default: detect)
--mirror MIRROR Mirror: auto, github, or aliyun (default: auto)
--base-url URL Override standalone archive base URL
--archive PATH Install from a local standalone archive
--version VERSION Release version (default: latest)
--registry URL npm registry (default: https://registry.npmmirror.com)
--no-modify-path Do not modify shell rc file
-s, --source SOURCE Record installation source
-h, --help Show this help message
Examples:
Example:
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash -s -- --source github
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash -s -- --method standalone
./install-qwen-standalone.sh --archive ./qwen-code-linux-x64.tar.gz
EOF
}
@ -318,30 +362,15 @@ done
validate_options
print_header() {
echo ""
echo "Installing Qwen Code version: $(display_install_version)"
echo ""
}
print_node_help() {
echo ""
echo "Node.js 22 or newer is required before installing Qwen Code with npm."
echo ""
echo "Install Node.js, then rerun this installer:"
case "$(uname -s 2>/dev/null || echo unknown)" in
Darwin)
echo "Node.js 22 or newer is required. Install from https://nodejs.org/ then rerun."
echo " brew install node"
echo " # or download from https://nodejs.org/"
;;
Linux)
echo " # Use your distribution package manager or:"
echo " https://nodejs.org/en/download/package-manager"
;;
*)
echo " https://nodejs.org/"
;;
esac
echo ""
echo "If you already use a Node version manager, activate Node.js 22+"
echo "in this shell before rerunning the installer."
}
require_node() {
@ -367,21 +396,14 @@ require_node() {
print_node_help
return 1
fi
log_success "Node.js ${node_version} detected."
}
require_npm() {
if command_exists npm; then
log_success "npm $(npm -v 2>/dev/null || echo unknown) detected."
return 0
fi
log_error "npm was not found."
echo ""
echo "Please install Node.js with npm included, then rerun this installer."
echo "Download Node.js from https://nodejs.org/ if your package manager"
echo "installed Node without npm."
log_error "npm was not found. Install Node.js with npm from https://nodejs.org/"
return 1
}
@ -423,8 +445,6 @@ create_source_json() {
"source": "${escaped_source}"
}
EOF
log_success "Installation source saved to ~/.qwen/source.json"
}
detect_target() {
@ -531,9 +551,13 @@ maybe_update_shell_path() {
fi
if [[ -f "${rc_file}" ]] && grep -qxF "${export_line}" "${rc_file}" 2>/dev/null; then
log_info "PATH update for ${install_bin_dir} already present in ${rc_file} (skipping)."
local current_tail
current_tail=$(tail -n 3 "${rc_file}" 2>/dev/null || true)
if [[ "${current_tail}" == "${begin_marker}"$'\n'"${export_line}"$'\n'"${end_marker}" ]]; then
PATH_UPDATE_APPLIED=1
return 0
fi
fi
mkdir -p "$(dirname "${rc_file}")" 2>/dev/null || true
{
@ -546,8 +570,7 @@ maybe_update_shell_path() {
return 0
}
log_success "Appended PATH prepend to ${rc_file}"
log_info "Open a new terminal, or run: source ${rc_file}"
PATH_UPDATE_APPLIED=1
}
github_base_url_for_version() {
@ -639,7 +662,7 @@ resolve_aliyun_version_path() {
return 1
fi
log_info "Resolved Aliyun latest to ${resolved_version_path}." >&2
: # resolved to ${resolved_version_path}
echo "${resolved_version_path}"
}
@ -733,10 +756,7 @@ standalone_base_url() {
fi
selected=$(race_mirror_head 2 "${gh_head}" "${oss_head}")
if [[ "${selected}" == "timeout" ]]; then
log_info "Mirror auto-selection timed out; defaulting to github." >&2
selected="github"
else
log_info "Mirror auto-selected via HEAD probe: ${selected}" >&2
fi
MIRROR="${selected}"
fi
@ -752,7 +772,66 @@ standalone_base_url() {
github_base_url_for_version "${version_path}"
}
download_file() {
get_content_length() {
local url="$1"
curl -fsSLI --retry 1 --connect-timeout 10 --max-time 15 "${url}" 2>/dev/null \
| grep -i '^content-length:' | tail -1 | tr -d '\r' | awk '{print $2}'
}
download_with_progress() {
local url="$1"
local output="$2"
if ! command_exists curl || ! is_terminal; then
download_file_simple "$url" "$output"
return $?
fi
local content_length
content_length=$(get_content_length "${url}")
if [[ -z "${content_length}" ]] || [[ "${content_length}" -le 0 ]] 2>/dev/null; then
download_file_simple "$url" "$output"
return $?
fi
# Skip progress bar for small files (e.g. SHA256SUMS)
if [[ "${content_length}" -lt 102400 ]] 2>/dev/null; then
curl -fsSL --retry 2 --connect-timeout 15 --max-time 300 "${url}" -o "${output}"
return $?
fi
printf "\033[?25l" >&2
print_progress 0 "${content_length}"
curl -fsSL --retry 2 --connect-timeout 15 --max-time 300 "${url}" -o "${output}" &
ACTIVE_DOWNLOAD_PID=$!
while kill -0 "${ACTIVE_DOWNLOAD_PID}" 2>/dev/null; do
if [[ -f "${output}" ]]; then
local file_size
file_size=$(wc -c < "${output}" 2>/dev/null | tr -d ' ')
if [[ -n "${file_size}" && "${file_size}" -gt 0 ]] 2>/dev/null; then
print_progress "${file_size}" "${content_length}"
fi
fi
sleep 1
done
wait "${ACTIVE_DOWNLOAD_PID}"
local exit_code=$?
ACTIVE_DOWNLOAD_PID=""
printf "\033[?25h" >&2
if [[ $exit_code -eq 0 ]]; then
finish_progress
else
echo "" >&2
fi
return $exit_code
}
download_file_simple() {
local url="$1"
local destination="$2"
@ -767,17 +846,33 @@ download_file() {
wget_args+=(--read-timeout=300)
fi
if wget --help 2>&1 | grep -q -- '--progress'; then
wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}" || return 1
wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}" &
ACTIVE_DOWNLOAD_PID=$!
wait "${ACTIVE_DOWNLOAD_PID}"
local exit_code=$?
ACTIVE_DOWNLOAD_PID=""
return "${exit_code}"
else
wget "${wget_args[@]}" "${url}" -O "${destination}" || return 1
wget "${wget_args[@]}" "${url}" -O "${destination}" &
ACTIVE_DOWNLOAD_PID=$!
wait "${ACTIVE_DOWNLOAD_PID}"
local exit_code=$?
ACTIVE_DOWNLOAD_PID=""
return "${exit_code}"
fi
return $?
fi
log_error "curl or wget is required to download the standalone archive."
return 1
}
download_file() {
local url="$1"
local destination="$2"
download_with_progress "${url}" "${destination}"
}
url_exists() {
local url="$1"
@ -855,8 +950,6 @@ verify_checksum() {
log_error "Checksum mismatch for ${archive_name}: expected ${expected}, got ${actual}."
return 1
fi
log_success "Checksum verified for ${archive_name}."
}
validate_archive_entry_path() {
@ -884,6 +977,22 @@ validate_archive_entry_path() {
esac
}
archive_contains_symlinks_or_hardlinks() {
local archive_path="$1"
case "${archive_path}" in
*.zip)
unzip -Z -v "${archive_path}" 2>/dev/null | grep -E 'Unix file attributes \(12[0-7]{4} octal\)' >/dev/null
;;
*.tar.gz|*.tgz|*.tar.xz)
tar -tvf "${archive_path}" 2>/dev/null | awk '$1 ~ /^[lh]/ { found=1 } END { exit found ? 0 : 1 }'
;;
*)
return 1
;;
esac
}
validate_archive_contents() {
local archive_path="$1"
local entries
@ -912,6 +1021,16 @@ validate_archive_contents() {
;;
esac
if [[ -z "${entries}" ]]; then
log_error "Archive is empty: ${archive_path}"
return 1
fi
if archive_contains_symlinks_or_hardlinks "${archive_path}"; then
log_error "Archive contains symlinks or hardlinks; refusing to install."
return 1
fi
while IFS= read -r entry; do
validate_archive_entry_path "${entry}" || return 1
done <<< "${entries}"
@ -1111,7 +1230,7 @@ install_standalone() {
register_temp_dir "${temp_dir}"
archive_path="${temp_dir}/${archive_name}"
echo "Downloading ${archive_name}"
log_info "Downloading ${archive_name}"
if ! download_file "${archive_url}" "${archive_path}"; then
if [[ -n "${github_fallback_base_url}" ]]; then
rm -f "${archive_path}"
@ -1120,7 +1239,6 @@ install_standalone() {
MIRROR="github"
github_fallback_base_url=""
log_warning "Aliyun standalone archive download failed; retrying GitHub mirror."
echo "Downloading ${archive_name}"
if download_file "${archive_url}" "${archive_path}"; then
:
else
@ -1147,13 +1265,11 @@ install_standalone() {
register_temp_dir "${temp_dir}"
fi
# Verify integrity before extraction or changing the install directory.
if ! verify_checksum "${archive_path}" "${checksum_source}" "${archive_name}"; then
rm -rf "${temp_dir}"
return 1
fi
# Extract into a temporary directory, then validate required entry points.
local extract_dir="${temp_dir}/extract"
if ! extract_archive "${archive_path}" "${extract_dir}"; then
rm -rf "${temp_dir}"
@ -1207,6 +1323,9 @@ install_standalone() {
return 1
fi
# Suppress INT/TERM during the critical mv swap to avoid leaving
# INSTALL_LIB_DIR absent if the user presses Ctrl+C between the two moves.
trap '' INT TERM
if [[ -e "${INSTALL_LIB_DIR}" ]]; then
mv "${INSTALL_LIB_DIR}" "${old_install_dir}"
fi
@ -1215,10 +1334,14 @@ install_standalone() {
if [[ -e "${old_install_dir}" ]]; then
mv "${old_install_dir}" "${INSTALL_LIB_DIR}"
fi
trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 130' INT
trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM
rm -rf "${temp_dir}" "${wrapper_tmp}"
log_error "Failed to install standalone archive to ${INSTALL_LIB_DIR}."
return 1
fi
trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 130' INT
trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM
if ! mv -f "${wrapper_tmp}" "${INSTALL_BIN_DIR}/qwen"; then
rm -rf "${INSTALL_LIB_DIR}" "${wrapper_tmp}"
@ -1235,9 +1358,6 @@ install_standalone() {
create_source_json
rm -rf "${temp_dir}"
log_success "Qwen Code standalone archive installed successfully."
log_info "Installed to ${INSTALL_LIB_DIR}"
}
npm_package_spec() {
@ -1257,17 +1377,6 @@ install_npm() {
local package_spec
package_spec=$(npm_package_spec)
if command_exists qwen; then
local qwen_version
qwen_version=$(qwen --version 2>/dev/null || echo "unknown")
log_info "Existing Qwen Code detected: ${qwen_version}"
if [[ "${VERSION}" == "latest" ]]; then
log_info "Upgrading to the latest version."
else
log_info "Installing requested version ${VERSION}."
fi
fi
local install_cmd=(
npm
install
@ -1277,37 +1386,75 @@ install_npm() {
"${NPM_REGISTRY}"
)
log_info "Running: npm install -g ${package_spec} --registry ${NPM_REGISTRY}"
if "${install_cmd[@]}"; then
log_success "Qwen Code installed successfully."
create_source_json
return 0
fi
log_error "Failed to install Qwen Code."
echo ""
echo "This installer does not change your npm prefix or shell profile."
echo "If the failure is a permission error, install Node.js with a user-owned"
echo "Node version manager or fix your npm global package directory, then run:"
echo " npm install -g ${package_spec} --registry ${NPM_REGISTRY}"
log_error "Failed to install. Try: npm install -g ${package_spec} --registry ${NPM_REGISTRY}"
return 1
}
gradient_line() {
local text="$1"
local r1=$2 g1=$3 b1=$4
local r2=$5 g2=$6 b2=$7
local r3=$8 g3=$9 b3=${10}
local len=${#text}
[ "$len" -eq 0 ] && return
if ! supports_truecolor; then
printf "%b%s%b\n" "${BRAND_PURPLE}" "${text}" "${NC}"
return
fi
local i=0
local half=$(( len / 2 ))
while [ $i -lt $len ]; do
local char="${text:$i:1}"
local r g b
if [ $i -lt $half ]; then
local t=$(( i * 1000 / half ))
r=$(( (r1 * (1000 - t) + r2 * t) / 1000 ))
g=$(( (g1 * (1000 - t) + g2 * t) / 1000 ))
b=$(( (b1 * (1000 - t) + b2 * t) / 1000 ))
else
local t=$(( (i - half) * 1000 / (len - half) ))
r=$(( (r2 * (1000 - t) + r3 * t) / 1000 ))
g=$(( (g2 * (1000 - t) + g3 * t) / 1000 ))
b=$(( (b2 * (1000 - t) + b3 * t) / 1000 ))
fi
if [ "$char" = " " ]; then
printf " "
else
printf "\033[38;2;%d;%d;%dm%s" "$r" "$g" "$b" "$char"
fi
i=$(( i + 1 ))
done
printf "\033[0m\n"
}
print_logo() {
# Per-character gradient matching CLI's ink-gradient rendering
# Direction: #4796E4 (blue) → #847ACE (purple) → #C3677F (rose)
gradient_line " ▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄" 71 150 228 132 122 206 195 103 127
gradient_line "██╔═══██╗██║ ██║██╔════╝████╗ ██║" 71 150 228 132 122 206 195 103 127
gradient_line "██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║" 71 150 228 132 122 206 195 103 127
gradient_line "██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║" 71 150 228 132 122 206 195 103 127
gradient_line "╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║" 71 150 228 132 122 206 195 103 127
gradient_line " ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝" 71 150 228 132 122 206 195 103 127
}
print_final_instructions() {
local install_bin_dir="${1:-}"
local install_dir="${2:-}"
local install_method="${3:-standalone}"
local installed_bin=""
local quoted_install_bin_dir=""
local standalone_uninstall_url="https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh"
if [[ -n "${install_bin_dir}" ]]; then
installed_bin="${install_bin_dir}/qwen"
quoted_install_bin_dir=$(shell_quote "${install_bin_dir}")
export PATH="${install_bin_dir}:${PATH}"
fi
# PRE_INSTALL_QWENS was captured by main() BEFORE the install ran
# (newline-separated list of every qwen binary found on disk). Filter out
# the one we just installed; whatever remains may shadow this install.
# Detect shadowing qwen executables
local other_qwens=""
if [[ -n "${PRE_INSTALL_QWENS:-}" ]]; then
local saved_ifs="${IFS}"
@ -1325,12 +1472,11 @@ print_final_instructions() {
IFS="${saved_ifs}"
fi
if [[ -n "${install_bin_dir}" ]]; then
export PATH="${install_bin_dir}:${PATH}"
if [[ -n "${install_bin_dir}" && "${NO_MODIFY_PATH:-0}" != "1" ]]; then
PATH_UPDATE_APPLIED=0
maybe_update_shell_path "${install_bin_dir}"
fi
echo ""
local installed_version="unknown"
if [[ -n "${installed_bin}" && -x "${installed_bin}" ]]; then
installed_version=$("${installed_bin}" --version 2>/dev/null || echo "unknown")
@ -1338,53 +1484,24 @@ print_final_instructions() {
installed_version=$(qwen --version 2>/dev/null || echo "unknown")
fi
echo "QWEN CODE"
echo ""
echo "Qwen Code ${installed_version} installed successfully."
echo ""
echo "To start:"
echo " cd <project>"
echo " qwen"
if [[ -n "${install_dir}" ]]; then
echo ""
echo "Installed to:"
echo " ${install_dir}"
local rc_name=""
case "${SHELL:-}" in
*/zsh) rc_name="~/.zshrc" ;;
*/bash) rc_name="~/.bashrc" ;;
*/fish) rc_name="~/.config/fish/config.fish" ;;
esac
if [[ "${PATH_UPDATE_APPLIED:-0}" == "1" && -n "${rc_name}" ]]; then
echo -e "${MUTED}Successfully added${NC} qwen ${MUTED}to \$PATH in${NC} ${rc_name}"
fi
echo ""
echo "Uninstall:"
if [[ "${install_method}" == "npm" ]]; then
echo " npm uninstall -g @qwen-code/qwen-code"
elif [[ -n "${install_dir}" && -n "${install_bin_dir}" ]]; then
echo " curl -fsSL ${standalone_uninstall_url} | QWEN_INSTALL_LIB_DIR=$(shell_quote "${install_dir}") QWEN_INSTALL_BIN_DIR=$(shell_quote "${install_bin_dir}") bash"
else
echo " curl -fsSL ${standalone_uninstall_url} | bash"
fi
if [[ -n "${install_bin_dir}" && "${NO_MODIFY_PATH:-0}" != "1" ]]; then
maybe_update_shell_path "${install_bin_dir}"
fi
if [[ -n "${other_qwens}" ]]; then
echo -e "${MUTED}Qwen Code ${installed_version} installed successfully, to start:${NC}"
echo ""
log_warning "Other 'qwen' executables exist on this system. Depending on your"
log_warning "shell PATH order, one of these may run instead of the install above:"
local saved_ifs="${IFS}"
IFS=$'\n'
local path
for path in ${other_qwens}; do
[[ -z "${path}" ]] && continue
log_warning " ${path}"
done
IFS="${saved_ifs}"
echo -e "cd <project> ${MUTED}# Open directory${NC}"
echo -e "qwen ${MUTED}# Run command${NC}"
echo ""
echo -e "${MUTED}For more information visit ${NC}https://qwenlm.github.io/qwen-code"
echo ""
echo "To make this install take priority, restart your terminal."
echo "Or invoke directly: ${installed_bin}"
return 0
fi
echo "(Open a new terminal for the PATH change to take effect.)"
}
main() {
@ -1441,7 +1558,6 @@ main() {
print_final_instructions "$(get_npm_global_bin)" "$(get_npm_global_root)" "npm"
;;
detect)
# Try the standalone archive first; fall back only when unavailable.
if install_standalone; then
print_final_instructions "${INSTALL_BIN_DIR}" "${INSTALL_LIB_DIR}" "standalone"
else
@ -1451,12 +1567,11 @@ main() {
if install_npm; then
print_final_instructions "$(get_npm_global_bin)" "$(get_npm_global_root)" "npm"
else
log_warning "Standalone archive was unavailable before npm fallback; npm fallback also failed."
log_warning "Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm."
log_error "Standalone archive was unavailable; npm fallback also failed."
exit 1
fi
else
log_warning "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure."
log_error "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug."
exit "${standalone_status}"
fi
fi

View file

@ -306,8 +306,11 @@ exit /b 1
:ValidateVersion
if /i "!VERSION!"=="latest" exit /b 0
echo(!VERSION!| findstr /R /C:"^v*[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul
if %ERRORLEVEL% EQU 0 exit /b 0
set "QWEN_VERSION_VALUE=!VERSION!"
powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_VERSION_VALUE; if ($value -match '^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$') { exit 0 }; exit 1"
set "PS_STATUS=%ERRORLEVEL%"
set "QWEN_VERSION_VALUE="
if %PS_STATUS% EQU 0 exit /b 0
echo ERROR: --version must be 'latest' or a semver string.
exit /b 1
@ -632,7 +635,7 @@ set "QWEN_ARCHIVE_FILE=%~1"
REM Enumerate archive entries and reject any with path traversal indicators:
REM empty names, leading '/', drive-rooted paths, '..' segments, or control chars.
REM This prevents Zip Slip attacks before extraction.
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }"
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); if ($archive.Entries.Count -eq 0) { [Console]::Error.WriteLine('Archive is empty: ' + $env:QWEN_ARCHIVE_FILE); exit 3 }; foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }"
set "PS_STATUS=%ERRORLEVEL%"
set "QWEN_ARCHIVE_FILE="
if %PS_STATUS% EQU 0 exit /b 0
@ -644,6 +647,10 @@ if %PS_STATUS% EQU 2 (
echo ERROR: Archive could not be inspected before extraction.
exit /b 1
)
if %PS_STATUS% EQU 3 (
echo ERROR: Archive is empty: %~1
exit /b 1
)
echo ERROR: Archive validation failed before extraction.
exit /b %PS_STATUS%

View file

@ -624,6 +624,22 @@ validate_archive_entry_path() {
esac
}
archive_contains_symlinks_or_hardlinks() {
local archive_path="$1"
case "${archive_path}" in
*.zip)
unzip -Z -v "${archive_path}" 2>/dev/null | grep -E 'Unix file attributes \(12[0-7]{4} octal\)' >/dev/null
;;
*.tar.gz|*.tgz|*.tar.xz)
tar -tvf "${archive_path}" 2>/dev/null | awk '$1 ~ /^[lh]/ { found=1 } END { exit found ? 0 : 1 }'
;;
*)
return 1
;;
esac
}
validate_archive_contents() {
local archive_path="$1"
local entries
@ -652,6 +668,16 @@ validate_archive_contents() {
;;
esac
if [[ -z "${entries}" ]]; then
log_error "Archive is empty: ${archive_path}"
return 1
fi
if archive_contains_symlinks_or_hardlinks "${archive_path}"; then
log_error "Archive contains symlinks or hardlinks; refusing to install."
return 1
fi
while IFS= read -r entry; do
validate_archive_entry_path "${entry}" || return 1
done <<< "${entries}"

View file

@ -70,13 +70,11 @@ describe('installation scripts', () => {
expect(script).toContain('npm_package_spec()');
expect(script).toContain('@qwen-code/qwen-code@latest');
expect(script).toContain('Installing Qwen Code version:');
expect(script).toContain('QWEN CODE');
expect(script).toContain(
'Qwen Code ${installed_version} installed successfully.',
);
expect(script).toContain('To start:');
expect(script).toContain('Installed to:');
expect(script).toContain('Uninstall:');
expect(script).toContain('print_logo');
expect(script).toContain('supports_truecolor()');
expect(script).toContain('COLORTERM');
expect(script).toContain('installed successfully, to start:');
expect(script).toContain('cd <project>');
expect(script).toContain('uninstall-qwen-standalone.sh');
expect(script).not.toContain('rm -rf $(shell_quote "${install_dir}")');
});
@ -126,7 +124,9 @@ describe('installation scripts', () => {
expect(script).toContain('validate_install_path');
expect(script).toContain('validate_https_url "${NPM_REGISTRY}"');
expect(script).toContain('qwen-code/node/bin/node');
expect(script).toContain('Archive contains symlinks; refusing to install');
expect(script).toContain(
'Archive contains symlinks or hardlinks; refusing to install',
);
expect(script).toContain('not a Qwen Code standalone install');
expect(script).toContain(
'Return 2 only when a standalone archive is unavailable',
@ -141,6 +141,9 @@ describe('installation scripts', () => {
expect(script).toContain(
'curl -fL --retry 2 --connect-timeout 15 --max-time 300 --progress-bar "${url}" -o "${destination}"',
);
expect(script).not.toContain('--trace-ascii');
expect(script).not.toContain('mkfifo');
expect(script).not.toContain('qwen_install_$$');
expect(script).toContain(
'curl -fsSL --retry 2 --connect-timeout 10 --max-time 30 "${url}"',
);
@ -148,12 +151,18 @@ describe('installation scripts', () => {
expect(script).toContain(
'wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}"',
);
expect(script).toMatch(
/wget --progress=bar:force:noscroll "\$\{wget_args\[@\]\}" "\$\{url\}" -O "\$\{destination\}" &[\s\S]{0,120}ACTIVE_DOWNLOAD_PID=\$!/,
);
expect(script).toMatch(
/wget "\$\{wget_args\[@\]\}" "\$\{url\}" -O "\$\{destination\}" &[\s\S]{0,120}ACTIVE_DOWNLOAD_PID=\$!/,
);
expect(script).toContain('wget_args+=(--read-timeout=300)');
expect(script).toContain(
'curl -fsL --retry 1 --connect-timeout 10 --max-time "${timeout}"',
);
expect(script).toContain('wget_args+=(--read-timeout=30)');
expect(script).toContain('echo "Downloading ${archive_name}"');
expect(script).toContain('Downloading ${archive_name}');
expect(script).not.toContain(
'curl -fsSL --retry 2 "${url}" -o "${destination}"',
);
@ -179,6 +188,11 @@ describe('installation scripts', () => {
expect(script).toContain(
'restore_stale_install_backup "${old_install_dir}" "${INSTALL_LIB_DIR}"',
);
expect(script).toContain('ACTIVE_DOWNLOAD_PID=""');
expect(script).toContain('restore_cursor >&2');
expect(script).toContain(
'kill "${ACTIVE_DOWNLOAD_PID}" 2>/dev/null || true',
);
expect(script).not.toContain(
'rm -rf "${new_install_dir}" "${old_install_dir}" "${wrapper_tmp}"',
);
@ -219,11 +233,9 @@ describe('installation scripts', () => {
expect(script).toContain('Installing Qwen Code version:');
expect(script).toContain('QWEN CODE');
expect(script).toContain(
'Qwen Code !INSTALLED_VERSION! installed successfully.',
'Qwen Code !INSTALLED_VERSION! installed successfully, to start:',
);
expect(script).toContain('To start:');
expect(script).toContain('Installed to:');
expect(script).toContain('Uninstall:');
expect(script).toContain('cd ^<project^>');
expect(script).toContain('uninstall-qwen-standalone.ps1');
expect(script).toContain('QWEN_VERSION_POINTER_FILE');
expect(script).toContain('QWEN_NORMALIZED_VERSION_FILE');
@ -234,9 +246,14 @@ describe('installation scripts', () => {
expect(script).toContain(
'[IO.File]::WriteAllText($env:QWEN_NORMALIZED_VERSION_FILE',
);
expect(script).not.toContain(
'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*$"',
expect(script).toContain('set "QWEN_VERSION_VALUE=!VERSION!"');
expect(script).toContain(
"$value -match '^v?[0-9]+\\.[0-9]+\\.[0-9]+([.-][A-Za-z0-9]+)*$'",
);
expect(script).not.toContain(
'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*[A-Za-z0-9.-]*$"',
);
expect(script).not.toContain('[A-Za-z0-9][A-Za-z0-9.-]*$');
expect(script).not.toContain('rmdir /S /Q "!SUMMARY_INSTALL_DIR!"');
expect(script).not.toContain('del /F /Q "!INSTALLED_BIN!"');
});
@ -313,6 +330,7 @@ describe('installation scripts', () => {
expect(script).toContain('Falling back to npm installation');
expect(script).toContain('set "STANDALONE_STATUS=!ERRORLEVEL!"');
expect(script).toContain('if !STANDALONE_STATUS! EQU 2');
expect(script).toContain('Archive is empty: %~1');
expect(script).toContain('set "ARG_KEY=%~1"');
expect(script).toContain('set "ARG_HAS_INLINE_VALUE=0"');
expect(script).toContain('if "!ARG_HAS_INLINE_VALUE!"=="1"');
@ -329,6 +347,7 @@ describe('installation scripts', () => {
expect(script).toContain('Archive contains symlinks or reparse points');
expect(script).toContain('unsafe path with control character');
expect(script).toContain('Failed to update user PATH');
expect(script).toContain('PRE_INSTALL_QWENS_LIST');
expect(script).toContain('QWEN_INSTALL_ROOT');
expect(script).toContain('npm fallback also failed');
expect(script).toContain('echo Downloading !ARCHIVE_NAME!');
@ -838,19 +857,8 @@ describe('standalone release packaging', () => {
expect(installPowerShellSource).not.toContain(
"$preferredDirectories += Join-Path $env:LOCALAPPDATA 'Microsoft\\WindowsApps'",
);
expect(installPowerShellSource).toContain(
'QWEN_NO_MODIFY_PATH=1; skipping current-session PATH refresh.',
);
expect(installPowerShellSource).toContain('QWEN_NO_MODIFY_PATH');
expect(installPowerShellSource).not.toContain('doskey.exe');
expect(installPowerShellSource).toContain(
'qwen is ready to use in this PowerShell session.',
);
expect(installPowerShellSource).toContain(
'Added qwen.cmd to a directory already on this cmd.exe PATH:',
);
expect(installPowerShellSource).toContain(
'Windows does not allow this PowerShell child process to update the parent cmd.exe PATH directly.',
);
expect(installBatchSource).toContain('QWEN_INSTALLER_PARENT_POWERSHELL');
expect(installBatchSource).toContain(
@ -1206,6 +1214,67 @@ describe('standalone release packaging', () => {
}
});
it('rejects unexpected files and non-file release assets', async () => {
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } =
await import(installationReleaseVerificationScriptUrl);
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-'));
try {
writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES);
writeFileSync(path.join(tmpDir, '.DS_Store'), '');
await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow(
/Unexpected file\(s\) in release directory: \.DS_Store/,
);
rmSync(path.join(tmpDir, '.DS_Store'));
rmSync(path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0]));
mkdirSync(path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0]));
await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow(
/Release asset is not a regular file: qwen-code-/,
);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
itOnUnix('rejects symlinked release assets and checksum files', async () => {
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } =
await import(installationReleaseVerificationScriptUrl);
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-'));
let linkedAsset = '';
let linkedChecksums = '';
try {
writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES);
const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES[0];
const assetPath = path.join(tmpDir, assetName);
linkedAsset = path.join(tmpDir, '..', `${assetName}.linked`);
writeFileSync(linkedAsset, `${assetName}\n`);
rmSync(assetPath);
symlinkSync(linkedAsset, assetPath);
await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow(
/Release asset is not a regular file: qwen-code-/,
);
rmSync(assetPath);
writeFileSync(assetPath, `${assetName}\n`);
const checksumPath = path.join(tmpDir, 'SHA256SUMS');
linkedChecksums = path.join(tmpDir, '..', 'SHA256SUMS.linked');
writeFileSync(linkedChecksums, readScript(checksumPath));
rmSync(checksumPath);
symlinkSync(linkedChecksums, checksumPath);
await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow(
/SHA256SUMS is not a regular file/,
);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
if (linkedAsset) rmSync(linkedAsset, { force: true });
if (linkedChecksums) rmSync(linkedChecksums, { force: true });
}
});
it('verifies release asset URLs from SHA256SUMS', async () => {
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } =
await import(installationReleaseVerificationScriptUrl);
@ -1291,6 +1360,83 @@ describe('standalone release packaging', () => {
).rejects.toThrow(/--base-url must use https/);
});
it('does not follow remote release redirects', async () => {
const { verifyReleaseBaseUrl } = await import(
installationReleaseVerificationScriptUrl
);
const fetchedOptions = [];
await expect(
verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', {
fetchImpl: async (_url, options = {}) => {
fetchedOptions.push(options);
return new Response(null, {
status: 302,
headers: { Location: 'https://169.254.169.254/latest/meta-data/' },
});
},
}),
).rejects.toThrow(/Redirect responses are not allowed/);
expect(fetchedOptions).toHaveLength(1);
expect(fetchedOptions[0].redirect).toBe('manual');
});
it('does not follow remote archive body redirects', async () => {
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } =
await import(installationReleaseVerificationScriptUrl);
const checksumContent = placeholderChecksumContent(
EXPECTED_STANDALONE_ARCHIVE_NAMES,
);
const redirectedAsset = EXPECTED_STANDALONE_ARCHIVE_NAMES[0];
await expect(
verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', {
fetchImpl: async (url) => {
if (url.endsWith('/SHA256SUMS')) {
return new Response(checksumContent);
}
if (url.endsWith(`/${redirectedAsset}`)) {
return new Response(null, {
status: 302,
headers: {
Location: 'https://169.254.169.254/latest/meta-data/',
},
});
}
const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) =>
url.endsWith(`/${name}`),
);
return new Response(`${assetName}\n`);
},
}),
).rejects.toThrow(/Redirect responses are not allowed/);
});
it('rejects private release base URLs at the verification entry point', async () => {
const { verifyReleaseBaseUrl } = await import(
installationReleaseVerificationScriptUrl
);
await expect(
verifyReleaseBaseUrl('https://127.0.0.1/releases/'),
).rejects.toThrow(/must not target a private network/);
await expect(
verifyReleaseBaseUrl('https://169.254.169.254/latest/meta-data/'),
).rejects.toThrow(/must not target a private network/);
await expect(
verifyReleaseBaseUrl('https://sub.localhost./releases/'),
).rejects.toThrow(/must not target a private network/);
// IPv4-mapped IPv6
await expect(
verifyReleaseBaseUrl('https://[::ffff:127.0.0.1]/releases/'),
).rejects.toThrow(/must not target a private network/);
// IPv4-compatible IPv6
await expect(
verifyReleaseBaseUrl('https://[::7f00:1]/releases/'),
).rejects.toThrow(/must not target a private network/);
});
it('downloads release archive bodies instead of relying on HEAD probes', async () => {
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } =
await import(installationReleaseVerificationScriptUrl);
@ -1818,7 +1964,7 @@ describe('standalone release packaging', () => {
expect(guide).toContain('ALIYUN_OSS_ACCESS_KEY_SECRET');
expect(guide).toContain('ALIYUN_OSS_BUCKET');
expect(guide).toContain('ALIYUN_OSS_ENDPOINT');
expect(guide).toContain('Public installation documentation');
expect(guide).toContain('hosted entrypoint');
expect(guide).toContain('node-pty');
expect(guide).toContain('clipboard');
});
@ -1862,6 +2008,168 @@ describe('standalone release packaging', () => {
});
});
describe('isPrivateOrReservedHost', () => {
it('rejects empty hostname', async () => {
const { isPrivateOrReservedHost } = await import(
installationReleaseVerificationScriptUrl
);
expect(isPrivateOrReservedHost('')).toBe(true);
});
it('rejects localhost variants', async () => {
const { isPrivateOrReservedHost } = await import(
installationReleaseVerificationScriptUrl
);
expect(isPrivateOrReservedHost('localhost')).toBe(true);
expect(isPrivateOrReservedHost('sub.localhost')).toBe(true);
expect(isPrivateOrReservedHost('localhost.')).toBe(true);
expect(isPrivateOrReservedHost('sub.localhost.')).toBe(true);
expect(isPrivateOrReservedHost('LOCALHOST')).toBe(true);
});
it('rejects private IPv4 addresses', async () => {
const { isPrivateOrReservedHost } = await import(
installationReleaseVerificationScriptUrl
);
expect(isPrivateOrReservedHost('127.0.0.1')).toBe(true);
expect(isPrivateOrReservedHost('10.0.0.1')).toBe(true);
expect(isPrivateOrReservedHost('192.168.1.1')).toBe(true);
expect(isPrivateOrReservedHost('172.16.0.1')).toBe(true);
expect(isPrivateOrReservedHost('169.254.1.1')).toBe(true);
});
it('rejects IPv6 loopback and link-local', async () => {
const { isPrivateOrReservedHost } = await import(
installationReleaseVerificationScriptUrl
);
expect(isPrivateOrReservedHost('::1')).toBe(true);
expect(isPrivateOrReservedHost('[::1]')).toBe(true);
expect(isPrivateOrReservedHost('fe80::1')).toBe(true);
});
it('rejects IPv4-mapped IPv6 addresses (2-part hex)', async () => {
const { isPrivateOrReservedHost } = await import(
installationReleaseVerificationScriptUrl
);
expect(isPrivateOrReservedHost('::ffff:7f00:1')).toBe(true);
expect(isPrivateOrReservedHost('::ffff:a00:1')).toBe(true);
});
it('rejects IPv4-mapped IPv6 addresses (3-part hex from Node normalization)', async () => {
const { isPrivateOrReservedHost } = await import(
installationReleaseVerificationScriptUrl
);
expect(isPrivateOrReservedHost('::ffff:0:7f00:1')).toBe(true);
expect(isPrivateOrReservedHost('::ffff:0:a00:1')).toBe(true);
expect(isPrivateOrReservedHost('::ffff:0:c0a8:101')).toBe(true);
});
it('does not collapse nonzero 3-part IPv4-mapped IPv6 prefixes', async () => {
const { isPrivateOrReservedHost } = await import(
installationReleaseVerificationScriptUrl
);
expect(isPrivateOrReservedHost('::ffff:abcd:7f00:1')).toBe(false);
});
it('blocks IPv4-compatible IPv6 addresses (deprecated but parseable)', async () => {
const { isPrivateOrReservedHost } = await import(
installationReleaseVerificationScriptUrl
);
// ::7f00:1 → 127.0.0.1 (loopback)
expect(isPrivateOrReservedHost('::7f00:1')).toBe(true);
// ::a9fe:a9fe → 169.254.169.254 (cloud metadata)
expect(isPrivateOrReservedHost('::a9fe:a9fe')).toBe(true);
// ::a00:1 → 10.0.0.1 (private)
expect(isPrivateOrReservedHost('::a00:1')).toBe(true);
// ::c0a8:101 → 192.168.1.1 (private)
expect(isPrivateOrReservedHost('::c0a8:101')).toBe(true);
});
it('allows public IP addresses', async () => {
const { isPrivateOrReservedHost } = await import(
installationReleaseVerificationScriptUrl
);
expect(isPrivateOrReservedHost('8.8.8.8')).toBe(false);
expect(isPrivateOrReservedHost('142.250.80.46')).toBe(false);
expect(isPrivateOrReservedHost('example.com')).toBe(false);
expect(isPrivateOrReservedHost('example.com.')).toBe(false);
// Public IPv6
expect(isPrivateOrReservedHost('2607:f8b0:4004:800::200e')).toBe(false);
});
it('does not flag decimal or octal encoded IPs (URL API normalizes them before reaching the helper)', async () => {
const { isPrivateOrReservedHost } = await import(
installationReleaseVerificationScriptUrl
);
// Decimal-encoded 127.0.0.1 — not 4 dotted parts, so parseIpv4Octets
// returns null and the value is treated as a non-IP hostname (safe).
expect(isPrivateOrReservedHost('2130706433')).toBe(false);
// Octal-encoded 127.0.0.1 — parsed as dotted quad but leading zeros
// are interpreted as decimal by Number(), so 0177 → 177 (not 127).
// The resulting IP 177.0.0.1 is public, so this returns false.
// Node's URL API normalizes these before they reach isPrivateOrReservedHost.
expect(isPrivateOrReservedHost('0177.0.0.1')).toBe(false);
});
it('handles IPv6 zone IDs and empty brackets', async () => {
const { isPrivateOrReservedHost } = await import(
installationReleaseVerificationScriptUrl
);
expect(isPrivateOrReservedHost('[]')).toBe(true);
// Node's URL API rejects URLs with IPv6 zone IDs as invalid, so this
// value would not normally reach isPrivateOrReservedHost. If it arrives
// raw, fe80::1%25eth0 contains ':' and is parsed as IPv6 link-local.
expect(isPrivateOrReservedHost('fe80::1%25eth0')).toBe(true);
});
});
describe('redactUrlForLog', () => {
it('strips username and password from URLs', async () => {
const { redactUrlForLog } = await import(
installationReleaseVerificationScriptUrl
);
expect(redactUrlForLog('https://user:pass@example.com/path')).toBe(
'https://example.com/path',
);
});
it('strips query parameters to prevent credential leakage', async () => {
const { redactUrlForLog } = await import(
installationReleaseVerificationScriptUrl
);
expect(
redactUrlForLog(
'https://example.com/path?X-Amz-Signature=secret&token=abc',
),
).toBe('https://example.com/path');
});
it('strips URL fragments to prevent credential leakage', async () => {
const { redactUrlForLog } = await import(
installationReleaseVerificationScriptUrl
);
expect(
redactUrlForLog('https://example.com/path#access_token=secret'),
).toBe('https://example.com/path');
});
it('redacts malformed URLs containing @, ?, or #', async () => {
const { redactUrlForLog } = await import(
installationReleaseVerificationScriptUrl
);
expect(redactUrlForLog('not-a-url@with-creds')).toBe('<redacted URL>');
expect(redactUrlForLog('not-a-url?with-query')).toBe('<redacted URL>');
expect(redactUrlForLog('not-a-url#with-fragment')).toBe('<redacted URL>');
});
it('passes through safe non-URL strings', async () => {
const { redactUrlForLog } = await import(
installationReleaseVerificationScriptUrl
);
expect(redactUrlForLog('just-a-string')).toBe('just-a-string');
});
});
// These end-to-end installs spawn child processes via execFileSync;
// the default 5s vitest timeout is too tight on slow CI runners even
// without Windows' cmd.exe + node.exe startup overhead.
@ -1895,24 +2203,10 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => {
.trim();
expect(version).toBe('0.0.0-smoke');
expect(output).toContain('Installing Qwen Code version: latest');
expect(output).toContain('QWEN CODE');
expect(output).toContain(
'Qwen Code 0.0.0-smoke installed successfully.',
);
expect(output).toContain('To start:\n cd <project>\n qwen');
expect(output).toContain(
`Installed to:\n ${path.join(installRoot, 'lib', 'qwen-code')}`,
);
expect(output).toContain('Uninstall:');
expect(output).toContain(
'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh',
);
expect(output).toContain(
`QWEN_INSTALL_LIB_DIR='${path.join(installRoot, 'lib', 'qwen-code')}'`,
);
expect(output).toContain(
`QWEN_INSTALL_BIN_DIR='${path.join(installRoot, 'bin')}'`,
);
expect(output).toContain('installed successfully, to start:');
expect(output).toContain('0.0.0-smoke');
expect(output).toContain('cd <project>');
expect(output).toContain('qwenlm.github.io/qwen-code');
expect(output).not.toContain('rm -rf');
} finally {
rmSync(tmpDir, { recursive: true, force: true });
@ -2236,6 +2530,133 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => {
},
);
itOnUnix(
'warns when an existing qwen could shadow the standalone install',
() => {
const createdDist = ensureMinimalDist();
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
try {
const archive = packageFakeStandalone(tmpDir);
const fakeBin = path.join(tmpDir, 'old-bin');
const existingQwen = path.join(fakeBin, 'qwen');
const installRoot = path.join(tmpDir, 'install');
const home = path.join(tmpDir, 'home');
mkdirSync(fakeBin, { recursive: true });
writeFileSync(existingQwen, '#!/usr/bin/env sh\necho old-qwen\n');
chmodSync(existingQwen, 0o755);
const output = runUnixInstaller(
archive,
installRoot,
home,
'standalone',
{
PATH: `${fakeBin}:${process.env.PATH}`,
SHELL: '/bin/bash',
},
).toString();
const installedBin = path.join(installRoot, 'bin', 'qwen');
const bashrc = readScript(path.join(home, '.bashrc'));
expect(output).toContain('installed successfully, to start:');
expect(bashrc).toContain('# Qwen Code PATH block begin');
expect(bashrc).toContain(
`export PATH='${path.join(installRoot, 'bin')}':$PATH`,
);
const resolvedQwen = execFileSync(
'bash',
['-c', 'source "${HOME}/.bashrc"; command -v qwen'],
{
env: {
...process.env,
HOME: home,
PATH: `${fakeBin}:${process.env.PATH}`,
SHELL: '/bin/bash',
},
},
)
.toString()
.trim();
expect(resolvedQwen).toBe(installedBin);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
restoreMinimalDist(createdDist);
}
},
);
itOnUnix(
'appends a fresh PATH block when an existing PATH line is not last',
() => {
const createdDist = ensureMinimalDist();
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
try {
const archive = packageFakeStandalone(tmpDir);
const fakeBin = path.join(tmpDir, 'old-bin');
const existingQwen = path.join(fakeBin, 'qwen');
const installRoot = path.join(tmpDir, 'install');
const home = path.join(tmpDir, 'home');
const installBinDir = path.join(installRoot, 'bin');
const installedBin = path.join(installBinDir, 'qwen');
const bashrc = path.join(home, '.bashrc');
mkdirSync(fakeBin, { recursive: true });
mkdirSync(home, { recursive: true });
writeFileSync(existingQwen, '#!/usr/bin/env sh\necho old-qwen\n');
chmodSync(existingQwen, 0o755);
writeFileSync(
bashrc,
[
`export PATH='${installBinDir}':$PATH`,
`export PATH='${fakeBin}':$PATH`,
].join('\n') + '\n',
);
runUnixInstaller(archive, installRoot, home, 'standalone', {
PATH: `${fakeBin}:${process.env.PATH}`,
SHELL: '/bin/bash',
});
const bashrcContents = readScript(bashrc);
expect(bashrcContents).toContain('# Qwen Code PATH block begin');
expect(
bashrcContents.endsWith(
[
'# Qwen Code PATH block begin',
`export PATH='${installBinDir}':$PATH`,
'# Qwen Code PATH block end',
'',
].join('\n'),
),
).toBe(true);
const resolvedQwen = execFileSync(
'bash',
['-c', 'source "${HOME}/.bashrc"; command -v qwen'],
{
env: {
...process.env,
HOME: home,
PATH: `${fakeBin}:${process.env.PATH}`,
SHELL: '/bin/bash',
},
},
)
.toString()
.trim();
expect(resolvedQwen).toBe(installedBin);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
restoreMinimalDist(createdDist);
}
},
);
itOnUnix(
'removes installer-owned shell rc PATH blocks even when extra lines are inserted',
() => {
@ -2420,6 +2841,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => {
).toString();
expect(output).toContain('Unsupported shell for automatic PATH update');
expect(output).toContain(path.join(installRoot, 'bin'));
expect(existsSync(path.join(home, '.profile'))).toBe(false);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
@ -2637,6 +3059,30 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => {
}
});
itOnUnix('rejects empty standalone archives', () => {
const createdDist = ensureMinimalDist();
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));
try {
const archive = path.join(tmpDir, 'qwen-code-linux-x64.tar.gz');
execFileSync('tar', ['-czf', archive, '-T', '/dev/null'], {
stdio: 'ignore',
});
writeChecksumFile(tmpDir, path.basename(archive));
expect(() =>
runUnixInstaller(
archive,
path.join(tmpDir, 'install'),
path.join(tmpDir, 'home'),
),
).toThrow(/Archive is empty/);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
restoreMinimalDist(createdDist);
}
});
itOnUnix(
'rejects standalone archives containing path traversal entries',
() => {

View file

@ -13,6 +13,7 @@ import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { RELEASE_TARGETS } from './build-standalone-release.js';
import { TARGETS } from './create-standalone-package.js';
import {
fail,
isMainModule,
@ -42,9 +43,8 @@ const REMOTE_FETCH_TIMEOUT_MS = 30_000;
// has to be reflected here (and there) before a new target ships, otherwise
// the verify and the build will disagree on expected filenames.
function standaloneArchiveNamesFromReleaseTargets(releaseTargets) {
return releaseTargets.map(
({ qwenTarget }) =>
`qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`,
return releaseTargets.map(({ qwenTarget }) =>
standaloneArchiveName(qwenTarget),
);
}
@ -118,14 +118,23 @@ async function verifyReleaseDirectory(dir, options = {}) {
const { silent = false } = options;
const checksums = readReleaseChecksums(dir);
assertExpectedChecksumEntries(checksums);
assertExpectedArchiveFiles(dir);
const unexpected = fs
.readdirSync(dir)
.filter((fileName) => !EXPECTED_RELEASE_ASSET_NAMES.includes(fileName))
.sort();
if (unexpected.length > 0) {
fail(`Unexpected file(s) in release directory: ${unexpected.join(', ')}`);
}
for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) {
const assetPath = path.join(dir, assetName);
if (!fs.existsSync(assetPath)) {
fail(`Missing release asset: ${assetName}`);
}
if (!fs.lstatSync(assetPath).isFile()) {
fail(`Release asset is not a regular file: ${assetName}`);
}
const actual = await sha256File(assetPath);
const expected = checksums.get(assetName);
if (actual !== expected) {
@ -145,6 +154,7 @@ async function verifyReleaseDirectory(dir, options = {}) {
async function verifyReleaseBaseUrl(baseUrl, options = {}) {
const { fetchImpl = fetch } = options;
const normalizedBaseUrl = normalizeHttpsBaseUrl(baseUrl);
const displayBaseUrl = redactUrlForLog(normalizedBaseUrl);
const checksumUrl = new URL('SHA256SUMS', normalizedBaseUrl).toString();
const checksums = parseSha256Sums(await fetchText(checksumUrl, fetchImpl));
assertExpectedChecksumEntries(checksums);
@ -152,7 +162,7 @@ async function verifyReleaseBaseUrl(baseUrl, options = {}) {
await assertRemoteAssetChecksums(normalizedBaseUrl, checksums, fetchImpl);
console.log(
`Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets at ${baseUrl}`,
`Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets at ${displayBaseUrl}`,
);
}
@ -161,6 +171,9 @@ function readReleaseChecksums(dir) {
if (!fs.existsSync(checksumPath)) {
fail(`SHA256SUMS was not found at ${checksumPath}`);
}
if (!fs.lstatSync(checksumPath).isFile()) {
fail('SHA256SUMS is not a regular file');
}
return parseSha256Sums(fs.readFileSync(checksumPath, 'utf8'));
}
@ -182,18 +195,6 @@ function assertExpectedChecksumEntries(checksums) {
}
}
function assertExpectedArchiveFiles(dir) {
const expected = new Set(EXPECTED_RELEASE_ASSET_NAMES);
const extra = fs
.readdirSync(dir)
.filter((assetName) => !expected.has(assetName))
.sort();
if (extra.length > 0) {
fail(`Unexpected release asset: ${extra.join(', ')}`);
}
}
function releaseAssetPaths(dir) {
return EXPECTED_RELEASE_ASSET_NAMES.map((assetName) =>
path.join(dir, assetName),
@ -228,8 +229,9 @@ async function assertRemoteAssetChecksums(
return;
}
if (failures.length === EXPECTED_STANDALONE_ARCHIVE_NAMES.length) {
const displayBaseUrl = redactUrlForLog(normalizedBaseUrl);
fail(
`All ${failures.length} release asset URLs are unavailable; check --base-url: ${normalizedBaseUrl}`,
`All ${failures.length} release asset URLs are unavailable; check --base-url: ${displayBaseUrl}`,
);
}
fail(
@ -240,14 +242,16 @@ async function assertRemoteAssetChecksums(
}
async function fetchSha256(url, fetchImpl) {
const displayUrl = redactUrlForLog(url);
const response = await fetchWithTimeout(fetchImpl, url);
assertNotRedirectResponse(response, displayUrl);
if (!response.ok) {
fail(
`Failed to download ${url}: ${response.status} ${response.statusText}`,
`Failed to download ${displayUrl}: ${response.status} ${response.statusText}`,
);
}
if (!response.body) {
fail(`Downloaded response has no body: ${url}`);
fail(`Downloaded response has no body: ${displayUrl}`);
}
const hash = crypto.createHash('sha256');
@ -263,10 +267,12 @@ function formatErrorReason(reason) {
}
async function fetchText(url, fetchImpl) {
const displayUrl = redactUrlForLog(url);
const response = await fetchWithTimeout(fetchImpl, url);
assertNotRedirectResponse(response, displayUrl);
if (!response.ok) {
fail(
`Failed to download ${url}: ${response.status} ${response.statusText}`,
`Failed to download ${displayUrl}: ${response.status} ${response.statusText}`,
);
}
return response.text();
@ -275,29 +281,221 @@ async function fetchText(url, fetchImpl) {
function fetchWithTimeout(fetchImpl, url, options = {}) {
return fetchImpl(url, {
...options,
redirect: 'manual',
signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS),
});
}
function assertNotRedirectResponse(response, displayUrl) {
if (response.status >= 300 && response.status < 400) {
fail(`Redirect responses are not allowed: ${displayUrl}`);
}
}
function normalizeHttpsBaseUrl(baseUrl) {
let parsed;
try {
parsed = new URL(baseUrl);
} catch {
fail(`--base-url must be a valid URL: ${baseUrl}`);
fail(`--base-url must be a valid URL: ${redactUrlForLog(baseUrl)}`);
}
const displayBaseUrl = redactUrlForLog(parsed.toString());
if (parsed.protocol !== 'https:') {
fail(`--base-url must use https: ${baseUrl}`);
fail(`--base-url must use https: ${displayBaseUrl}`);
}
if (isPrivateOrReservedHost(parsed.hostname)) {
fail(`--base-url must not target a private network: ${displayBaseUrl}`);
}
parsed.username = '';
parsed.password = '';
if (!parsed.pathname.endsWith('/')) {
parsed.pathname = `${parsed.pathname}/`;
}
return parsed.toString();
}
function redactUrlForLog(url) {
try {
const parsed = new URL(url);
parsed.username = '';
parsed.password = '';
parsed.search = '';
parsed.hash = '';
return parsed.toString();
} catch {
const value = String(url);
return value.includes('@') || value.includes('?') || value.includes('#')
? '<redacted URL>'
: value;
}
}
function standaloneArchiveName(qwenTarget) {
const targetConfig = TARGETS.get(qwenTarget);
if (!targetConfig) {
fail(`Unknown release target: ${qwenTarget}`);
}
return `qwen-code-${qwenTarget}.${targetConfig.outputExtension}`;
}
function isPrivateOrReservedHost(hostname) {
const normalized = hostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '');
if (!normalized) {
return true;
}
if (normalized === 'localhost' || normalized.endsWith('.localhost')) {
return true;
}
const mappedIpv4 = ipv4FromMappedIpv6(normalized);
if (mappedIpv4) {
return isPrivateOrReservedIpv4(mappedIpv4);
}
if (parseIpv4Octets(normalized)) {
return isPrivateOrReservedIpv4(normalized);
}
if (!normalized.includes(':')) {
return false;
}
// IPv4-compatible IPv6 (deprecated RFC 4291 §2.5.5.1): ::x.x.x.x or ::HHHH:HHHH
const compatIpv4 = ipv4FromCompatibleIpv6(normalized);
if (compatIpv4) {
return isPrivateOrReservedIpv4(compatIpv4);
}
return isPrivateOrReservedIpv6(normalized);
}
function parseIpv4Octets(value) {
const parts = value.split('.');
if (parts.length !== 4 || !parts.every((part) => /^\d+$/.test(part))) {
return null;
}
const octets = parts.map(Number);
if (octets.some((octet) => octet < 0 || octet > 255)) {
return null;
}
return octets;
}
function isPrivateOrReservedIpv4(value) {
const octets = parseIpv4Octets(value);
if (!octets) {
return false;
}
const [first, second, third] = octets;
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 0 && third === 0) ||
(first === 192 && second === 0 && third === 2) ||
(first === 192 && second === 168) ||
(first === 198 && (second === 18 || second === 19)) ||
(first === 198 && second === 51 && third === 100) ||
(first === 203 && second === 0 && third === 113) ||
first >= 224
);
}
function ipv4FromMappedIpv6(value) {
const match = value.match(/^(?:::ffff:|0:0:0:0:0:ffff:)(.+)$/i);
if (!match) {
return null;
}
const suffix = match[1];
if (parseIpv4Octets(suffix)) {
return suffix;
}
// Node.js normalizes IPv4-mapped IPv6 to hex form. Handle both 2-part
// (::ffff:7f00:1) and 3-part (::ffff:0:7f00:1) representations.
const hexParts = suffix.split(':');
if (
(hexParts.length !== 2 && hexParts.length !== 3) ||
!hexParts.every((part) => /^[0-9a-f]{1,4}$/i.test(part))
) {
return null;
}
// For 3 parts like "0:7f00:1", skip the leading zero segment.
const relevantParts =
hexParts.length === 3
? hexParts[0] === '0'
? hexParts.slice(-2)
: null
: hexParts;
if (!relevantParts) {
return null;
}
const high = Number.parseInt(relevantParts[0], 16);
const low = Number.parseInt(relevantParts[1], 16);
return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`;
}
// Detect IPv4-compatible IPv6 addresses (::x.x.x.x or ::HHHH:HHHH form).
// These are deprecated (RFC 4291) but Node.js URL parser still accepts them.
function ipv4FromCompatibleIpv6(value) {
// Must start with :: but NOT ::ffff: (already handled by ipv4FromMappedIpv6)
if (!value.startsWith('::') || /^::ffff:/i.test(value)) {
return null;
}
const suffix = value.slice(2);
if (!suffix || suffix.startsWith(':')) {
return null;
}
// Dotted-quad form: ::169.254.169.254
if (parseIpv4Octets(suffix)) {
return suffix;
}
// Hex form: ::a9fe:a9fe (two hex groups encoding 4 IPv4 octets)
const hexParts = suffix.split(':');
if (
hexParts.length !== 2 ||
!hexParts.every((part) => /^[0-9a-f]{1,4}$/i.test(part))
) {
return null;
}
const high = Number.parseInt(hexParts[0], 16);
const low = Number.parseInt(hexParts[1], 16);
return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`;
}
function isPrivateOrReservedIpv6(value) {
if (value === '::' || value === '::1' || value === '0:0:0:0:0:0:0:1') {
return true;
}
const firstHextet = Number.parseInt(value.split(':', 1)[0] || '0', 16);
if (Number.isNaN(firstHextet)) {
return false;
}
return (
(firstHextet >= 0xfc00 && firstHextet <= 0xfdff) ||
(firstHextet >= 0xfe80 && firstHextet <= 0xfebf)
);
}
export {
EXPECTED_STANDALONE_ARCHIVE_NAMES,
EXPECTED_RELEASE_ASSET_NAMES,
isPrivateOrReservedHost,
redactUrlForLog,
releaseAssetPaths,
verifyReleaseBaseUrl,
verifyReleaseDirectory,