Files
qwen-code/scripts/create-standalone-package.js
jinyeandQwen-Coder c61044fae4 feat(managed-agent): Start daemon and Managed locally with one command (#14928)
* feat(managed-agent): add local serve and lightweight runtime packages

Co-authored-by: Qwen-Coder <[email protected]>

* feat(managed-agent): download local server runtime on first use

Require local Java 21, install the exact-version service JAR atomically from OSS or GitHub, and reuse verified runtime caches offline. Add protected signed Central publication and identical release distribution without bundling Java in the CLI.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(ci): register managed runtime workflow size

Record the new release workflow in the repository size baseline so the static CI gate can track it.

Co-authored-by: Qwen-Coder <[email protected]>

* feat(managed-agent): Share the local daemon and Managed entry

Serve daemon APIs and the signed Managed proxy from one loopback listener. Keep workspace ownership explicit, enable authorized local directory import and preserve the daemon trust boundary.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): expose configured models in local Managed tasks

Co-authored-by: Qwen-Coder <[email protected]>

* test(web-shell): Cover Managed and local deep-link restoration

Co-authored-by: Qwen-Coder <[email protected]>

* test(managed-agent): Align local database expectation with main schema

Co-authored-by: Qwen-Coder <[email protected]>

* fix(managed): Keep local model and approval choices independent

Use native initial model selection for local model presets and persist it with the selected approval mode before admitting the first Turn. Preserve custom Agent definition policy pins and creation replay semantics.

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-10-22 16:25:18 +00:00

