diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02cfa0bd6..2f35e2827 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -381,6 +381,11 @@ jobs: npm run bundle npm run prepare:package + - name: 'Build Standalone Archives' + env: + RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' + run: 'npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone' + - name: 'Publish @qwen-code/qwen-code' working-directory: 'dist' run: |- @@ -413,6 +418,8 @@ jobs: gh release create "${RELEASE_TAG}" \ dist/cli.js \ + dist/standalone/qwen-code-* \ + dist/standalone/SHA256SUMS \ --target "${RELEASE_BRANCH}" \ --title "Release ${RELEASE_TAG}" \ --notes-start-tag "${PREVIOUS_RELEASE_TAG}" \ diff --git a/.gitignore b/.gitignore index 2dae5710a..f6ea62c7f 100644 --- a/.gitignore +++ b/.gitignore @@ -89,4 +89,4 @@ storybook-static # Dev symlink: qc-helper bundled skill docs (created by scripts/dev.js) packages/core/src/skills/bundled/qc-helper/docs -tmp/ \ No newline at end of file +tmp/ diff --git a/README.md b/README.md index b900dc3b4..3d817d4da 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,18 @@ Qwen Code is an open-source AI agent for the terminal, optimized for Qwen series ### Quick Install (Recommended) +The installer uses a standalone Qwen Code archive when one is available for +your platform, so the default path does not require a preinstalled Node.js +runtime. If a standalone archive is not available, it falls back to npm and then +requires Node.js 20 or later with npm on PATH. + #### Linux / macOS ```bash bash -c "$(curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh)" ``` -#### Windows (Run as Administrator) +#### Windows Works in both Command Prompt and PowerShell: @@ -57,13 +62,18 @@ Works in both Command Prompt and PowerShell: 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')" ``` -> **Note**: It's recommended to restart your terminal after installation to ensure environment variables take effect. +> **Note**: It's recommended to restart your terminal after installation if +> `qwen` is not immediately available on PATH. For offline installation, download +> a release archive such as `qwen-code-linux-x64.tar.gz` or +> `qwen-code-win-x64.zip` plus `SHA256SUMS`, then run the installer with +> `--archive PATH`. ### Manual Installation #### Prerequisites -Make sure you have Node.js 20 or later installed. Download it from [nodejs.org](https://nodejs.org/en/download). +Manual npm installation requires Node.js 20 or later. Download it from +[nodejs.org](https://nodejs.org/en/download). #### NPM diff --git a/docs/users/overview.md b/docs/users/overview.md index 55506fefa..a40753d76 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -9,13 +9,17 @@ ### 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 +available on PATH. + **Linux / macOS** ```sh curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.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')" @@ -23,7 +27,11 @@ powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou > [!note] > -> It's recommended to restart your terminal after installation to ensure environment variables take effect. If the installation fails, please refer to [Manual Installation](./quickstart#manual-installation) in the Quickstart guide. +> It's recommended to restart your terminal after installation if `qwen` is not +> immediately available on PATH. If the installation fails, please refer to +> [Manual Installation](./quickstart#manual-installation) in the Quickstart +> guide. For offline installation, download a release archive and run the +> installer with `--archive PATH`; keep `SHA256SUMS` next to the archive. ### Start using Qwen Code: diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index ee3ff6081..aba0a8c07 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -12,6 +12,10 @@ Make sure you have: - A code project to work with - An API key from Alibaba Cloud Model Studio ([Beijing](https://bailian.console.aliyun.com/) / [intl](https://modelstudio.console.alibabacloud.com/)), or an Alibaba Cloud Coding Plan ([Beijing](https://bailian.console.aliyun.com/cn-beijing/?tab=coding-plan#/efm/coding-plan-index) / [intl](https://modelstudio.console.alibabacloud.com/?tab=coding-plan#/efm/coding-plan-index)) subscription +The recommended installer uses a standalone archive when one is available for +your platform. If it falls back to npm, you will need Node.js 20 or later with +npm available on PATH. + ## Step 1: Install Qwen Code To install Qwen Code, use one of the following methods: @@ -24,7 +28,7 @@ To install Qwen Code, use one of the following methods: curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.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')" @@ -32,13 +36,17 @@ powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou > [!note] > -> It's recommended to restart your terminal after installation to ensure environment variables take effect. +> It's recommended to restart your terminal after installation if `qwen` is not +> immediately available on PATH. For offline installation, download a release +> archive such as `qwen-code-linux-x64.tar.gz` or `qwen-code-win-x64.zip` plus +> `SHA256SUMS`, then run the installer with `--archive PATH`. ### Manual Installation **Prerequisites** -Make sure you have Node.js 20 or later installed. Download it from [nodejs.org](https://nodejs.org/en/download). +Manual npm installation requires Node.js 20 or later. Download it from +[nodejs.org](https://nodejs.org/en/download). **NPM** diff --git a/package.json b/package.json index da6f1bf6e..b8e386847 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,8 @@ "preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci", "prepare": "husky && npm run build && npm run bundle", "prepare:package": "node scripts/prepare-package.js", + "package:standalone": "node scripts/create-standalone-package.js", + "package:standalone:release": "node scripts/build-standalone-release.js", "release:version": "node scripts/version.js", "telemetry": "node scripts/telemetry.js", "check:lockfile": "node scripts/check-lockfile.js", diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js new file mode 100644 index 000000000..b0bf2bd17 --- /dev/null +++ b/scripts/build-standalone-release.js @@ -0,0 +1,300 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { fileURLToPath } from 'node:url'; +import { writeSha256Sums } from './create-standalone-package.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '..'); + +const RELEASE_TARGETS = [ + { + qwenTarget: 'darwin-arm64', + nodeTarget: 'darwin-arm64', + nodeArchiveExtension: 'tar.gz', + }, + { + qwenTarget: 'darwin-x64', + nodeTarget: 'darwin-x64', + nodeArchiveExtension: 'tar.gz', + }, + { + qwenTarget: 'linux-arm64', + nodeTarget: 'linux-arm64', + nodeArchiveExtension: 'tar.xz', + }, + { + qwenTarget: 'linux-x64', + nodeTarget: 'linux-x64', + nodeArchiveExtension: 'tar.xz', + }, + { qwenTarget: 'win-x64', nodeTarget: 'win-x64', nodeArchiveExtension: 'zip' }, +]; +const EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length; + +if (isMainModule()) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + printUsage(); + return; + } + + const nodeVersion = args.nodeVersion || process.versions.node; + const outDir = path.resolve( + args.outDir || path.join(rootDir, 'dist', 'standalone'), + ); + const runtimeParent = path.resolve( + args.runtimeDir || process.env.RUNNER_TEMP || os.tmpdir(), + ); + fs.mkdirSync(runtimeParent, { recursive: true }); + const runtimeDir = fs.mkdtempSync( + path.join(runtimeParent, 'qwen-node-runtime-'), + ); + const nodeDistUrl = `https://nodejs.org/dist/v${nodeVersion}`; + + try { + fs.mkdirSync(outDir, { recursive: true }); + const checksumsPath = path.join(runtimeDir, 'SHASUMS256.txt'); + await downloadFile(`${nodeDistUrl}/SHASUMS256.txt`, checksumsPath); + const checksums = parseChecksums(fs.readFileSync(checksumsPath, 'utf8')); + + for (const target of RELEASE_TARGETS) { + await packageTarget({ + ...target, + nodeDistUrl, + nodeVersion, + outDir, + releaseVersion: args.version, + runtimeDir, + checksums, + }); + } + + await writeSha256Sums(outDir); + assertStandaloneOutput(outDir); + } finally { + fs.rmSync(runtimeDir, { recursive: true, force: true }); + } +} + +function isMainModule() { + return process.argv[1] && path.resolve(process.argv[1]) === __filename; +} + +async function packageTarget({ + qwenTarget, + nodeTarget, + nodeArchiveExtension, + nodeDistUrl, + nodeVersion, + outDir, + releaseVersion, + runtimeDir, + checksums, +}) { + const archiveName = `node-v${nodeVersion}-${nodeTarget}.${nodeArchiveExtension}`; + const archivePath = path.join(runtimeDir, archiveName); + + await downloadFile(`${nodeDistUrl}/${archiveName}`, archivePath); + await verifyNodeArchive(archivePath, archiveName, checksums); + + const args = [ + 'scripts/create-standalone-package.js', + '--target', + qwenTarget, + '--node-archive', + archivePath, + '--out-dir', + outDir, + '--skip-checksums', + ]; + if (releaseVersion) { + args.push('--version', releaseVersion); + } + + execFileSync(process.execPath, args, { + cwd: rootDir, + stdio: 'inherit', + }); +} + +async function downloadFile(url, destination) { + console.log(`Downloading ${url}`); + const response = await fetch(url); + if (!response.ok) { + fail( + `Failed to download ${url}: ${response.status} ${response.statusText}`, + ); + } + if (!response.body) { + fail(`Failed to download ${url}: response body was empty`); + } + await pipeline( + Readable.fromWeb(response.body), + fs.createWriteStream(destination), + ); +} + +function parseChecksums(content) { + const checksums = new Map(); + for (const line of content.split(/\r?\n/)) { + const [hash, fileName] = line.trim().split(/\s+/, 2); + if (hash && fileName) { + checksums.set(fileName.replace(/^\*/, ''), hash); + } + } + return checksums; +} + +async function verifyNodeArchive(archivePath, archiveName, checksums) { + const expected = checksums.get(archiveName); + if (!expected) { + fail(`Node.js SHASUMS256.txt does not list ${archiveName}`); + } + + const actual = await sha256File(archivePath); + if (actual !== expected) { + fail(`Checksum verification failed for ${archiveName}`); + } + + console.log(`Verified Node.js runtime checksum for ${archiveName}`); +} + +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + +function assertStandaloneOutput(outDir) { + const checksumPath = path.join(outDir, 'SHA256SUMS'); + if (!fs.existsSync(checksumPath)) { + fail(`Standalone SHA256SUMS was not created at ${checksumPath}`); + } + + const archiveNames = fs + .readFileSync(checksumPath, 'utf8') + .split(/\r?\n/) + .filter((line) => /^[0-9a-f]{64}\s+/.test(line)) + .map((line) => line.trim().split(/\s+/, 2)[1]?.replace(/^\*/, '')) + .filter(Boolean) + .sort(); + const expectedArchiveNames = RELEASE_TARGETS.map( + ({ qwenTarget }) => + `qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`, + ).sort(); + const missing = expectedArchiveNames.filter( + (archiveName) => !archiveNames.includes(archiveName), + ); + const extra = archiveNames.filter( + (archiveName) => !expectedArchiveNames.includes(archiveName), + ); + + if ( + archiveNames.length !== EXPECTED_ARCHIVE_COUNT || + missing.length > 0 || + extra.length > 0 + ) { + fail( + [ + `Expected standalone checksums for ${expectedArchiveNames.join(', ')}`, + `found ${archiveNames.join(', ') || 'none'}.`, + missing.length > 0 ? `Missing: ${missing.join(', ')}.` : '', + extra.length > 0 ? `Extra: ${extra.join(', ')}.` : '', + ] + .filter(Boolean) + .join(' '), + ); + } + + console.log(`Verified ${archiveNames.length} standalone release checksums.`); +} + +function parseArgs(argv) { + const args = { + help: false, + nodeVersion: undefined, + outDir: undefined, + runtimeDir: undefined, + version: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--help': + case '-h': + args.help = true; + break; + case '--node-version': + args.nodeVersion = readOptionValue(argv, index, arg); + index += 1; + break; + case '--out-dir': + args.outDir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--runtime-dir': + args.runtimeDir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--version': + args.version = readOptionValue(argv, index, arg); + index += 1; + break; + default: + fail(`Unknown option: ${arg}`); + } + } + + return args; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + +function printUsage() { + console.log(` +Usage: + npm run package:standalone:release -- [OPTIONS] + +Options: + --version VERSION Release version written to standalone manifests. + --out-dir PATH Output directory. Defaults to dist/standalone. + --runtime-dir PATH Temporary Node.js runtime download directory. + --node-version VERSION Node.js version to download. Defaults to current Node. +`); +} + +function fail(message) { + throw new Error(`ERROR: ${message}`); +} + +export { assertStandaloneOutput, parseChecksums, RELEASE_TARGETS }; diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js new file mode 100644 index 000000000..968d6da45 --- /dev/null +++ b/scripts/create-standalone-package.js @@ -0,0 +1,605 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '..'); +const distDir = path.join(rootDir, 'dist'); + +const TARGETS = new Map([ + [ + 'darwin-arm64', + { outputExtension: 'tar.gz', nodeExecutable: ['bin', 'node'] }, + ], + [ + 'darwin-x64', + { outputExtension: 'tar.gz', nodeExecutable: ['bin', 'node'] }, + ], + [ + 'linux-arm64', + { outputExtension: 'tar.gz', nodeExecutable: ['bin', 'node'] }, + ], + ['linux-x64', { outputExtension: 'tar.gz', nodeExecutable: ['bin', 'node'] }], + ['win-x64', { outputExtension: 'zip', nodeExecutable: ['node.exe'] }], +]); + +const DIST_REQUIRED_PATHS = ['cli.js', 'vendor', 'bundled/qc-helper/docs']; +const DIST_ALLOWED_ENTRIES = new Set([ + 'cli.js', + 'vendor', + 'bundled', + 'package.json', + 'README.md', + 'LICENSE', + 'locales', + 'examples', +]); +const DIST_ALLOWED_ENTRY_PATTERNS = [ + /^sandbox-macos-(permissive|restrictive)-(open|closed|proxied)\.sb$/, +]; +const ROOT_REQUIRED_PATHS = ['README.md', 'LICENSE']; + +if (isMainModule()) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + printUsage(); + return; + } + + const target = args.target; + if (!target || !TARGETS.has(target)) { + fail(`--target must be one of: ${Array.from(TARGETS.keys()).join(', ')}`); + } + + if (!args.nodeArchive) { + fail('--node-archive is required'); + } + + const nodeArchive = path.resolve(args.nodeArchive); + if (!fs.existsSync(nodeArchive)) { + fail(`Node.js archive not found: ${nodeArchive}`); + } + + assertRequiredInputs(); + + const version = args.version || readPackageVersion(); + const outDir = path.resolve(args.outDir || path.join(distDir, 'standalone')); + fs.mkdirSync(outDir, { recursive: true }); + + const targetConfig = TARGETS.get(target); + const outputName = `qwen-code-${target}.${targetConfig.outputExtension}`; + const outputPath = path.join(outDir, outputName); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-standalone-')); + + try { + const packageRoot = path.join(tempRoot, 'qwen-code'); + const runtimeExtractDir = path.join(tempRoot, 'runtime'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.mkdirSync(runtimeExtractDir, { recursive: true }); + + copyRuntimeAssets(packageRoot, outDir); + extractNodeArchive(nodeArchive, runtimeExtractDir); + const nodeDir = path.join(packageRoot, 'node'); + copyExtractedNode(runtimeExtractDir, nodeDir); + validateNodeRuntime(target, nodeDir); + writeShims(packageRoot); + writeManifest(packageRoot, { + version, + target, + nodeArchive: path.basename(nodeArchive), + }); + + if (fs.existsSync(outputPath)) { + fs.rmSync(outputPath, { force: true }); + } + createArchive(targetConfig.outputExtension, outputPath, tempRoot); + if (!args.skipChecksums) { + await writeSha256Sums(outDir); + } + + console.log(`Created ${path.relative(rootDir, outputPath)}`); + if (!args.skipChecksums) { + console.log( + `Updated ${path.relative(rootDir, path.join(outDir, 'SHA256SUMS'))}`, + ); + } + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function isMainModule() { + return process.argv[1] && path.resolve(process.argv[1]) === __filename; +} + +function parseArgs(argv) { + const args = { + help: false, + outDir: undefined, + nodeArchive: undefined, + skipChecksums: false, + target: undefined, + version: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--help': + case '-h': + args.help = true; + break; + case '--target': + args.target = readOptionValue(argv, index, arg); + index += 1; + break; + case '--node-archive': + args.nodeArchive = readOptionValue(argv, index, arg); + index += 1; + break; + case '--out-dir': + args.outDir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--version': + args.version = readOptionValue(argv, index, arg); + index += 1; + break; + case '--skip-checksums': + args.skipChecksums = true; + break; + default: + fail(`Unknown option: ${arg}`); + } + } + + return args; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + +function printUsage() { + console.log(`Qwen Code standalone package builder + +Usage: + npm run package:standalone -- --target TARGET --node-archive PATH [OPTIONS] + +Options: + --target TARGET One of: ${Array.from(TARGETS.keys()).join(', ')} + --node-archive PATH Downloaded Node.js runtime archive. + --out-dir DIR Output directory. Defaults to dist/standalone. + --version VERSION Qwen Code version. Defaults to package.json version. + --skip-checksums Do not update SHA256SUMS. Used by release packaging. + -h, --help Show this help message.`); +} + +function assertRequiredInputs() { + if (!fs.existsSync(distDir)) { + fail('dist/ directory not found. Run "npm run bundle" first.'); + } + + for (const relativePath of DIST_REQUIRED_PATHS) { + const fullPath = path.join(distDir, relativePath); + if (!fs.existsSync(fullPath)) { + fail(`Required dist asset missing: ${fullPath}`); + } + } + + for (const relativePath of ROOT_REQUIRED_PATHS) { + const fullPath = path.join(rootDir, relativePath); + if (!fs.existsSync(fullPath)) { + fail(`Required repository file missing: ${fullPath}`); + } + } +} + +function readPackageVersion() { + const packageJsonPath = path.join(rootDir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return packageJson.version; +} + +function copyRuntimeAssets(packageRoot, outDir) { + const libDir = path.join(packageRoot, 'lib'); + const skippedDistEntry = topLevelDistEntryForPath(outDir); + fs.mkdirSync(libDir, { recursive: true }); + + for (const entry of fs.readdirSync(distDir)) { + if (entry === skippedDistEntry || entry === '.DS_Store') { + continue; + } + if (!isAllowedDistEntry(entry)) { + fail(`Unexpected dist asset: ${path.join(distDir, entry)}`); + } + fs.cpSync(path.join(distDir, entry), path.join(libDir, entry), { + recursive: true, + dereference: true, + verbatimSymlinks: false, + }); + } + assertNoSymlinks(libDir, 'Copied runtime assets still contain symlinks.'); + + for (const fileName of ROOT_REQUIRED_PATHS) { + fs.copyFileSync( + path.join(rootDir, fileName), + path.join(packageRoot, fileName), + ); + } + + fs.copyFileSync( + path.join(rootDir, 'package.json'), + path.join(packageRoot, 'package.json'), + ); +} + +function topLevelDistEntryForPath(candidatePath) { + const relative = path.relative(distDir, candidatePath); + if ( + relative === '' || + relative.startsWith('..') || + path.isAbsolute(relative) + ) { + return undefined; + } + + return relative.split(path.sep)[0]; +} + +function isAllowedDistEntry(entry) { + return ( + DIST_ALLOWED_ENTRIES.has(entry) || + DIST_ALLOWED_ENTRY_PATTERNS.some((pattern) => pattern.test(entry)) + ); +} + +function extractNodeArchive(nodeArchive, extractDir) { + if (nodeArchive.endsWith('.zip')) { + extractZipArchive(nodeArchive, extractDir); + return; + } + + if ( + nodeArchive.endsWith('.tar.gz') || + nodeArchive.endsWith('.tgz') || + nodeArchive.endsWith('.tar.xz') + ) { + run('tar', ['-xf', nodeArchive, '-C', extractDir]); + return; + } + + fail( + `Unsupported Node.js archive format: ${nodeArchive}. Expected .zip, .tar.gz, .tgz, or .tar.xz.`, + ); +} + +function extractZipArchive(nodeArchive, extractDir) { + if (process.platform === 'win32') { + run( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + 'Expand-Archive -LiteralPath $env:QWEN_NODE_ARCHIVE -DestinationPath $env:QWEN_EXTRACT_DIR -Force', + ], + { + env: { + ...process.env, + QWEN_NODE_ARCHIVE: nodeArchive, + QWEN_EXTRACT_DIR: extractDir, + }, + }, + ); + return; + } + + run('unzip', ['-q', nodeArchive, '-d', extractDir]); +} + +function copyExtractedNode(extractDir, nodeDir) { + const entries = fs + .readdirSync(extractDir) + .filter((entry) => entry !== '.DS_Store'); + if (entries.length === 0) { + fail('Node.js archive did not contain any files.'); + } + + const sourceRoot = + entries.length === 1 && + fs.statSync(path.join(extractDir, entries[0])).isDirectory() + ? path.join(extractDir, entries[0]) + : extractDir; + + // Official Unix Node.js archives include internal npm/npx symlinks. + // The installer rejects symlinks in final archives, so keep safe internal + // targets by copying their referents during a single checked traversal. + copyNodeRuntimeEntry(sourceRoot, nodeDir, { + realRoot: fs.realpathSync(sourceRoot), + sourceRoot, + activeDirectories: new Set(), + }); +} + +function copyNodeRuntimeEntry(source, destination, state) { + const lstat = fs.lstatSync(source); + + if (lstat.isSymbolicLink()) { + copyNodeRuntimeEntry( + resolveRuntimeSymlink(source, state), + destination, + state, + ); + return; + } + + if (lstat.isDirectory()) { + const realSource = fs.realpathSync(source); + if (state.activeDirectories.has(realSource)) { + fail( + `Node.js runtime contains a symlink cycle at ${displayRuntimePath( + state, + source, + )}`, + ); + } + + state.activeDirectories.add(realSource); + fs.mkdirSync(destination, { recursive: true }); + fs.chmodSync(destination, lstat.mode); + for (const entry of fs.readdirSync(source)) { + copyNodeRuntimeEntry( + path.join(source, entry), + path.join(destination, entry), + state, + ); + } + state.activeDirectories.delete(realSource); + return; + } + + if (lstat.isFile()) { + fs.copyFileSync(source, destination); + fs.chmodSync(destination, lstat.mode); + return; + } + + fail(`Unsupported Node.js runtime entry type: ${source}`); +} + +function resolveRuntimeSymlink(source, state) { + const target = fs.readlinkSync(source); + const resolvedTarget = path.resolve(path.dirname(source), target); + let realTarget; + try { + realTarget = fs.realpathSync(resolvedTarget); + } catch (error) { + const errorCode = + error && typeof error === 'object' && 'code' in error + ? error.code + : undefined; + const reason = + errorCode === 'ELOOP' ? 'a symlink cycle' : 'a missing target'; + fail( + `Node.js runtime symlink points to ${reason}: ${displayRuntimePath( + state, + source, + )} -> ${target}`, + ); + } + + if (!isPathInside(state.realRoot, realTarget)) { + fail( + `Node.js runtime symlink escapes the archive: ${displayRuntimePath( + state, + source, + )} -> ${target}`, + ); + } + + return resolvedTarget; +} + +function displayRuntimePath(state, source) { + return path.relative(state.sourceRoot, source) || '.'; +} + +function assertNoSymlinks(root, message) { + for (const entry of walkDirectory(root)) { + if (fs.lstatSync(entry).isSymbolicLink()) { + fail(`${message} First symlink: ${path.relative(root, entry)}`); + } + } +} + +function* walkDirectory(root) { + for (const entry of fs.readdirSync(root)) { + const fullPath = path.join(root, entry); + yield fullPath; + if (fs.lstatSync(fullPath).isDirectory()) { + yield* walkDirectory(fullPath); + } + } +} + +function isPathInside(root, candidate) { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative)) + ); +} + +function validateNodeRuntime(target, nodeDir) { + const targetConfig = TARGETS.get(target); + const executablePath = path.join(nodeDir, ...targetConfig.nodeExecutable); + const displayPath = targetConfig.nodeExecutable.join('/'); + + if (!fs.existsSync(executablePath)) { + fail(`Node.js runtime for ${target} must contain ${displayPath}.`); + } + + if (target !== 'win-x64') { + const mode = fs.statSync(executablePath).mode; + if ((mode & 0o111) === 0) { + fail( + `Node.js runtime for ${target} must provide executable ${displayPath}.`, + ); + } + } +} + +function writeShims(packageRoot) { + const binDir = path.join(packageRoot, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + + const unixShim = `#!/usr/bin/env sh +set -e +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +exec "$ROOT/node/bin/node" "$ROOT/lib/cli.js" "$@" +`; + const unixShimPath = path.join(binDir, 'qwen'); + fs.writeFileSync(unixShimPath, unixShim); + fs.chmodSync(unixShimPath, 0o755); + + const windowsShim = `@echo off +setlocal +set "ROOT=%~dp0.." +"%ROOT%\\node\\node.exe" "%ROOT%\\lib\\cli.js" %* +`; + fs.writeFileSync(path.join(binDir, 'qwen.cmd'), windowsShim); +} + +function writeManifest(packageRoot, manifest) { + const manifestPath = path.join(packageRoot, 'manifest.json'); + fs.writeFileSync( + manifestPath, + JSON.stringify( + { + name: '@qwen-code/qwen-code', + version: manifest.version, + target: manifest.target, + nodeArchive: manifest.nodeArchive, + createdAt: new Date().toISOString(), + }, + null, + 2, + ) + '\n', + ); +} + +function createArchive(outputExtension, outputPath, cwd) { + if (outputExtension === 'zip') { + createZipArchive(outputPath, cwd); + return; + } + + run('tar', ['-czf', outputPath, '-C', cwd, 'qwen-code']); +} + +function createZipArchive(outputPath, cwd) { + if (process.platform === 'win32') { + run( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + 'Compress-Archive -LiteralPath $env:QWEN_PACKAGE_ROOT -DestinationPath $env:QWEN_OUTPUT_PATH -Force', + ], + { + env: { + ...process.env, + QWEN_PACKAGE_ROOT: path.join(cwd, 'qwen-code'), + QWEN_OUTPUT_PATH: outputPath, + }, + }, + ); + return; + } + + run('zip', ['-qr', outputPath, 'qwen-code'], { cwd }); +} + +async function writeSha256Sums(outDir) { + const entries = fs + .readdirSync(outDir) + .filter( + (entry) => + entry.startsWith('qwen-code-') && + (entry.endsWith('.tar.gz') || entry.endsWith('.zip')), + ) + .sort(); + + if (entries.length === 0) { + fail( + `No qwen-code archives found in ${outDir}; refusing to write empty SHA256SUMS.`, + ); + } + + const lines = []; + for (const entry of entries) { + const filePath = path.join(outDir, entry); + const hash = await sha256File(filePath); + lines.push(`${hash} ${entry}`); + } + + fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); +} + +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + +function run(command, args, options = {}) { + try { + execFileSync(command, args, { + stdio: 'inherit', + ...options, + }); + } catch (error) { + const detail = + error && typeof error === 'object' && 'message' in error + ? `: ${error.message}` + : ''; + fail(`Command failed: ${command} ${args.join(' ')}${detail}`); + } +} + +function fail(message) { + throw new Error(`Error: ${message}`); +} + +export { writeSha256Sums }; diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 8a41bc47a..21a2b52f2 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -1,148 +1,187 @@ # Installation Guide for Qwen Code with Source Tracking -This guide describes how to install Node.js and Qwen Code with source information tracking. +This guide describes the source-tracking installation scripts for Qwen Code. +The scripts prefer standalone release archives and can fall back to npm when a +standalone archive is not available. ## Overview -The installation scripts automate the process of installing Node.js (if not present or below version 20) and Qwen Code, while capturing and storing the installation source information for analytics and tracking purposes. +The installers are intentionally lightweight: + +- They try a standalone archive first by default. +- They do not install Node.js, NVM, or any other Node version manager. +- They do not edit npm config or shell profiles. +- They do not start `qwen` automatically after installation. +- They store source information in `~/.qwen/source.json` or + `%USERPROFILE%\.qwen\source.json` when `--source` is provided. + +Standalone archives include a private Node.js runtime, so users do not need a +local Node.js installation on the standalone path. Node.js 20 or newer and npm +are only required when the installer falls back to npm or when +`--method npm` is used. ## Installation Scripts -We provide platform-specific installation scripts: +- Linux/macOS: `install-qwen-with-source.sh` +- Windows: `install-qwen-with-source.bat` -- **Linux/macOS**: `install-qwen-with-source.sh` -- **Windows**: `install-qwen-with-source.bat` +## Release Artifacts -## Linux/macOS Installation +GitHub releases publish these standalone archives: -### Script: install-qwen-with-source.sh +- `qwen-code-darwin-arm64.tar.gz` +- `qwen-code-darwin-x64.tar.gz` +- `qwen-code-linux-arm64.tar.gz` +- `qwen-code-linux-x64.tar.gz` +- `qwen-code-win-x64.zip` +- `SHA256SUMS` -#### Features: +Archive layout: -- Checks for existing Node.js installation and version -- Installs Node.js 20+ if needed using NVM -- Installs Qwen Code globally with source information -- Stores the source information in `~/.qwen/source.json` +```text +qwen-code/ + bin/qwen + bin/qwen.cmd + lib/cli.js + node/ + package.json + README.md + LICENSE + manifest.json +``` -#### Usage: +## Install Methods + +The default method is `detect`: + +1. Detect the current platform. +2. Try to download and install the matching standalone archive. +3. Verify the archive with `SHA256SUMS`. +4. Fall back to npm if the standalone archive is not available. + +You can force a method: ```bash -# Install with a specific source -sh install-qwen-with-source.sh --source github - -# Install with internal source -sh install-qwen-with-source.sh -s internal - -# Show help -sh install-qwen-with-source.sh --help +bash install-qwen-with-source.sh --method standalone +bash install-qwen-with-source.sh --method npm ``` -#### Supported Source Values: +```bat +install-qwen-with-source.bat --method standalone +install-qwen-with-source.bat --method npm +``` -- `github` - Installed from GitHub repository -- `npm` - Installed from npm registry -- `internal` - Internal installation -- `local-build` - Local build installation +## Optional Native Modules -#### How it Works: +The standalone archives bundle Qwen Code and a private Node.js runtime. They do +not currently install npm optional native modules such as `node-pty` and +`@teddyzhu/clipboard`. Qwen Code is designed to degrade when these optional +modules are absent, but terminal pty behavior and clipboard image support may +not be identical to an npm installation. -1. The script accepts a `--source` parameter to specify where Qwen Code is being installed from -2. It installs Node.js if needed -3. It installs Qwen Code globally -4. It creates `~/.qwen/source.json` with the specified source information +Use `--method npm` if you specifically need npm to resolve optional native +modules for the current machine. -#### Important Notes: - -⚠️ **After installation, you need to restart your terminal or run:** +## Linux/macOS Usage ```bash -source ~/.bashrc # For bash users -# or -source ~/.zshrc # For zsh users +# Default: standalone archive with npm fallback +bash install-qwen-with-source.sh + +# Record a source value +bash install-qwen-with-source.sh --source github + +# Use npm explicitly +bash install-qwen-with-source.sh --method npm --registry https://registry.npmjs.org + +# Use the Aliyun standalone mirror +bash install-qwen-with-source.sh --mirror aliyun + +# Install an offline archive +# SHA256SUMS must be in the same directory. +bash install-qwen-with-source.sh --archive ./qwen-code-linux-x64.tar.gz ``` -This is required to load the newly installed Node.js and Qwen Code into your PATH. +Standalone installs to: -#### Prerequisites: +- Runtime: `~/.local/lib/qwen-code` +- Shim: `~/.local/bin/qwen` -- curl (for NVM installation and script download) -- bash-compatible shell +Override with `QWEN_INSTALL_ROOT`, `QWEN_INSTALL_LIB_PARENT`, +`QWEN_INSTALL_LIB_DIR`, or `QWEN_INSTALL_BIN_DIR` when needed. -## Windows Installation +## Windows Usage -### Script: install-qwen-with-source.bat +```bat +REM Default: standalone archive with npm fallback +install-qwen-with-source.bat -#### Features: +REM Record a source value +install-qwen-with-source.bat --source github -- Checks for existing Node.js installation and version (requires version 18+) -- Automatically downloads and installs Node.js 24 LTS if not present or version is too low -- Installs Qwen Code globally with source information -- Stores the source information in `%USERPROFILE%\.qwen\source.json` +REM Use npm explicitly +install-qwen-with-source.bat --method npm --registry https://registry.npmjs.org -#### Prerequisites: +REM Use the Aliyun standalone mirror +install-qwen-with-source.bat --mirror aliyun -- **PowerShell (Administrator)**: The script must be run in PowerShell with Administrator privileges -- Internet connection for downloading Node.js and Qwen Code - -#### Usage: - -> ⚠️ **Important**: You must run PowerShell as Administrator to install Node.js and global npm packages. - -**Step 1**: Open PowerShell as Administrator - -- Right-click on PowerShell and select "Run as Administrator" -- Or press `Win + X` and select "Windows PowerShell (Admin)" - -**Step 2**: Navigate to the script directory and run: - -```powershell -# Install with a specific source using --source parameter -./install-qwen-with-source.bat --source github - -# Install with short parameter -./install-qwen-with-source.bat -s internal - -# Use default source (unknown) -./install-qwen-with-source.bat +REM Install an offline archive +REM SHA256SUMS must be in the same directory. +install-qwen-with-source.bat --archive qwen-code-win-x64.zip ``` -#### Supported Source Values: +Standalone installs to: -- `github` - Installed from GitHub repository -- `npm` - Installed from npm registry -- `internal` - Internal installation -- `local-build` - Local build installation +- Runtime: `%LOCALAPPDATA%\qwen-code\qwen-code` +- Shim: `%LOCALAPPDATA%\qwen-code\bin\qwen.cmd` -#### How it Works: +Override with `QWEN_INSTALL_ROOT`, `QWEN_INSTALL_LIB_DIR`, or +`QWEN_INSTALL_BIN_DIR` when needed. -1. The script accepts a `--source` or `-s` parameter to specify where Qwen Code is being installed from -2. It checks if Node.js is already installed and if the version is 18 or higher -3. If Node.js is not installed or version is too low, it automatically downloads and installs Node.js 24 LTS -4. It installs Qwen Code globally using npm -5. It creates `%USERPROFILE%\.qwen\source.json` with the specified source information +Restart the terminal if `qwen` is not immediately available on PATH. -#### Why Administrator Privileges are Required: +## Mirrors and Overrides -- Installing Node.js requires writing to `C:\Program Files\nodejs` -- Installing global npm packages requires elevated permissions -- Modifying system PATH environment variables requires Administrator access +Options: -## Installation Source Feature +- `--method detect|standalone|npm` +- `--mirror github|aliyun` +- `--base-url URL` +- `--archive PATH` +- `--version VERSION` +- `--registry REGISTRY` +- `--source SOURCE` -### Overview +Environment variables: -This feature implements the ability to capture and store the installation source of the Qwen Code package. The source information is used for analytics and tracking purposes. +- `QWEN_INSTALL_METHOD` +- `QWEN_INSTALL_MIRROR` +- `QWEN_INSTALL_BASE_URL` +- `QWEN_INSTALL_ARCHIVE` +- `QWEN_INSTALL_VERSION` +- `QWEN_NPM_REGISTRY` -### Storage Location +Use `--base-url` for private mirrors. The URL must contain +`qwen-code-` archives and `SHA256SUMS` in the same directory. Custom +base URLs must use `https://`. -The installation source is stored in a separate file at: +For Aliyun OSS/CDN, release publishing must upload byte-identical artifacts to +both the versioned directory, for example `v0.16.0/`, and the `latest/` +directory used by the default installer path. -- **Unix/Linux/macOS**: `~/.qwen/source.json` -- **Windows**: `%USERPROFILE%\.qwen\source.json` (equivalent to `C:\Users\{username}\.qwen\source.json`) +## Supported Source Values -### File Format +The source value may only contain letters, numbers, dot, underscore, and dash. +Common values are: -The `source.json` file contains: +- `github` +- `npm` +- `internal` +- `local-build` + +## Source Tracking + +When `--source` or `-s` is provided, the installer writes: ```json { @@ -150,53 +189,23 @@ The `source.json` file contains: } ``` -### How the Source Information is Used +Locations: -1. **Telemetry Tracking**: The source information is included in RUM (Real User Monitoring) telemetry logs -2. **Analytics**: Helps understand how users are discovering and installing Qwen Code -3. **Distribution Analysis**: Tracks which distribution channels are most popular +- Linux/macOS: `~/.qwen/source.json` +- Windows: `%USERPROFILE%\.qwen\source.json` -### Technical Implementation +The telemetry logger reads this file when available. Missing, invalid, or +unreadable source files are ignored. -- The source information is stored as a separate JSON file -- The `QwenLogger` class reads this file during telemetry initialization -- The source is included in the `app.channel` field of the RUM payload -- The implementation gracefully handles missing files, unknown values, and parsing errors +## Manual Installation -### Verification - -After installation and restarting your terminal (or sourcing your shell configuration), you can verify the source information: - -**Linux/macOS:** - -```bash -cat ~/.qwen/source.json -``` - -**Windows:** - -```cmd -type %USERPROFILE%\.qwen\source.json -``` - -## Manual Installation (Without Source Tracking) - -If you prefer not to use the installation scripts or don't want source tracking: - -### Prerequisites - -```bash -# Node.js 20+ -curl -qL https://www.npmjs.com/install.sh | sh -``` - -### NPM Installation +If source tracking is not needed and Node.js 20 or newer is already available: ```bash npm install -g @qwen-code/qwen-code@latest ``` -### Homebrew (macOS, Linux) +Homebrew users can also install Qwen Code with: ```bash brew install qwen-code @@ -204,47 +213,47 @@ brew install qwen-code ## Troubleshooting -### Script Execution Issues +### Standalone Archive Missing -**Linux/macOS:** +In `detect` mode, the installer falls back to npm. In `standalone` mode, install +fails so that automation can detect the missing artifact. + +### Node.js Missing or Too Old + +This only blocks npm installation. Install or activate Node.js 20 or newer, then +rerun the installer with `--method npm` or let `detect` fall back again. + +### npm Missing + +Install a Node.js distribution that includes npm, then rerun the installer. + +### Permission Errors During npm Install + +The installers do not rewrite npm prefix settings. If global npm installation +fails with a permission error, fix the npm global install location or use a +user-owned Node.js installation, then rerun: ```bash -# Run with sh -sh install-qwen-with-source.sh --source github +npm install -g @qwen-code/qwen-code@latest --registry https://registry.npmmirror.com ``` -**Windows (PowerShell as Administrator):** +### qwen Is Not on PATH After Installation -```powershell -# Run the script with --source parameter -./install-qwen-with-source.bat --source github +Restart the terminal first. For standalone installs, add the shim directory: -# Or with short parameter -./install-qwen-with-source.bat -s github +```bash +export PATH="$HOME/.local/bin:$PATH" ``` -### Node.js Installation Issues +For npm installs, add npm's global binary directory. On Linux/macOS this is +usually: -**Linux/macOS:** +```bash +export PATH="$(npm prefix -g)/bin:$PATH" +``` -- Ensure NVM is installed: `curl -o- https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install_nvm.sh | bash` -- Restart your terminal or run: `source ~/.bashrc` +On Windows standalone installs, add this directory to PATH: -**Windows:** - -- Install NVM for Windows from: https://github.com/coreybutler/nvm-windows/releases -- After installation, run the script again - -### Permission Issues - -You may need administrative privileges for global npm installation: - -- **Linux/macOS**: Use `sudo` with npm -- **Windows**: Run PowerShell as Administrator (required for Node.js installation and global npm packages) - -## Notes - -- The scripts require internet access to download Node.js and Qwen Code -- Administrative privileges may be required for global npm installation -- The installation source is stored locally and used for tracking purposes only -- If the source file is missing or invalid, the application continues to work normally +```bat +%LOCALAPPDATA%\qwen-code\bin +``` diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index fe5263e0e..c8ce8cf06 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -1,304 +1,771 @@ @echo off -REM Script to install Node.js and Qwen Code with source information -REM This script handles the installation process and sets the installation source -REM -REM Usage: install-qwen-with-source.bat --source -REM install-qwen-with-source.bat -s -REM +REM Qwen Code Installation Script +REM Installs Qwen Code from a standalone archive when available, with npm fallback. +REM This script intentionally does not install Node.js or change npm config. setlocal enabledelayedexpansion set "SOURCE=unknown" +set "METHOD=" +if defined QWEN_INSTALL_METHOD set "METHOD=!QWEN_INSTALL_METHOD!" +set "MIRROR=github" +if defined QWEN_INSTALL_MIRROR set "MIRROR=!QWEN_INSTALL_MIRROR!" +set "BASE_URL=" +if defined QWEN_INSTALL_BASE_URL set "BASE_URL=!QWEN_INSTALL_BASE_URL!" +set "ARCHIVE_PATH=" +if defined QWEN_INSTALL_ARCHIVE set "ARCHIVE_PATH=!QWEN_INSTALL_ARCHIVE!" +set "VERSION=latest" +if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!" +set "NPM_REGISTRY=https://registry.npmmirror.com" +if defined QWEN_NPM_REGISTRY set "NPM_REGISTRY=!QWEN_NPM_REGISTRY!" +if defined LOCALAPPDATA ( + set "INSTALL_BASE=!LOCALAPPDATA!\qwen-code" +) else ( + set "INSTALL_BASE=!USERPROFILE!\AppData\Local\qwen-code" +) +if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!" +set "INSTALL_DIR=!INSTALL_BASE!\qwen-code" +if defined QWEN_INSTALL_LIB_DIR set "INSTALL_DIR=!QWEN_INSTALL_LIB_DIR!" +set "INSTALL_BIN_DIR=!INSTALL_BASE!\bin" +if defined QWEN_INSTALL_BIN_DIR set "INSTALL_BIN_DIR=!QWEN_INSTALL_BIN_DIR!" -REM Parse command line arguments +REM Parse flags before any network or filesystem work. :parse_args if "%~1"=="" goto end_parse if /i "%~1"=="--source" ( - if not "%~2"=="" ( - set "SOURCE=%~2" - shift - shift - goto parse_args + if "%~2"=="" ( + echo ERROR: --source requires a value + exit /b 1 ) + set "SOURCE=%~2" + shift + shift + goto parse_args ) if /i "%~1"=="-s" ( - if not "%~2"=="" ( - set "SOURCE=%~2" - shift - shift - goto parse_args + if "%~2"=="" ( + echo ERROR: -s requires a value + exit /b 1 ) + set "SOURCE=%~2" + shift + shift + goto parse_args ) -shift -goto parse_args +if /i "%~1"=="--method" ( + if "%~2"=="" ( + echo ERROR: --method requires a value + exit /b 1 + ) + set "METHOD=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--mirror" ( + if "%~2"=="" ( + echo ERROR: --mirror requires a value + exit /b 1 + ) + set "MIRROR=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--base-url" ( + if "%~2"=="" ( + echo ERROR: --base-url requires a value + exit /b 1 + ) + set "BASE_URL=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--archive" ( + if "%~2"=="" ( + echo ERROR: --archive requires a value + exit /b 1 + ) + set "ARCHIVE_PATH=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--version" ( + if "%~2"=="" ( + echo ERROR: --version requires a value + exit /b 1 + ) + set "VERSION=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--registry" ( + if "%~2"=="" ( + echo ERROR: --registry requires a value + exit /b 1 + ) + set "NPM_REGISTRY=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="-h" goto usage +if /i "%~1"=="--help" goto usage + +echo ERROR: Unknown option: %~1 +echo. +goto usage_error :end_parse -echo =========================================== -echo Qwen Code Installation Script with Source Tracking -echo =========================================== -echo. -echo INFO: Installation source: %SOURCE% -echo. +call :ValidateOptions +if %ERRORLEVEL% NEQ 0 exit /b 1 -REM Check if Node.js is already installed -call :CheckCommandExists node -if !ERRORLEVEL! EQU 0 ( - for /f "delims=" %%i in ('node --version') do set "NODE_VERSION=%%i" - echo INFO: Node.js is already installed: !NODE_VERSION! - - REM Extract major version number - set "MAJOR_VERSION=!NODE_VERSION:v=!" - for /f "tokens=1 delims=." %%a in ("!MAJOR_VERSION!") do ( - set "MAJOR_VERSION=%%a" - ) - - if !MAJOR_VERSION! GEQ 20 ( - echo INFO: Node.js version !NODE_VERSION! is sufficient. Skipping Node.js installation. - goto :InstallQwenCode +echo =========================================== +echo Qwen Code Installation Script +echo =========================================== +echo. +echo INFO: Install method: !METHOD! +if /i not "!METHOD!"=="npm" ( + echo INFO: Standalone mirror: !MIRROR! + if not "!BASE_URL!"=="" echo INFO: Standalone base URL: !BASE_URL! + if not "!ARCHIVE_PATH!"=="" ( + echo INFO: Standalone archive: !ARCHIVE_PATH! ) else ( - echo INFO: Node.js version !NODE_VERSION! is too low. Need version 20 or higher. - echo INFO: Installing Node.js 20+ - call :InstallNodeJSDirectly - if !ERRORLEVEL! NEQ 0 ( - echo ERROR: Failed to install Node.js. Cannot continue with Qwen Code installation. - exit /b 1 - ) - ) -) else ( - echo INFO: Node.js not found. Installing Node.js 20+ - call :InstallNodeJSDirectly - if !ERRORLEVEL! NEQ 0 ( - echo ERROR: Failed to install Node.js. Cannot continue with Qwen Code installation. - exit /b 1 + echo INFO: Standalone version: !VERSION! ) ) +if /i not "!METHOD!"=="standalone" echo INFO: npm registry: !NPM_REGISTRY! +if not "!SOURCE!"=="unknown" echo INFO: Installation source: !SOURCE! +echo. -:InstallQwenCode - -REM Verify npm is available before installing Qwen Code -REM Always use full path to npm to avoid local node_modules conflicts -set "NODEJS_PATH=C:\Program Files\nodejs" -set "NODEJS_PATH_X86=C:\Program Files (x86)\nodejs" - -if exist "!NODEJS_PATH!\npm.cmd" ( - echo INFO: Using npm from !NODEJS_PATH! - set "NPM_CMD=!NODEJS_PATH!\npm.cmd" -) else if exist "!NODEJS_PATH_X86!\npm.cmd" ( - echo INFO: Using npm from !NODEJS_PATH_X86! - set "NPM_CMD=!NODEJS_PATH_X86!\npm.cmd" -) else ( - call :CheckCommandExists npm - if !ERRORLEVEL! NEQ 0 ( - echo ERROR: npm command not found. Node.js installation may have failed. - echo INFO: Please restart your command prompt and try again. - echo INFO: If the problem persists, manually install Node.js from: https://nodejs.org/ - exit /b 1 - ) - set "NPM_CMD=npm" +REM Dispatch after validation; detect falls back to npm only when unavailable. +if /i "!METHOD!"=="standalone" ( + call :InstallStandalone + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + endlocal + exit /b 0 ) -REM Install Qwen Code with source information -echo INFO: Installing Qwen Code with source: %SOURCE% -echo INFO: Running: %NPM_CMD% install -g @qwen-code/qwen-code@latest --registry https://registry.npmmirror.com -call "%NPM_CMD%" install -g @qwen-code/qwen-code@latest --registry https://registry.npmmirror.com +if /i "!METHOD!"=="npm" ( + call :InstallNpm + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "" + endlocal + exit /b 0 +) -if %ERRORLEVEL% EQU 0 ( - echo SUCCESS: Qwen Code installed successfully! -) else ( - echo ERROR: Failed to install Qwen Code. +call :InstallStandalone +set "STANDALONE_STATUS=!ERRORLEVEL!" +if !STANDALONE_STATUS! EQU 0 ( + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + endlocal + exit /b 0 +) + +if !STANDALONE_STATUS! EQU 2 ( + echo WARNING: Falling back to npm installation. + call :InstallNpm + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Standalone archive was unavailable before npm fallback; npm fallback also failed. + echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 20+ and rerun --method npm. + exit /b !ERRORLEVEL! + ) + call :PrintFinalInstructions "" + endlocal + exit /b 0 +) + +echo WARNING: Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure. +exit /b !STANDALONE_STATUS! + +:usage +call :PrintUsage +exit /b 0 + +:usage_error +call :PrintUsage +exit /b 1 + +:PrintUsage +echo Qwen Code Installer +echo. +echo Usage: install-qwen-with-source.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: github or aliyun. +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 -h, --help Show this help message. +exit /b 0 + +:ValidateOptions +if "!METHOD!"=="" set "METHOD=detect" + +set "QWEN_VALIDATE_METHOD=!METHOD!" +set "QWEN_VALIDATE_MIRROR=!MIRROR!" +set "QWEN_VALIDATE_BASE_URL=!BASE_URL!" +set "QWEN_VALIDATE_ARCHIVE_PATH=!ARCHIVE_PATH!" +set "QWEN_VALIDATE_VERSION=!VERSION!" +set "QWEN_VALIDATE_NPM_REGISTRY=!NPM_REGISTRY!" +set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" +set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_VALIDATE_METHOD=" +set "QWEN_VALIDATE_MIRROR=" +set "QWEN_VALIDATE_BASE_URL=" +set "QWEN_VALIDATE_ARCHIVE_PATH=" +set "QWEN_VALIDATE_VERSION=" +set "QWEN_VALIDATE_NPM_REGISTRY=" +set "QWEN_VALIDATE_INSTALL_BASE=" +set "QWEN_VALIDATE_INSTALL_DIR=" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=" +if %PS_STATUS% NEQ 0 ( + echo ERROR: installer options contain unsafe command characters. exit /b 1 ) -REM Create source.json only if --source or -s was explicitly provided -if not "!SOURCE!"=="unknown" ( - echo INFO: Creating source.json in %USERPROFILE%\.qwen... - - set "QWEN_DIR=%USERPROFILE%\.qwen" - if not exist "!QWEN_DIR!" ( - mkdir "!QWEN_DIR!" - ) - - REM Create the source.json file with the installation source - ( - echo { - echo "source": "!SOURCE!" - echo } - ) > "!QWEN_DIR!\source.json" - - echo SUCCESS: Installation source saved to %USERPROFILE%\.qwen\source.json +if "!INSTALL_BASE!"=="" ( + echo ERROR: QWEN_INSTALL_ROOT must not be empty. + exit /b 1 ) - -REM Verify installation -call :CheckCommandExists qwen -if %ERRORLEVEL% EQU 0 ( - echo SUCCESS: Qwen Code is available as 'qwen' command. - call qwen --version - echo. - echo INFO: Starting Qwen Code... - echo. - call qwen -) else ( - echo WARNING: Qwen Code may not be in PATH. Please check your npm global bin directory. - echo. - echo =========================================== - echo SUCCESS: Installation completed! - echo The source information is stored in %USERPROFILE%\.qwen\source.json - echo. - echo =========================================== +if "!INSTALL_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_LIB_DIR must not be empty. + exit /b 1 ) +if "!INSTALL_BIN_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_BIN_DIR must not be empty. + exit /b 1 +) +if "!INSTALL_BASE:~1,2!"==":\" goto validate_install_base_ok +if "!INSTALL_BASE:~1,2!"==":/" goto validate_install_base_ok +if "!INSTALL_BASE:~0,2!"=="\\" goto validate_install_base_ok +echo ERROR: QWEN_INSTALL_ROOT must be an absolute path. +exit /b 1 +:validate_install_base_ok +if "!INSTALL_DIR:~1,2!"==":\" goto validate_install_dir_ok +if "!INSTALL_DIR:~1,2!"==":/" goto validate_install_dir_ok +if "!INSTALL_DIR:~0,2!"=="\\" goto validate_install_dir_ok +echo ERROR: QWEN_INSTALL_LIB_DIR must be an absolute path. +exit /b 1 +:validate_install_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":\" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":/" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~0,2!"=="\\" goto validate_install_bin_dir_ok +echo ERROR: QWEN_INSTALL_BIN_DIR must be an absolute path. +exit /b 1 +:validate_install_bin_dir_ok -endlocal -exit /b 0 +if /i "!METHOD!"=="detect" goto validate_method_ok +if /i "!METHOD!"=="standalone" goto validate_method_ok +if /i "!METHOD!"=="npm" goto validate_method_ok +echo ERROR: --method must be detect, standalone, or npm. +exit /b 1 -REM ============================================================ -REM Function: CheckCommandExists -REM Description: Check if a command exists in the system -REM ============================================================ -:CheckCommandExists -where %~1 >nul 2>&1 +:validate_method_ok +if /i "!MIRROR!"=="github" goto validate_mirror_ok +if /i "!MIRROR!"=="aliyun" goto validate_mirror_ok +echo ERROR: --mirror must be github or aliyun. +exit /b 1 + +:validate_mirror_ok +call :ValidateHttpsUrlVar "BASE_URL" "--base-url" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateVersion +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateSource exit /b %ERRORLEVEL% -REM ============================================================ -REM Function: InstallNodeJSDirectly -REM Description: Download and install Node.js directly from official website -REM ============================================================ -:InstallNodeJSDirectly -echo INFO: Downloading Node.js LTS (20.x) from official website +:ValidateHttpsUrlVar +set "URL_VALUE=!%~1!" +set "URL_OPTION=%~2" +if "!URL_VALUE!"=="" exit /b 0 +if /i "!URL_VALUE:~0,8!"=="https://" exit /b 0 -REM Create temp directory for download -set "TEMP_DIR=%TEMP%\qwen-nodejs-install" -if not exist "%TEMP_DIR%" mkdir "%TEMP_DIR%" +echo ERROR: !URL_OPTION! must start with https:// +exit /b 1 -REM Determine architecture -set "ARCH=x64" -if "%PROCESSOR_ARCHITECTURE%"=="x86" set "ARCH=x86" -if "%PROCESSOR_ARCHITECTURE%"=="AMD64" set "ARCH=x64" -if defined PROCESSOR_ARCHITEW6432 set "ARCH=x64" +: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 +echo ERROR: --version must be 'latest' or a semver string. +exit /b 1 -REM Set Node.js download URL (LTS version 20.x) -set "NODE_VERSION=20.18.1" -set "NODE_URL=https://nodejs.org/dist/v!NODE_VERSION!/node-v!NODE_VERSION!-!ARCH!.msi" -set "NODE_INSTALLER=%TEMP_DIR%\nodejs-installer.msi" +:ValidateSource +if "!SOURCE!"=="unknown" exit /b 0 +echo(!SOURCE!| findstr /R /C:"^[A-Za-z0-9._-][A-Za-z0-9._-]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 -echo INFO: Downloading from: !NODE_URL! -echo INFO: Architecture: !ARCH! +echo ERROR: --source may only contain letters, numbers, dot, underscore, or dash. +exit /b 1 -REM Download Node.js installer using PowerShell -powershell -Command "try { Invoke-WebRequest -Uri '!NODE_URL!' -OutFile '!NODE_INSTALLER!' -UseBasicParsing; Write-Host 'Download completed successfully.' } catch { Write-Host 'Download failed:' $_.Exception.Message; exit 1 }" - -if !ERRORLEVEL! NEQ 0 ( - echo ERROR: Failed to download Node.js installer from official source. - echo INFO: Please manually download and install Node.js from: https://nodejs.org/ - echo INFO: After manual installation, restart your command prompt and run this script again. +:DetectTarget +set "TARGET=" +if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" set "TARGET=win-x64" +if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" set "TARGET=win-x64" +if "!TARGET!"=="" ( + echo WARNING: Standalone archive is not available for this Windows architecture. exit /b 1 ) +exit /b 0 -if not exist "!NODE_INSTALLER!" ( - echo ERROR: Node.js installer not found after download. - exit /b 1 +:ReleaseVersionPath +if /i "!VERSION!"=="latest" ( + set "VERSION_PATH=latest" + exit /b 0 ) +set "VERSION_PATH=!VERSION!" +if /i "!VERSION_PATH:~0,1!"=="v" exit /b 0 +set "VERSION_PATH=v!VERSION_PATH!" +exit /b 0 -echo INFO: Installing Node.js silently -REM Install Node.js silently -msiexec /i "!NODE_INSTALLER!" /quiet /norestart ADDLOCAL=ALL - -if !ERRORLEVEL! NEQ 0 ( - echo ERROR: Failed to install Node.js. - echo INFO: You may need to run this script as Administrator. - echo INFO: Or manually install Node.js from: https://nodejs.org/ - exit /b 1 -) - -echo INFO: Node.js installation completed. - -REM Clean up installer -del "!NODE_INSTALLER!" 2>nul -rmdir "!TEMP_DIR!" 2>nul - -REM Refresh environment variables -echo INFO: Refreshing environment variables -call :RefreshEnvVars - -REM Verify installation and return success -set "NODEJS_INSTALL_PATH=C:\Program Files\nodejs" -if exist "!NODEJS_INSTALL_PATH!\node.exe" ( - for /f "delims=" %%i in ('"!NODEJS_INSTALL_PATH!\node.exe" --version') do set "NODE_VERSION=%%i" - echo SUCCESS: Node.js !NODE_VERSION! installed successfully! +:StandaloneBaseUrl +if not "!BASE_URL!"=="" ( + set "STANDALONE_BASE_URL=!BASE_URL!" exit /b 0 ) -set "NODEJS_INSTALL_PATH_X86=C:\Program Files (x86)\nodejs" -if exist "!NODEJS_INSTALL_PATH_X86!\node.exe" ( - for /f "delims=" %%i in ('"!NODEJS_INSTALL_PATH_X86!\node.exe" --version') do set "NODE_VERSION=%%i" - echo SUCCESS: Node.js !NODE_VERSION! installed successfully! +call :ReleaseVersionPath +if /i "!MIRROR!"=="aliyun" ( + set "STANDALONE_BASE_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/!VERSION_PATH!" exit /b 0 ) -call :CheckCommandExists node -if !ERRORLEVEL! EQU 0 ( - for /f "delims=" %%i in ('node --version') do set "NODE_VERSION=%%i" - echo SUCCESS: Node.js !NODE_VERSION! installed successfully! +if /i "!VERSION_PATH!"=="latest" ( + set "STANDALONE_BASE_URL=https://github.com/QwenLM/qwen-code/releases/latest/download" exit /b 0 +) + +set "STANDALONE_BASE_URL=https://github.com/QwenLM/qwen-code/releases/download/!VERSION_PATH!" +exit /b 0 + +:UrlExists +set "QWEN_CHECK_URL=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_CHECK_URL=" +exit /b %PS_STATUS% + +:DownloadFile +set "QWEN_DOWNLOAD_URL=%~1" +set "QWEN_DOWNLOAD_DEST=%~2" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $client = New-Object Net.WebClient; $client.DownloadFile($env:QWEN_DOWNLOAD_URL, $env:QWEN_DOWNLOAD_DEST); exit 0 } catch { exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_DOWNLOAD_URL=" +set "QWEN_DOWNLOAD_DEST=" +exit /b %PS_STATUS% + +:VerifyChecksum +set "ARCHIVE_FILE=%~1" +set "CHECKSUM_SOURCE=%~2" +set "ARCHIVE_NAME=%~3" +set "CHECKSUM_FILE=!CHECKSUM_SOURCE!" +set "TEMP_CHECKSUM=" +set "REQUIRE_CHECKSUM=1" + +if "!CHECKSUM_FILE!"=="" ( + for %%I in ("!ARCHIVE_FILE!") do set "CHECKSUM_FILE=%%~dpISHA256SUMS" ) else ( - echo WARNING: Node.js installed but not found in PATH. - echo INFO: Trying to use Node.js from default installation path - - REM Try to use Node.js directly from installation path - set "NODE_PATH=C:\Program Files\nodejs" - if exist "%NODE_PATH%\node.exe" ( - echo INFO: Found Node.js at %NODE_PATH% - REM Update PATH for current session - set "PATH=%PATH%;%NODE_PATH%" - - REM Test if node works now - "%NODE_PATH%\node.exe" --version >nul 2>&1 - if !ERRORLEVEL! EQU 0 ( - for /f "delims=" %%i in ('"%NODE_PATH%\node.exe" --version') do set "NODE_VERSION=%%i" - echo SUCCESS: Node.js %NODE_VERSION% is working from %NODE_PATH% - exit /b 0 + if /i "!CHECKSUM_FILE:~0,8!"=="https://" ( + set "REQUIRE_CHECKSUM=1" + set "TEMP_CHECKSUM=%TEMP%\qwen-code-checksums-%RANDOM%%RANDOM%.txt" + call :DownloadFile "!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. + exit /b 1 ) + set "CHECKSUM_FILE=!TEMP_CHECKSUM!" ) - - REM Try x86 path - set "NODE_PATH_X86=C:\Program Files (x86)\nodejs" - if exist "%NODE_PATH_X86%\node.exe" ( - echo INFO: Found Node.js at %NODE_PATH_X86% - REM Update PATH for current session - set "PATH=%PATH%;%NODE_PATH_X86%" - - REM Test if node works now - "%NODE_PATH_X86%\node.exe" --version >nul 2>&1 - if !ERRORLEVEL! EQU 0 ( - for /f "delims=" %%i in ('"%NODE_PATH_X86%\node.exe" --version') do set "NODE_VERSION=%%i" - echo SUCCESS: Node.js %NODE_VERSION% is working from %NODE_PATH_X86% - exit /b 0 - ) +) + +if not exist "!CHECKSUM_FILE!" ( + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: SHA256SUMS not found; cannot verify archive. + exit /b 1 ) - - echo ERROR: Node.js installation completed but cannot be executed + echo WARNING: SHA256SUMS not found; skipping checksum verification. + exit /b 0 +) + +set "EXPECTED_HASH=" +for /f "usebackq tokens=1,2" %%H in ("!CHECKSUM_FILE!") do ( + set "CHECKSUM_HASH=%%H" + set "CHECKSUM_NAME=%%I" + if "!CHECKSUM_NAME:~0,1!"=="*" set "CHECKSUM_NAME=!CHECKSUM_NAME:~1!" + if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!" ( + if "!EXPECTED_HASH!"=="" set "EXPECTED_HASH=!CHECKSUM_HASH!" + ) +) + +if "!EXPECTED_HASH!"=="" ( + if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: Checksum entry for !ARCHIVE_NAME! not found. + exit /b 1 + ) + echo WARNING: Checksum entry for !ARCHIVE_NAME! not found; skipping checksum verification. + exit /b 0 +) + +set "ACTUAL_HASH=" +set "QWEN_HASH_FILE=!ARCHIVE_FILE!" +for /f "delims=" %%H in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; (Get-FileHash -Algorithm SHA256 -LiteralPath $env:QWEN_HASH_FILE).Hash" 2^>nul') do ( + if "!ACTUAL_HASH!"=="" set "ACTUAL_HASH=%%H" +) +set "QWEN_HASH_FILE=" + +if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + +if "!ACTUAL_HASH!"=="" ( + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: Could not calculate SHA-256 checksum for archive. + exit /b 1 + ) + echo WARNING: Could not calculate SHA-256 checksum; skipping checksum verification. + exit /b 0 +) + +if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( + echo ERROR: Checksum verification failed for !ARCHIVE_NAME!. exit /b 1 ) +echo SUCCESS: Checksum verified for !ARCHIVE_NAME!. exit /b 0 -REM ============================================================ -REM Function: RefreshEnvVars -REM Description: Refresh environment variables without restarting -REM ============================================================ -:RefreshEnvVars -REM Add Node.js to PATH if not already there -set "NODEJS_DIR=C:\Program Files\nodejs" -if exist "!NODEJS_DIR!\node.exe" ( - echo INFO: Found Node.js at !NODEJS_DIR! - set "PATH=!PATH!;!NODEJS_DIR!" +:InstallStandalone +set "TEMP_DIR=" +set "CHECKSUM_SOURCE=" + +REM Resolve the archive from a local file or from the configured release mirror. +if not "!ARCHIVE_PATH!"=="" ( + set "ARCHIVE_FILE=!ARCHIVE_PATH!" + for %%I in ("!ARCHIVE_FILE!") do set "ARCHIVE_NAME=%%~nxI" + if not exist "!ARCHIVE_FILE!" ( + echo ERROR: Standalone archive not found: !ARCHIVE_FILE! + exit /b 1 + ) +) else ( + call :DetectTarget + if !ERRORLEVEL! NEQ 0 exit /b 2 + + set "ARCHIVE_NAME=qwen-code-win-x64.zip" + call :StandaloneBaseUrl + set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" + set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" + + if /i "!METHOD!"=="detect" ( + call :UrlExists "!ARCHIVE_URL!" + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Standalone archive not found: !ARCHIVE_NAME! + exit /b 2 + ) + ) + + set "TEMP_DIR=%TEMP%\qwen-code-install-%RANDOM%%RANDOM%" + mkdir "!TEMP_DIR!" >nul 2>&1 + set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" + + echo INFO: Downloading !ARCHIVE_URL! + call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo WARNING: Failed to download standalone archive. + exit /b 2 + ) ) -REM Try alternative path for x86 systems -set "NODEJS_DIR_X86=C:\Program Files (x86)\nodejs" -if exist "!NODEJS_DIR_X86!\node.exe" ( - echo INFO: Found Node.js at !NODEJS_DIR_X86! - set "PATH=!PATH!;!NODEJS_DIR_X86!" +if "!TEMP_DIR!"=="" ( + set "TEMP_DIR=%TEMP%\qwen-code-install-%RANDOM%%RANDOM%" + mkdir "!TEMP_DIR!" >nul 2>&1 ) +REM Verify integrity before extraction or changing the install directory. +call :VerifyChecksum "!ARCHIVE_FILE!" "!CHECKSUM_SOURCE!" "!ARCHIVE_NAME!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +REM Extract into a temporary directory, then validate required entry points. +set "EXTRACT_DIR=!TEMP_DIR!\extract" +mkdir "!EXTRACT_DIR!" >nul 2>&1 +set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" +set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_ARCHIVE_FILE=" +set "QWEN_EXTRACT_DIR=" +if !PS_STATUS! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to extract standalone archive. + exit /b 1 +) + +call :RejectArchiveLinks "!EXTRACT_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +if not exist "!EXTRACT_DIR!\qwen-code\bin\qwen.cmd" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\bin\qwen.cmd. + exit /b 1 +) + +if not exist "!EXTRACT_DIR!\qwen-code\node\node.exe" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\node\node.exe. + exit /b 1 +) + +if not exist "!INSTALL_BASE!" mkdir "!INSTALL_BASE!" +if not exist "!INSTALL_BIN_DIR!" mkdir "!INSTALL_BIN_DIR!" +for %%I in ("!INSTALL_DIR!") do set "INSTALL_PARENT=%%~dpI" +if not exist "!INSTALL_PARENT!" mkdir "!INSTALL_PARENT!" + +REM Stage into .new and keep .old so failed upgrades can roll back. +set "NEW_INSTALL_DIR=!INSTALL_DIR!.new" +set "OLD_INSTALL_DIR=!INSTALL_DIR!.old" + +call :EnsureManagedInstallDir "!INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!NEW_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!OLD_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +if exist "!NEW_INSTALL_DIR!" rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 +if exist "!OLD_INSTALL_DIR!" rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 +move /Y "!EXTRACT_DIR!\qwen-code" "!NEW_INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to stage standalone archive. + exit /b 1 +) + +if exist "!INSTALL_DIR!" ( + move /Y "!INSTALL_DIR!" "!OLD_INSTALL_DIR!" >nul + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to back up existing install at !INSTALL_DIR!. + exit /b 1 + ) +) +move /Y "!NEW_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to install standalone archive to !INSTALL_DIR!. + exit /b 1 +) + +( +echo @echo off +echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* +) > "!INSTALL_BIN_DIR!\qwen.cmd.new" +if !ERRORLEVEL! NEQ 0 ( + if exist "!INSTALL_DIR!" rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 + if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) +move /Y "!INSTALL_BIN_DIR!\qwen.cmd.new" "!INSTALL_BIN_DIR!\qwen.cmd" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!INSTALL_BIN_DIR!\qwen.cmd.new" del /F /Q "!INSTALL_BIN_DIR!\qwen.cmd.new" >nul 2>&1 + if exist "!INSTALL_DIR!" rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 + if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) + +if exist "!OLD_INSTALL_DIR!" rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + +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! +exit /b 0 + +:RejectArchiveLinks +set "QWEN_EXTRACT_DIR=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_EXTRACT_DIR=" +if %PS_STATUS% NEQ 0 echo ERROR: Archive contains symlinks or reparse points; refusing to install. +exit /b %PS_STATUS% + +:EnsureManagedInstallDir +set "MANAGED_DIR=%~1" +if not exist "!MANAGED_DIR!" exit /b 0 +if exist "!MANAGED_DIR!\manifest.json" exit /b 0 + +echo ERROR: !MANAGED_DIR! exists but is not a Qwen Code standalone install. +echo ERROR: Refusing to overwrite it. Move or remove it manually, then rerun the installer. +exit /b 1 + +:RequireNode +where node >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Node.js was not found. + echo. + echo Node.js 20 or newer is required before installing Qwen Code with npm. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 +) + +for /f "delims=" %%i in ('node -p "process.versions.node" 2^>nul') do set "NODE_VERSION=%%i" +if "%NODE_VERSION%"=="" ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 20 or newer is required before installing Qwen Code with npm. + exit /b 1 +) + +for /f "tokens=1 delims=." %%a in ("%NODE_VERSION%") do set "MAJOR_VERSION=%%a" +set /a NODE_MAJOR_NUM=%MAJOR_VERSION% >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 20 or newer is required before installing Qwen Code with npm. + exit /b 1 +) + +if %NODE_MAJOR_NUM% LSS 20 ( + echo ERROR: Node.js %NODE_VERSION% is installed, but Node.js 20 or newer is required. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 +) + +echo SUCCESS: 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. + exit /b 1 +) + +for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i" +echo SUCCESS: npm %NPM_VERSION% detected. +exit /b 0 + +:InstallNpm +call :RequireNode +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :RequireNpm +if %ERRORLEVEL% NEQ 0 exit /b 1 + +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! + echo INFO: Upgrading to the latest version. +) + +echo INFO: Running: npm install -g @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! +call npm install -g @qwen-code/qwen-code@latest --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 @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! + exit /b 1 +) + +echo SUCCESS: Qwen Code installed successfully. +call :CreateSourceJson +exit /b 0 + +:CreateSourceJson +if "!SOURCE!"=="unknown" exit /b 0 + +set "QWEN_DIR=!USERPROFILE!\.qwen" +if not exist "!QWEN_DIR!" mkdir "!QWEN_DIR!" + +( +echo { +echo "source": "!SOURCE!" +echo } +) > "!QWEN_DIR!\source.json" + +echo SUCCESS: Installation source saved to !USERPROFILE!\.qwen\source.json +exit /b 0 + +:PrintFinalInstructions +set "EXTRA_BIN=%~1" +if not "!EXTRA_BIN!"=="" set "PATH=!EXTRA_BIN!;!PATH!" + +echo. +echo =========================================== +echo Installation completed! +echo =========================================== +echo. + +where qwen >nul 2>&1 +if %ERRORLEVEL% EQU 0 ( + for /f "delims=" %%i in ('qwen --version 2^>nul') do set "QWEN_VERSION=%%i" + echo SUCCESS: Qwen Code is ready to use: !QWEN_VERSION! + echo. + echo You can now run: qwen + echo. + echo INFO: Run qwen in your project directory to start an interactive session. + exit /b 0 +) + +echo WARNING: Qwen Code was installed, but qwen is not on PATH in this prompt. +echo. +echo Restart your command prompt, then run: qwen +if not "!EXTRA_BIN!"=="" ( + echo. + echo Or add this directory to PATH: + echo !EXTRA_BIN! + echo Then run: + echo qwen + exit /b 0 +) + +for /f "delims=" %%i in ('npm prefix -g 2^>nul') do set "NPM_PREFIX=%%i" +if not "!NPM_PREFIX!"=="" ( + echo. + echo Or add this npm global directory to PATH: + echo !NPM_PREFIX! + echo Then run: + echo qwen +) exit /b 0 diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index ce6d46c26..1d8c5d7d7 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -1,574 +1,972 @@ -#!/bin/bash +#!/usr/bin/env bash # Qwen Code Installation Script -# This script installs Node.js (via NVM) and Qwen Code CLI -# Supports Linux and macOS +# Installs Qwen Code from a standalone archive when available, with npm fallback. +# This script intentionally does not install Node.js or change npm config. # -# Usage: install-qwen-with-source.sh --source [github|npm|internal|local-build] -# install-qwen-with-source.sh -s [github|npm|internal|local-build] +# Usage: +# install-qwen-with-source.sh --source [github|npm|internal|local-build] +# install-qwen-with-source.sh --method [detect|standalone|npm] -# Re-execute with bash if running with sh or other shells -# This block must use POSIX-compliant syntax ([ not [[) since it runs before we know bash is available if [ -z "${BASH_VERSION}" ] && [ -z "${__QWEN_INSTALL_REEXEC:-}" ]; then - # Check if we're in a git hook environment - case "${0}" in - *.git/hooks/*) export __QWEN_IN_GIT_HOOK=1 ;; - esac - if [ -n "${GIT_DIR:-}" ]; then - export __QWEN_IN_GIT_HOOK=1 - fi - - # Try to find bash if command -v bash >/dev/null 2>&1; then - export __QWEN_INSTALL_REEXEC=1 - # Re-exec with bash, preserving all arguments - exec bash -- "${0}" "$@" - else - echo "Error: This script requires bash. Please install bash first." + if [ -f "${0}" ]; then + export __QWEN_INSTALL_REEXEC=1 + exec bash -- "${0}" "$@" + fi + + echo "Error: This script requires bash. Run the installer with: curl ... | bash" exit 1 fi + + echo "Error: This script requires bash. Please install bash first." + exit 1 fi -# Enable strict mode (bash-specific options) -# pipefail requires bash 3+; check before setting -if [ -n "${BASH_VERSION:-}" ]; then - # shellcheck disable=SC3040 - set -eo pipefail -else - set -e -fi +set -eo pipefail -# ============================================ -# Color definitions -# ============================================ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' -# ============================================ -# Log functions -# ============================================ log_info() { - echo -e "${BLUE}ℹ️ $1${NC}" + echo -e "${BLUE}INFO:${NC} $1" } log_success() { - echo -e "${GREEN}✅ $1${NC}" + echo -e "${GREEN}SUCCESS:${NC} $1" } log_warning() { - echo -e "${YELLOW}⚠️ $1${NC}" + echo -e "${YELLOW}WARNING:${NC} $1" } log_error() { - echo -e "${RED}❌ $1${NC}" + echo -e "${RED}ERROR:${NC} $1" } -# ============================================ -# Utility functions -# ============================================ command_exists() { command -v "$1" >/dev/null 2>&1 } -get_shell_profile() { - local current_shell - current_shell=$(basename "${SHELL}") - case "${current_shell}" in - bash) - echo "${HOME}/.bashrc" - ;; - zsh) - echo "${HOME}/.zshrc" - ;; - fish) - # Fish uses its own syntax; bash/zsh export statements are not compatible. - # Return empty string to signal callers to skip automatic profile writes. - echo "" - ;; - *) - echo "${HOME}/.profile" - ;; - esac +TEMP_DIRS=() + +cleanup_temp_dirs() { + local temp_dir + for temp_dir in "${TEMP_DIRS[@]}"; do + if [[ -n "${temp_dir}" ]]; then + rm -rf "${temp_dir}" + fi + done +} + +register_temp_dir() { + local temp_dir="$1" + TEMP_DIRS+=("${temp_dir}") +} + +shell_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +trap cleanup_temp_dirs EXIT +trap 'cleanup_temp_dirs; exit 130' INT +trap 'cleanup_temp_dirs; exit 143' TERM + +print_usage() { + cat </dev/null; then - log_info "Cleaning npmrc conflicts..." - # Backup original npmrc before modifying - cp -f "${npmrc}" "${npmrc}.bak" - log_info "Backed up original .npmrc to ${npmrc}.bak" - grep -Ev '^(prefix|globalconfig) *= *' "${npmrc}.bak" > "${npmrc}.tmp" || true - mv -f "${npmrc}.tmp" "${npmrc}" || true - log_success "Removed conflicting prefix/globalconfig entries from .npmrc" +print_header() { + echo "==========================================" + echo " Qwen Code Installation Script" + echo "==========================================" + echo "" + log_info "System: $(uname -s 2>/dev/null || echo unknown) $(uname -r 2>/dev/null || true)" + log_info "Install method: ${METHOD}" + if [[ "${METHOD}" != "npm" ]]; then + log_info "Standalone mirror: ${MIRROR}" + if [[ -n "${BASE_URL}" ]]; then + log_info "Standalone base URL: ${BASE_URL}" + fi + if [[ -n "${ARCHIVE_PATH}" ]]; then + log_info "Standalone archive: ${ARCHIVE_PATH}" + else + log_info "Standalone version: ${VERSION}" fi fi + if [[ "${METHOD}" != "standalone" ]]; then + log_info "npm registry: ${NPM_REGISTRY}" + fi + if [[ "${SOURCE}" != "unknown" ]]; then + log_info "Installation source: ${SOURCE}" + fi + echo "" } -# ============================================ -# Install NVM -# ============================================ -install_nvm() { - local NVM_DIR="${NVM_DIR:-${HOME}/.nvm}" - local NVM_VERSION="${NVM_VERSION:-v0.40.3}" - - if [[ -s "${NVM_DIR}/nvm.sh" ]]; then - log_info "NVM is already installed at ${NVM_DIR}" - return 0 - fi - - log_info "Installing NVM ${NVM_VERSION}..." - - # Download and install NVM from Aliyun OSS - # Use temporary file instead of pipe to avoid potential subshell issues - local NVM_INSTALL_TEMP - NVM_INSTALL_TEMP=$(mktemp) - if "${DOWNLOAD_CMD}" "${DOWNLOAD_ARGS}" "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install_nvm.sh" > "${NVM_INSTALL_TEMP}"; then - # Run the script in current shell environment - # shellcheck source=/dev/null - . "${NVM_INSTALL_TEMP}" - rm -f "${NVM_INSTALL_TEMP}" - log_success "NVM installed successfully" - else - rm -f "${NVM_INSTALL_TEMP}" - log_error "Failed to install NVM" - log_info "Please install NVM manually: https://github.com/nvm-sh/nvm#install--update-script" - exit 1 - fi - - # Configure shell profile - local PROFILE_FILE - PROFILE_FILE=$(get_shell_profile) - - # Fish shell returns empty string from get_shell_profile because export/source - # syntax is incompatible with fish. Skip automatic profile writes for fish users. - if [[ -z "${PROFILE_FILE}" ]]; then - log_warning "Fish shell detected: automatic shell profile configuration is not supported." - log_info "Please add NVM configuration manually. See: https://github.com/nvm-sh/nvm#fish" - # Check if profile file is writable - elif [[ -f "${PROFILE_FILE}" ]] && [[ ! -w "${PROFILE_FILE}" ]]; then - log_warning "Cannot write to ${PROFILE_FILE} (permission denied)" - log_info "Skipping shell profile configuration" - log_info "You may need to manually add NVM configuration to your shell profile" - elif ! grep -q 'NVM_DIR' "${PROFILE_FILE}" 2>/dev/null; then - # shellcheck disable=SC2016 - # The following echo statements intentionally use single quotes to write literal strings - { - echo "" - echo "# NVM configuration (added by Qwen Code installer)" - echo "export NVM_DIR=\"\$HOME/.nvm\"" - echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' - echo '[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"' - } >> "${PROFILE_FILE}" 2>/dev/null || { - log_warning "Failed to write to ${PROFILE_FILE}" - log_info "Skipping shell profile configuration" - return 0 - } - log_info "Added NVM config to ${PROFILE_FILE}" - fi - - # Load NVM for current session - export NVM_DIR="${NVM_DIR}" - # shellcheck source=/dev/null - [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" - - log_success "NVM configured successfully" - return 0 -} - -# ============================================ -# Install Node.js via NVM -# ============================================ -install_nodejs_with_nvm() { - local NODE_VERSION="${NODE_VERSION:-20}" - local NVM_DIR="${NVM_DIR:-${HOME}/.nvm}" - - # Ensure NVM is loaded - export NVM_DIR="${NVM_DIR}" - # shellcheck source=/dev/null - [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" - - if ! command_exists nvm; then - log_error "NVM not loaded properly" - return 1 - fi - - # Set Node.js mirror source for faster downloads in China - export NVM_NODEJS_ORG_MIRROR="https://npmmirror.com/mirrors/node" - - # Install Node.js - log_info "Installing Node.js v${NODE_VERSION}..." - if nvm install "${NODE_VERSION}"; then - nvm alias default "${NODE_VERSION}" || true - nvm use default || true - log_success "Node.js v${NODE_VERSION} installed successfully" - - # Verify installation - log_info "Node.js version: $(node -v)" || true - log_info "npm version: $(npm -v)" || true - - return 0 - else - log_error "Failed to install Node.js" - return 1 - fi -} - -# ============================================ -# Check Node.js version -# ============================================ -check_node_version() { - if ! command_exists node; then - return 1 - fi - - local current_version - current_version=$(node -v | sed 's/v//') - local major_version - major_version=$(echo "${current_version}" | cut -d. -f1 | sed 's/[^0-9]//g') - - # Handle cases where major_version is empty or non-numeric - if [[ -z "${major_version}" ]]; then - log_warning "Unable to determine Node.js version from: $(node -v)" - return 1 - fi - - if [[ "${major_version}" -ge 20 ]]; then - log_success "Node.js v${current_version} is already installed (>= 20)" - return 0 - else - log_warning "Node.js v${current_version} is installed but version < 20" - return 1 - fi -} - -# ============================================ -# Install Node.js -# ============================================ -install_nodejs() { - local platform - platform=$(uname -s) - - case "${platform}" in - Linux|Darwin) - log_info "Installing Node.js on ${platform}..." - - # Install NVM - if ! install_nvm; then - log_error "Failed to install NVM" - return 1 - fi - - # Load NVM - export NVM_DIR="${HOME}/.nvm" - # shellcheck source=/dev/null - [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" - - # Install Node.js - if ! install_nodejs_with_nvm; then - log_error "Failed to install Node.js" - return 1 - fi +print_node_help() { + echo "" + echo "Node.js 20 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 " brew install node" + echo " # or download from https://nodejs.org/" ;; - MINGW*|CYGWIN*|MSYS*) - log_error "Windows platform detected. Please use Windows installer or WSL." - log_info "Visit: https://nodejs.org/en/download/" - exit 1 + Linux) + echo " # Use your distribution package manager or:" + echo " https://nodejs.org/en/download/package-manager" ;; *) - log_error "Unsupported platform: ${platform}" - exit 1 + echo " https://nodejs.org/" + ;; + esac + echo "" + echo "If you already use a Node version manager, activate Node.js 20+" + echo "in this shell before rerunning the installer." +} + +require_node() { + if ! command_exists node; then + log_error "Node.js was not found." + print_node_help + return 1 + fi + + local node_version + node_version=$(node -p "process.versions.node" 2>/dev/null || true) + local node_major + node_major=$(node -p "Number(process.versions.node.split('.')[0])" 2>/dev/null || true) + + if [[ -z "${node_major}" ]] || ! [[ "${node_major}" =~ ^[0-9]+$ ]]; then + log_error "Unable to determine Node.js version." + print_node_help + return 1 + fi + + if [[ "${node_major}" -lt 20 ]]; then + log_error "Node.js ${node_version:-unknown} is installed, but Node.js 20 or newer is required." + 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." + return 1 +} + +get_npm_global_bin() { + local prefix + prefix=$(npm prefix -g 2>/dev/null || true) + + if [[ -z "${prefix}" ]]; then + return 0 + fi + + case "$(uname -s 2>/dev/null || echo unknown)" in + MINGW*|MSYS*|CYGWIN*) + echo "${prefix}" + ;; + *) + echo "${prefix}/bin" ;; esac } -# ============================================ -# Check and install Node.js -# ============================================ -check_and_install_nodejs() { - if check_node_version; then - log_info "Using existing Node.js installation" - clean_npmrc_conflict - else - log_warning "Installing or upgrading Node.js..." - install_nodejs - fi -} - -# ============================================ -# Fix npm permissions (without using sudo) -# ============================================ -fix_npm_permissions() { - log_info "Checking npm permissions..." - - local NPM_GLOBAL_DIR - NPM_GLOBAL_DIR=$(npm config get prefix 2>/dev/null) || true - - # Determine whether we need to fall back to ~/.npm-global: - # 1. prefix is empty or contains an error string - # 2. prefix is a system directory (would break sudo setuid binaries) - # 3. prefix directory is not writable - local use_user_dir=false - - if [[ -z "${NPM_GLOBAL_DIR}" ]] || [[ "${NPM_GLOBAL_DIR}" == *"error"* ]]; then - log_info "npm prefix is unset or invalid, switching to user directory" - use_user_dir=true - else - # SAFETY CHECK: Never use system directories - case "${NPM_GLOBAL_DIR}" in - /|/usr|/usr/local|/bin|/sbin|/lib|/lib64|/opt|/snap|/var|/etc) - log_warning "npm prefix is a system directory (${NPM_GLOBAL_DIR}), switching to user directory to avoid breaking system binaries." - use_user_dir=true - ;; - esac - fi - - if [[ "${use_user_dir}" == false ]] && [[ ! -w "${NPM_GLOBAL_DIR}" ]]; then - log_warning "npm global directory is not writable: ${NPM_GLOBAL_DIR}, switching to user directory." - use_user_dir=true - fi - - if [[ "${use_user_dir}" == true ]]; then - NPM_GLOBAL_DIR="${HOME}/.npm-global" - # Create the directory before setting prefix so npm config set succeeds - mkdir -p "${NPM_GLOBAL_DIR}" - npm config set prefix "${NPM_GLOBAL_DIR}" - log_success "npm prefix set to: ${NPM_GLOBAL_DIR}" - - # Only add ~/.npm-global/bin to PATH when we actually use it - local PROFILE_FILE - PROFILE_FILE=$(get_shell_profile) - if [[ -n "${PROFILE_FILE}" ]] && ! grep -q '.npm-global/bin' "${PROFILE_FILE}" 2>/dev/null; then - { - echo "" - echo "# NPM global bin (added by Qwen Code installer)" - echo "export PATH=\"\$HOME/.npm-global/bin:\$PATH\"" - } >> "${PROFILE_FILE}" 2>/dev/null || log_warning "Failed to write PATH update to ${PROFILE_FILE}" - log_info "Added npm global bin to PATH in ${PROFILE_FILE}" - fi - else - log_info "npm global directory is writable: ${NPM_GLOBAL_DIR}" - fi - - return 0 -} - -# ============================================ -# Install Qwen Code -# ============================================ -install_qwen_code() { - # Ensure NVM node is in PATH - export NVM_DIR="${HOME}/.nvm" - # shellcheck source=/dev/null - [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" 2>/dev/null || true - - # Add npm global bin to PATH - local NPM_GLOBAL_BIN - NPM_GLOBAL_BIN=$(npm config get prefix 2>/dev/null)/bin - if [[ -n "${NPM_GLOBAL_BIN}" ]]; then - export PATH="${NPM_GLOBAL_BIN}:${PATH}" - fi - - if command_exists qwen; then - local QWEN_VERSION - QWEN_VERSION=$(qwen --version 2>/dev/null || echo "unknown") - log_success "Qwen Code is already installed: ${QWEN_VERSION}" - log_info "Upgrading to the latest version..." - fi - - # Clean npmrc conflicts - clean_npmrc_conflict - - # Fix npm permissions if needed - fix_npm_permissions - - # Install Qwen Code - log_info "Installing Qwen Code..." - if npm install -g @qwen-code/qwen-code@latest --registry https://registry.npmmirror.com; then - log_success "Qwen Code installed successfully!" - - # Verify installation - if command_exists qwen; then - local qwen_version - qwen_version=$(qwen --version 2>/dev/null) || qwen_version="unknown" - log_info "Qwen Code version: ${qwen_version}" - fi - else - log_error "Failed to install Qwen Code!" - log_info "Please check your internet connection and try again" - exit 1 - fi - - # Create source.json if source parameter was provided - if [[ "${SOURCE}" != "unknown" ]]; then - create_source_json - fi -} - -# ============================================ -# Create source.json -# ============================================ create_source_json() { - local QWEN_DIR="${HOME}/.qwen" + if [[ "${SOURCE}" == "unknown" ]]; then + return 0 + fi - mkdir -p "${QWEN_DIR}" + local qwen_dir="${HOME}/.qwen" + mkdir -p "${qwen_dir}" - # Escape special characters in SOURCE for JSON - local ESCAPED_SOURCE - ESCAPED_SOURCE=$(printf '%s' "${SOURCE}" | sed 's/\\/\\\\/g; s/"/\\"/g') + local escaped_source + escaped_source=$(printf '%s' "${SOURCE}" | sed 's/\\/\\\\/g; s/"/\\"/g') - cat > "${QWEN_DIR}/source.json" < "${qwen_dir}/source.json" </dev/null || echo unknown) + local arch + arch=$(uname -m 2>/dev/null || echo unknown) + + case "${os}" in + Darwin) + os="darwin" + ;; + Linux) + os="linux" + ;; + *) + return 1 + ;; + esac + + case "${arch}" in + x86_64|amd64) + arch="x64" + ;; + arm64|aarch64) + arch="arm64" + ;; + *) + return 1 + ;; + esac + + echo "${os}-${arch}" +} + +archive_extension_for_target() { + case "$1" in + darwin-*|linux-*) + echo "tar.gz" + ;; + *) + return 1 + ;; + esac +} + +release_version_path() { + if [[ "${VERSION}" == "latest" ]]; then + echo "latest" + return 0 + fi + + case "${VERSION}" in + v*) + echo "${VERSION}" + ;; + *) + echo "v${VERSION}" + ;; + esac +} + +standalone_base_url() { + if [[ -n "${BASE_URL}" ]]; then + echo "${BASE_URL%/}" + return 0 + fi + + local version_path + version_path=$(release_version_path) + + if [[ "${MIRROR}" == "aliyun" ]]; then + echo "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/${version_path}" + return 0 + fi + + if [[ "${version_path}" == "latest" ]]; then + echo "https://github.com/QwenLM/qwen-code/releases/latest/download" + return 0 + fi + + echo "https://github.com/QwenLM/qwen-code/releases/download/${version_path}" +} + +download_file() { + local url="$1" + local destination="$2" + + if command_exists curl; then + curl -fsSL --retry 2 "${url}" -o "${destination}" + return $? + fi + + if command_exists wget; then + wget -q --tries=3 "${url}" -O "${destination}" || return 1 + return $? + fi + + log_error "curl or wget is required to download the standalone archive." + return 1 +} + +url_exists() { + local url="$1" + + if command_exists curl; then + curl -fsIL --retry 1 "${url}" >/dev/null 2>&1 + return $? + fi + + if command_exists wget; then + wget -q --spider "${url}" >/dev/null 2>&1 + return $? + fi + + return 1 +} + +sha256_file() { + local file_path="$1" + + if command_exists sha256sum; then + sha256sum "${file_path}" | awk '{print $1}' + return 0 + fi + + if command_exists shasum; then + shasum -a 256 "${file_path}" | awk '{print $1}' + return 0 + fi + + return 1 +} + +verify_checksum() { + local archive_path="$1" + local checksum_source="$2" + local archive_name="$3" + local checksum_file="${checksum_source}" + local temp_checksum="" + + if [[ -z "${checksum_file}" ]]; then + checksum_file="$(dirname "${archive_path}")/SHA256SUMS" + elif [[ "${checksum_file}" == http://* || "${checksum_file}" == https://* ]]; then + temp_checksum="$(mktemp)" + if ! download_file "${checksum_file}" "${temp_checksum}"; then + rm -f "${temp_checksum}" + log_error "Could not download SHA256SUMS for checksum verification." + return 1 fi - log_info "Using HOME=${HOME}" + checksum_file="${temp_checksum}" fi - # Ensure download tool is available - ensure_download_tool - - # Check and install Node.js - check_and_install_nodejs - echo "" - - # Install Qwen Code - install_qwen_code - echo "" - - # ============================================ - # Final instructions - # ============================================ - echo "==========================================" - echo "✅ Installation completed!" - echo "==========================================" - echo "" - - # Ensure NVM and npm global bin are in PATH - export NVM_DIR="${HOME}/.nvm" - # shellcheck source=/dev/null - [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" 2>/dev/null || true - local NPM_GLOBAL_BIN - NPM_GLOBAL_BIN=$(npm config get prefix 2>/dev/null)/bin - if [[ -n "${NPM_GLOBAL_BIN}" ]]; then - export PATH="${NPM_GLOBAL_BIN}:${PATH}" + if [[ ! -f "${checksum_file}" ]]; then + rm -f "${temp_checksum}" + log_error "SHA256SUMS not found; cannot verify archive." + return 1 fi - # Check if qwen is immediately available - if command_exists qwen; then - log_success "Qwen Code is ready to use!" - echo "" - echo "You can now run: qwen" - echo "" - # Auto-start qwen - log_info "Starting Qwen Code..." - echo "" - exec qwen - else - log_warning "Qwen Code command not found in current session" - echo "" - echo "To use Qwen Code immediately without restarting your terminal," - echo "run the following command in your current shell:" - echo " eval \$(${0} --print-env)" - echo "" - log_info "Or simply restart your terminal, then run: qwen" + local expected + expected=$(awk -v archive_name="${archive_name}" ' + { + name = $2 + sub(/^\*/, "", name) + if (name == archive_name) { + print $1 + exit + } + } + ' "${checksum_file}") + if [[ -z "${expected}" ]]; then + rm -f "${temp_checksum}" + log_error "Checksum entry for ${archive_name} not found." + return 1 + fi + + local actual + if ! actual=$(sha256_file "${archive_path}"); then + rm -f "${temp_checksum}" + log_error "No SHA-256 utility found; cannot verify archive." + return 1 + fi + + rm -f "${temp_checksum}" + + if [[ "${expected}" != "${actual}" ]]; then + log_error "Checksum verification failed for ${archive_name}." + return 1 + fi + + log_success "Checksum verified for ${archive_name}." +} + +validate_archive_entry_path() { + local entry="$1" + + while [[ "${entry}" == ./* ]]; do + entry="${entry#./}" + done + + case "${entry}" in + ""|/*|..|../*|*/..|*/../*|*\\*) + log_error "Archive contains unsafe path: ${entry:-}" + return 1 + ;; + esac +} + +validate_archive_contents() { + local archive_path="$1" + local entries + local entry + + case "${archive_path}" in + *.zip) + if ! command_exists unzip; then + log_error "unzip is required to inspect ${archive_path}." + return 1 + fi + if ! entries=$(unzip -Z1 "${archive_path}"); then + log_error "Failed to inspect archive entries: ${archive_path}" + return 1 + fi + ;; + *.tar.gz|*.tgz|*.tar.xz) + if ! entries=$(tar -tf "${archive_path}"); then + log_error "Failed to inspect archive entries: ${archive_path}" + return 1 + fi + ;; + *) + log_error "Unsupported archive format: ${archive_path}" + return 1 + ;; + esac + + while IFS= read -r entry; do + validate_archive_entry_path "${entry}" || return 1 + done <<< "${entries}" +} + +extract_archive() { + local archive_path="$1" + local destination="$2" + + mkdir -p "${destination}" || return 1 + validate_archive_contents "${archive_path}" || return 1 + + case "${archive_path}" in + *.zip) + if ! command_exists unzip; then + log_error "unzip is required to extract ${archive_path}." + return 1 + fi + unzip -q "${archive_path}" -d "${destination}" || return 1 + ;; + *.tar.gz|*.tgz) + tar -xzf "${archive_path}" -C "${destination}" || return 1 + ;; + *.tar.xz) + tar -xf "${archive_path}" -C "${destination}" || return 1 + ;; + *) + log_error "Unsupported archive format: ${archive_path}" + return 1 + ;; + esac + + local symlink_entry + symlink_entry=$(find "${destination}" -type l -print | sed -n '1p') + if [[ -n "${symlink_entry}" ]]; then + log_error "Archive contains symlinks; refusing to install." + return 1 fi } -# Run main function +ensure_managed_install_dir() { + local install_dir="$1" + + if [[ ! -e "${install_dir}" ]]; then + return 0 + fi + + if [[ -f "${install_dir}/manifest.json" ]]; then + return 0 + fi + + log_error "${install_dir} exists but is not a Qwen Code standalone install." + log_error "Refusing to overwrite it. Move or remove it manually, then rerun the installer." + return 1 +} + +write_unix_wrapper() { + local wrapper_path="$1" + local qwen_bin="$2" + local quoted_qwen_bin + quoted_qwen_bin=$(shell_quote "${qwen_bin}") + + cat > "${wrapper_path}" </dev/null || echo "unknown") + log_info "Existing Qwen Code detected: ${qwen_version}" + log_info "Upgrading to the latest version." + fi + + local install_cmd=( + npm + install + -g + @qwen-code/qwen-code@latest + --registry + "${NPM_REGISTRY}" + ) + + log_info "Running: npm install -g @qwen-code/qwen-code@latest --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 @qwen-code/qwen-code@latest --registry ${NPM_REGISTRY}" + return 1 +} + +print_final_instructions() { + local install_bin_dir="${1:-}" + if [[ -n "${install_bin_dir}" ]]; then + export PATH="${install_bin_dir}:${PATH}" + fi + + echo "" + echo "==========================================" + echo "Installation completed!" + echo "==========================================" + echo "" + + if command_exists qwen; then + local qwen_version + qwen_version=$(qwen --version 2>/dev/null || echo "unknown") + log_success "Qwen Code is ready to use: ${qwen_version}" + echo "" + echo "You can now run: qwen" + echo "" + log_info "Run qwen in your project directory to start an interactive session." + return 0 + fi + + log_warning "Qwen Code was installed, but qwen is not on PATH in this shell." + echo "" + echo "Restart your terminal, then run: qwen" + if [[ -n "${install_bin_dir}" ]]; then + echo "" + echo "Or run this in the current shell:" + echo " export PATH=\"${install_bin_dir}:\$PATH\"" + echo " qwen" + fi +} + +main() { + if [[ -z "${HOME:-}" ]]; then + log_error "HOME is not set; cannot determine where to install Qwen Code." + exit 1 + fi + + print_header + + case "${METHOD}" in + standalone) + install_standalone + print_final_instructions "${INSTALL_BIN_DIR}" + ;; + npm) + install_npm + print_final_instructions "$(get_npm_global_bin)" + ;; + detect) + # Try the standalone archive first; fall back only when unavailable. + if install_standalone; then + print_final_instructions "${INSTALL_BIN_DIR}" + else + standalone_status=$? + if [[ "${standalone_status}" -eq 2 ]]; then + log_warning "Falling back to npm installation." + if install_npm; then + print_final_instructions "$(get_npm_global_bin)" + 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 20+ and rerun --method npm." + exit 1 + fi + else + log_warning "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure." + exit "${standalone_status}" + fi + fi + ;; + esac +} + main "$@" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js new file mode 100644 index 000000000..0f70578f0 --- /dev/null +++ b/scripts/tests/install-script.test.js @@ -0,0 +1,1309 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; + +const { + appendFileSync, + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} = await vi.importActual('node:fs'); +const { execFileSync } = await vi.importActual('node:child_process'); +const crypto = await vi.importActual('node:crypto'); +const { tmpdir } = await vi.importActual('node:os'); +const path = await vi.importActual('node:path'); +const { pathToFileURL } = await vi.importActual('node:url'); +const readScript = (path) => readFileSync(path, 'utf8'); +const standaloneReleaseScriptUrl = pathToFileURL( + path.resolve('scripts/build-standalone-release.js'), +).href; +// These E2E cases execute the Unix shell installer and POSIX symlink behavior. +// Windows batch behavior has separate Windows-only E2E coverage below. +const itOnUnix = process.platform === 'win32' ? it.skip : it; +const itOnWindows = process.platform === 'win32' ? it : it.skip; + +describe('installation scripts', () => { + it('keeps the Linux/macOS installer lightweight', () => { + const script = readScript( + 'scripts/installation/install-qwen-with-source.sh', + ); + + expect(script).not.toContain('install_nvm'); + expect(script).not.toContain('install_nvm.sh'); + expect(script).not.toContain('nvm install'); + expect(script).not.toContain('NVM_NODEJS_ORG_MIRROR'); + expect(script).not.toContain('npm config set prefix'); + expect(script).not.toContain('clean_npmrc_conflict'); + expect(script).not.toContain('.npmrc'); + expect(script).not.toContain('.npm-global'); + expect(script).not.toMatch(/^\s*exec\s+qwen\s*$/m); + expect(script).not.toContain('--print-env'); + expect(script).not.toContain('brew install node@20'); + expect(script).toContain('brew install node'); + expect(script).toContain( + '--source may only contain letters, numbers, dot, underscore, or dash', + ); + expect(script).toContain('Node.js 20 or newer is required'); + expect(script).toContain( + 'npm install -g @qwen-code/qwen-code@latest --registry', + ); + expect(script).toContain('You can now run: qwen'); + }); + + it('supports code-server-style standalone install on Linux/macOS', () => { + const script = readScript( + 'scripts/installation/install-qwen-with-source.sh', + ); + + expect(script).toContain('--method METHOD'); + expect(script).toContain('--mirror MIRROR'); + expect(script).toContain('--base-url URL'); + expect(script).toContain('--archive PATH'); + expect(script).toContain('install_standalone()'); + expect(script).toContain('install_npm()'); + expect(script).toContain('detect_target()'); + expect(script).toContain('verify_checksum()'); + expect(script).toContain('SHA256SUMS not found; cannot verify archive'); + expect(script).toContain('awk -v archive_name'); + expect(script).not.toContain( + 'grep -E "(^|[[:space:]])[*]?${archive_name}$"', + ); + expect(script).toContain('validate_archive_contents()'); + expect(script).toContain('Archive contains unsafe path'); + expect(script).toContain('qwen-code-${target}'); + expect(script).toContain('*.tar.xz)'); + expect(script).toContain('METHOD="${METHOD:-detect}"'); + expect(script).toContain('must start with https://'); + expect(script).toContain('Falling back to npm installation'); + expect(script).toContain('standalone_status=$?'); + expect(script).toContain('[[ "${standalone_status}" -eq 2 ]]'); + expect(script).toContain( + 'Standalone install failed. Retry with --method npm', + ); + expect(script).not.toContain('ln -sf "${INSTALL_LIB_DIR}/bin/qwen"'); + expect(script).toContain('shell_quote()'); + expect(script).toContain('exec ${quoted_qwen_bin} "\\$@"'); + expect(script).toContain('validate_version()'); + 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('not a Qwen Code standalone install'); + expect(script).toContain( + 'Return 2 only when a standalone archive is unavailable', + ); + expect(script).toContain('npm fallback also failed'); + expect(script).toContain( + 'unzip -q "${archive_path}" -d "${destination}" || return 1', + ); + expect(script).toContain( + 'tar -xzf "${archive_path}" -C "${destination}" || return 1', + ); + expect(script).toContain('wget -q --tries=3 "${url}" -O "${destination}"'); + expect(script).toContain('TEMP_DIRS+='); + expect(script).not.toContain('-print -quit'); + }); + + it('keeps the Windows installer lightweight', () => { + const script = readScript( + 'scripts/installation/install-qwen-with-source.bat', + ); + + expect(script).not.toContain('InstallNodeJSDirectly'); + expect(script).not.toContain('node-v!NODE_VERSION!'); + expect(script).not.toContain('msiexec'); + expect(script).not.toContain('Invoke-WebRequest'); + expect(script).not.toContain('PowerShell (Administrator)'); + expect(script).not.toContain('echo INFO: Installation source: %SOURCE%'); + expect(script).not.toMatch(/^\s*call\s+qwen\s*$/m); + expect(script).toContain(':ValidateSource'); + expect(script).toContain(':PrintUsage'); + expect(script).toContain('findstr /R'); + expect(script).toContain( + '--source may only contain letters, numbers, dot, underscore, or dash', + ); + expect(script).toContain('Node.js 20 or newer is required'); + expect(script).toContain('Please install Node.js'); + expect(script).toContain( + 'npm install -g @qwen-code/qwen-code@latest --registry', + ); + expect(script).toContain('You can now run: qwen'); + }); + + it('supports code-server-style standalone install on Windows', () => { + const script = readScript( + 'scripts/installation/install-qwen-with-source.bat', + ); + + expect(script).toContain('--method METHOD'); + expect(script).toContain('--mirror MIRROR'); + expect(script).toContain('--base-url URL'); + expect(script).toContain('--archive PATH'); + expect(script).toContain(':InstallStandalone'); + expect(script).toContain(':InstallNpm'); + expect(script).toContain(':VerifyChecksum'); + expect(script).toContain('SHA256SUMS not found; cannot verify archive'); + expect(script).toContain('Get-FileHash -Algorithm SHA256'); + expect(script).toContain('tokens=1,2'); + expect(script).toContain('CHECKSUM_NAME'); + expect(script).toContain('if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!"'); + expect(script).not.toContain('findstr /C:"!ARCHIVE_NAME!"'); + expect(script).not.toContain('certutil -hashfile'); + expect(script).toContain('qwen-code-win-x64.zip'); + expect(script).toContain('Expand-Archive'); + expect(script).toContain('$env:QWEN_DOWNLOAD_URL'); + expect(script).toContain('$env:QWEN_ARCHIVE_FILE'); + expect(script).toContain( + 'if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!"', + ); + expect(script).not.toContain('%QWEN_INSTALL_ROOT%'); + expect(script).toContain('set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!"'); + expect(script).toContain( + 'installer options contain unsafe command characters', + ); + expect(script).toContain('[char[]](10,13,33,34'); + expect(script).toContain('if "!INSTALL_BASE:~1,2!"==":/"'); + expect(script).toContain('if "!INSTALL_DIR:~1,2!"==":/"'); + expect(script).toContain('if "!INSTALL_BIN_DIR:~1,2!"==":/"'); + expect(script).toContain(':ValidateVersion'); + expect(script).toContain( + 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', + ); + expect(script).toContain("$ErrorActionPreference = 'Stop'; try"); + expect(script).toContain( + '[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL)', + ); + expect(script).toContain('must start with https://'); + 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( + 'Standalone install failed. Retry with --method npm', + ); + expect(script).toContain('qwen-code\\node\\node.exe'); + expect(script).toContain('Archive contains symlinks or reparse points'); + expect(script).toContain('QWEN_INSTALL_ROOT'); + expect(script).toContain('npm fallback also failed'); + }); +}); + +describe('standalone release packaging', () => { + it('defines a standalone packaging script', () => { + const packageJson = JSON.parse(readScript('package.json')); + + expect(packageJson.scripts['package:standalone']).toBe( + 'node scripts/create-standalone-package.js', + ); + expect(packageJson.scripts['package:standalone:release']).toBe( + 'node scripts/build-standalone-release.js', + ); + expect(existsSync('scripts/create-standalone-package.js')).toBe(true); + expect(existsSync('scripts/build-standalone-release.js')).toBe(true); + + const packageScript = readScript('scripts/create-standalone-package.js'); + expect(packageScript).toContain('Copyright 2025 Qwen Team'); + expect(packageScript).toContain("'bundled/qc-helper/docs'"); + expect(packageScript).toContain('DIST_ALLOWED_ENTRIES'); + expect(packageScript).toContain('Unexpected dist asset'); + expect(packageScript).toContain('topLevelDistEntryForPath(outDir)'); + expect(packageScript).toContain("path.join(packageRoot, 'package.json')"); + expect(packageScript).toContain('validateNodeRuntime'); + expect(packageScript).toContain('copyNodeRuntimeEntry'); + expect(packageScript).toContain('symlink cycle'); + expect(packageScript).toContain('refusing to write empty SHA256SUMS'); + expect(packageScript).toContain('--skip-checksums'); + expect(packageScript).toContain('dereference: true'); + expect(packageScript).toContain('fs.createReadStream'); + expect(packageScript).toContain('Expand-Archive'); + expect(packageScript).toContain('Compress-Archive'); + + const releaseScript = readScript('scripts/build-standalone-release.js'); + expect(releaseScript).toContain('Copyright 2025 Qwen Team'); + expect(releaseScript).toContain('https://nodejs.org/dist/v${nodeVersion}'); + expect(releaseScript).toContain('SHASUMS256.txt'); + expect(releaseScript).toContain('verifyNodeArchive'); + expect(releaseScript).toContain( + 'EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length', + ); + expect(releaseScript).toContain('nodeArchiveExtension'); + expect(releaseScript).toContain('fs.createReadStream'); + expect(releaseScript).toContain('expectedArchiveNames'); + expect(releaseScript).toContain('qwen-code-${qwenTarget}'); + expect(releaseScript).toContain('scripts/create-standalone-package.js'); + expect(releaseScript).toContain('--skip-checksums'); + expect(releaseScript).toContain('writeSha256Sums(outDir)'); + }); + + it('loads the standalone release packaging helper', () => { + const output = execFileSync( + process.execPath, + ['scripts/build-standalone-release.js', '--help'], + { encoding: 'utf8' }, + ); + + expect(output).toContain('package:standalone:release'); + expect(output).toContain('--node-version VERSION'); + }); + + it('parses Node.js SHASUMS entries', async () => { + const { parseChecksums } = await import(standaloneReleaseScriptUrl); + + const checksums = parseChecksums( + [ + 'a'.repeat(64) + ' node-v20.19.0-linux-x64.tar.xz', + 'b'.repeat(64) + ' *node-v20.19.0-win-x64.zip', + '', + ].join('\n'), + ); + + expect(checksums.get('node-v20.19.0-linux-x64.tar.xz')).toBe( + 'a'.repeat(64), + ); + expect(checksums.get('node-v20.19.0-win-x64.zip')).toBe('b'.repeat(64)); + }); + + it('validates standalone release checksum output', async () => { + const { assertStandaloneOutput, RELEASE_TARGETS } = await import( + standaloneReleaseScriptUrl + ); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-test-')); + + try { + const lines = RELEASE_TARGETS.map(({ qwenTarget }) => { + const extension = qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'; + return `${'a'.repeat(64)} qwen-code-${qwenTarget}.${extension}`; + }); + writeFileSync(path.join(tmpDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); + + expect(() => assertStandaloneOutput(tmpDir)).not.toThrow(); + + writeFileSync( + path.join(tmpDir, 'SHA256SUMS'), + `${lines.join('\n')}\n${'b'.repeat(64)} qwen-code-extra.tar.gz\n`, + ); + expect(() => assertStandaloneOutput(tmpDir)).toThrow(/Extra/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects a runtime archive without a Node executable', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + const target = process.platform === 'win32' ? 'win-x64' : 'linux-x64'; + const fakeRuntimeArchive = + process.platform === 'win32' + ? createBadWindowsNodeArchive(tmpDir) + : createBadUnixNodeArchive(tmpDir); + + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + target, + '--node-archive', + fakeRuntimeArchive, + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ), + ).toThrow(/Node\.js runtime for .* must contain/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + it('packages a win-x64 standalone archive', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + const outDir = path.join(tmpDir, 'out'); + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'win-x64', + '--node-archive', + createFakeWindowsNodeArchive(tmpDir), + '--out-dir', + outDir, + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ); + + const archive = path.join(outDir, 'qwen-code-win-x64.zip'); + const extractDir = path.join(tmpDir, 'extract'); + mkdirSync(extractDir, { recursive: true }); + extractZipForTest(archive, extractDir); + + expect(existsSync(path.join(extractDir, 'qwen-code'))).toBe(true); + expect( + existsSync(path.join(extractDir, 'qwen-code', 'bin', 'qwen.cmd')), + ).toBe(true); + expect( + existsSync(path.join(extractDir, 'qwen-code', 'node', 'node.exe')), + ).toBe(true); + expect(readScript(path.join(outDir, 'SHA256SUMS'))).toContain( + 'qwen-code-win-x64.zip', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }, 30_000); + + itOnUnix('dereferences safe Node.js runtime symlinks', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + const archive = packageFakeStandalone(tmpDir, { + withSafeNodeSymlink: true, + }); + const installRoot = path.join(tmpDir, 'install'); + runUnixInstaller(archive, installRoot, path.join(tmpDir, 'home')); + + const npmShim = path.join( + installRoot, + 'lib', + 'qwen-code', + 'node', + 'bin', + 'npm', + ); + expect(existsSync(npmShim)).toBe(true); + expect(lstatSync(npmShim).isSymbolicLink()).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + itOnUnix('rejects Node.js runtime symlinks that escape the archive', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'linux-x64', + '--node-archive', + createFakeNodeArchive(tmpDir, { + withEscapingNodeSymlink: true, + }), + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ), + ).toThrow(/symlink escapes the archive/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + itOnUnix('rejects Node.js runtime symlink cycles', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'linux-x64', + '--node-archive', + createFakeNodeArchive(tmpDir, { + withNodeSymlinkCycle: true, + }), + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ), + ).toThrow(/symlink cycle/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + it('rejects unexpected dist assets', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + writeFileSync('dist/debug-cache.tmp', 'debug\n'); + + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'win-x64', + '--node-archive', + createFakeWindowsNodeArchive(tmpDir), + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ), + ).toThrow(/Unexpected dist asset/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } else { + rmSync('dist/debug-cache.tmp', { force: true }); + } + } + }); + + it('uploads standalone archives during release', () => { + const workflow = readScript('.github/workflows/release.yml'); + + expect(workflow).toContain('npm run package:standalone:release --'); + expect(workflow).not.toContain('verify_node_checksum()'); + expect(workflow).not.toContain('download_node()'); + expect(workflow).toContain('dist/standalone/qwen-code-*'); + expect(workflow).toContain('dist/standalone/SHA256SUMS'); + }); + + it('does not whitelist internal planning documents in gitignore', () => { + const gitignore = readScript('.gitignore'); + + expect(gitignore).not.toContain('!.qwen/design/'); + expect(gitignore).not.toContain('!.qwen/e2e-tests/'); + }); + + it('documents optional native module parity for standalone installs', () => { + const guide = readScript('scripts/installation/INSTALLATION_GUIDE.md'); + + expect(guide).toContain('Optional Native Modules'); + expect(guide).toContain('node-pty'); + expect(guide).toContain('clipboard'); + }); +}); + +describe('Linux/macOS installer end-to-end', () => { + itOnUnix( + 'installs a local standalone archive with checksum verification', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + runUnixInstaller(archive, installRoot, home); + + expect(existsSync(path.join(installRoot, 'bin', 'qwen'))).toBe(true); + expect( + existsSync( + path.join(installRoot, 'lib', 'qwen-code', 'node', 'bin', 'node'), + ), + ).toBe(true); + expect(readScript(path.join(home, '.qwen', 'source.json'))).toContain( + '"source": "smoke"', + ); + + const version = execFileSync(path.join(installRoot, 'bin', 'qwen'), [ + '--version', + ]) + .toString() + .trim(); + expect(version).toBe('0.0.0-smoke'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }, + ); + + itOnUnix('shell-quotes custom install paths in the generated wrapper', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + const installLibDir = path.join( + installRoot, + 'lib', + 'qwen-code$(touch qwen-pwned)', + ); + + runUnixInstaller(archive, installRoot, home, 'standalone', { + QWEN_INSTALL_LIB_DIR: installLibDir, + }); + + const version = execFileSync( + path.join(installRoot, 'bin', 'qwen'), + ['--version'], + { + cwd: tmpDir, + }, + ) + .toString() + .trim(); + expect(version).toBe('0.0.0-smoke'); + expect(existsSync(path.join(tmpDir, 'qwen-pwned'))).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + itOnUnix('rejects a tampered local archive', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + appendFileSync(archive, 'tamper'); + + expect(() => + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Checksum verification failed/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + itOnUnix('rejects a local archive when SHA256SUMS is missing', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + rmSync(path.join(path.dirname(archive), 'SHA256SUMS'), { force: true }); + + expect(() => + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/SHA256SUMS not found/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + itOnUnix('rejects standalone archives containing symlinks', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createSymlinkStandaloneArchive(tmpDir); + + expect(() => + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Archive contains symlinks/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + itOnUnix( + 'rejects standalone archives containing path traversal entries', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createTraversalStandaloneArchive(tmpDir); + + expect(() => + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Archive contains unsafe path/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + + itOnUnix('refuses to overwrite a non-managed install directory', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const installDir = path.join(installRoot, 'lib', 'qwen-code'); + mkdirSync(installDir, { recursive: true }); + writeFileSync(path.join(installDir, 'important.txt'), 'keep me\n'); + + expect(() => + runUnixInstaller(archive, installRoot, path.join(tmpDir, 'home')), + ).toThrow(/not a Qwen Code standalone install/); + expect(readScript(path.join(installDir, 'important.txt'))).toBe( + 'keep me\n', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + itOnUnix('does not fall back to npm when detect finds a bad archive', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + appendFileSync(archive, 'tamper'); + + let failureMessage = ''; + try { + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + 'detect', + ); + } catch (error) { + failureMessage = error.message; + } + + expect(failureMessage).toContain('Checksum verification failed'); + expect(failureMessage).toContain('Standalone install failed'); + expect(failureMessage).not.toContain('Falling back to npm installation'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + itOnUnix( + 'falls back to npm in detect mode when archive is unavailable', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const fakeBin = path.join(tmpDir, 'bin'); + const home = path.join(tmpDir, 'home'); + const npmLog = path.join(tmpDir, 'npm-args.txt'); + mkdirSync(fakeBin, { recursive: true }); + mkdirSync(home, { recursive: true }); + + writeFileSync( + path.join(fakeBin, 'curl'), + '#!/usr/bin/env sh\nexit 22\n', + ); + writeFileSync( + path.join(fakeBin, 'node'), + [ + '#!/usr/bin/env sh', + 'if [ "$1" = "-p" ]; then', + ' case "$2" in', + ' *split*) echo 20 ;;', + ' *) echo 20.19.0 ;;', + ' esac', + ' exit 0', + 'fi', + 'exit 0', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'npm'), + [ + '#!/usr/bin/env sh', + 'case "$1" in', + ' -v) echo 10.0.0 ;;', + ' prefix) echo "$QWEN_FAKE_NPM_PREFIX" ;;', + ' install) printf "%s\\n" "$*" > "$QWEN_FAKE_NPM_LOG" ;;', + 'esac', + 'exit 0', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'qwen'), + '#!/usr/bin/env sh\necho 0.0.0-npm\n', + ); + for (const command of ['curl', 'node', 'npm', 'qwen']) { + chmodSync(path.join(fakeBin, command), 0o755); + } + + const output = execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-with-source.sh', + '--method', + 'detect', + '--base-url', + 'https://example.invalid/qwen-code', + '--source', + 'smoke', + ], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + QWEN_FAKE_NPM_LOG: npmLog, + QWEN_FAKE_NPM_PREFIX: path.join(tmpDir, 'npm-prefix'), + }, + stdio: 'pipe', + }, + ).toString(); + + expect(output).toContain('Falling back to npm installation'); + expect(readScript(npmLog)).toContain( + 'install -g @qwen-code/qwen-code@latest --registry', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + + itOnUnix('preserves context when npm fallback also fails', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const fakeBin = path.join(tmpDir, 'bin'); + mkdirSync(fakeBin, { recursive: true }); + writeFileSync(path.join(fakeBin, 'curl'), '#!/usr/bin/env sh\nexit 22\n'); + chmodSync(path.join(fakeBin, 'curl'), 0o755); + + let failureMessage = ''; + try { + execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-with-source.sh', + '--method', + 'detect', + '--base-url', + 'https://example.invalid/qwen-code', + '--source', + 'smoke', + ], + { + env: { + HOME: path.join(tmpDir, 'home'), + PATH: `${fakeBin}:/usr/bin:/bin`, + }, + stdio: 'pipe', + }, + ); + } catch (error) { + failureMessage = [ + error.message, + error.stdout?.toString() || '', + error.stderr?.toString() || '', + ].join('\n'); + } + + expect(failureMessage).toContain('Falling back to npm installation'); + expect(failureMessage).toMatch( + /Node\.js was not found|Unable to determine Node\.js version/, + ); + expect(failureMessage).toContain('npm fallback also failed'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('Windows installer end-to-end', () => { + itOnWindows( + 'installs a local standalone archive with checksum verification', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + runWindowsInstaller(archive, installRoot, home); + + expect(existsSync(path.join(installRoot, 'bin', 'qwen.cmd'))).toBe( + true, + ); + expect( + existsSync(path.join(installRoot, 'qwen-code', 'node', 'node.exe')), + ).toBe(true); + expect(readScript(path.join(home, '.qwen', 'source.json'))).toContain( + '"source": "smoke"', + ); + + const version = runWindowsCommand( + `call "${path.join(installRoot, 'bin', 'qwen.cmd')}" --version`, + { USERPROFILE: home }, + ) + .toString() + .trim(); + expect(version).toBe('0.0.0-smoke'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + + itOnWindows('rejects a tampered local archive', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + appendFileSync(archive, 'tamper'); + + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Checksum verification failed/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + itOnWindows('rejects unsafe environment-derived install paths', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + const marker = path.join(tmpDir, 'pwned.txt'); + + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + 'standalone', + { + QWEN_INSTALL_ROOT: `${path.join(tmpDir, 'install')}" & echo pwned > "${marker}" & "`, + }, + ), + ).toThrow(/unsafe command characters/); + expect(existsSync(marker)).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +function ensureMinimalDist() { + if (existsSync('dist')) { + return false; + } + + mkdirSync('dist/vendor', { recursive: true }); + mkdirSync('dist/bundled/qc-helper/docs', { recursive: true }); + writeFileSync('dist/cli.js', 'console.log("qwen");\n'); + writeFileSync( + 'dist/package.json', + JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.0.0' }), + ); + return true; +} + +function createFakeNodeArchive(tmpDir, options = {}) { + const fakeNodeDir = path.join(tmpDir, 'node-v20.0.0-linux-x64'); + mkdirSync(path.join(fakeNodeDir, 'bin'), { recursive: true }); + writeFileSync( + path.join(fakeNodeDir, 'bin', 'node'), + '#!/usr/bin/env sh\necho 0.0.0-smoke\n', + ); + chmodSync(path.join(fakeNodeDir, 'bin', 'node'), 0o755); + + if (options.withSafeNodeSymlink) { + mkdirSync(path.join(fakeNodeDir, 'lib'), { recursive: true }); + writeFileSync(path.join(fakeNodeDir, 'lib', 'npm-cli.js'), 'npm cli\n'); + symlinkSync('../lib/npm-cli.js', path.join(fakeNodeDir, 'bin', 'npm')); + } + + if (options.withEscapingNodeSymlink) { + const outsideTarget = path.join(tmpDir, 'outside-node-helper.js'); + writeFileSync(outsideTarget, 'outside\n'); + symlinkSync(outsideTarget, path.join(fakeNodeDir, 'bin', 'npm')); + } + + if (options.withNodeSymlinkCycle) { + symlinkSync('../bin', path.join(fakeNodeDir, 'bin', 'cycle')); + } + + const archive = path.join(tmpDir, 'node-v20.0.0-linux-x64.tar.gz'); + execFileSync( + 'tar', + ['-czf', archive, '-C', tmpDir, path.basename(fakeNodeDir)], + { + env: { ...process.env, LC_ALL: 'C' }, + stdio: 'ignore', + }, + ); + return archive; +} + +function createBadUnixNodeArchive(tmpDir) { + const fakeRuntimeDir = path.join(tmpDir, 'not-node'); + mkdirSync(fakeRuntimeDir, { recursive: true }); + writeFileSync(path.join(fakeRuntimeDir, 'README.txt'), 'not node\n'); + + const archive = path.join(tmpDir, 'bad-runtime.tar.gz'); + execFileSync('tar', ['-czf', archive, '-C', tmpDir, 'not-node'], { + env: { ...process.env, LC_ALL: 'C' }, + stdio: 'ignore', + }); + return archive; +} + +function createBadWindowsNodeArchive(tmpDir) { + const fakeRuntimeDir = path.join(tmpDir, 'not-node'); + mkdirSync(fakeRuntimeDir, { recursive: true }); + writeFileSync(path.join(fakeRuntimeDir, 'README.txt'), 'not node\n'); + + const archive = path.join(tmpDir, 'bad-runtime.zip'); + createZipForTest(archive, tmpDir, path.basename(fakeRuntimeDir)); + return archive; +} + +function createFakeWindowsNodeArchive(tmpDir) { + const fakeNodeDir = path.join(tmpDir, 'node-v20.0.0-win-x64'); + mkdirSync(fakeNodeDir, { recursive: true }); + writeFileSync(path.join(fakeNodeDir, 'node.exe'), 'fake node.exe\n'); + + const archive = path.join(tmpDir, 'node-v20.0.0-win-x64.zip'); + createZipForTest(archive, tmpDir, path.basename(fakeNodeDir)); + return archive; +} + +function createFakeWindowsStandaloneArchive(tmpDir) { + const packageRoot = path.join(tmpDir, 'qwen-code'); + const outDir = path.join(tmpDir, 'out'); + mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); + mkdirSync(path.join(packageRoot, 'node'), { recursive: true }); + mkdirSync(outDir, { recursive: true }); + + writeFileSync( + path.join(packageRoot, 'bin', 'qwen.cmd'), + ['@echo off', 'echo 0.0.0-smoke', ''].join('\r\n'), + ); + writeFileSync(path.join(packageRoot, 'node', 'node.exe'), 'fake node.exe\n'); + writeFileSync( + path.join(packageRoot, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code' }), + ); + + const archive = path.join(outDir, 'qwen-code-win-x64.zip'); + createZipForTest(archive, tmpDir, path.basename(packageRoot)); + writeChecksumFile(outDir, path.basename(archive)); + return archive; +} + +function createZipForTest(archive, cwd, entry) { + if (process.platform === 'win32') { + execFileSync( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + 'Compress-Archive -LiteralPath $env:QWEN_TEST_ZIP_ENTRY -DestinationPath $env:QWEN_TEST_ZIP_ARCHIVE -Force', + ], + { + env: { + ...process.env, + QWEN_TEST_ZIP_ENTRY: path.join(cwd, entry), + QWEN_TEST_ZIP_ARCHIVE: archive, + }, + stdio: 'ignore', + }, + ); + return; + } + + execFileSync('zip', ['-qr', archive, entry], { + cwd, + stdio: 'ignore', + }); +} + +function extractZipForTest(archive, destination) { + if (process.platform === 'win32') { + execFileSync( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + 'Expand-Archive -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE -DestinationPath $env:QWEN_TEST_ZIP_DESTINATION -Force', + ], + { + env: { + ...process.env, + QWEN_TEST_ZIP_ARCHIVE: archive, + QWEN_TEST_ZIP_DESTINATION: destination, + }, + stdio: 'ignore', + }, + ); + return; + } + + execFileSync('unzip', ['-q', archive, '-d', destination], { + stdio: 'ignore', + }); +} + +function packageFakeStandalone(tmpDir, nodeArchiveOptions = {}) { + const outDir = path.join(tmpDir, 'out'); + mkdirSync(outDir, { recursive: true }); + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'linux-x64', + '--node-archive', + createFakeNodeArchive(tmpDir, nodeArchiveOptions), + '--out-dir', + outDir, + '--version', + '0.0.0-smoke', + ], + { stdio: 'pipe' }, + ); + return path.join(outDir, 'qwen-code-linux-x64.tar.gz'); +} + +function runUnixInstaller( + archive, + installRoot, + home, + method = 'standalone', + extraEnv = {}, +) { + mkdirSync(home, { recursive: true }); + try { + return execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-with-source.sh', + '--method', + method, + '--archive', + archive, + '--source', + 'smoke', + ], + { + env: { + ...process.env, + HOME: home, + QWEN_INSTALL_ROOT: installRoot, + ...extraEnv, + }, + stdio: 'pipe', + }, + ); + } catch (error) { + const processError = error; + throw new Error( + [ + processError.message, + processError.stdout?.toString() || '', + processError.stderr?.toString() || '', + ].join('\n'), + ); + } +} + +function runWindowsInstaller( + archive, + installRoot, + home, + method = 'standalone', + extraEnv = {}, +) { + mkdirSync(home, { recursive: true }); + try { + return runWindowsCommand( + [ + `call "${path.resolve('scripts/installation/install-qwen-with-source.bat')}"`, + '--method', + method, + '--archive', + `"${archive}"`, + '--source', + 'smoke', + ].join(' '), + { + USERPROFILE: home, + QWEN_INSTALL_ROOT: installRoot, + ...extraEnv, + }, + ); + } catch (error) { + const processError = error; + throw new Error( + [ + processError.message, + processError.stdout?.toString() || '', + processError.stderr?.toString() || '', + ].join('\n'), + ); + } +} + +function runWindowsCommand(command, env = {}) { + return execFileSync(process.env.ComSpec || 'cmd.exe', ['/d', '/c', command], { + env: { + ...process.env, + ...env, + }, + stdio: 'pipe', + // cmd.exe parses the command string itself; preserve quoted paths. + windowsVerbatimArguments: true, + }); +} + +function createSymlinkStandaloneArchive(tmpDir) { + const packageRoot = path.join(tmpDir, 'malicious', 'qwen-code'); + mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); + mkdirSync(path.join(packageRoot, 'node', 'bin'), { recursive: true }); + symlinkSync('/usr/bin/env', path.join(packageRoot, 'bin', 'qwen')); + writeFileSync( + path.join(packageRoot, 'node', 'bin', 'node'), + '#!/usr/bin/env sh\necho 0.0.0-smoke\n', + ); + chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755); + writeFileSync( + path.join(packageRoot, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code' }), + ); + + const outDir = path.join(tmpDir, 'out'); + mkdirSync(outDir, { recursive: true }); + const archive = path.join(outDir, 'qwen-code-linux-x64.tar.gz'); + execFileSync( + 'tar', + ['-czf', archive, '-C', path.dirname(packageRoot), 'qwen-code'], + { + env: { ...process.env, LC_ALL: 'C' }, + stdio: 'ignore', + }, + ); + writeChecksumFile(outDir, path.basename(archive)); + return archive; +} + +function createTraversalStandaloneArchive(tmpDir) { + const maliciousRoot = path.join(tmpDir, 'malicious'); + const packageRoot = path.join(maliciousRoot, 'qwen-code'); + mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); + mkdirSync(path.join(packageRoot, 'node', 'bin'), { recursive: true }); + writeFileSync( + path.join(packageRoot, 'bin', 'qwen'), + '#!/usr/bin/env sh\necho 0.0.0-smoke\n', + ); + chmodSync(path.join(packageRoot, 'bin', 'qwen'), 0o755); + writeFileSync( + path.join(packageRoot, 'node', 'bin', 'node'), + '#!/usr/bin/env sh\necho 0.0.0-smoke\n', + ); + chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755); + writeFileSync( + path.join(packageRoot, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code' }), + ); + writeFileSync(path.join(tmpDir, 'qwen-slip'), 'path traversal\n'); + + const outDir = path.join(tmpDir, 'out'); + mkdirSync(outDir, { recursive: true }); + const archive = path.join(outDir, 'qwen-code-linux-x64.zip'); + execFileSync('zip', ['-qr', archive, 'qwen-code', '../qwen-slip'], { + cwd: maliciousRoot, + stdio: 'ignore', + }); + writeChecksumFile(outDir, path.basename(archive)); + return archive; +} + +function writeChecksumFile(outDir, archiveName) { + const archive = path.join(outDir, archiveName); + const hash = crypto + .createHash('sha256') + .update(readFileSync(archive)) + .digest('hex'); + writeFileSync(path.join(outDir, 'SHA256SUMS'), `${hash} ${archiveName}\n`); +} diff --git a/scripts/tests/test-setup.ts b/scripts/tests/test-setup.ts index d4c4b4655..5a769fc6c 100644 --- a/scripts/tests/test-setup.ts +++ b/scripts/tests/test-setup.ts @@ -6,7 +6,15 @@ import { vi } from 'vitest'; -vi.mock('fs', () => ({ - ...vi.importActual('fs'), - appendFileSync: vi.fn(), -})); +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + const appendFileSync = vi.fn(); + return { + ...actual, + appendFileSync, + default: { + ...actual, + appendFileSync, + }, + }; +});