qwen-code/scripts/create-standalone-package.js
Dragon 4ec9f63d0a
feat(core): add CodeModeOnly tool execution (#10607)
* feat(core): add CodeModeOnly tool execution

* fix(core): preserve CodeModeOnly execution semantics

* fix(core): drop the tool_search reminder in CodeModeOnly

CodeModeOnly hides `tool_search` and binds every deferred tool — schema
included — into the `exec` description, so progressive discovery has
nothing left to do. The session prelude still announced those tools as
"reachable via `tool_search`", pointing the model at a tool that is
never declared and re-billing the text on every cached prefix. Worse,
tools already callable as `tools.web_fetch(...)` looked gated behind a
lookup step that does not exist.

The subagent and fork-resume callers already opted out, but the
main-session prelude sites did not, so the gate now lives where the
prelude is built rather than in each caller.

* chore: refresh settings schema

* chore: refresh companion notices

* fix: register exec tool metadata

* fix(cli): localize exec tool display name

* fix(core): stop offering ToolSearch lookups in CodeModeOnly

CodeModeOnly hides tool_search and binds every deferred schema into the
exec description, so text that tells the model to look a tool up
describes a surface it cannot reach. Return an empty deferred summary
from the registry itself - this also covers the fork-resume reminder
that bypassed the earlier call-site gate - and point the image zoom
hint at tools.zoom_image.

* feat(core): teach the system prompt CodeModeOnly's tool surface

In CodeModeOnly the model can only call exec, so the tool guidance and the
worked examples described a surface it does not have: bare tool names it
cannot call, an instruction to issue several tool calls in one response, and
examples in a direct tool-call syntax that does not exist there.

Give "Using Your Tools" a code-mode branch that routes every tool through
tools.<name>, states which tools are direct controls instead, and replaces
multi-call parallelism with batching inside one exec program. Swap the four
model-family example sets for one shared exec set, since the syntax
differences they exist for do not apply. The mechanics of exec itself stay in
its tool description; the prompt carries only policy.

* test: include code mode host in standalone fixture

* fix(core): return code mode media as multimodal output

* feat(core): support image tool results in code mode

* fix(core): carry deferred tool schemas in the exec description

CodeModeOnly hides tool_search and never surfaces a nested call as a history
functionCall, so a signature collapsed to Record<string, unknown> could never
be filled in later: parameter names were reachable only by guessing, or by
reading them off validation errors one at a time. Deferred tools keep their
registry state; only the generated signature gains the schema.

* fix(node-repl): restore the bin exec bit after rebuild

npm chmods dist/index.js when it links the bin at install time, but the
build deletes that exact file and tsc re-emits it as 0644. After any
rebuild node_modules/.bin/node-repl-mcp therefore points at a
non-executable target and spawning it fails with EACCES (exit 126)
before the shebang is ever read.

OR the exec bits into the emitted mode instead of setting 0o755, so a
restrictive umask is preserved rather than widened.

* fix(cli): pin patched Ink version

* feat(cli): add collapsible tool call details

* fix(dev): use CLI tsconfig outside repository

* fix(cli): preserve expanded tool details after completion

* fix(cli): fail headless runs on model API errors

* fix(core): expose shell timeout bounds in code mode

* feat(core): add code mode timeout helpers

* feat(cli): expose code mode in settings

* fix(mcp): preserve structured results for model and exec consumers

* fix(core): preserve code mode output and truncate inline

* feat(core): port scoped context tools to code mode

* fix(build): synchronize pnpm lockfile and capability docs

* fix(core): recover from rejected Responses encrypted reasoning
2026-09-11 07:40:35 +00:00

1062 lines
33 KiB
JavaScript

#!/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 { createRequire } from 'node:module';
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'] }],
]);
// Standalone target -> prebuildify platform-arch dir name (process.platform
// based, so Windows is 'win32'). Only this archive's matching prebuild is
// bundled, keeping each archive lean and correct-arch.
const TARGET_PREBUILD_DIR = new Map([
['darwin-arm64', 'darwin-arm64'],
['darwin-x64', 'darwin-x64'],
['linux-arm64', 'linux-arm64'],
['linux-x64', 'linux-x64'],
['win-x64', 'win32-x64'],
]);
const TARGET_CLIPBOARD_PACKAGE = new Map([
['darwin-arm64', '@teddyzhu/clipboard-darwin-arm64'],
['darwin-x64', '@teddyzhu/clipboard-darwin-x64'],
['linux-arm64', '@teddyzhu/clipboard-linux-arm64-gnu'],
['linux-x64', '@teddyzhu/clipboard-linux-x64-gnu'],
['win-x64', '@teddyzhu/clipboard-win32-x64-msvc'],
]);
// Temporary OpenTUI preview: platform packages whose native render library is
// resolved at runtime via `import('@opentui/core-<platform>-<arch>')`. Linux
// is glibc-only: the bundled Bun runtime is glibc-linked and cannot start on
// musl hosts, so shipping -musl render packages would claim support the
// archive cannot deliver.
const TARGET_OPENTUI_PACKAGES = new Map([
['darwin-arm64', ['@opentui/core-darwin-arm64']],
['darwin-x64', ['@opentui/core-darwin-x64']],
['linux-arm64', ['@opentui/core-linux-arm64']],
['linux-x64', ['@opentui/core-linux-x64']],
['win-x64', ['@opentui/core-win32-x64']],
]);
const DIST_REQUIRED_PATHS = [
'cli.js',
'cli-entry.js',
'codeModeHost.js',
'chunks',
'vendor',
'bundled/qc-helper/docs',
];
const DIST_ALLOWED_ENTRIES = new Set([
'cli.js',
// bin wrapper emitted by prepare-package.js. Standalone shims use it for
// `qwen serve` so daemon startup gets the same fast path as npm installs.
'cli-entry.js',
// fzf fuzzy-search worker; esbuild emits it as a standalone entry that must
// sit next to cli.js so `new URL('./fzfWorker.js', ...)` resolves at runtime.
'fzfWorker.js',
'codeModeHost.js',
'chunks',
'vendor',
'bundled',
'package.json',
'README.md',
'LICENSE',
// Digest of the review sources this bundle was built from, stamped by
// copy_bundle_assets.js. Harmless to ship: a standalone install lays the
// dist entries out under `lib/`, and the staleness check only applies to
// a `<root>/dist/cli.js` layout — it never reads the stamp there.
'review-sources.sha256',
'locales',
'examples',
// OpenTUI renderer runtime assets (tree-sitter grammars, parser worker,
// web-tree-sitter wasm, native render library) relocated by
// copy_bundle_assets.js; the bundled renderer sets OTUI_ASSET_ROOT to this
// directory for code-block syntax highlighting. copyOpenTuiAddon syncs the
// target's native library into it.
'opentui-assets',
// Web Shell SPA served at the daemon root by `qwen serve` (index.html +
// assets/). Copied into dist/web-shell/ by copy_bundle_assets.js when the
// web-shell workspace has been built; optional, so it's allowed but not
// required.
'web-shell',
]);
const DIST_ALLOWED_ENTRY_PATTERNS = [
/^sandbox-macos-(permissive|restrictive)-(open|closed|proxied)\.sb$/,
];
// Emitted into dist/ by prepare-package.js for npm publishing only;
// standalone archives must not copy them into lib/.
const DIST_NPM_PACKAGE_ONLY_ENTRIES = new Set([
'export-transcript-document.js',
'export-transcript-document.css',
'postinstall.js',
'patches',
]);
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.runtime !== 'node' && args.runtime !== 'bun') {
fail('--runtime must be either "node" or "bun"');
}
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 = standaloneArchiveName(target, args.runtime);
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, args.runtime);
copyNativeAddon(packageRoot, target);
copyClipboardAddon(packageRoot, target, args.nativeModulesDir);
if (args.runtime === 'bun') {
copyOpenTuiAddon(packageRoot, target, args.opentuiModulesDir);
}
extractNodeArchive(nodeArchive, runtimeExtractDir);
const nodeDir = path.join(packageRoot, 'node');
if (args.runtime === 'bun') {
installBunRuntime(runtimeExtractDir, nodeDir, target);
validateBunRuntime(target, packageRoot);
} else {
copyExtractedNode(runtimeExtractDir, nodeDir);
}
validateNodeRuntime(target, nodeDir);
writeShims(packageRoot, args.runtime);
writeManifest(packageRoot, {
version,
target,
nodeArchive: path.basename(nodeArchive),
runtime: args.runtime,
});
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;
}
// Canonical standalone archive name for a target and runtime flavor. The bun
// runtime is the temporary OpenTUI preview flavor, so its archives carry a
// -opentui-preview suffix and sit alongside the classic Node.js archives in
// gated releases. build-standalone-release.js and
// verify-installation-release.js derive their expected names from this
// function, so the suffix stays consistent across the release pipeline.
function standaloneArchiveName(target, runtime = 'node') {
const targetConfig = TARGETS.get(target);
if (!targetConfig) {
fail(`Unknown target: ${target}`);
}
const flavorSuffix = runtime === 'bun' ? '-opentui-preview' : '';
return `qwen-code-${target}${flavorSuffix}.${targetConfig.outputExtension}`;
}
function parseArgs(argv) {
const args = {
help: false,
nativeModulesDir: undefined,
opentuiModulesDir: undefined,
outDir: undefined,
nodeArchive: undefined,
runtime: 'node',
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 '--runtime':
args.runtime = readOptionValue(argv, index, arg);
index += 1;
break;
case '--node-archive':
args.nodeArchive = readOptionValue(argv, index, arg);
index += 1;
break;
case '--native-modules-dir':
args.nativeModulesDir = readOptionValue(argv, index, arg);
index += 1;
break;
case '--opentui-modules-dir':
args.opentuiModulesDir = 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(', ')}
--runtime RUNTIME Runtime archive flavor: "node" (default) or "bun".
Temporary OpenTUI preview: "bun" installs the Bun
binary at the bundled runtime path so the OpenTUI
renderer (needs bun:ffi) works standalone.
--node-archive PATH Downloaded Node.js runtime archive.
--native-modules-dir DIR
Staged native node_modules directory. Missing
clipboard packages are fatal when this is supplied.
--opentui-modules-dir DIR
Staged node_modules directory holding @opentui
platform packages. Used with --runtime bun; missing
packages are fatal when this is supplied.
--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}. ` +
'Run "npm run bundle" and "npm run prepare:package" first.',
);
}
}
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, runtime) {
const libDir = path.join(packageRoot, 'lib');
const skippedDistEntry = topLevelDistEntryForPath(outDir);
fs.mkdirSync(libDir, { recursive: true });
// Classic Node packaging carries no renderer payload: the OpenTUI backend
// needs bun:ffi, and cross-built archives would hold another platform's
// native library, so the all-or-nothing asset gate could never activate.
// (--runtime=bun copies it here and prunes it to the target's libraries in
// copyOpenTuiAddon.)
for (const entry of fs.readdirSync(distDir)) {
// Standalone rebuilds a clean, target-trimmed lib/node_modules via the
// native addon copy steps. If a local dist/node_modules exists from older
// packaging output or manual testing, copying it would drag in unrelated
// packages or every platform's native prebuild.
if (
entry === skippedDistEntry ||
entry === '.DS_Store' ||
entry === 'node_modules' ||
(entry === 'opentui-assets' && runtime !== 'bun') ||
DIST_NPM_PACKAGE_ONLY_ENTRIES.has(entry)
) {
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'),
);
}
// Bundle the @qwen-code/audio-capture native addon (compiled JS + only this
// target's prebuild + its runtime dep node-gyp-build) into lib/node_modules so
// streaming voice works in standalone installs. The addon is esbuild-external
// and resolved at runtime via import('@qwen-code/audio-capture') from
// lib/cli.js, so lib/node_modules is where Node looks. Without it, standalone
// users fall back to SoX/arecord (batch only) — #5502 follow-up #5590.
function copyNativeAddon(packageRoot, target) {
const prebuildDirName = TARGET_PREBUILD_DIR.get(target);
const addonSrc = path.join(rootDir, 'packages', 'audio-capture');
const prebuildSrc = path.join(addonSrc, 'prebuilds', prebuildDirName);
if (!hasNativePrebuild(prebuildSrc)) {
if (process.env.QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD === '1') {
fail(
`Required audio-capture prebuild is missing for ${prebuildDirName}: ${prebuildSrc}`,
);
}
// No prebuild for this target (e.g. a local build without the release
// artifacts). Ship without the addon: voice degrades to the SoX/arecord
// fallback, streaming is unavailable. The release pipeline downloads
// prebuilds before packaging, so release archives do bundle it.
console.warn(
`[standalone] no audio-capture prebuild for ${prebuildDirName}; ` +
'bundling without the native addon (streaming voice unavailable; ' +
'batch via SoX still works).',
);
return;
}
const nodeRequire = createRequire(import.meta.url);
const nodeGypBuildSrc = path.dirname(
nodeRequire.resolve('node-gyp-build/package.json'),
);
const modulesDir = path.join(packageRoot, 'lib', 'node_modules');
const addonDest = path.join(modulesDir, '@qwen-code', 'audio-capture');
fs.mkdirSync(addonDest, { recursive: true });
// Trimmed manifest: keep type/exports so ESM resolution works; drop the
// install hook (no npm runs inside the archive).
const addonPkg = JSON.parse(
fs.readFileSync(path.join(addonSrc, 'package.json'), 'utf8'),
);
delete addonPkg.scripts;
delete addonPkg.devDependencies;
fs.writeFileSync(
path.join(addonDest, 'package.json'),
JSON.stringify(addonPkg, null, 2) + '\n',
);
const copyOpts = {
recursive: true,
dereference: true,
verbatimSymlinks: false,
};
fs.cpSync(path.join(addonSrc, 'dist'), path.join(addonDest, 'dist'), {
...copyOpts,
filter: (src) => !/\.test\.(d\.)?[mc]?[jt]s(\.map)?$/.test(src),
});
fs.cpSync(
prebuildSrc,
path.join(addonDest, 'prebuilds', prebuildDirName),
copyOpts,
);
// node-gyp-build is the addon's only runtime dependency (zero-dep itself).
fs.cpSync(nodeGypBuildSrc, path.join(modulesDir, 'node-gyp-build'), copyOpts);
assertNoSymlinks(modulesDir, 'Bundled native addon still contains symlinks.');
}
function copyClipboardAddon(packageRoot, target, nativeModulesDir) {
const modulesSrc = path.resolve(
nativeModulesDir || path.join(rootDir, 'node_modules'),
);
const nativePackage = TARGET_CLIPBOARD_PACKAGE.get(target);
const packageNames = ['@teddyzhu/clipboard', nativePackage];
const packageSources = packageNames.map((packageName) =>
path.join(modulesSrc, packageName),
);
const nativePackageSrc = packageSources[1];
const hasRequiredFiles =
packageSources.every((packageSrc) =>
fs.existsSync(path.join(packageSrc, 'package.json')),
) &&
fs.readdirSync(nativePackageSrc).some((entry) => entry.endsWith('.node'));
if (!hasRequiredFiles) {
const message = `clipboard packages for ${target} are missing from ${modulesSrc}`;
if (nativeModulesDir) {
fail(`Required ${message}`);
}
console.warn(
`[standalone] ${message}; bundling without clipboard image support.`,
);
return;
}
const modulesDest = path.join(packageRoot, 'lib', 'node_modules');
const copyOpts = {
recursive: true,
dereference: true,
verbatimSymlinks: false,
};
for (let index = 0; index < packageNames.length; index += 1) {
fs.cpSync(
packageSources[index],
path.join(modulesDest, packageNames[index]),
copyOpts,
);
}
assertNoSymlinks(
modulesDest,
'Bundled clipboard addon still contains symlinks.',
);
}
// Temporary OpenTUI preview: bundle the target's @opentui platform package(s)
// into lib/node_modules so the backend's runtime
// `import('@opentui/core-<platform>-<arch>')` resolves inside the archive.
function copyOpenTuiAddon(packageRoot, target, opentuiModulesDir) {
const packageNames = TARGET_OPENTUI_PACKAGES.get(target) || [];
const modulesSrc = path.resolve(
opentuiModulesDir || path.join(rootDir, 'node_modules'),
);
const modulesDest = path.join(packageRoot, 'lib', 'node_modules');
// The relocated OpenTUI asset root written by copy_bundle_assets.js carries
// only the BUILD host's native library; sync this TARGET's libraries into
// it so the runtime's OTUI_ASSET_ROOT completeness check (which includes
// the native library) passes inside the archive.
const assetRoot = path.join(packageRoot, 'lib', 'opentui-assets');
const copyOpts = {
recursive: true,
dereference: true,
verbatimSymlinks: false,
};
let copiedAnyPackage = false;
for (const packageName of packageNames) {
const packageSrc = path.join(modulesSrc, packageName);
const nativeLibraries = fs.existsSync(path.join(packageSrc, 'package.json'))
? fs
.readdirSync(packageSrc)
.filter((entry) => /\.(dylib|so|dll)$/.test(entry))
: [];
if (nativeLibraries.length === 0) {
const message = `OpenTUI platform package ${packageName} for ${target} is missing from ${modulesSrc}`;
if (opentuiModulesDir) {
fail(`Required ${message}`);
}
console.warn(`[standalone] ${message}; OpenTUI renderer unavailable.`);
continue;
}
copiedAnyPackage = true;
fs.cpSync(packageSrc, path.join(modulesDest, packageName), copyOpts);
for (const library of nativeLibraries) {
const assetDestDir = path.join(assetRoot, packageName);
fs.mkdirSync(assetDestDir, { recursive: true });
fs.copyFileSync(
path.join(packageSrc, library),
path.join(assetDestDir, library),
);
}
}
// The relocated tree ships the BUILD host's platform library; any other
// platform package directory is dead weight the runtime can never load.
if (packageNames.length > 0) {
const targetBasenames = new Set(
packageNames.map((packageName) => packageName.split('/')[1]),
);
const scopeDir = path.join(assetRoot, '@opentui');
if (fs.existsSync(scopeDir)) {
for (const entry of fs.readdirSync(scopeDir)) {
if (/^core-/.test(entry) && !targetBasenames.has(entry)) {
fs.rmSync(path.join(scopeDir, entry), {
recursive: true,
force: true,
});
}
}
}
}
// The warn-only degradation path copies nothing, so lib/node_modules may
// not exist here.
if (copiedAnyPackage) {
assertNoSymlinks(
modulesDest,
'Bundled OpenTUI addon still contains symlinks.',
);
}
}
function hasNativePrebuild(prebuildDir) {
return (
fs.existsSync(prebuildDir) &&
fs.readdirSync(prebuildDir).some((entry) => entry.endsWith('.node'))
);
}
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 validateBunRuntime(target, packageRoot) {
const relativePath =
target === 'win-x64'
? path.join('bun', 'bun.exe')
: path.join('bun', 'bin', 'bun');
const executablePath = path.join(packageRoot, relativePath);
if (!fs.existsSync(executablePath)) {
fail(`Bun runtime for ${target} must contain ${relativePath}.`);
}
if (target !== 'win-x64') {
const mode = fs.statSync(executablePath).mode;
if ((mode & 0o111) === 0) {
fail(
`Bun runtime for ${target} must provide executable ${relativePath}.`,
);
}
}
}
// Temporary OpenTUI preview support: Bun release archives contain a single
// executable (`bun` / `bun.exe`). It is installed under `bun/` and launched
// by that name — Bun invoked as `node` enters its node-compat CLI mode,
// which breaks the interactive TUI. A placeholder is also placed at the
// classic `node/bin/node` path so installer scripts that validate that
// layout keep working unchanged. It must be a REGULAR file: the installers
// refuse archives containing symlinks or hardlinks, and a hardlink mirror
// tripped exactly that check. Unix gets a tiny exec shim (zero size cost);
// Windows gets a plain copy because a `.sh` shim cannot impersonate an exe.
function installBunRuntime(extractDir, nodeDir, target) {
const executableName = target === 'win-x64' ? 'bun.exe' : 'bun';
const sourcePath = findFileRecursive(extractDir, executableName);
if (!sourcePath) {
fail(`Bun runtime archive did not contain ${executableName}.`);
}
const targetConfig = TARGETS.get(target);
const packageRoot = path.dirname(nodeDir);
const bunRelativePath =
target === 'win-x64'
? path.join('bun', 'bun.exe')
: path.join('bun', 'bin', 'bun');
const bunPath = path.join(packageRoot, bunRelativePath);
fs.mkdirSync(path.dirname(bunPath), { recursive: true });
fs.copyFileSync(sourcePath, bunPath);
fs.chmodSync(bunPath, 0o755);
// Installer-script compatibility mirror at the Node.js layout path.
const compatPath = path.join(nodeDir, ...targetConfig.nodeExecutable);
fs.mkdirSync(path.dirname(compatPath), { recursive: true });
if (target === 'win-x64') {
fs.copyFileSync(bunPath, compatPath);
} else {
fs.writeFileSync(
compatPath,
'#!/usr/bin/env sh\n' +
'# OpenTUI preview: the bundled runtime is Bun; this placeholder keeps\n' +
'# installers that validate the classic Node.js layout working.\n' +
'exec "$(dirname "$0")/../../bun/bin/bun" "$@"\n',
);
}
fs.chmodSync(compatPath, 0o755);
}
function findFileRecursive(dir, fileName) {
for (const entry of fs.readdirSync(dir)) {
const fullPath = path.join(dir, entry);
if (fs.statSync(fullPath).isDirectory()) {
const nested = findFileRecursive(fullPath, fileName);
if (nested) {
return nested;
}
} else if (entry === fileName) {
return fullPath;
}
}
return undefined;
}
function writeShims(packageRoot, runtime) {
const binDir = path.join(packageRoot, 'bin');
fs.mkdirSync(binDir, { recursive: true });
const unixRuntime =
runtime === 'bun' ? '$ROOT/bun/bin/bun' : '$ROOT/node/bin/node';
// The bun flavor is the OpenTUI preview: default the renderer to opentui so
// the archive launches what it was built to preview. An explicit user
// QWEN_TUI_RENDERER still wins. No STRICT default: probe failures keep
// falling back to ink.
const unixRendererDefault =
runtime === 'bun'
? 'export QWEN_TUI_RENDERER="${QWEN_TUI_RENDERER:-opentui}"\n'
: '';
const unixShim = `#!/usr/bin/env sh
set -e
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
${unixRendererDefault}QWEN_CODE_LAUNCHER_PATH="$ROOT/bin/qwen" exec "${unixRuntime}" "$ROOT/lib/cli-entry.js" "$@"
`;
const unixShimPath = path.join(binDir, 'qwen');
fs.writeFileSync(unixShimPath, unixShim);
fs.chmodSync(unixShimPath, 0o755);
const windowsRuntime =
runtime === 'bun' ? '%ROOT%\\bun\\bun.exe' : '%ROOT%\\node\\node.exe';
const windowsRendererDefault =
runtime === 'bun'
? 'if not defined QWEN_TUI_RENDERER set "QWEN_TUI_RENDERER=opentui"\n'
: '';
const windowsShim = `@echo off
setlocal
set "ROOT=%~dp0.."
${windowsRendererDefault}set "QWEN_CODE_LAUNCHER_PATH=%ROOT%\\bin\\qwen.cmd"
"${windowsRuntime}" "%ROOT%\\lib\\cli-entry.js" %*
exit /b %ERRORLEVEL%
`;
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,
runtime: manifest.runtime || 'node',
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 {
TARGET_CLIPBOARD_PACKAGE,
TARGETS,
standaloneArchiveName,
writeSha256Sums,
// Exported so a test can hold the allowlist and the build's stamp together:
// the packager aborts on any dist entry it does not know, and nothing else
// would notice a one-sided rename until a release was cut.
isAllowedDistEntry,
};