Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
89 changes: 89 additions & 0 deletions scripts/create-standalone-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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',
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

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] copyNativeAddon can throw raw ENOENT (if packages/audio-capture/dist/ is missing) or MODULE_NOT_FOUND (if node-gyp-build isn't resolvable) without a contextual error message. The rest of this file uses fail() with descriptive messages (e.g., "Required dist asset missing: ..."). Wrapping these calls in try/catch with fail(...) would match the existing pattern and make build failures self-diagnosing — especially important in CI where a raw ENOENT from cpSync doesn't mention the addon-bundling step.

Suggested change
const nodeRequire = createRequire(import.meta.url);
let nodeGypBuildSrc;
try {
const nodeRequire = createRequire(import.meta.url);
nodeGypBuildSrc = path.dirname(
nodeRequire.resolve('node-gyp-build/package.json'),
);
} catch {
fail('node-gyp-build (runtime dep of @qwen-code/audio-capture) is not resolvable from the repo root. Run npm install before packaging.');
}

Similarly, add an existence check before the dist/ copy:

const addonDist = path.join(addonSrc, 'dist');
if (!fs.existsSync(addonDist)) {
  fail(`audio-capture compiled output is missing: ${addonDist}. Run 'npm run build' first.`);
}

— qwen3.7-max via Qwen Code /review

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 (
Expand Down
155 changes: 155 additions & 0 deletions scripts/tests/install-script.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

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] This test verifies that dist/index.js is copied and test files are filtered, but never asserts the .node prebuild binary or node-gyp-build were bundled — the two core reasons copyNativeAddon exists. If the prebuild copy or node-gyp-build copy were accidentally removed, this test would still pass while streaming voice silently breaks in every standalone release.

Consider adding:

// Verify .node prebuild is bundled
const addonPrebuild = path.join(
  extractDir, 'qwen-code', 'lib', 'node_modules',
  '@qwen-code', 'audio-capture', 'prebuilds', 'linux-x64',
);
expect(existsSync(path.join(addonPrebuild, '@qwen-code+audio-capture.node'))).toBe(true);

// Verify node-gyp-build is bundled
const nodeGypBuildDir = path.join(
  extractDir, 'qwen-code', 'lib', 'node_modules', 'node-gyp-build',
);
expect(existsSync(path.join(nodeGypBuildDir, 'package.json'))).toBe(true);

— qwen3.7-max via Qwen Code /review

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-'));
Expand Down Expand Up @@ -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',
);
Expand Down
Loading