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 dccfdc150bd..d0bfcb9989f 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,82 @@ 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 (!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 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 938a3961273..ccbcf3ecccb 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1689,6 +1689,158 @@ 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-')); + 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(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + target, + '--node-archive', + fakeRuntimeArchive, + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { + env: { + ...process.env, + QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD: '1', + }, + stdio: 'pipe', + }, + ), + ).toThrow(new RegExp(`audio-capture prebuild.*${prebuildDirName}`)); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }); + + 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', + prebuildDirName, + ); + const createdPrebuildDir = !existsSync(prebuildDir); + + try { + mkdirSync(prebuildDir, { recursive: true }); + + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + target, + '--node-archive', + fakeRuntimeArchive, + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { + env: { + ...process.env, + QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD: '1', + }, + stdio: 'pipe', + }, + ), + ).toThrow(new RegExp(`audio-capture prebuild.*${prebuildDirName}`)); + } 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-')); + const prebuildDir = path.join( + 'packages', + 'audio-capture', + 'prebuilds', + 'linux-x64', + ); + const prebuildFile = path.join( + prebuildDir, + '@qwen-code+audio-capture.node', + ); + const createdPrebuildDir = !existsSync(prebuildDir); + 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 }); + } + if (createdPrebuildDir) { + rmSync(prebuildDir, { recursive: true, 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-')); @@ -1812,6 +1964,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', );