diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0299816281b..4562f0e0402 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -391,11 +391,6 @@ jobs: echo "Dry run enabled. Skipping push." fi - - name: 'Build Bundle and Prepare Package' - run: |- - npm run bundle - npm run prepare:package - - name: 'Download audio capture prebuilds' if: |- ${{ github.repository == 'QwenLM/qwen-code' }} @@ -404,6 +399,13 @@ jobs: name: 'audio-capture-prebuilds' path: 'packages/audio-capture/prebuilds' + - name: 'Build Bundle and Prepare Package' + env: + QWEN_REQUIRE_AUDIO_CAPTURE_PREBUILD: "${{ github.repository == 'QwenLM/qwen-code' && '1' || '' }}" + run: |- + npm run bundle + npm run prepare:package + - name: 'Build Standalone Archives' env: RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' diff --git a/packages/cli/src/ui/voice/native-audio-recorder.test.ts b/packages/cli/src/ui/voice/native-audio-recorder.test.ts index 170a9dc9952..8ef409cddd4 100644 --- a/packages/cli/src/ui/voice/native-audio-recorder.test.ts +++ b/packages/cli/src/ui/voice/native-audio-recorder.test.ts @@ -158,6 +158,77 @@ describe('createNativeAudioRecorder', () => { expect(backend.startRecording).toHaveBeenCalledTimes(1); }); + it('explains mirror registry installs when the native package is missing', async () => { + const recorder = createNativeAudioRecorder({ + loadBackend: () => { + throw new Error( + "Cannot find package '@qwen-code/audio-capture' imported from /qwen/dist/cli.js", + ); + }, + }); + + await expect(recorder.start()).rejects.toThrow( + /mirror or private registry/, + ); + await expect(recorder.start()).rejects.toThrow(/@qwen-code\/audio-capture/); + }); + + it.each(['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND'])( + 'explains mirror registry installs for %s native package errors', + async (code) => { + const error = Object.assign( + new Error('missing @qwen-code/audio-capture dependency'), + { code }, + ); + const recorder = createNativeAudioRecorder({ + loadBackend: () => { + throw error; + }, + }); + + await expect(recorder.start()).rejects.toThrow( + /mirror or private registry/, + ); + await expect(recorder.start()).rejects.toThrow( + /@qwen-code\/audio-capture/, + ); + }, + ); + + it('does not rewrite wrapped native addon load failures as missing packages', async () => { + const loadError = new Error( + "Native audio capture addon could not be loaded. Reinstall @qwen-code/audio-capture, or use the SoX fallback. (Cannot find module 'node-gyp-build')", + ); + const recorder = createNativeAudioRecorder({ + loadBackend: () => { + throw loadError; + }, + }); + + await expect(recorder.start()).rejects.toBe(loadError); + }); + + it('does not explain native start failures as missing packages', async () => { + const startError = new Error( + "Cannot find package '@qwen-code/audio-capture' while starting", + ); + const backend = { + startRecording: vi.fn(() => { + throw startError; + }), + stopRecording: vi.fn(() => new Uint8Array([1])), + isRecording: vi.fn(() => false), + microphoneAuthorizationStatus: vi.fn(() => 'unknown' as const), + }; + const recorder = createNativeAudioRecorder({ + loadBackend: () => backend, + }); + + await expect(recorder.start()).rejects.toBe(startError); + + expect(backend.startRecording).toHaveBeenCalledTimes(1); + }); + it('allows retry after native stop throws', async () => { const backend = { startRecording: vi.fn(), diff --git a/packages/cli/src/ui/voice/native-audio-recorder.ts b/packages/cli/src/ui/voice/native-audio-recorder.ts index a9dd2bc99d5..b4b4a768223 100644 --- a/packages/cli/src/ui/voice/native-audio-recorder.ts +++ b/packages/cli/src/ui/voice/native-audio-recorder.ts @@ -15,6 +15,7 @@ import type { // Native silence detection sets a flag we poll for; older addons lack it. const SILENCE_POLL_INTERVAL_MS = 200; const debugLogger = createDebugLogger('VOICE_NATIVE_RECORDER'); +const AUDIO_CAPTURE_PACKAGE = '@qwen-code/audio-capture'; interface NativeAudioRecorderOptions { loadBackend?: () => @@ -71,7 +72,11 @@ class NativeAudioRecorder implements VoiceRecorder { this.starting = true; let backend: NativeAudioCaptureBackend; try { - backend = await this.loadBackend(); + try { + backend = await this.loadBackend(); + } catch (loadError) { + throw explainMissingNativePackage(loadError); + } const silenceDetection = options.silenceDetection === true; backend.startRecording({ sampleRate: 16000, @@ -130,6 +135,39 @@ async function loadDefaultBackend(): Promise { return createNativeAudioCaptureBackend(); } +function explainMissingNativePackage(error: unknown): unknown { + if (!(error instanceof Error) || !isMissingNativePackageError(error)) { + return error; + } + + debugLogger.warn( + '[voice] native package missing:', + error.message, + (error as NodeJS.ErrnoException).code, + ); + return new Error( + `Native voice capture package '${AUDIO_CAPTURE_PACKAGE}' is missing. ` + + 'If Qwen Code was installed from a mirror or private registry, the ' + + 'registry may not have synced this optional package. Reinstall from ' + + 'https://registry.npmjs.org or make sure the configured registry ' + + `provides ${AUDIO_CAPTURE_PACKAGE}. (${error.message})`, + { cause: error }, + ); +} + +function isMissingNativePackageError(error: Error): boolean { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') { + return error.message.includes(AUDIO_CAPTURE_PACKAGE); + } + + return ( + error.message.includes(AUDIO_CAPTURE_PACKAGE) && + (error.message.startsWith('Cannot find package') || + error.message.startsWith('Cannot find module')) + ); +} + export function createNativeAudioRecorder( options: NativeAudioRecorderOptions = {}, ): VoiceRecorder { diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index bbc19d2727b..9f25d97d821 100644 --- a/scripts/prepare-package.js +++ b/scripts/prepare-package.js @@ -11,21 +11,32 @@ */ import fs from 'node:fs'; +import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const defaultRootDir = path.resolve(__dirname, '..'); +const TEST_FILE_RE = /\.(test|spec)\.(d\.)?[mc]?[jt]s(\.map)?$/; -export function preparePackage({ rootDir = defaultRootDir } = {}) { +export function preparePackage({ + rootDir = defaultRootDir, + requireNativeAudioCapture = process.env + .QWEN_REQUIRE_AUDIO_CAPTURE_PREBUILD === '1', +} = {}) { const distDir = path.join(rootDir, 'dist'); verifyBundleArtifacts(rootDir, distDir); copyDocumentationFiles(rootDir, distDir); copyLocales(rootDir, distDir); copyExtensionExamples(rootDir, distDir); - writeDistPackageJson(rootDir, distDir); + const bundleNativeAudioCapture = copyNativeAudioCapturePackage( + rootDir, + distDir, + { required: requireNativeAudioCapture }, + ); + writeDistPackageJson(rootDir, distDir, { bundleNativeAudioCapture }); printPackageStructure(distDir); } @@ -129,7 +140,163 @@ function copyExtensionExamples(rootDir, distDir) { } } -function writeDistPackageJson(rootDir, distDir) { +function copyNativeAudioCapturePackage(rootDir, distDir, { required } = {}) { + console.log('Copying native audio capture package...'); + + const addonSrc = path.join(rootDir, 'packages', 'audio-capture'); + const addonDest = path.join( + distDir, + 'node_modules', + '@qwen-code', + 'audio-capture', + ); + const requiredPaths = [ + path.join(addonSrc, 'dist'), + path.join(addonSrc, 'prebuilds'), + path.join(addonSrc, 'package.json'), + ]; + + fs.rmSync(addonDest, { recursive: true, force: true }); + + for (const requiredPath of requiredPaths) { + if (!fs.existsSync(requiredPath)) { + const message = `audio capture package artifact not found at ${requiredPath}`; + if (required) { + throw new Error( + `Required ${message}. ` + + 'Cannot publish package without native voice capture.', + ); + } + console.warn(`Warning: ${message}`); + return false; + } + } + for (const [artifactPath, description, predicate] of [ + [ + path.join(addonSrc, 'dist'), + 'runtime JS', + (filePath) => /\.[cm]?js$/.test(filePath) && !TEST_FILE_RE.test(filePath), + ], + [ + path.join(addonSrc, 'prebuilds'), + 'native prebuild', + (filePath) => filePath.endsWith('.node'), + ], + ]) { + if (!hasFileMatching(artifactPath, predicate)) { + const message = `audio capture package artifact has no ${description}: ${artifactPath}`; + if (required) { + throw new Error( + `Required ${message}. ` + + 'Cannot publish package without native voice capture.', + ); + } + console.warn(`Warning: ${message}`); + return false; + } + } + + let addonPkg; + try { + addonPkg = JSON.parse( + fs.readFileSync(path.join(addonSrc, 'package.json'), 'utf8'), + ); + } catch { + const message = `audio capture package.json is not valid JSON at ${path.join( + addonSrc, + 'package.json', + )}`; + if (required) { + throw new Error( + `Required ${message}. ` + + 'Cannot publish package without native voice capture.', + ); + } + console.warn(`Warning: ${message}`); + return false; + } + const dependencySources = []; + const addonRequire = createRequire(path.join(addonSrc, 'package.json')); + for (const dependencyName of Object.keys(addonPkg.dependencies ?? {})) { + try { + dependencySources.push([ + dependencyName, + path.dirname(addonRequire.resolve(`${dependencyName}/package.json`)), + ]); + } catch { + const message = `audio capture dependency not resolvable: ${dependencyName}`; + if (required) { + throw new Error( + `Required ${message}. ` + + 'Cannot publish package without native voice capture.', + ); + } + console.warn(`Warning: ${message}`); + return false; + } + } + + delete addonPkg.scripts; + delete addonPkg.devDependencies; + + const copyOpts = { + recursive: true, + dereference: true, + verbatimSymlinks: false, + }; + + fs.mkdirSync(addonDest, { recursive: true }); + + fs.writeFileSync( + path.join(addonDest, 'package.json'), + JSON.stringify(addonPkg, null, 2) + '\n', + ); + fs.cpSync(path.join(addonSrc, 'dist'), path.join(addonDest, 'dist'), { + ...copyOpts, + filter: (src) => !TEST_FILE_RE.test(src), + }); + fs.cpSync( + path.join(addonSrc, 'prebuilds'), + path.join(addonDest, 'prebuilds'), + { + ...copyOpts, + filter: (src) => { + const stat = fs.statSync(src); + return stat.isDirectory() || src.endsWith('.node'); + }, + }, + ); + + for (const [dependencyName, dependencySrc] of dependencySources) { + fs.cpSync( + dependencySrc, + path.join(addonDest, 'node_modules', dependencyName), + copyOpts, + ); + } + + console.log('Copied native audio capture package'); + return true; +} + +function hasFileMatching(dir, predicate) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + const stat = fs.statSync(entryPath); + if (stat.isDirectory()) { + if (hasFileMatching(entryPath, predicate)) return true; + } else if (stat.isFile() && predicate(entryPath)) { + return true; + } + } + return false; +} + +function writeDistPackageJson( + rootDir, + distDir, + { bundleNativeAudioCapture = false } = {}, +) { console.log('Creating package.json for distribution...'); const cliEntryContent = `#!/usr/bin/env node @@ -190,6 +357,9 @@ if (result.signal) { 'bundled', 'web-shell', ], + ...(bundleNativeAudioCapture + ? { bundledDependencies: ['@qwen-code/audio-capture'] } + : {}), config: rootPackageJson.config, dependencies: {}, optionalDependencies: { diff --git a/scripts/tests/package-assets.test.js b/scripts/tests/package-assets.test.js index 2dae062e0b2..70a51db27dd 100644 --- a/scripts/tests/package-assets.test.js +++ b/scripts/tests/package-assets.test.js @@ -54,7 +54,7 @@ describe('package asset scripts', () => { createBundleArtifacts(rootDir); stubConsole(); - preparePackage({ rootDir }); + preparePackage({ rootDir, requireNativeAudioCapture: false }); const distPackageJson = JSON.parse( readFileSync(path.join(rootDir, 'dist', 'package.json'), 'utf8'), @@ -64,9 +64,108 @@ describe('package asset scripts', () => { ); expect(distPackageJson.files).toContain('examples'); + expect(distPackageJson.bundledDependencies).toContain( + '@qwen-code/audio-capture', + ); expect(distPackageJson.optionalDependencies).toMatchObject({ '@qwen-code/audio-capture': rootPackageJson.version, }); + expect( + existsSync( + path.join( + rootDir, + 'dist', + 'node_modules', + '@qwen-code', + 'audio-capture', + 'dist', + 'index.js', + ), + ), + ).toBe(true); + expect( + existsSync( + path.join( + rootDir, + 'dist', + 'node_modules', + '@qwen-code', + 'audio-capture', + 'prebuilds', + 'darwin-arm64', + 'debug.log', + ), + ), + ).toBe(false); + expect( + existsSync( + path.join( + rootDir, + 'dist', + 'node_modules', + '@qwen-code', + 'audio-capture', + 'prebuilds', + 'darwin-arm64', + '@qwen-code+audio-capture.node', + ), + ), + ).toBe(true); + const distAudioPackageJson = JSON.parse( + readFileSync( + path.join( + rootDir, + 'dist', + 'node_modules', + '@qwen-code', + 'audio-capture', + 'package.json', + ), + 'utf8', + ), + ); + expect(distAudioPackageJson.scripts).toBeUndefined(); + expect(distAudioPackageJson.devDependencies).toBeUndefined(); + expect( + existsSync( + path.join( + rootDir, + 'dist', + 'node_modules', + '@qwen-code', + 'audio-capture', + 'node_modules', + 'node-gyp-build', + 'package.json', + ), + ), + ).toBe(true); + expect( + existsSync( + path.join( + rootDir, + 'dist', + 'node_modules', + '@qwen-code', + 'audio-capture', + 'dist', + 'index.test.js', + ), + ), + ).toBe(false); + expect( + existsSync( + path.join( + rootDir, + 'dist', + 'node_modules', + '@qwen-code', + 'audio-capture', + 'dist', + 'index.spec.js', + ), + ), + ).toBe(false); expect( existsSync( path.join(rootDir, 'dist', 'examples', 'mcp-server', 'package.json'), @@ -74,6 +173,197 @@ describe('package asset scripts', () => { ).toBe(true); }); + it('omits bundledDependencies when audio-capture artifacts are missing', () => { + const rootDir = createFixtureRoot(); + rmSync(path.join(rootDir, 'packages', 'audio-capture', 'prebuilds'), { + recursive: true, + force: true, + }); + createBundleArtifacts(rootDir); + stubConsole(); + + preparePackage({ rootDir, requireNativeAudioCapture: false }); + + const distPackageJson = JSON.parse( + readFileSync(path.join(rootDir, 'dist', 'package.json'), 'utf8'), + ); + expect(distPackageJson.bundledDependencies).toBeUndefined(); + expect( + existsSync( + path.join( + rootDir, + 'dist', + 'node_modules', + '@qwen-code', + 'audio-capture', + ), + ), + ).toBe(false); + }); + + it('removes stale bundled audio-capture files when artifacts are missing', () => { + const rootDir = createFixtureRoot(); + createBundleArtifacts(rootDir); + stubConsole(); + + preparePackage({ rootDir, requireNativeAudioCapture: false }); + rmSync(path.join(rootDir, 'packages', 'audio-capture', 'prebuilds'), { + recursive: true, + force: true, + }); + + preparePackage({ rootDir, requireNativeAudioCapture: false }); + + const distPackageJson = JSON.parse( + readFileSync(path.join(rootDir, 'dist', 'package.json'), 'utf8'), + ); + expect(distPackageJson.bundledDependencies).toBeUndefined(); + expect( + existsSync( + path.join( + rootDir, + 'dist', + 'node_modules', + '@qwen-code', + 'audio-capture', + ), + ), + ).toBe(false); + }); + + it('fails packaging when required audio-capture artifacts are missing', () => { + const rootDir = createFixtureRoot(); + rmSync(path.join(rootDir, 'packages', 'audio-capture', 'prebuilds'), { + recursive: true, + force: true, + }); + createBundleArtifacts(rootDir); + stubConsole(); + + expect(() => + preparePackage({ rootDir, requireNativeAudioCapture: true }), + ).toThrow(/Required audio capture package artifact not found at/); + }); + + it('fails packaging when required audio-capture runtime output is empty', () => { + const rootDir = createFixtureRoot(); + rmSync(path.join(rootDir, 'packages', 'audio-capture', 'dist', 'index.js')); + createBundleArtifacts(rootDir); + stubConsole(); + + expect(() => + preparePackage({ rootDir, requireNativeAudioCapture: true }), + ).toThrow(/Required audio capture package artifact has no runtime JS/); + }); + + it('fails packaging when required audio-capture prebuilds are empty', () => { + const rootDir = createFixtureRoot(); + rmSync( + path.join( + rootDir, + 'packages', + 'audio-capture', + 'prebuilds', + 'darwin-arm64', + '@qwen-code+audio-capture.node', + ), + ); + createBundleArtifacts(rootDir); + stubConsole(); + + expect(() => + preparePackage({ rootDir, requireNativeAudioCapture: true }), + ).toThrow(/Required audio capture package artifact has no native prebuild/); + }); + + it('omits bundledDependencies when audio-capture dependencies are missing and not required', () => { + const rootDir = createFixtureRoot(); + const audioPackagePath = path.join( + rootDir, + 'packages', + 'audio-capture', + 'package.json', + ); + const audioPackageJson = JSON.parse(readFileSync(audioPackagePath, 'utf8')); + audioPackageJson.dependencies['missing-audio-runtime'] = '1.0.0'; + writeFileSync(audioPackagePath, JSON.stringify(audioPackageJson, null, 2)); + createBundleArtifacts(rootDir); + stubConsole(); + + preparePackage({ rootDir, requireNativeAudioCapture: false }); + + const distPackageJson = JSON.parse( + readFileSync(path.join(rootDir, 'dist', 'package.json'), 'utf8'), + ); + expect(distPackageJson.bundledDependencies).toBeUndefined(); + expect( + existsSync( + path.join( + rootDir, + 'dist', + 'node_modules', + '@qwen-code', + 'audio-capture', + ), + ), + ).toBe(false); + }); + + it('fails packaging when required audio-capture dependencies are missing', () => { + const rootDir = createFixtureRoot(); + const audioPackagePath = path.join( + rootDir, + 'packages', + 'audio-capture', + 'package.json', + ); + const audioPackageJson = JSON.parse(readFileSync(audioPackagePath, 'utf8')); + audioPackageJson.dependencies['missing-audio-runtime'] = '1.0.0'; + writeFileSync(audioPackagePath, JSON.stringify(audioPackageJson, null, 2)); + createBundleArtifacts(rootDir); + stubConsole(); + + expect(() => + preparePackage({ rootDir, requireNativeAudioCapture: true }), + ).toThrow(/Required audio capture dependency not resolvable/); + }); + + it('omits bundledDependencies when audio-capture package JSON is invalid and not required', () => { + const rootDir = createFixtureRoot(); + writeFile(rootDir, 'packages/audio-capture/package.json', '{ invalid json'); + createBundleArtifacts(rootDir); + stubConsole(); + + preparePackage({ rootDir, requireNativeAudioCapture: false }); + + const distPackageJson = JSON.parse( + readFileSync(path.join(rootDir, 'dist', 'package.json'), 'utf8'), + ); + expect(distPackageJson.bundledDependencies).toBeUndefined(); + expect( + existsSync( + path.join( + rootDir, + 'dist', + 'node_modules', + '@qwen-code', + 'audio-capture', + ), + ), + ).toBe(false); + }); + + it('fails packaging when required audio-capture package JSON is invalid', () => { + const rootDir = createFixtureRoot(); + writeFile(rootDir, 'packages/audio-capture/package.json', '{ invalid json'); + createBundleArtifacts(rootDir); + stubConsole(); + + expect(() => + preparePackage({ rootDir, requireNativeAudioCapture: true }), + ).toThrow(/Required audio capture package\.json is not valid JSON/); + }); + function createFixtureRoot() { const rootDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-assets-')); tempDirs.push(rootDir); @@ -107,6 +397,60 @@ describe('package asset scripts', () => { 'packages/cli/src/i18n/locales/en.json', '{"hello":"world"}\n', ); + writeFile( + rootDir, + 'packages/audio-capture/package.json', + JSON.stringify( + { + name: '@qwen-code/audio-capture', + version: '0.17.0', + type: 'module', + main: 'dist/index.js', + dependencies: { + 'node-gyp-build': '^4.8.4', + }, + scripts: { + install: 'node install.js', + }, + devDependencies: { + typescript: '^5.3.3', + }, + }, + null, + 2, + ), + ); + writeFile(rootDir, 'packages/audio-capture/dist/index.js', ''); + writeFile( + rootDir, + 'packages/audio-capture/dist/index.test.js', + 'throw new Error("should not copy tests");\n', + ); + writeFile( + rootDir, + 'packages/audio-capture/dist/index.spec.js', + 'throw new Error("should not copy specs");\n', + ); + writeFile( + rootDir, + 'packages/audio-capture/prebuilds/darwin-arm64/@qwen-code+audio-capture.node', + 'fake native addon\n', + ); + writeFile( + rootDir, + 'packages/audio-capture/prebuilds/darwin-arm64/debug.log', + 'should not ship\n', + ); + writeFile( + rootDir, + 'packages/audio-capture/node_modules/node-gyp-build/package.json', + '{"name":"node-gyp-build","version":"4.8.4"}\n', + ); + writeFile( + rootDir, + 'packages/audio-capture/node_modules/node-gyp-build/index.js', + '', + ); for (const template of [ 'agent',