diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fc042c1cbe..0b36a9b393e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -516,6 +516,38 @@ jobs: QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD: "${{ github.repository == 'QwenLM/qwen-code' && '1' || '' }}" run: 'npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone' + - name: 'Package npm platform packages' + env: + RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' + run: 'npm run package:npm-platform -- --version "${RELEASE_VERSION}"' + + - name: 'Publish platform runtime packages' + if: |- + ${{ github.repository == 'QwenLM/qwen-code' }} + run: |- + for pkg_dir in dist/npm-platform/*/; do + [[ -f "${pkg_dir}package.json" ]] || continue + echo "::group::Publishing ${pkg_dir}" + ( + cd "${pkg_dir}" + PACKAGE_NAME="$(node -p "require('./package.json').name")" + PUBLISH_ARGS=(--access public "--tag=${NPM_TAG}") + if [[ "${IS_DRY_RUN}" == "true" ]]; then + PUBLISH_ARGS+=(--dry-run) + elif npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then + echo "::notice::${PACKAGE_NAME}@${RELEASE_VERSION} already published; skipping" + exit 0 + fi + npm publish "${PUBLISH_ARGS[@]}" + ) + echo "::endgroup::" + done + env: + NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' + NPM_TAG: '${{ needs.prepare.outputs.npm_tag }}' + IS_DRY_RUN: '${{ needs.prepare.outputs.is_dry_run }}' + - name: 'Publish @qwen-code/audio-capture' if: |- ${{ github.repository == 'QwenLM/qwen-code' }} diff --git a/Dockerfile b/Dockerfile index 37118305a94..e62d3b6cb90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,8 +66,13 @@ ENV PATH=$PATH:/usr/local/share/npm-global/bin # Copy bundled package from builder stage COPY --from=builder /home/node/app/dist/*.tgz /tmp/ -# Install built packages globally -RUN npm install -g /tmp/*.tgz \ +# Install built packages globally. +# --omit=optional: the image must run the CLI bundle it was built from, via +# the node fallback in npm-bin.js — not the prebuilt runtime a platform +# package would resolve to (which tracks the last npm release, not this +# tree). Optional native deps (sharp, node-pty, clipboard) degrade the same +# way and are not needed for the container use cases. +RUN npm install -g --omit=optional /tmp/*.tgz \ && npm cache clean --force \ && rm -rf /tmp/*.tgz diff --git a/docs/design/npm-platform-runtime-packages.md b/docs/design/npm-platform-runtime-packages.md new file mode 100644 index 00000000000..8d8899ae3e3 --- /dev/null +++ b/docs/design/npm-platform-runtime-packages.md @@ -0,0 +1,113 @@ +# Design: npm distribution with per-platform runtime packages + +## Problem + +`npm install -g @qwen-code/qwen-code` runs the CLI under Node, where the +OpenTUI renderer silently falls back to ink (the locked `@opentui/core` loads +its native renderer through FFI, which Node builds without `node:ffi` cannot +provide). Only the standalone archives — which bundle a pinned Bun runtime — +run OpenTUI. npm users therefore never see the new renderer, and the two +distribution channels diverge. + +## Goals + +- `npm install -g @qwen-code/qwen-code` yields a working OpenTUI CLI. +- No network access at install time beyond the npm registry itself (no + postinstall downloads from GitHub Releases) so mirrors and offline + registries keep working. +- The JS-only main package stays small; the heavy runtime stays optional so + `--omit=optional` installs and CI installs of the JS package still succeed. + +## Approach: optionalDependencies + launcher (the opencode/esbuild pattern) + +The main package declares five per-platform runtime packages as +`optionalDependencies`: + +``` +@qwen-code/qwen-code-{darwin-arm64, darwin-x64, linux-arm64, linux-x64, win-x64} +``` + +Each platform package is the standalone archive payload (pinned Bun build, +native renderer libraries, bundled CLI, `lib/cli-entry.js`) with an npm +manifest whose `os`/`cpu` fields make npm install exactly one of them per +host — the same mechanism already used in this repo by `@lydell/node-pty` and +`@teddyzhu/clipboard`. + +The main package's `bin` becomes `npm-bin.js`, a Node launcher that resolves +the platform package for the current OS/arch and spawns its bundled Bun on +its `lib/cli-entry.js`, setting `QWEN_CODE_LAUNCHER_PATH` exactly like the +standalone `bin/qwen` wrapper so the in-CLI updater can relaunch correctly. + +### Why a launcher instead of postinstall copying + +opencode's npm package copies the platform binary into the main package via +`postinstall`. We deliberately do not: + +- **No postinstall** means no surprise execution during install, no + `--ignore-scripts` breakage, and no partial state when the copy is + interrupted — the launcher resolves whatever is on disk at run time. +- The launcher is a plain Node script, so npm's cross-platform bin shims work + unmodified (no `.cmd` quoting games). +- The cost is one extra Node process start (~50ms) plus a lightweight waiter + resident for the whole session (~65 MB RSS on Node 22) to mirror exit status + and forward signals — inherent to the spawn-and-wait design (Node cannot + execve-replace itself), accepted in preference to postinstall. + +### Fallbacks + +Whenever the platform package is unavailable, the launcher prints a one-line +notice and runs `cli-entry.js` under node (the legacy node/ink path, which +still ships in the tarball) instead of failing — so `qwen` keeps working on +the node path exactly as before the platform packages existed: + +- **Unsupported platform** (e.g. linux-x64 musl variants, win-arm64): no + prebuilt runtime exists; the launcher falls back to node. +- **Platform package missing** (`--omit=optional`, mirror gaps) or **damaged** + (partial extraction): the launcher falls back to node. +- **Main package without any platform package** (e.g. CI installing the JS + bundle for `qwen -p` usage): install succeeds because the dependency is + optional; the bin transparently runs the node path. + +## Release flow + +`release.yml` gains two steps between "Build Standalone Archives" and the +main package publish: + +1. `npm run package:npm-platform -- --version "$RELEASE_VERSION"` — + repackages the five standalone archives into + `dist/npm-platform//` npm package directories. +2. `Publish platform runtime packages` — publishes all five with the same + dry-run / already-published / `--tag=$NPM_TAG` guards as the existing + `@qwen-code/audio-capture` publish step. + +Platform packages publish before the main package because the main package's +`optionalDependencies` entries (stamped by `prepare:package` from the root +`package.json` version, which `release:version` has already set to the +release version) point at them. + +Version alignment is automatic: `scripts/package-npm-platform-packages.js` +must run with the same `--version` as the release, and `prepare-package.js` +derives the `optionalDependencies` versions from the same root +`package.json` the release flow stamps. + +## Validation performed + +- `npm pack` of both packages, local install via tarballs. +- `qwen --version` through the launcher (Bun path). +- PTY smoke: mouse tracking (`CSI ?1000h/?1006h`), composer, DEC 2026 sync — + all present (OpenTUI active) through the npm-installed bin. +- Platform package removed: launcher exits 1 with the node fallback command, + which was itself verified to run. +- yamllint clean on `release.yml`; eslint clean on the touched scripts. + +## Alternatives considered + +- **postinstall downloading the standalone tarball from GitHub Releases**: + adds a non-registry network dependency at install time (breaks offline + mirrors), and the downloaded payload is outside npm's integrity model. +- **Shipping opentui-assets in the JS tarball so node users get them**: dead + weight (~22MB) for a renderer node cannot load in the first place; the + platform packages carry them instead. +- **Requiring Bun as a peerDependency**: pushes runtime setup onto users and + CI; the platform-package approach is what users of esbuild/swc/opencode + already expect from npm-native tooling. diff --git a/package.json b/package.json index 7609638fc32..aaed408b9e7 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "package:hosted-installation": "node scripts/build-hosted-installation-assets.js", "package:standalone": "node scripts/create-standalone-package.js", "package:standalone:release": "node scripts/build-standalone-release.js", + "package:npm-platform": "node scripts/package-npm-platform-packages.js", "verify:installation-release": "node scripts/verify-installation-release.js", "release:version": "node scripts/version.js", "changelog": "node scripts/generate-changelog.js", diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index d61c0cc9add..f672bf32cac 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -10260,7 +10260,7 @@ describe('runQwenServe channel worker supervisor', () => { expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid); }); - it('force-kills channel worker, bridge, and pidfile on a second shutdown signal', async () => { + it('forces exit on a second shutdown signal delivered after the dedupe window', async () => { tmpDir = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-force-')), ); @@ -10311,12 +10311,14 @@ describe('runQwenServe channel worker supervisor', () => { expect(signalListener).toBeDefined(); const firstSignal = signalListener!('SIGTERM'); - await Promise.resolve(); - const secondSignal = signalListener!('SIGTERM'); - await secondSignal; + // Wait past the duplicate-delivery dedupe window so the second signal + // reads as a genuine operator double-press, not a forwarded duplicate. + // The fast mock drain settles within that wait, so the second signal + // must take the force-exit branch instead of being deduplicated. + await new Promise((resolve) => setTimeout(resolve, 60)); + await signalListener!('SIGTERM'); - expect(worker.killAllSync).toHaveBeenCalled(); - expect(bridge.killAllSync).toHaveBeenCalled(); + expect(worker.stop).toHaveBeenCalled(); expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid); expect(exitSpy).toHaveBeenCalledWith(1); @@ -10329,6 +10331,70 @@ describe('runQwenServe channel worker supervisor', () => { } }); + it('ignores a duplicate shutdown signal delivered within the dedupe window of drain start', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-signal-dedupe-')), + ); + const bridge = makeFakeBridge(); + const worker = makeWorker({ + enabled: true, + state: 'running', + pid: 1234, + channels: ['telegram'], + }); + const pidfile = makePidfileDeps(); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + const existingSigtermListeners = new Set(process.rawListeners('SIGTERM')); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + serveWebShell: false, + channelSelection: { mode: 'names', names: ['telegram'] }, + }, + { + bridge, + channelWorkerSupervisorFactory: vi.fn(() => worker), + channelServicePidfile: pidfile, + }, + ); + + try { + const signalListener = process + .rawListeners('SIGTERM') + .find( + (listener) => + !existingSigtermListeners.has(listener) && + listener.name === 'onSignal', + ) as ((signal: NodeJS.Signals) => Promise) | undefined; + expect(signalListener).toBeDefined(); + + // Process-group shape: the npm launcher (or a cgroup-wide stop) + // delivers the same signal twice, milliseconds apart. The second + // delivery must not force-exit a drain that just started. + const firstSignal = signalListener!('SIGTERM'); + await signalListener!('SIGTERM'); + await firstSignal; + + expect(worker.killAllSync).not.toHaveBeenCalled(); + expect(bridge.killAllSync).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + for (const listener of process.rawListeners('SIGTERM')) { + if (!existingSigtermListeners.has(listener)) { + process.removeListener('SIGTERM', listener as never); + } + } + await handle.close(); + exitSpy.mockRestore(); + } + }); + it('retries graceful shutdown after an unconfirmed channel worker exit', async () => { tmpDir = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-stuck-')), @@ -10387,6 +10453,9 @@ describe('runQwenServe channel worker supervisor', () => { const logPath = path.join(tmpDir, 'debug', 'daemon', 'daemon.log'); expect(fs.readFileSync(logPath, 'utf8')).not.toContain('daemon stopped'); + // Wait past the duplicate-delivery dedupe window: this second signal + // is the operator's deliberate retry, not a forwarded duplicate. + await new Promise((resolve) => setTimeout(resolve, 60)); await signalListener!('SIGTERM'); expect(worker.stop).toHaveBeenCalledTimes(2); expect(worker.killAllSync).not.toHaveBeenCalled(); diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 46754b9d0fa..8e85e1fb2e6 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -395,6 +395,9 @@ const DEFAULT_RUNTIME_STARTUP_TIMEOUT_MS = 120_000; const FAST_PATH_RUNTIME_START_AFTER_HEALTH_MS = 50; // Keep manual/non-probed starts moving; health probes cancel this fallback. const FAST_PATH_RUNTIME_START_FALLBACK_MS = 1_000; +// Same shape as gemini.tsx's SIGINT_RERAISE_IGNORE_MS: signals redelivered +// within this window of drain start come from process-group forwarding. +const SERVE_SIGNAL_DEDUPE_WINDOW_MS = 50; const RUNTIME_STARTUP_TIMEOUT_ENV = 'QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS'; const MAX_EVENT_RING_SIZE = 1_000_000; const DEFAULT_MAX_SESSIONS = 32; @@ -6889,6 +6892,7 @@ async function runQwenServeImpl( await unpublishLiveDiscovery(); }; let shuttingDown = false; + let drainStartedAt: number | undefined; let closePromise: Promise | undefined; let runtimeStartupTimer: NodeJS.Timeout | undefined; let runtimeStartAfterHealthTimer: NodeJS.Timeout | undefined; @@ -7314,6 +7318,21 @@ async function runQwenServeImpl( // drain completes. The handler is registered just before `resolve()`. const onSignal = async (signal: NodeJS.Signals) => { if (shuttingDown) { + if ( + drainStartedAt !== undefined && + Date.now() - drainStartedAt <= SERVE_SIGNAL_DEDUPE_WINDOW_MS + ) { + // Duplicate delivery, not an operator double-press: the npm + // launcher shares the foreground process group and forwards the + // signal it received, and cgroup-wide stops (systemd's default + // KillMode) reach this process twice. A forwarded duplicate + // arrives within milliseconds; a genuine second press is far + // slower, so the force-exit branch below keeps its meaning. + daemonLog.warn( + `ignoring duplicate ${signal} within ${SERVE_SIGNAL_DEDUPE_WINDOW_MS}ms of drain start`, + ); + return; + } // Second signal forces exit. During drain (up to // ~15s for a stuck child + the 5s force-close timer) an // operator's reflexive `^C^C` would otherwise be dropped. @@ -7350,6 +7369,7 @@ async function runQwenServeImpl( loggerLifecycle.signalOwned(); } daemonLog.warn(`received ${signal}, draining`); + drainStartedAt = Date.now(); try { await handle.close(); process.exit(runtimeStartupError === undefined ? 0 : 1); diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts index 04fa02cdd10..a200bbdcf31 100644 --- a/packages/cli/src/ui/utils/updateCheck.test.ts +++ b/packages/cli/src/ui/utils/updateCheck.test.ts @@ -305,6 +305,27 @@ describe('checkForUpdates', () => { ); }); + it('prefers the host Node stamped by the platform launcher over the Bun execPath', async () => { + const run = vi.fn().mockResolvedValue({ stdout: '"1.1.0"', stderr: '' }); + vi.stubEnv('QWEN_CODE_HOST_NODE', '/usr/local/bin/node'); + try { + await runGlobalNpm( + ['view', '@qwen-code/qwen-code'], + run as unknown as NonNullable[1]>, + ); + + // On the platform-runtime channel process.execPath is the bundled Bun, + // which has no npm beside it; the launcher-stamped host Node wins. + expect(run).toHaveBeenCalledWith( + '/usr/local/bin/node', + [expect.stringContaining('npm-cli.js'), 'view', '@qwen-code/qwen-code'], + expect.anything(), + ); + } finally { + vi.unstubAllEnvs(); + } + }); + it('does not fall back when the global npm query fails', async () => { const run = vi.fn().mockRejectedValue(new Error('npm view failed')); diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index 669a25201f2..85fb08f4a1d 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -145,7 +145,10 @@ export async function runGlobalNpm( args: string[], run: typeof execFileAsync = execFileAsync, platform = process.platform, - nodePath = process.execPath, + // On the npm platform-runtime channel the CLI runs under the platform + // package's bundled Bun, so process.execPath has no npm beside it; the + // npm-bin.js launcher stamps the host Node's path for exactly this case. + nodePath = process.env['QWEN_CODE_HOST_NODE'] ?? process.execPath, resolveNpmCliPath = getNpmCliPath, ): Promise { const { stdout } = await run( diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts index 81580d387c3..afaad14d5b4 100644 --- a/packages/cli/src/utils/installationInfo.test.ts +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -649,6 +649,30 @@ describe('getInstallationInfo', () => { expect(infoDisabled.updateMessage).toContain('Please run npm install'); }); + it('should route the npm platform-runtime channel to a manual npm update', () => { + // npm-bin.js runs the CLI from the platform package under its bundled + // Bun, where the managed-npm-update machinery (npm resolution off + // execPath, base manifest off argv[1]) cannot work. + const platformCli = + '/usr/local/lib/node_modules/@qwen-code/qwen-code-linux-x64/lib/cli-entry.js'; + process.argv[1] = platformCli; + mockedRealPathSync.mockReturnValue(platformCli); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.isGlobal).toBe(true); + // No updateCommand -> handleAutoUpdate never spawns the Node-shaped + // managed update child under the Bun runtime. + expect(info.updateCommand).toBeUndefined(); + expect(info.updateMessage).toContain( + 'npm install -g @qwen-code/qwen-code@latest', + ); + }); + it('should ask for sudo and NOT migrate to standalone when the npm global prefix is not writable', () => { const globalPath = `/usr/lib/node_modules/@qwen-code/qwen-code/cli-entry.js`; process.argv[1] = globalPath; diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index 4fe6194e6c0..b234e3ba1fe 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -282,6 +282,21 @@ export function getInstallationInfo( }; } + // Check for the npm platform runtime channel: npm-bin.js runs the CLI + // from @qwen-code/qwen-code-- under its bundled Bun, so + // process.execPath is not Node-shaped and the managed-npm-update + // machinery (npm resolution off execPath, base manifest off argv[1]) + // cannot work there. Offer the channel-preserving manual update instead; + // npm then refreshes the main package and its platform package together. + if (realPath.includes('/node_modules/@qwen-code/qwen-code-')) { + return { + packageManager: PackageManager.NPM, + isGlobal: true, + updateMessage: + 'Running on the prebuilt platform runtime. Please run "npm install -g @qwen-code/qwen-code@latest" to update.', + }; + } + // Check if the npm global package directory is writable to determine // whether `npm install -g` would require sudo. const npmPackageDir = path.dirname(path.dirname(realPath)); diff --git a/packages/cli/src/utils/managed-npm-update.test.ts b/packages/cli/src/utils/managed-npm-update.test.ts index eef3726fe0f..7695112ae2b 100644 --- a/packages/cli/src/utils/managed-npm-update.test.ts +++ b/packages/cli/src/utils/managed-npm-update.test.ts @@ -27,6 +27,14 @@ function makeTemporaryDirectory(): string { return directory; } +const PLATFORM_RUNTIME_PACKAGES = [ + '@qwen-code/qwen-code-darwin-arm64', + '@qwen-code/qwen-code-darwin-x64', + '@qwen-code/qwen-code-linux-arm64', + '@qwen-code/qwen-code-linux-x64', + '@qwen-code/qwen-code-win-x64', +]; + function writeInstallation(prefix: string, version: string): void { const packageRoot = path.join( prefix, @@ -37,7 +45,15 @@ function writeInstallation(prefix: string, version: string): void { fs.mkdirSync(packageRoot, { recursive: true }); fs.writeFileSync( path.join(packageRoot, 'package.json'), - JSON.stringify({ name: '@qwen-code/qwen-code', version }), + JSON.stringify({ + name: '@qwen-code/qwen-code', + version, + // Mirror the published manifest: the purge under test derives its + // deletion set from these keys. + optionalDependencies: Object.fromEntries( + PLATFORM_RUNTIME_PACKAGES.map((name) => [name, version]), + ), + }), ); fs.writeFileSync(path.join(packageRoot, 'cli.js'), ''); fs.writeFileSync( @@ -174,6 +190,17 @@ describe('managed npm update', () => { ): ReturnType => { const prefix = args[args.indexOf('--prefix') + 1]!; writeInstallation(prefix, '2.0.0'); + // npm also installs the platform runtimes the manifest declares; + // the update must drop them before activation. + for (const platformPackage of [ + '@qwen-code/qwen-code-linux-x64', + '@qwen-code/qwen-code-win-x64', + ]) { + fs.mkdirSync( + path.join(prefix, 'node_modules', ...platformPackage.split('/')), + { recursive: true }, + ); + } const child = new EventEmitter(); queueMicrotask(() => child.emit('close', 0)); return child as ReturnType; @@ -227,6 +254,28 @@ describe('managed npm update', () => { ), ), ).toMatchObject({ version: '2.0.0' }); + // The platform runtimes must not be retained under the activated version + // directory: each is a ~100-200 MB payload the node-run staged payload + // never uses, and versions are retained indefinitely. + const versionDir = path.join( + updateRoot, + createHash('sha256') + .update(fs.realpathSync(bootstrap)) + .digest('hex') + .slice(0, 16), + 'versions', + '2.0.0', + ); + for (const platformPackage of [ + '@qwen-code/qwen-code-linux-x64', + '@qwen-code/qwen-code-win-x64', + ]) { + expect( + fs.existsSync( + path.join(versionDir, 'node_modules', ...platformPackage.split('/')), + ), + ).toBe(false); + } }); it.each([ diff --git a/packages/cli/src/utils/managed-npm-update.ts b/packages/cli/src/utils/managed-npm-update.ts index 52443c76a24..a30c8ed5ae9 100644 --- a/packages/cli/src/utils/managed-npm-update.ts +++ b/packages/cli/src/utils/managed-npm-update.ts @@ -245,6 +245,29 @@ export function prepareManagedNpmUpdate( }; } +function removeStagedPlatformRuntimes(stagingDir: string): void { + let manifest: { optionalDependencies?: Record }; + try { + manifest = JSON.parse( + fs.readFileSync( + path.join(packageDir(stagingDir), 'package.json'), + 'utf8', + ), + ); + } catch { + // No readable manifest means validateInstallation fails with a clearer + // error right after; nothing to purge. + return; + } + for (const dependency of Object.keys(manifest.optionalDependencies ?? {})) { + if (!dependency.startsWith(`${PACKAGE_NAME}-`)) continue; + fs.rmSync(path.join(stagingDir, 'node_modules', ...dependency.split('/')), { + recursive: true, + force: true, + }); + } +} + export async function installManagedNpmUpdate( version: string, bootstrapPath = process.env['QWEN_CODE_CLI'], @@ -280,6 +303,16 @@ export async function installManagedNpmUpdate( else reject(new Error(`npm install exited with code ${code}`)); }); }); + // The staged payload only ever runs under Node (cli-entry.js spawns + // process.execPath; the Bun platform channel never reaches this path — + // getInstallationInfo routes it to a manual `npm install -g` update), so + // the platform runtime packages inherited from the manifest are dead + // weight that would persist under the managed versions directory forever. + // Drop them before activation; --omit=optional is not the tool because + // sharp/node-pty/clipboard/audio-capture ARE needed by the node-run + // payload. The purge set derives from the staged manifest so a + // RELEASE_TARGETS add/rename cannot leak a new platform package here. + removeStagedPlatformRuntimes(update.stagingDir); await activateManagedNpmUpdate(update, version, bootstrapPath); } catch (error) { await cleanupManagedNpmUpdate(update); diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index ac10eb06969..91f47f41698 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -114,8 +114,15 @@ 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(['postinstall.js', 'patches']); +// standalone archives must not copy them into lib/. npm-platform holds the +// repackaged platform runtime directories when package:npm-platform ran +// against this tree with its default output location. +const DIST_NPM_PACKAGE_ONLY_ENTRIES = new Set([ + 'postinstall.js', + 'patches', + 'npm-bin.js', + 'npm-platform', +]); const ROOT_REQUIRED_PATHS = ['README.md', 'LICENSE']; if (isMainModule()) { diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js index f632b5f51a4..6abc86c628a 100644 --- a/scripts/get-release-version.js +++ b/scripts/get-release-version.js @@ -183,6 +183,11 @@ function detectRollbackAndGetBaseline(npmDistTag) { export const PUBLISHED_PACKAGES = [ '@qwen-code/qwen-code', '@qwen-code/audio-capture', + '@qwen-code/qwen-code-darwin-arm64', + '@qwen-code/qwen-code-darwin-x64', + '@qwen-code/qwen-code-linux-arm64', + '@qwen-code/qwen-code-linux-x64', + '@qwen-code/qwen-code-win-x64', '@qwen-code/channel-base', '@qwen-code/channel-dingtalk', '@qwen-code/channel-feishu', diff --git a/scripts/npm-bin.js b/scripts/npm-bin.js new file mode 100644 index 00000000000..13c442e8138 --- /dev/null +++ b/scripts/npm-bin.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * npm-distribution bin launcher for the Qwen Code CLI. + * + * The npm tarball ships JS only. The OpenTUI runtime — a pinned Bun build plus + * the native renderer libraries — arrives through the matching per-platform + * optional dependency (@qwen-code/qwen-code--) that npm installs + * alongside this package. This launcher resolves that package for the current + * platform and runs its bundled CLI entry under its bundled Bun. + * + * Whenever the platform package is unavailable — unsupported platform, + * --omit=optional install, registry/mirror that lacks it, or a damaged + * extraction — the launcher falls back to the node entry (cli-entry.js) that + * ships in this same package, so `qwen` keeps working (including the exit-44 + * post-update relaunch that re-enters through this bin) exactly as it did + * before the platform packages existed. + */ + +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PLATFORM_PACKAGES = { + 'darwin-arm64': '@qwen-code/qwen-code-darwin-arm64', + 'darwin-x64': '@qwen-code/qwen-code-darwin-x64', + 'linux-arm64': '@qwen-code/qwen-code-linux-arm64', + 'linux-x64': '@qwen-code/qwen-code-linux-x64', + 'win32-x64': '@qwen-code/qwen-code-win-x64', +}; + +function fail(message) { + process.stderr.write(`qwen: ${message}\n`); + process.exit(1); +} + +function platformPackageName(platform = process.platform, arch = process.arch) { + return PLATFORM_PACKAGES[`${platform}-${arch}`] ?? null; +} + +function resolvePlatformPackageDir(name) { + // The platform packages deliberately publish no "exports" map, so their + // package.json stays resolvable as a subpath from this launcher. + const require = createRequire(import.meta.url); + return dirname(require.resolve(`${name}/package.json`)); +} + +function launchChild(command, commandArgs, onLaunchError) { + const child = spawn(command, commandArgs, { stdio: 'inherit' }); + let spawnFailed = false; + + // Ctrl+C and SIGTERM reach this launcher and the CLI child alike (both share + // the foreground process group). The child owns the exit decision — the + // TUI's double-Ctrl+C guard runs there — so this launcher never exits + // first: it waits for the child to close and mirrors its status. Signals + // sent to the launcher alone are forwarded. On Windows the console already + // delivers CTRL_C_EVENT to every attached process, and child.kill('SIGINT') + // maps to TerminateProcess — an ungraceful kill that would defeat the CLI's + // double-Ctrl+C guard — so SIGINT is not forwarded there. + const forwardedSignals = + process.platform === 'win32' + ? ['SIGTERM'] + : ['SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGTERM']; + const forwarders = forwardedSignals.map((signal) => { + const handler = () => { + child.kill(signal); + }; + process.on(signal, handler); + return [signal, handler]; + }); + if (process.platform === 'win32') { + // Presence-only SIGINT watcher. Without one, libuv's console control + // handler returns FALSE for CTRL_C_EVENT and Windows terminates this + // launcher instantly while the CLI child keeps running, so the child's + // exit status is never mirrored. Do NOT forward: child.kill('SIGINT') + // maps to TerminateProcess on Windows. + const noop = () => {}; + process.on('SIGINT', noop); + forwarders.push(['SIGINT', noop]); + } + + child.on('error', (error) => { + // A spawn failure also emits 'close' (with a negative code); mark the + // failure so the close-mirror path below stays silent while the error + // handler decides what happens next. + spawnFailed = true; + // Drop this child's forwarders before the fallback launches: a stale + // handler would intercept the fallback child's re-raised death signal + // and swallow it, making the launcher exit 0 for a signal-killed run. + for (const [name, handler] of forwarders) { + process.removeListener(name, handler); + } + onLaunchError(error); + }); + child.on('close', (code, signal) => { + if (spawnFailed) return; + if (signal) { + // Drop the forwarders first: registering a listener replaced the + // default terminating action, so a re-raise that re-enters them would + // be swallowed and this launcher would hang on a dead child. + for (const [name, handler] of forwarders) { + process.removeListener(name, handler); + } + process.kill(process.pid, signal); + } else { + process.exit(code ?? 1); + } + }); +} + +function main() { + const args = process.argv.slice(2); + const isWindows = process.platform === 'win32'; + + // This launcher always runs under the host Node; publish its path so the + // update check can resolve npm even when the CLI itself runs under the + // platform package's bundled Bun (where process.execPath is the Bun binary + // and no npm lives next to it). + process.env['QWEN_CODE_HOST_NODE'] ??= process.execPath; + + // npm-bin.js and cli-entry.js both sit at the package root; argv[1] can be + // a .bin symlink elsewhere, so locate the fallback via this module. + const fallbackEntry = join( + dirname(fileURLToPath(import.meta.url)), + 'cli-entry.js', + ); + const runNodeFallback = (reason) => { + process.stderr.write(`qwen: ${reason} Falling back to node.\n`); + launchChild(process.execPath, [fallbackEntry, ...args], (error) => + fail(`failed to launch ${fallbackEntry}: ${error.message}`), + ); + }; + + const packageName = platformPackageName(); + if (!packageName) { + runNodeFallback( + `no prebuilt runtime exists for ${process.platform}-${process.arch}.`, + ); + return; + } + + let packageDir; + try { + packageDir = resolvePlatformPackageDir(packageName); + } catch { + runNodeFallback( + `the ${packageName} runtime package was not installed. ` + + 'This usually happens with --omit=optional installs or a ' + + 'registry/mirror that lacks the platform package.', + ); + return; + } + + const runtime = isWindows + ? join(packageDir, 'bun', 'bun.exe') + : join(packageDir, 'bun', 'bin', 'bun'); + const cliEntry = join(packageDir, 'lib', 'cli-entry.js'); + if (!existsSync(runtime) || !existsSync(cliEntry)) { + runNodeFallback( + `the ${packageName} runtime package is damaged (missing Bun or CLI). ` + + 'Reinstalling the package usually fixes this.', + ); + return; + } + + launchChild(runtime, [cliEntry, ...args], (error) => { + // The runtime exists but cannot execute — e.g. a glibc-linked Bun on a + // musl host, or a noexec mount. The node entry ships in this same + // package, so degrade to it instead of dying here. + runNodeFallback( + `the ${packageName} runtime failed to start (${error.message}).`, + ); + }); +} + +main(); diff --git a/scripts/package-npm-platform-packages.js b/scripts/package-npm-platform-packages.js new file mode 100644 index 00000000000..c3079cf32b8 --- /dev/null +++ b/scripts/package-npm-platform-packages.js @@ -0,0 +1,200 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Repackages the standalone archives built by package:standalone:release into + * npm platform packages (@qwen-code/qwen-code--). + * + * Each platform package carries the full standalone runtime — the pinned Bun + * build, the native renderer libraries, and the bundled CLI — so that + * `npm install @qwen-code/qwen-code` gets a working OpenTUI CLI via the + * optionalDependencies mechanism (os/cpu fields make npm pick exactly one + * platform package per install). The JS-only main package resolves the + * matching platform package at runtime through scripts/npm-bin.js. + * + * Run after package:standalone:release, with the same --version, so the + * platform package versions line up with the main package version that + * prepare:package stamps into dist/package.json. + */ + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +import { RELEASE_TARGETS } from './build-standalone-release.js'; +import { fail, parseArgs } from './release-script-utils.js'; + +// RELEASE_TARGETS (build-standalone-release.js) is the authority on which +// platforms ship; derive the npm platform packages from it so a target +// rename/add cannot drift between the archive builder and this repackager. +const OS_NAMES = { darwin: 'darwin', linux: 'linux', win: 'win32' }; +const PLATFORMS = RELEASE_TARGETS.map((target) => { + const [osPart, cpu] = target.qwenTarget.split('-'); + return { + archive: + `qwen-code-${target.qwenTarget}.` + + (target.qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'), + name: `@qwen-code/qwen-code-${target.qwenTarget}`, + os: [OS_NAMES[osPart]], + cpu: [cpu], + // The bundled Bun is glibc-linked. Declare the libc so npm skips musl + // hosts (Alpine) instead of installing a runtime that cannot execve; + // the launcher's resolution-failure then degrades those hosts to node. + libc: osPart === 'linux' ? ['glibc'] : undefined, + }; +}); + +const ROOT_DIR = path.resolve(import.meta.dirname, '..'); + +function parseOptions(argv) { + const args = parseArgs(argv, { + '--version': { key: 'version' }, + '--standalone-dir': { key: 'standaloneDir' }, + '--out-dir': { key: 'outDir' }, + }); + if (!args.version) { + fail('--version is required (must match the release version)'); + } + return { + version: args.version, + standaloneDir: path.resolve( + args.standaloneDir ?? path.join(ROOT_DIR, 'dist', 'standalone'), + ), + outDir: path.resolve( + args.outDir ?? path.join(ROOT_DIR, 'dist', 'npm-platform'), + ), + }; +} + +function extractArchive(archivePath, destDir) { + fs.mkdirSync(destDir, { recursive: true }); + if (archivePath.endsWith('.zip')) { + execFileSync('unzip', ['-qo', archivePath, '-d', destDir], { + stdio: 'inherit', + }); + } else { + execFileSync('tar', ['-xzf', archivePath, '-C', destDir], { + stdio: 'inherit', + }); + } +} + +function assertFile(...segments) { + const filePath = path.join(...segments); + if (!fs.existsSync(filePath)) { + throw new Error(`platform package is incomplete: missing ${filePath}`); + } + return filePath; +} + +function packagePlatform(platform, options) { + const archivePath = path.join(options.standaloneDir, platform.archive); + if (!fs.existsSync(archivePath)) { + throw new Error( + `standalone archive not found: ${archivePath}. ` + + 'Run package:standalone:release first.', + ); + } + + const shortName = platform.name.replace('@qwen-code/', ''); + const packageDir = path.join(options.outDir, shortName); + fs.rmSync(packageDir, { recursive: true, force: true }); + + const stagingDir = fs.mkdtempSync(path.join(options.outDir, '.staging-')); + try { + extractArchive(archivePath, stagingDir); + + // The archives carry a top-level qwen-code/ directory; lift its contents + // to the package root. + const archiveRoot = path.join(stagingDir, 'qwen-code'); + if (!fs.existsSync(archiveRoot)) { + throw new Error(`archive ${platform.archive} has no qwen-code/ root`); + } + fs.mkdirSync(options.outDir, { recursive: true }); + fs.renameSync(archiveRoot, packageDir); + } finally { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } + + // The standalone metadata package.json describes the archive layout and is + // not a valid npm manifest for this package; replace it with the platform + // package manifest. + fs.rmSync(path.join(packageDir, 'package.json')); + // The archive doubles as a standalone-installer payload. Strip everything + // isStandaloneInstallDir() probes (manifest.json, the bin/qwen shim, the + // node/ compat mirror) so the CLI never mistakes an npm platform package + // for a standalone install; node/ and bin/ are also dead weight on the npm + // channel — the launcher runs lib/cli-entry.js under the bundled Bun. + fs.rmSync(path.join(packageDir, 'manifest.json'), { force: true }); + fs.rmSync(path.join(packageDir, 'bin'), { recursive: true, force: true }); + fs.rmSync(path.join(packageDir, 'node'), { recursive: true, force: true }); + const manifest = { + name: platform.name, + version: options.version, + description: + 'Qwen Code prebuilt runtime (Bun + OpenTUI native renderer) for ' + + `${platform.os.join('/')} ${platform.cpu.join('/')}`, + license: 'Apache-2.0', + repository: { + type: 'git', + url: 'git+https://github.com/QwenLM/qwen-code.git', + }, + // No exports/scripts on purpose: the package is pure payload, resolved + // by the main package's npm-bin.js launcher at runtime. os/cpu make npm + // skip the package on non-matching platforms without failing installs. + os: platform.os, + cpu: platform.cpu, + ...(platform.libc ? { libc: platform.libc } : {}), + }; + fs.writeFileSync( + path.join(packageDir, 'package.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + + // Sanity-check the layout the npm-bin.js launcher depends on. + const isWindows = platform.os.includes('win32'); + assertFile(packageDir, 'lib', 'cli-entry.js'); + assertFile(packageDir, 'bun', ...(isWindows ? ['bun.exe'] : ['bin', 'bun'])); + for (const stripped of [ + path.join(packageDir, 'manifest.json'), + path.join(packageDir, 'bin'), + path.join(packageDir, 'node'), + ]) { + if (fs.existsSync(stripped)) { + throw new Error( + `platform package still carries a standalone fingerprint: ${stripped}`, + ); + } + } + + console.log(`packaged ${platform.name}@${options.version} -> ${packageDir}`); + return manifest; +} + +function main() { + const options = parseOptions(process.argv.slice(2)); + fs.mkdirSync(options.outDir, { recursive: true }); + + const manifests = PLATFORMS.map((platform) => + packagePlatform(platform, options), + ); + + const optionalDependencies = Object.fromEntries( + manifests.map((manifest) => [manifest.name, manifest.version]), + ); + fs.writeFileSync( + path.join(options.outDir, 'optional-dependencies.json'), + `${JSON.stringify(optionalDependencies, null, 2)}\n`, + ); + console.log( + `optionalDependencies for the main package: ${JSON.stringify(optionalDependencies)}`, + ); +} + +main(); diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index 3940945f09c..3a8bcb1cbd2 100644 --- a/scripts/prepare-package.js +++ b/scripts/prepare-package.js @@ -269,6 +269,11 @@ function writeDistPackageJson(rootDir, distDir) { fs.chmodSync(cliEntryPath, 0o755); console.log('Created dist cli-entry.js wrapper'); + const npmBinPath = path.join(distDir, 'npm-bin.js'); + fs.copyFileSync(path.join(__dirname, 'npm-bin.js'), npmBinPath); + fs.chmodSync(npmBinPath, 0o755); + console.log('Created dist npm-bin.js launcher'); + const rootPackageJson = JSON.parse( fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'), ); @@ -307,10 +312,14 @@ function writeDistPackageJson(rootDir, distDir) { type: 'module', main: 'cli.js', bin: { - qwen: 'cli-entry.js', + // Resolves the per-platform optional dependency (bundled Bun runtime) + // and runs the CLI under it; when the platform package is unavailable + // it falls back to cli-entry.js under node. + qwen: 'npm-bin.js', }, files: [ 'cli-entry.js', + 'npm-bin.js', 'cli.js', // Worker thread entry loaded by FzfWorkerHandle at runtime via // `resolveBundleDir(import.meta.url)` + `path.join(dir, 'fzfWorker.js')`. @@ -327,17 +336,27 @@ function writeDistPackageJson(rootDir, distDir) { 'bundled', 'web-shell', // OpenTUI renderer runtime assets (tree-sitter grammars, parser worker, - // web-tree-sitter wasm) are intentionally NOT published in the npm - // package: npm installs run on Node, where the runtime gate falls back - // to ink, so the assets would be dead weight (~22MB on the linux CI - // platform's native render library alone) against the unpacked-size - // budget. Standalone archives (which bake Bun and do render OpenTUI) - // carry them via create-standalone-package.js instead. + // web-tree-sitter wasm) are intentionally NOT published in this JS + // tarball: they ship inside the per-platform runtime packages below, + // which also carry the pinned Bun build — duplicating them here would + // add ~22MB of dead weight against the unpacked-size budget for node + // installs that cannot use them anyway. ], config: rootPackageJson.config, dependencies: {}, optionalDependencies: { '@qwen-code/audio-capture': rootPackageJson.version, + // Prebuilt OpenTUI runtimes (pinned Bun + native renderer + CLI bundle) + // published by scripts/package-npm-platform-packages.js. os/cpu fields + // in each platform package make npm install exactly one per host; the + // npm-bin.js bin launcher resolves it at runtime. Version-locked to the + // main package version (the same treatment @qwen-code/audio-capture + // gets; the node-pty/clipboard groups pin their own upstream versions). + '@qwen-code/qwen-code-darwin-arm64': rootPackageJson.version, + '@qwen-code/qwen-code-darwin-x64': rootPackageJson.version, + '@qwen-code/qwen-code-linux-arm64': rootPackageJson.version, + '@qwen-code/qwen-code-linux-x64': rootPackageJson.version, + '@qwen-code/qwen-code-win-x64': rootPackageJson.version, '@lydell/node-pty': '1.2.0-beta.10', '@lydell/node-pty-darwin-arm64': '1.2.0-beta.10', '@lydell/node-pty-darwin-x64': '1.2.0-beta.10', diff --git a/scripts/tests/get-release-version.test.js b/scripts/tests/get-release-version.test.js index dedec44837e..18aa62397cf 100644 --- a/scripts/tests/get-release-version.test.js +++ b/scripts/tests/get-release-version.test.js @@ -670,6 +670,11 @@ describe('assertVersionUnreleased', () => { expect(PUBLISHED_PACKAGES).toEqual([ '@qwen-code/qwen-code', '@qwen-code/audio-capture', + '@qwen-code/qwen-code-darwin-arm64', + '@qwen-code/qwen-code-darwin-x64', + '@qwen-code/qwen-code-linux-arm64', + '@qwen-code/qwen-code-linux-x64', + '@qwen-code/qwen-code-win-x64', '@qwen-code/channel-base', '@qwen-code/channel-dingtalk', '@qwen-code/channel-feishu', diff --git a/scripts/tests/npm-bin.test.js b/scripts/tests/npm-bin.test.js new file mode 100644 index 00000000000..ec16d74ab0c --- /dev/null +++ b/scripts/tests/npm-bin.test.js @@ -0,0 +1,336 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { spawnMock, existsSyncMock, resolveMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + existsSyncMock: vi.fn(() => true), + resolveMock: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ spawn: spawnMock })); +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), + existsSync: existsSyncMock, +})); +vi.mock('node:module', () => ({ + createRequire: () => ({ resolve: resolveMock }), +})); + +function createFakeChild() { + const handlers = {}; + return { + handlers, + kill: vi.fn(), + on: vi.fn((event, handler) => { + handlers[event] = handler; + }), + }; +} + +describe('scripts/npm-bin.js platform launcher', () => { + const originalArgv = process.argv; + const originalPlatform = process.platform; + const originalArch = process.arch; + let exitSpy; + let stderrSpy; + let killSpy; + let onSpy; + let removeListenerSpy; + let fakeChild; + + const setPlatform = (platform) => { + Object.defineProperty(process, 'platform', { value: platform }); + }; + const setArch = (arch) => { + Object.defineProperty(process, 'arch', { value: arch }); + }; + + const importLauncher = async () => { + await import('../npm-bin.js'); + }; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + fakeChild = createFakeChild(); + spawnMock.mockReturnValue(fakeChild); + resolveMock.mockReturnValue('/platform/pkg/package.json'); + existsSyncMock.mockReturnValue(true); + process.argv = ['node', 'npm-bin.js', '--version']; + setPlatform(originalPlatform); + setArch(originalArch); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => {}); + killSpy = vi.spyOn(process, 'kill').mockImplementation(() => undefined); + onSpy = vi.spyOn(process, 'on'); + removeListenerSpy = vi.spyOn(process, 'removeListener'); + }); + + afterEach(() => { + process.argv = originalArgv; + setPlatform(originalPlatform); + setArch(originalArch); + exitSpy.mockRestore(); + stderrSpy.mockRestore(); + killSpy.mockRestore(); + onSpy.mockRestore(); + removeListenerSpy.mockRestore(); + }); + + it('runs the bundled CLI under the bundled runtime when the platform package resolves', async () => { + // Pin the platform: beforeEach restores the runner's real one, and on a + // Windows runner the launcher correctly takes the win32 branch, which + // would fail the POSIX-layout assertions below. + setPlatform('linux'); + setArch('x64'); + + await importLauncher(); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const [runtime, commandArgs, options] = spawnMock.mock.calls[0]; + // Exact layout, not a substring: swapping the isWindows branches must + // fail here instead of degrading every install of the other OS to node. + expect(String(runtime).replaceAll('\\', '/')).toBe( + '/platform/pkg/bun/bin/bun', + ); + expect(commandArgs[0].replaceAll('\\', '/')).toBe( + '/platform/pkg/lib/cli-entry.js', + ); + expect(commandArgs.slice(1)).toEqual(['--version']); + expect(options.stdio).toBe('inherit'); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ['darwin', 'arm64', '@qwen-code/qwen-code-darwin-arm64'], + ['darwin', 'x64', '@qwen-code/qwen-code-darwin-x64'], + ['linux', 'arm64', '@qwen-code/qwen-code-linux-arm64'], + ['linux', 'x64', '@qwen-code/qwen-code-linux-x64'], + ['win32', 'x64', '@qwen-code/qwen-code-win-x64'], + ])( + 'resolves %s-%s through %s to the matching Bun layout', + async (platform, arch, packageName) => { + setPlatform(platform); + setArch(arch); + + await importLauncher(); + + expect(resolveMock).toHaveBeenCalledWith(`${packageName}/package.json`); + expect(spawnMock).toHaveBeenCalledTimes(1); + const [runtime, commandArgs] = spawnMock.mock.calls[0]; + const expectedRuntime = + platform === 'win32' + ? '/platform/pkg/bun/bun.exe' + : '/platform/pkg/bun/bin/bun'; + expect(String(runtime).replaceAll('\\', '/')).toBe(expectedRuntime); + expect(commandArgs[0].replaceAll('\\', '/')).toBe( + '/platform/pkg/lib/cli-entry.js', + ); + expect(exitSpy).not.toHaveBeenCalled(); + }, + ); + + it('falls back to the node entry when the platform package is not installed', async () => { + resolveMock.mockImplementation(() => { + throw new Error('Cannot find module'); + }); + + await importLauncher(); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const [runtime, commandArgs] = spawnMock.mock.calls[0]; + expect(runtime).toBe(process.execPath); + expect(commandArgs[0].replaceAll('\\', '/')).toMatch(/cli-entry\.js$/); + expect(commandArgs[0].replaceAll('\\', '/')).not.toContain('/platform/'); + expect(commandArgs.slice(1)).toEqual(['--version']); + const notice = String(stderrSpy.mock.calls[0][0]); + expect(notice).toContain('was not installed'); + expect(notice).toContain('Falling back to node'); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('falls back to the node entry on an unmapped platform', async () => { + setPlatform('freebsd'); + + await importLauncher(); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock.mock.calls[0][0]).toBe(process.execPath); + expect(spawnMock.mock.calls[0][1].slice(1)).toEqual(['--version']); + expect(String(stderrSpy.mock.calls[0][0])).toContain('no prebuilt runtime'); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('falls back to the node entry when the platform package lacks the CLI entry', async () => { + existsSyncMock.mockImplementation((p) => !String(p).includes('cli-entry')); + + await importLauncher(); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock.mock.calls[0][0]).toBe(process.execPath); + expect(spawnMock.mock.calls[0][1].slice(1)).toEqual(['--version']); + expect(String(stderrSpy.mock.calls[0][0])).toContain('damaged'); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('falls back to the node entry when the platform package lacks the runtime', async () => { + // Separator-agnostic: on Windows the launcher probes backslash paths + // (\platform\pkg\bun\bun.exe), which a plain '/bun/' match never sees. + existsSyncMock.mockImplementation( + (p) => !String(p).replaceAll('\\', '/').includes('/bun/'), + ); + + await importLauncher(); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock.mock.calls[0][0]).toBe(process.execPath); + expect(String(stderrSpy.mock.calls[0][0])).toContain('damaged'); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('falls back to the node entry when the platform runtime fails to spawn', async () => { + setPlatform('linux'); + const children = []; + spawnMock.mockImplementation(() => { + const child = createFakeChild(); + children.push(child); + return child; + }); + + await importLauncher(); + + // musl/Alpine shape: the runtime file exists but cannot execve. Node also + // emits 'close' (with a negative code) after a spawn error, so the + // fallback must suppress the close-mirror path. + children[0].handlers['error'](new Error('spawn ENOENT')); + children[0].handlers['close'](-2, null); + + // The failed child's forwarders must be dropped before the fallback + // spawns: a stale handler would intercept the fallback child's re-raised + // death signal and swallow it, so the launcher exits 0 for a + // signal-killed run. + const firstChildPairs = onSpy.mock.calls + .filter(([signal]) => + ['SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGTERM'].includes(signal), + ) + .slice(0, 4); + expect(firstChildPairs.length).toBe(4); + for (const [signal, handler] of firstChildPairs) { + expect(removeListenerSpy).toHaveBeenCalledWith(signal, handler); + } + + expect(spawnMock).toHaveBeenCalledTimes(2); + const [runtime, commandArgs] = spawnMock.mock.calls[1]; + expect(runtime).toBe(process.execPath); + expect(commandArgs[0].replaceAll('\\', '/')).toMatch(/cli-entry\.js$/); + expect(commandArgs.slice(1)).toEqual(['--version']); + const notice = String(stderrSpy.mock.calls[0][0]); + expect(notice).toContain('failed to start'); + expect(notice).toContain('Falling back to node'); + expect(exitSpy).not.toHaveBeenCalled(); + + // The fallback child owns the exit decision like the first child; drive + // it to its own close. A module-scoped spawnFailed would hit the + // suppression guard here and leave the launcher hanging. + children[1].handlers['close'](null, 'SIGTERM'); + expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM'); + children[1].handlers['close'](0, null); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('exits 1 when the node fallback itself fails to spawn', async () => { + resolveMock.mockImplementation(() => { + throw new Error('Cannot find module'); + }); + + await importLauncher(); + + fakeChild.handlers['error'](new Error('spawn node ENOENT')); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(String(stderrSpy.mock.calls.at(-1)[0])).toContain( + 'failed to launch', + ); + }); + + it('mirrors the child exit code', async () => { + await importLauncher(); + + fakeChild.handlers['close'](7, null); + expect(exitSpy).toHaveBeenCalledWith(7); + + // code 0 is where `?? 1` and `|| 1` differ: a successful CLI run must + // not surface as launcher failure to wrappers reading $?. + fakeChild.handlers['close'](0, null); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('re-raises the child death signal after dropping its own forwarders', async () => { + await importLauncher(); + + fakeChild.handlers['close'](null, 'SIGTERM'); + // The forwarders registered at launch must be removed first, otherwise + // the re-raise re-enters them and the launcher hangs on a dead child. + const removedSignals = removeListenerSpy.mock.calls.map((call) => call[0]); + expect(removedSignals).toContain('SIGTERM'); + expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM'); + expect(exitSpy).not.toHaveBeenCalled(); + + // Removal must precede the re-raise (load-bearing: a queued re-raise is + // dropped when the watcher stops before the signal pipe drains), and it + // must use the exact handler references registered at launch — a + // wrong-reference removeListener is a silent no-op on real Node. + expect( + Math.max(...removeListenerSpy.mock.invocationCallOrder), + ).toBeLessThan(Math.min(...killSpy.mock.invocationCallOrder)); + const forwarders = onSpy.mock.calls.filter(([signal]) => + String(signal).startsWith('SIG'), + ); + expect(forwarders.length).toBeGreaterThan(0); + for (const [signal, handler] of forwarders) { + expect(removeListenerSpy).toHaveBeenCalledWith(signal, handler); + } + }); + + it('forwards terminating signals to the child on unix', async () => { + setPlatform('linux'); + + await importLauncher(); + + const signals = onSpy.mock.calls.map((call) => call[0]); + expect(signals).toEqual( + expect.arrayContaining(['SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGTERM']), + ); + // Registration alone is not forwarding: invoke each captured handler and + // check the kill actually reaches the child. + for (const signal of ['SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGTERM']) { + const handler = onSpy.mock.calls.find((call) => call[0] === signal)?.[1]; + handler(); + expect(fakeChild.kill).toHaveBeenCalledWith(signal); + } + }); + + it('watches SIGINT on Windows without forwarding it', async () => { + setPlatform('win32'); + + await importLauncher(); + + const signals = onSpy.mock.calls.map((call) => call[0]); + expect(signals).toContain('SIGTERM'); + // Presence-only: without a SIGINT watcher, libuv's console control + // handler lets Windows terminate the launcher instantly on CTRL_C while + // the CLI child keeps running. Invoking the watcher must not kill the + // child — child.kill('SIGINT') maps to TerminateProcess on Windows. + expect(signals).toContain('SIGINT'); + const sigintHandler = onSpy.mock.calls.find( + (call) => call[0] === 'SIGINT', + )?.[1]; + sigintHandler(); + expect(fakeChild.kill).not.toHaveBeenCalled(); + }); +}); diff --git a/scripts/tests/package-assets.test.js b/scripts/tests/package-assets.test.js index 8bd465e3743..3b8a2ca7bc6 100644 --- a/scripts/tests/package-assets.test.js +++ b/scripts/tests/package-assets.test.js @@ -774,6 +774,28 @@ describe('package asset scripts', () => { '@qwen-code/audio-capture': rootPackageJson.version, }); + // The platform runtime channel: bin points at the launcher, the launcher + // ships in the tarball, and the five platform packages are version-locked + // to the main package (npm picks one via their os/cpu fields). + expect(distPackageJson.bin).toEqual({ qwen: 'npm-bin.js' }); + expect(distPackageJson.files).toContain('npm-bin.js'); + // Content equality, not just existence: a truncated or wrong-file copy + // (e.g. cli-entry.js) would ship a dead launcher as the published bin. + expect(readFileSync(path.join(rootDir, 'dist', 'npm-bin.js'), 'utf8')).toBe( + readFileSync(new URL('../npm-bin.js', import.meta.url), 'utf8'), + ); + for (const platformPackage of [ + '@qwen-code/qwen-code-darwin-arm64', + '@qwen-code/qwen-code-darwin-x64', + '@qwen-code/qwen-code-linux-arm64', + '@qwen-code/qwen-code-linux-x64', + '@qwen-code/qwen-code-win-x64', + ]) { + expect(distPackageJson.optionalDependencies[platformPackage]).toBe( + rootPackageJson.version, + ); + } + expect(distPackageJson.optionalDependencies.sharp).toBe('0.35.4'); expect( existsSync( diff --git a/scripts/tests/package-npm-platform-packages.test.js b/scripts/tests/package-npm-platform-packages.test.js new file mode 100644 index 00000000000..7587fb516a0 --- /dev/null +++ b/scripts/tests/package-npm-platform-packages.test.js @@ -0,0 +1,236 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_PATH = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'package-npm-platform-packages.js', +); + +const PLATFORM_ARCHIVES = [ + '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', +]; + +// Mirrors the standalone payload layout create-standalone-package.js stamps, +// including the standalone fingerprints the repackager must strip. +function createStandaloneFixture(standaloneDir, archiveName) { + const isWindows = archiveName.endsWith('.zip'); + const root = path.join(standaloneDir, '.fixture', 'qwen-code'); + rmSync(path.dirname(root), { recursive: true, force: true }); + mkdirSync(path.join(root, 'lib'), { recursive: true }); + mkdirSync(path.join(root, 'bin'), { recursive: true }); + mkdirSync(path.join(root, 'node', 'bin'), { recursive: true }); + mkdirSync(path.join(root, 'bun', 'bin'), { recursive: true }); + writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ name: '@qwen-code/qwen-code', files: ['lib'] }), + ); + writeFileSync( + path.join(root, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code', target: 'fixture' }), + ); + writeFileSync(path.join(root, 'lib', 'cli-entry.js'), '// cli\n'); + writeFileSync( + path.join(root, 'bin', isWindows ? 'qwen.cmd' : 'qwen'), + '#!/bin/sh\n', + ); + writeFileSync( + path.join(root, 'node', isWindows ? 'node.exe' : path.join('bin', 'node')), + '// node mirror\n', + ); + writeFileSync( + path.join(root, 'bun', isWindows ? 'bun.exe' : path.join('bin', 'bun')), + '// bun\n', + ); + + const archivePath = path.join(standaloneDir, archiveName); + if (isWindows) { + execFileSync('zip', ['-qr', archivePath, 'qwen-code'], { + cwd: path.dirname(root), + }); + } else { + execFileSync('tar', ['-czf', archivePath, 'qwen-code'], { + cwd: path.dirname(root), + }); + } +} + +// The fixture builder shells out to `zip` and the script under test to +// `unzip` (win-x64 archive); the required test_windows lane has neither, and +// the production repackager is legitimately Linux-release-runner-only, so +// skip where the binaries are absent — same gate install-script.test.js uses. +const zipAvailable = + spawnSync('zip', ['--version']).error === undefined && + spawnSync('unzip', ['-v']).error === undefined; +if (process.env.CI && process.platform !== 'win32' && !zipAvailable) { + console.warn( + '`zip`/`unzip` missing on a CI host; platform-package tests would skip.', + ); +} + +const EXPECTED_PLATFORM_FIELDS = { + '@qwen-code/qwen-code-darwin-arm64': { os: ['darwin'], cpu: ['arm64'] }, + '@qwen-code/qwen-code-darwin-x64': { os: ['darwin'], cpu: ['x64'] }, + '@qwen-code/qwen-code-linux-arm64': { + os: ['linux'], + cpu: ['arm64'], + libc: ['glibc'], + }, + '@qwen-code/qwen-code-linux-x64': { + os: ['linux'], + cpu: ['x64'], + libc: ['glibc'], + }, + '@qwen-code/qwen-code-win-x64': { os: ['win32'], cpu: ['x64'] }, +}; + +describe.skipIf(!zipAvailable)( + 'scripts/package-npm-platform-packages.js', + () => { + let workDir; + let standaloneDir; + let outDir; + + beforeEach(() => { + workDir = mkdtempSync(path.join(tmpdir(), 'npm-platform-test-')); + standaloneDir = path.join(workDir, 'standalone'); + outDir = path.join(workDir, 'out'); + mkdirSync(standaloneDir, { recursive: true }); + for (const archiveName of PLATFORM_ARCHIVES) { + createStandaloneFixture(standaloneDir, archiveName); + } + }); + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); + }); + + const runScript = (extraArgs = []) => + execFileSync( + process.execPath, + [ + SCRIPT_PATH, + '--version', + '9.9.9', + '--standalone-dir', + standaloneDir, + '--out-dir', + outDir, + ...extraArgs, + ], + { encoding: 'utf8' }, + ); + + it('packages every release target and emits the optionalDependencies map', () => { + runScript(); + + const optionalDependencies = JSON.parse( + readFileSync(path.join(outDir, 'optional-dependencies.json'), 'utf8'), + ); + expect(optionalDependencies).toEqual({ + '@qwen-code/qwen-code-darwin-arm64': '9.9.9', + '@qwen-code/qwen-code-darwin-x64': '9.9.9', + '@qwen-code/qwen-code-linux-arm64': '9.9.9', + '@qwen-code/qwen-code-linux-x64': '9.9.9', + '@qwen-code/qwen-code-win-x64': '9.9.9', + }); + + for (const name of Object.keys(optionalDependencies)) { + const packageDir = path.join(outDir, name.replace('@qwen-code/', '')); + const manifest = JSON.parse( + readFileSync(path.join(packageDir, 'package.json'), 'utf8'), + ); + expect(manifest.name).toBe(name); + expect(manifest.version).toBe('9.9.9'); + // Exact platform fields: npm's selection depends on the values, not + // the cardinality, and the libc keeps glibc-linked Bun off musl hosts. + const { os, cpu, libc } = manifest; + expect({ os, cpu, ...(libc ? { libc } : {}) }).toEqual( + EXPECTED_PLATFORM_FIELDS[name], + ); + expect(existsSync(path.join(packageDir, 'lib', 'cli-entry.js'))).toBe( + true, + ); + // The pinned Bun runtime is the one artifact the platform package + // exists to deliver; it must survive repackaging in the launcher's + // layout. + const isWindows = name === '@qwen-code/qwen-code-win-x64'; + expect( + existsSync( + path.join( + packageDir, + 'bun', + ...(isWindows ? ['bun.exe'] : ['bin', 'bun']), + ), + ), + ).toBe(true); + } + }); + + it('strips every standalone fingerprint isStandaloneInstallDir probes', () => { + runScript(); + + for (const shortName of [ + 'qwen-code-darwin-arm64', + 'qwen-code-darwin-x64', + 'qwen-code-linux-arm64', + 'qwen-code-linux-x64', + 'qwen-code-win-x64', + ]) { + const packageDir = path.join(outDir, shortName); + expect(existsSync(path.join(packageDir, 'manifest.json'))).toBe(false); + expect(existsSync(path.join(packageDir, 'bin'))).toBe(false); + // The node/ compat mirror is standalone-installer-only dead weight — on + // win-x64 a byte-for-byte second copy of the Bun executable. + expect(existsSync(path.join(packageDir, 'node'))).toBe(false); + } + }); + + it('accepts the sibling scripts --key=value syntax', () => { + execFileSync( + process.execPath, + [ + SCRIPT_PATH, + `--version=9.9.9`, + `--standalone-dir=${standaloneDir}`, + `--out-dir=${outDir}`, + ], + { encoding: 'utf8' }, + ); + expect(existsSync(path.join(outDir, 'optional-dependencies.json'))).toBe( + true, + ); + }); + + it('refuses to run without --version', () => { + expect(() => + execFileSync( + process.execPath, + [SCRIPT_PATH, '--standalone-dir', standaloneDir, '--out-dir', outDir], + { encoding: 'utf8', stdio: 'pipe' }, + ), + ).toThrowError(/--version is required/); + }); + }, +); diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index d27bca3b0af..f8c1c5da1e1 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -441,6 +441,7 @@ describe('package scripts', () => { const publishJob = getWorkflowJob(workflow, 'publish'); for (const stepName of [ + 'Publish platform runtime packages', 'Publish @qwen-code/audio-capture', 'Publish @qwen-code/qwen-code', 'Publish @qwen-code/channel-base', @@ -470,6 +471,27 @@ describe('package scripts', () => { ); expect(channelStep).toContain('(\n'); expect(channelStep).toContain(')'); + // Same contract for the platform runtime loop: `exit 0` must skip only + // the current package. + const platformStep = getWorkflowStep( + publishJob, + 'Publish platform runtime packages', + ); + expect(platformStep).toContain('(\n'); + expect(platformStep).toContain(')'); + // The skip branch must sit INSIDE the per-package subshell; hoisting it + // to loop level would stop the whole step at the first published package. + expect(platformStep).toMatch(/\(\n[\s\S]*?\bexit 0\b[\s\S]*?\n\s*\)/); + // The platform runtimes must publish before the main package whose + // optionalDependencies point at them, or installs race the registry and + // can silently fall back to node. + const mainStep = getWorkflowStep( + publishJob, + 'Publish @qwen-code/qwen-code', + ); + expect(publishJob.indexOf(platformStep)).toBeLessThan( + publishJob.indexOf(mainStep), + ); // A fully-skipped publish must be visible, not silently green. expect(channelStep).toContain( 'Every channel package was already published; nothing shipped',