qwen-code/scripts/installation/install-qwen-standalone.ps1
易良 aef3e704b4
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.
2026-06-04 17:23:04 +08:00

402 lines
14 KiB
PowerShell

# Qwen Code Windows hosted PowerShell entrypoint.
# Pairs with install-qwen-standalone.bat: this shim downloads the .bat into TEMP,
# verifies its checksum, and runs it with forwarded arguments.
#
# PowerShell (runs in current session, qwen available immediately):
# irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex
#
# cmd.exe (runs in current session, qwen available immediately):
# curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat -o %TEMP%\install-qwen.bat && %TEMP%\install-qwen.bat
#
# To pin a specific release, set $env:QWEN_INSTALL_VERSION before invoking,
# e.g. $env:QWEN_INSTALL_VERSION = 'vX.Y.Z'. This is equivalent to passing
# --version vX.Y.Z to install-qwen-standalone.bat directly.
#
# To point this shim at a non-production hosted endpoint (staging buckets,
# private mirrors), set $env:QWEN_INSTALLER_BAT_URL to the alternate .bat URL.
# The override is required to be HTTPS so a misconfigured value can't silently
# downgrade the download channel. The downstream .bat continues to honor
# QWEN_INSTALL_BASE_URL for archive resolution.
#
# By default the matching SHA256SUMS file is read from the same hosted
# directory as the .bat. Set $env:QWEN_INSTALLER_CHECKSUMS_URL to override it
# when testing a custom installer endpoint.
$ErrorActionPreference = 'Stop'
function Download-File {
param([string]$Url, [string]$OutFile)
$prevProgressPreference = $global:ProgressPreference
$global:ProgressPreference = 'SilentlyContinue'
try {
if (Get-Command curl.exe -ErrorAction SilentlyContinue) {
curl.exe --connect-timeout 15 --max-time 300 --retry 2 -sSfLo $OutFile $Url
if ($LASTEXITCODE -ne 0) {
throw "curl.exe download failed (exit code $LASTEXITCODE)"
}
return
}
Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300
} finally {
$global:ProgressPreference = $prevProgressPreference
}
}
function Get-QwenInstallBase {
if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_ROOT)) {
return $env:QWEN_INSTALL_ROOT
}
if (-not [string]::IsNullOrEmpty($env:LOCALAPPDATA)) {
return Join-Path $env:LOCALAPPDATA 'qwen-code'
}
return Join-Path (Join-Path $env:USERPROFILE 'AppData\Local') 'qwen-code'
}
function Get-QwenInstallBinDir {
if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_BIN_DIR)) {
return $env:QWEN_INSTALL_BIN_DIR
}
return Join-Path (Get-QwenInstallBase) 'bin'
}
function Get-CurrentCmdShimStatePath {
return Join-Path (Get-QwenInstallBase) 'current-cmd-shim.txt'
}
function Save-CurrentCmdPathShim {
param([string]$ShimPath)
if ([string]::IsNullOrEmpty($ShimPath)) {
return
}
try {
$statePath = Get-CurrentCmdShimStatePath
New-Item -ItemType Directory -Path (Split-Path -Parent $statePath) -Force | Out-Null
[IO.File]::WriteAllText($statePath, $ShimPath, [Text.UTF8Encoding]::new($false))
} catch {
# Best-effort cleanup hint only. The installer still works if this fails.
}
}
function Update-CurrentSessionPath {
param([string]$BinDir)
if ([string]::IsNullOrEmpty($BinDir)) {
return
}
$entries = @($env:Path -split ';' | Where-Object { -not [string]::IsNullOrEmpty($_) })
foreach ($entry in $entries) {
if ([string]::Equals($entry, $BinDir, [StringComparison]::OrdinalIgnoreCase)) {
return
}
}
$env:Path = (@($BinDir) + $entries) -join ';'
}
function Get-ParentProcessName {
try {
$current = Get-CimInstance Win32_Process -Filter "ProcessId = $PID" -ErrorAction Stop
if ($null -eq $current -or $null -eq $current.ParentProcessId) {
return $null
}
$parent = Get-CimInstance Win32_Process -Filter "ProcessId = $($current.ParentProcessId)" -ErrorAction Stop
if ($null -eq $parent) {
return $null
}
return $parent.Name
} catch {
return $null
}
}
function Get-NormalizedPath {
param([string]$PathValue)
if ([string]::IsNullOrEmpty($PathValue)) {
return $null
}
$trimmed = $PathValue.Trim().Trim('"')
if ([string]::IsNullOrEmpty($trimmed)) {
return $null
}
try {
return [IO.Path]::GetFullPath($trimmed).TrimEnd('\')
} catch {
return $trimmed.TrimEnd('\')
}
}
function Test-PathContainsDirectory {
param([string]$PathValue, [string]$Directory)
$target = Get-NormalizedPath -PathValue $Directory
if ([string]::IsNullOrEmpty($target)) {
return $false
}
foreach ($entry in @($PathValue -split ';')) {
$normalizedEntry = Get-NormalizedPath -PathValue $entry
if ([string]::Equals($normalizedEntry, $target, [StringComparison]::OrdinalIgnoreCase)) {
return $true
}
}
return $false
}
function Test-WritableDirectory {
param([string]$Directory)
if ([string]::IsNullOrEmpty($Directory)) {
return $false
}
if (-not (Test-Path -LiteralPath $Directory -PathType Container)) {
return $false
}
$probe = Join-Path $Directory ('.qwen-write-test-' + [IO.Path]::GetRandomFileName())
try {
[IO.File]::WriteAllText($probe, '')
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
return $true
} catch {
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
return $false
}
}
function Add-PathCandidate {
param(
[System.Collections.Generic.List[string]]$Candidates,
[string]$Directory
)
$normalizedDirectory = Get-NormalizedPath -PathValue $Directory
if ([string]::IsNullOrEmpty($normalizedDirectory)) {
return
}
foreach ($candidate in $Candidates) {
$normalizedCandidate = Get-NormalizedPath -PathValue $candidate
if ([string]::Equals($normalizedCandidate, $normalizedDirectory, [StringComparison]::OrdinalIgnoreCase)) {
return
}
}
[void]$Candidates.Add($Directory.Trim().Trim('"'))
}
function Test-SystemManagedPathDirectory {
param([string]$Directory)
$normalizedDirectory = Get-NormalizedPath -PathValue $Directory
return (
-not [string]::IsNullOrEmpty($normalizedDirectory) -and
$normalizedDirectory -match '\\Microsoft\\WindowsApps$'
)
}
function Install-CurrentCmdPathShim {
param([string]$QwenCommand, [string]$PathValue)
$pathEntries = @($PathValue -split ';' | Where-Object { -not [string]::IsNullOrEmpty($_) })
$candidates = [System.Collections.Generic.List[string]]::new()
$preferredDirectories = @()
if (-not [string]::IsNullOrEmpty($env:APPDATA)) {
$preferredDirectories += Join-Path $env:APPDATA 'npm'
}
if (-not [string]::IsNullOrEmpty($env:USERPROFILE)) {
$preferredDirectories += Join-Path $env:USERPROFILE '.bun\bin'
}
foreach ($preferredDirectory in $preferredDirectories) {
$preferredNormalized = Get-NormalizedPath -PathValue $preferredDirectory
foreach ($entry in $pathEntries) {
$entryNormalized = Get-NormalizedPath -PathValue $entry
if ([string]::Equals($entryNormalized, $preferredNormalized, [StringComparison]::OrdinalIgnoreCase)) {
Add-PathCandidate -Candidates $candidates -Directory $entry
}
}
}
$userRoot = Get-NormalizedPath -PathValue $env:USERPROFILE
foreach ($entry in $pathEntries) {
if (Test-SystemManagedPathDirectory -Directory $entry) {
continue
}
$entryNormalized = Get-NormalizedPath -PathValue $entry
if (
-not [string]::IsNullOrEmpty($userRoot) -and
-not [string]::IsNullOrEmpty($entryNormalized) -and
$entryNormalized.StartsWith($userRoot, [StringComparison]::OrdinalIgnoreCase)
) {
Add-PathCandidate -Candidates $candidates -Directory $entry
}
}
foreach ($candidate in $candidates) {
if (-not (Test-WritableDirectory -Directory $candidate)) {
continue
}
$shimPath = Join-Path $candidate 'qwen.cmd'
if (Test-Path -LiteralPath $shimPath -PathType Leaf) {
$existingShim = Get-Content -LiteralPath $shimPath -Raw -ErrorAction SilentlyContinue
if ($existingShim -notmatch 'Qwen Code current-session shim') {
continue
}
}
$shim = "@echo off`r`nREM Qwen Code current-session shim. Generated by install-qwen-standalone.ps1.`r`ncall `"$QwenCommand`" %*`r`n"
# Write to a sibling temp file first, then atomically rename so a partial
# write (process killed, disk full) cannot leave a half-written shim on
# PATH.
$shimTempPath = "$shimPath.new"
[IO.File]::WriteAllText($shimTempPath, $shim, [Text.UTF8Encoding]::new($false))
Move-Item -LiteralPath $shimTempPath -Destination $shimPath -Force
Save-CurrentCmdPathShim -ShimPath $shimPath
return $shimPath
}
return $null
}
function Update-CurrentShell {
$qwenInstallBinDir = Get-QwenInstallBinDir
$qwenCommandPath = Join-Path $qwenInstallBinDir 'qwen.cmd'
if (-not (Test-Path -LiteralPath $qwenCommandPath -PathType Leaf)) {
return
}
if ($env:QWEN_NO_MODIFY_PATH -eq '1') {
return
}
$inheritedPath = $env:Path
Update-CurrentSessionPath -BinDir $qwenInstallBinDir
$parentProcessName = Get-ParentProcessName
if ($parentProcessName -ieq 'cmd.exe') {
if (Test-PathContainsDirectory -PathValue $inheritedPath -Directory $qwenInstallBinDir) {
return
}
$shimPath = Install-CurrentCmdPathShim -QwenCommand $qwenCommandPath -PathValue $inheritedPath
if (-not [string]::IsNullOrEmpty($shimPath)) {
return
}
return
}
}
$qwenDefaultInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat'
$qwenDefaultChecksumsUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/SHA256SUMS'
if ([string]::IsNullOrEmpty($env:QWEN_INSTALLER_BAT_URL)) {
$qwenInstallerUrl = $qwenDefaultInstallerUrl
} else {
if ($env:QWEN_INSTALLER_BAT_URL -notmatch '^https://') {
Write-Error "QWEN_INSTALLER_BAT_URL must start with https://"
exit 1
}
$qwenInstallerUrl = $env:QWEN_INSTALLER_BAT_URL
}
if ([string]::IsNullOrEmpty($env:QWEN_INSTALLER_CHECKSUMS_URL)) {
if ($qwenInstallerUrl -eq $qwenDefaultInstallerUrl) {
$qwenChecksumsUrl = $qwenDefaultChecksumsUrl
} else {
$qwenChecksumsUrl = [Uri]::new([Uri]$qwenInstallerUrl, 'SHA256SUMS').AbsoluteUri
}
} else {
if ($env:QWEN_INSTALLER_CHECKSUMS_URL -notmatch '^https://') {
Write-Error "QWEN_INSTALLER_CHECKSUMS_URL must start with https://"
exit 1
}
$qwenChecksumsUrl = $env:QWEN_INSTALLER_CHECKSUMS_URL
}
$qwenInstallerName = [IO.Path]::GetFileName(([Uri]$qwenInstallerUrl).AbsolutePath)
if ([string]::IsNullOrEmpty($qwenInstallerName)) {
$qwenInstallerName = 'install-qwen-standalone.bat'
}
if ([string]::IsNullOrEmpty($env:TEMP)) {
Write-Error "TEMP environment variable is not set. Please set TEMP to a writable directory."
exit 1
}
# Use a cryptographically random staging filename so a same-user attacker cannot
# pre-stage a malicious .bat at a predictable path and race the verify/execute
# window between Get-FileHash and `& $qwenInstallerPath`.
$qwenStagingSuffix = [IO.Path]::GetRandomFileName()
$qwenInstallerPath = Join-Path $env:TEMP "qwen-installer-$qwenStagingSuffix.bat"
$qwenChecksumsPath = Join-Path $env:TEMP "qwen-installation-SHA256SUMS-$qwenStagingSuffix"
try {
Download-File -Url $qwenInstallerUrl -OutFile $qwenInstallerPath
} catch {
Write-Error "Failed to download Qwen Code installer from ${qwenInstallerUrl}: $($_.Exception.Message)"
exit 1
}
try {
Download-File -Url $qwenChecksumsUrl -OutFile $qwenChecksumsPath
} catch {
Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue
Write-Error "Failed to download Qwen Code installer checksums from ${qwenChecksumsUrl}: $($_.Exception.Message)"
exit 1
}
$qwenExpectedHash = $null
foreach ($qwenChecksumLine in Get-Content -LiteralPath $qwenChecksumsPath) {
if ($qwenChecksumLine -match '^([0-9a-fA-F]{64})\s+\*?(.+)$') {
if ($Matches[2] -eq $qwenInstallerName) {
$qwenExpectedHash = $Matches[1].ToLowerInvariant()
break
}
}
}
if ([string]::IsNullOrEmpty($qwenExpectedHash)) {
Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $qwenChecksumsPath -Force -ErrorAction SilentlyContinue
Write-Error "Checksum entry for ${qwenInstallerName} not found in ${qwenChecksumsUrl}"
exit 1
}
$qwenActualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $qwenInstallerPath).Hash.ToLowerInvariant()
if ($qwenActualHash -ne $qwenExpectedHash) {
Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $qwenChecksumsPath -Force -ErrorAction SilentlyContinue
Write-Error "Checksum mismatch for ${qwenInstallerName}: expected ${qwenExpectedHash}, got ${qwenActualHash}."
exit 1
}
$qwenInstallerExitCode = 0
$qwenPreviousParentPowerShell = $env:QWEN_INSTALLER_PARENT_POWERSHELL
try {
$env:QWEN_INSTALLER_PARENT_POWERSHELL = '1'
& $qwenInstallerPath @args
$qwenInstallerExitCode = $LASTEXITCODE
} finally {
if ($null -eq $qwenPreviousParentPowerShell) {
Remove-Item Env:\QWEN_INSTALLER_PARENT_POWERSHELL -ErrorAction SilentlyContinue
} else {
$env:QWEN_INSTALLER_PARENT_POWERSHELL = $qwenPreviousParentPowerShell
}
Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $qwenChecksumsPath -Force -ErrorAction SilentlyContinue
}
if ($qwenInstallerExitCode -ne 0) {
exit $qwenInstallerExitCode
}
Update-CurrentShell