From ae93d514d487df58a7ba2998e57b065f91051bbb Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 22 Jun 2026 17:38:45 +0800 Subject: [PATCH 1/5] fix(voice): bundle native audio addon into standalone archives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone archives shipped only curated dist/ entries, so the esbuild-external @qwen-code/audio-capture addon couldn't be resolved at runtime — streaming voice was unavailable in standalone installs (batch only, and only with SoX on PATH). create-standalone-package.js now bundles the addon into lib/node_modules (where the bundled lib/cli.js resolves bare specifiers): the trimmed package.json (install hook removed; type/exports kept for ESM resolution) + dist + only this target's prebuild (win-x64 -> win32-x64) + its zero-dep runtime dependency node-gyp-build. Targets without a matching prebuild (e.g. local builds) ship without it and degrade to SoX/arecord as before (warns, doesn't fail). The release pipeline already downloads prebuilds before packaging. Refs: #5502, #5590. Co-authored-by: Qwen-Coder --- scripts/create-standalone-package.js | 78 ++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index dccfdc150bd..29bdd3eb14e 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -11,6 +11,7 @@ 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'; @@ -36,6 +37,17 @@ const TARGETS = new Map([ ['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 DIST_REQUIRED_PATHS = [ 'cli.js', 'chunks', @@ -118,6 +130,7 @@ async function main() { fs.mkdirSync(runtimeExtractDir, { recursive: true }); copyRuntimeAssets(packageRoot, outDir); + copyNativeAddon(packageRoot, target); extractNodeArchive(nodeArchive, runtimeExtractDir); const nodeDir = path.join(packageRoot, 'node'); copyExtractedNode(runtimeExtractDir, nodeDir); @@ -278,6 +291,71 @@ function copyRuntimeAssets(packageRoot, outDir) { ); } +// 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 (!fs.existsSync(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, + ); + 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 topLevelDistEntryForPath(candidatePath) { const relative = path.relative(distDir, candidatePath); if ( From fe50b802ca4b0a325d47df5e490bb9b5cd22c67c Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 22 Jun 2026 17:49:58 +0800 Subject: [PATCH 2/5] fix(release): require audio prebuilds for official standalone archives --- .github/workflows/release.yml | 1 + scripts/create-standalone-package.js | 5 ++++ scripts/tests/install-script.test.js | 37 ++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0d3ed2800a..81c3a4a84d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -407,6 +407,7 @@ jobs: - name: 'Build Standalone Archives' env: RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' + 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: 'Publish @qwen-code/audio-capture' diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 29bdd3eb14e..a06ece7a806 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -302,6 +302,11 @@ function copyNativeAddon(packageRoot, target) { const addonSrc = path.join(rootDir, 'packages', 'audio-capture'); const prebuildSrc = path.join(addonSrc, 'prebuilds', prebuildDirName); if (!fs.existsSync(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 diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 938a3961273..be161d75d6b 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1689,6 +1689,40 @@ describe('standalone release packaging', () => { } }, 30_000); + it('requires the native audio prebuild when release packaging opts in', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'linux-x64', + '--node-archive', + createFakeNodeArchive(tmpDir), + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { + env: { + ...process.env, + QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD: '1', + }, + stdio: 'pipe', + }, + ), + ).toThrow(/audio-capture prebuild.*linux-x64/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }); + itOnUnix('dereferences safe Node.js runtime symlinks', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); @@ -1812,6 +1846,9 @@ describe('standalone release packaging', () => { // release.yml builds standalone archives, verifies them, and creates GitHub Release expect(releaseWorkflow).toContain('npm run package:standalone:release --'); + expect(releaseWorkflow).toContain( + 'QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD', + ); expect(releaseWorkflow).toContain( 'npm run verify:installation-release -- --dir dist/standalone', ); From df4e1c3f081a28321a1e4062cd04ffc92b69b9a2 Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 22 Jun 2026 18:37:59 +0800 Subject: [PATCH 3/5] fix(voice): Exclude audio test artifacts from standalone addon --- scripts/create-standalone-package.js | 9 +++-- scripts/tests/install-script.test.js | 51 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index a06ece7a806..6066953e9b9 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -345,11 +345,10 @@ function copyNativeAddon(packageRoot, target) { dereference: true, verbatimSymlinks: false, }; - fs.cpSync( - path.join(addonSrc, 'dist'), - path.join(addonDest, 'dist'), - copyOpts, - ); + 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), diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index be161d75d6b..9ceec16bf5c 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1723,6 +1723,57 @@ describe('standalone release packaging', () => { } }); + itOnUnix('does not package audio-capture test artifacts', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + const prebuildDir = path.join( + 'packages', + 'audio-capture', + 'prebuilds', + 'linux-x64', + ); + const prebuildFile = path.join(prebuildDir, 'node.napi.node'); + const createdPrebuild = !existsSync(prebuildFile); + + try { + mkdirSync(prebuildDir, { recursive: true }); + if (createdPrebuild) { + writeFileSync(prebuildFile, 'fake native addon\n'); + } + + const archive = packageFakeStandalone(tmpDir); + const extractDir = path.join(tmpDir, 'extract'); + mkdirSync(extractDir, { recursive: true }); + execFileSync('tar', ['-xzf', archive, '-C', extractDir], { + stdio: 'ignore', + }); + + const addonDist = path.join( + extractDir, + 'qwen-code', + 'lib', + 'node_modules', + '@qwen-code', + 'audio-capture', + 'dist', + ); + expect(existsSync(path.join(addonDist, 'index.js'))).toBe(true); + expect(existsSync(path.join(addonDist, 'platform.test.js'))).toBe(false); + expect(existsSync(path.join(addonDist, 'platform.test.d.ts'))).toBe( + false, + ); + expect(existsSync(path.join(addonDist, 'platform.test.js.map'))).toBe( + false, + ); + } finally { + if (createdPrebuild) { + rmSync(prebuildFile, { force: true }); + } + restoreMinimalDist(createdDist); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + itOnUnix('dereferences safe Node.js runtime symlinks', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); From 2709c771b57fb657ff8f921449e344da20d3b733 Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 22 Jun 2026 18:48:43 +0800 Subject: [PATCH 4/5] fix(voice): Require native prebuild file for standalone addon --- scripts/create-standalone-package.js | 9 ++++- scripts/tests/install-script.test.js | 55 +++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 6066953e9b9..d0bfcb9989f 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -301,7 +301,7 @@ 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 (!fs.existsSync(prebuildSrc)) { + if (!hasNativePrebuild(prebuildSrc)) { if (process.env.QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD === '1') { fail( `Required audio-capture prebuild is missing for ${prebuildDirName}: ${prebuildSrc}`, @@ -360,6 +360,13 @@ function copyNativeAddon(packageRoot, target) { assertNoSymlinks(modulesDir, 'Bundled native 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 ( diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 9ceec16bf5c..cd48fe2a3d9 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1723,6 +1723,52 @@ describe('standalone release packaging', () => { } }); + it('requires a native audio prebuild file when release packaging opts in', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + const prebuildDir = path.join( + 'packages', + 'audio-capture', + 'prebuilds', + 'linux-x64', + ); + const createdPrebuildDir = !existsSync(prebuildDir); + + try { + mkdirSync(prebuildDir, { recursive: true }); + + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'linux-x64', + '--node-archive', + createFakeNodeArchive(tmpDir), + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { + env: { + ...process.env, + QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD: '1', + }, + stdio: 'pipe', + }, + ), + ).toThrow(/audio-capture prebuild.*linux-x64/); + } finally { + if (createdPrebuildDir) { + rmSync(prebuildDir, { recursive: true, force: true }); + } + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }); + itOnUnix('does not package audio-capture test artifacts', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); @@ -1732,7 +1778,11 @@ describe('standalone release packaging', () => { 'prebuilds', 'linux-x64', ); - const prebuildFile = path.join(prebuildDir, 'node.napi.node'); + const prebuildFile = path.join( + prebuildDir, + '@qwen-code+audio-capture.node', + ); + const createdPrebuildDir = !existsSync(prebuildDir); const createdPrebuild = !existsSync(prebuildFile); try { @@ -1769,6 +1819,9 @@ describe('standalone release packaging', () => { if (createdPrebuild) { rmSync(prebuildFile, { force: true }); } + if (createdPrebuildDir) { + rmSync(prebuildDir, { recursive: true, force: true }); + } restoreMinimalDist(createdDist); rmSync(tmpDir, { recursive: true, force: true }); } From 26d81201f1b730efeff824acf1e769568e5179b7 Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 22 Jun 2026 19:32:21 +0800 Subject: [PATCH 5/5] test(release): fix audio prebuild checks on Windows --- scripts/tests/install-script.test.js | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index cd48fe2a3d9..ccbcf3ecccb 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1692,6 +1692,13 @@ describe('standalone release packaging', () => { it('requires the native audio prebuild when release packaging opts in', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + const target = process.platform === 'win32' ? 'win-x64' : 'linux-x64'; + const prebuildDirName = + process.platform === 'win32' ? 'win32-x64' : 'linux-x64'; + const fakeRuntimeArchive = + process.platform === 'win32' + ? createFakeWindowsNodeArchive(tmpDir) + : createFakeNodeArchive(tmpDir); try { expect(() => @@ -1700,9 +1707,9 @@ describe('standalone release packaging', () => { [ 'scripts/create-standalone-package.js', '--target', - 'linux-x64', + target, '--node-archive', - createFakeNodeArchive(tmpDir), + fakeRuntimeArchive, '--out-dir', path.join(tmpDir, 'out'), '--version', @@ -1716,7 +1723,7 @@ describe('standalone release packaging', () => { stdio: 'pipe', }, ), - ).toThrow(/audio-capture prebuild.*linux-x64/); + ).toThrow(new RegExp(`audio-capture prebuild.*${prebuildDirName}`)); } finally { rmSync(tmpDir, { recursive: true, force: true }); restoreMinimalDist(createdDist); @@ -1726,11 +1733,18 @@ describe('standalone release packaging', () => { it('requires a native audio prebuild file when release packaging opts in', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + const target = process.platform === 'win32' ? 'win-x64' : 'linux-x64'; + const prebuildDirName = + process.platform === 'win32' ? 'win32-x64' : 'linux-x64'; + const fakeRuntimeArchive = + process.platform === 'win32' + ? createFakeWindowsNodeArchive(tmpDir) + : createFakeNodeArchive(tmpDir); const prebuildDir = path.join( 'packages', 'audio-capture', 'prebuilds', - 'linux-x64', + prebuildDirName, ); const createdPrebuildDir = !existsSync(prebuildDir); @@ -1743,9 +1757,9 @@ describe('standalone release packaging', () => { [ 'scripts/create-standalone-package.js', '--target', - 'linux-x64', + target, '--node-archive', - createFakeNodeArchive(tmpDir), + fakeRuntimeArchive, '--out-dir', path.join(tmpDir, 'out'), '--version', @@ -1759,7 +1773,7 @@ describe('standalone release packaging', () => { stdio: 'pipe', }, ), - ).toThrow(/audio-capture prebuild.*linux-x64/); + ).toThrow(new RegExp(`audio-capture prebuild.*${prebuildDirName}`)); } finally { if (createdPrebuildDir) { rmSync(prebuildDir, { recursive: true, force: true });