1224 lines
40 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';
import { copyManagedLocalRuntime } from './managed-local-package.js';
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 = [
'mem0/main.js',
'mem0/write-confirmation.js',
'generated-ui/main.js',
'generated-ui/app.html',
'generated-ui/skills/generated-ui/SKILL.md',
'generated-ui/THIRD_PARTY_NOTICES.txt',
'cli.js',
'cli-entry.js',
'codeModeHost.js',
'mod-worker.js',
'execution-worker.js',
'sandboxBwrapRelay.js',
'sandboxLandlockRelay.js',
'sandboxFileWorker.js',
'chunks',
'vendor',
'bundled/qc-helper/docs',
];
const DIST_ALLOWED_ENTRIES = new Set([
'mem0',
'generated-ui',
'cli.js',
'execution-worker.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',
'glob-search-worker.js',
'codeModeHost.js',
'mod-worker.js',
// Sandbox relays + confined file worker; esbuild emits them as
// standalone entries that sandboxAsset() resolves from the bundle dir at
// execution time (packages/core/src/sandbox/sandbox-execution.ts).
'sandboxBwrapRelay.js',
'sandboxLandlockRelay.js',
'sandboxFileWorker.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 (Boolean(args.managedServerJar) !== Boolean(args.managedJreHome)) {
fail('--managed-server-jar and --managed-jre-home are required together.');
}
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();
if (args.managedServerJar && version !== readPackageVersion()) {
fail(
'Managed package --version must match the built CLI version. Build all components from the same versioned checkout.',
);
}
const outDir = path.resolve(
args.outDir ||
path.join(
distDir,
args.managedServerJar ? 'managed-local' : 'standalone',
),
);
fs.mkdirSync(outDir, { recursive: true });
const targetConfig = TARGETS.get(target);
const outputName = args.managedServerJar
? standaloneArchiveName(target, args.runtime).replace(
'qwen-code-',
'qwen-code-managed-local-',
)
: 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);
// Node needs the PTY packages on every target; Bun uses them on POSIX.
// Bun's Windows PTY gate makes them unreachable in that target archive.
if (args.runtime !== 'bun' || target !== 'win-x64') {
copyNodePtyAddon(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);
if (args.managedServerJar) {
copyManagedLocalRuntime({
destination: path.join(packageRoot, 'lib', 'managed-runtime'),
serverJar: path.resolve(args.managedServerJar),
jreHome: path.resolve(args.managedJreHome),
version,
target,
runtime: args.runtime,
});
}
writeManifest(packageRoot, {
version,
target,
nodeArchive: path.basename(nodeArchive),
runtime: args.runtime,
...(args.managedServerJar ? { managedLocal: true } : {}),
});
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,
managedServerJar: undefined,
managedJreHome: 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 '--managed-server-jar':
args.managedServerJar = readOptionValue(argv, index, arg);
index += 1;
break;
case '--managed-jre-home':
args.managedJreHome = 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.
--managed-server-jar PATH
Include the matching Managed server Spring Boot JAR.
--managed-jre-home DIR Complete extracted Java 21 runtime for TARGET, including
legal/. Required with --managed-server-jar; creates a
distinct qwen-code-managed-local-TARGET archive.
--native-modules-dir DIR
Staged native node_modules directory holding
@teddyzhu/clipboard* and @lydell/node-pty*
packages (PTY: all Node targets and POSIX Bun).
Missing clipboard packages are fatal when this is
supplied; missing node-pty packages warn unless
QWEN_STANDALONE_REQUIRE_NODE_PTY_PREBUILD=1.
--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,
or dist/managed-local for a Managed package.
--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.',
);
}
// Bundle @lydell/node-pty (the wrapper plus only this target's platform
// prebuild package) into lib/node_modules so the web terminal can spawn a PTY
// in standalone installs. getPty() resolves the wrapper via a runtime
// import('@lydell/node-pty') — esbuild.config.js keeps every node-pty
// specifier external — and the wrapper in turn requires
// `@lydell/node-pty-<platform>-<arch>` from node_modules. Without this step
// the archive declares the packages in optionalDependencies but ships none of
// them, so every web terminal creation fails with "PTY not available"
// (#11872). Missing packages warn-and-degrade locally (like the audio-capture
// step) rather than failing the build, so a developer archive still builds for
// a target whose prebuild the host install does not have: npm skips
// optionalDependencies whose os/cpu do not match the installing machine, and a
// staged --native-modules-dir can carry the clipboard packages without this
// target's node-pty prebuild. An unmapped target is not that case —
// copyNativeAddon reads the same TARGET_PREBUILD_DIR and its unconditional
// path.join() throws on the undefined dir name before this step is reached.
// Release builds cannot degrade silently: release.yml exports
// QWEN_STANDALONE_REQUIRE_NODE_PTY_PREBUILD=1 for the archive build, turning a
// missing prebuild into a hard failure now that every shipped target has a
// pinned package.
function copyNodePtyAddon(packageRoot, target, nativeModulesDir) {
const prebuildDirName = TARGET_PREBUILD_DIR.get(target);
const nativePackage = `@lydell/node-pty-${prebuildDirName}`;
const packageNames = ['@lydell/node-pty', nativePackage];
const modulesSrc = path.resolve(
nativeModulesDir || path.join(rootDir, 'node_modules'),
);
const packageSources = packageNames.map((packageName) =>
path.join(modulesSrc, packageName),
);
const hasRequiredFiles =
packageSources.every((packageSrc) =>
fs.existsSync(path.join(packageSrc, 'package.json')),
) &&
hasNativePrebuild(
path.join(packageSources[1], 'prebuilds', prebuildDirName),
);
if (!hasRequiredFiles) {
const message = `node-pty packages for ${target} are missing from ${modulesSrc}`;
if (process.env.QWEN_STANDALONE_REQUIRE_NODE_PTY_PREBUILD === '1') {
fail(`Required ${message}`);
}
console.warn(
`[standalone] ${message}; bundling without PTY support ` +
'(web terminal will report "PTY not available").',
);
return;
}
const modulesDest = path.join(packageRoot, 'lib', 'node_modules');
const copyOpts = {
recursive: true,
dereference: true,
verbatimSymlinks: false,
// The win32-x64 prebuild package ships .pdb debug symbols beside its .node
// addons: 10,780,672 B, 86% of its 12,485,696 B prebuild payload and
// ~2.05 MiB of the compressed win-x64 archive. Nothing reads them at
// runtime (a PDB is only opened by a debugger or crash-dump symbolizer),
// so they are dropped at packaging time.
filter: (src) => !src.endsWith('.pdb'),
};
for (let index = 0; index < packageNames.length; index += 1) {
fs.cpSync(
packageSources[index],
path.join(modulesDest, packageNames[index]),
copyOpts,
);
}
assertNoSymlinks(
modulesDest,
'Bundled node-pty 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,
...(manifest.managedLocal ? { managedLocal: true } : {}),
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,
// Exported so a test can hold the target map and the manifest's pins
// together: the release build now fails on a missing prebuild, so a target
// mapped to an unpinned package name would abort the whole archive step
// instead of degrading one archive.
TARGET_PREBUILD_DIR,
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,
};