mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-23 12:44:02 +00:00
feat(installer): add standalone archive installation (#3776)
* feat(installer): add standalone archive installation * fix(installer): harden standalone archive installs * fix(installer): address standalone review findings * chore(installer): clarify review followups * fix(installer): stabilize standalone script checks * chore(installer): remove internal planning docs * chore(installer): simplify standalone release review fixes * test(installer): add Windows batch install smoke * test(installer): fix Windows batch smoke quoting * test(installer): preserve Windows cmd quotes * fix(installer): use robust Windows checksum hashing * ci: narrow installer debug matrix * fix(installer): address standalone review hardening * fix(installer): avoid Windows validation parse errors * fix(installer): simplify Windows option validation * fix(installer): harden standalone review fixes
This commit is contained in:
parent
576bd8e0a7
commit
cb7059f54d
13 changed files with 4038 additions and 907 deletions
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
|
|
@ -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}" \
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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/
|
||||
tmp/
|
||||
|
|
|
|||
16
README.md
16
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
300
scripts/build-standalone-release.js
Normal file
300
scripts/build-standalone-release.js
Normal file
|
|
@ -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 };
|
||||
605
scripts/create-standalone-package.js
Normal file
605
scripts/create-standalone-package.js
Normal file
|
|
@ -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 };
|
||||
|
|
@ -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-<target>` 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
|
||||
```
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1309
scripts/tests/install-script.test.js
Normal file
1309
scripts/tests/install-script.test.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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<typeof import('fs')>('fs');
|
||||
const appendFileSync = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
appendFileSync,
|
||||
default: {
|
||||
...actual,
|
||||
appendFileSync,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue