mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
58b4f35dfa
commit
aef3e704b4
18 changed files with 1490 additions and 373 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -608,4 +608,4 @@ function fail(message) {
|
|||
throw new Error(`Error: ${message}`);
|
||||
}
|
||||
|
||||
export { writeSha256Sums };
|
||||
export { TARGETS, writeSha256Sums };
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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%
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue