Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
Expand All @@ -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 }}'
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/src/ui/voice/native-audio-recorder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,64 @@ 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 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(),
Expand Down
37 changes: 36 additions & 1 deletion packages/cli/src/ui/voice/native-audio-recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: () =>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -130,6 +135,36 @@ async function loadDefaultBackend(): Promise<NativeAudioCaptureBackend> {
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 {
return (
error.message.includes(AUDIO_CAPTURE_PACKAGE) &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] False-positive error rewrite. packages/audio-capture/src/index.ts:62-70 (loadBinding()) wraps any inner failure from node-gyp-build or prebuild loading with 'Native audio capture addon could not be loaded. Reinstall @qwen-code/audio-capture, ... (' + error.message + ')'. When the inner error is itself a Cannot find module 'node-gyp-build' (e.g. the bundled dep was not copied, or its files are corrupted), the resulting message contains both @qwen-code/audio-capture and Cannot find module, so this substring check passes and the error is rewritten to "package is missing, reinstall from npmjs.org".

That confidently points the operator at the registry mirror when the package is installed and the real root cause is an incomplete bundled node_modules/ tree (precisely the failure mode the bundling added in this PR is supposed to prevent). The original Cannot find module 'node-gyp-build' detail ends up buried in parentheses at the end of the rewritten message.

Suggested change
error.message.includes(AUDIO_CAPTURE_PACKAGE) &&
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'))
);
}

The startsWith guard rejects loadBinding()'s wrapped message (which starts with Native audio capture addon could not be loaded) while still matching the ESM/CJS loader's genuine resolution failures (which start with Cannot find package '@qwen-code/audio-capture' ...).

— qwen3.7-max via Qwen Code /review

(error.message.includes('Cannot find package') ||
error.message.includes('Cannot find module') ||
(error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND' ||
(error as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND')
);
}

export function createNativeAudioRecorder(
options: NativeAudioRecorderOptions = {},
): VoiceRecorder {
Expand Down
110 changes: 107 additions & 3 deletions scripts/prepare-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 nodeRequire = createRequire(import.meta.url);

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);
}

Expand Down Expand Up @@ -129,7 +140,97 @@ 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'),
];

for (const requiredPath of requiredPaths) {
if (!fs.existsSync(requiredPath)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] copyNativeAudioCapturePackage validates that dist/, prebuilds/, and package.json exist as paths, but not that they have usable contents. An empty dist/ (e.g. a stale tsconfig.tsbuildinfo no-opping the addon's tsc build, or a wrong outDir) or an empty prebuilds/ directory passes all three existsSync checks, so bundledDependencies: ['@qwen-code/audio-capture'] is written and a broken addon ships — even under the required (main-repo release) gate. The sibling standalone packaging path already guards against exactly this with hasNativePrebuild() (scripts/create-standalone-package.js:363), which requires an actual .node file. Worse, at runtime an empty dist/ produces an ERR_MODULE_NOT_FOUND whose message contains both @qwen-code/audio-capture and the not-found code, so isMissingNativePackageError matches and the user is told to "reinstall from a mirror/registry" — advice that can't fix a present-but-empty package. Consider validating contents (e.g. the resolved main/entry under dist/ exists, and prebuilds/ contains at least one .node, reusing hasNativePrebuild).

— claude-opus-4-8[1m] via Qwen Code /qreview

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;
}
}

const addonPkg = JSON.parse(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] JSON.parse(fs.readFileSync(..., 'package.json')) has no try/catch. Every other early-return path in copyNativeAudioCapturePackage honors the required contract — warn + return false when not required, throw when required — but a malformed packages/audio-capture/package.json (invalid JSON, truncated file, merge-conflict markers) bypasses all of that and throws a raw SyntaxError unconditionally, crashing npm run prepare:package for every caller regardless of required.

Suggested change
const addonPkg = JSON.parse(
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 ${addonSrc}`;
if (required) {
throw new Error(
`Required ${message}. ` +
'Cannot publish package without native voice capture.',
);
}
console.warn(`Warning: ${message}`);
return false;
}

Matches the required handling pattern used on lines 161, 183, and 207 for other artifact failures.

— qwen3.7-max via Qwen Code /review

fs.readFileSync(path.join(addonSrc, 'package.json'), 'utf8'),
);
const dependencySources = [];
for (const dependencyName of Object.keys(addonPkg.dependencies ?? {})) {
try {
dependencySources.push([
dependencyName,
path.dirname(nodeRequire.resolve(`${dependencyName}/package.json`)),
]);
} catch {
throw new Error(
`Required audio capture dependency not resolvable: ${dependencyName}. ` +
'Cannot publish package without native voice capture.',
);
}
}

delete addonPkg.scripts;
delete addonPkg.devDependencies;

const copyOpts = {
recursive: true,
dereference: true,
verbatimSymlinks: false,
};

fs.rmSync(addonDest, { recursive: true, force: true });
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\.(d\.)?[mc]?[jt]s(\.map)?$/.test(src),
});
fs.cpSync(
path.join(addonSrc, 'prebuilds'),
path.join(addonDest, 'prebuilds'),
copyOpts,
);

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 writeDistPackageJson(
rootDir,
distDir,
{ bundleNativeAudioCapture = false } = {},
) {
console.log('Creating package.json for distribution...');

const cliEntryContent = `#!/usr/bin/env node
Expand Down Expand Up @@ -190,6 +291,9 @@ if (result.signal) {
'bundled',
'web-shell',
],
...(bundleNativeAudioCapture
? { bundledDependencies: ['@qwen-code/audio-capture'] }
: {}),
config: rootPackageJson.config,
dependencies: {},
optionalDependencies: {
Expand Down
Loading
Loading