Skip to content
Merged
Changes from 1 commit
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
78 changes: 78 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,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);

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(

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] fs.cpSync copies the entire packages/audio-capture/dist/ verbatim, including platform.test.js, platform.test.d.ts, and platform.test.js.map. The addon's own package.json files field explicitly excludes test artifacts ("!dist/**/*.test.*"), but this raw filesystem copy bypasses that filter.

Suggested change
fs.cpSync(
fs.cpSync(
path.join(addonSrc, 'dist'),
path.join(addonDest, 'dist'),
{ ...copyOpts, filter: (src) => !/\.test\.[mc]?[jt]s(\.map)?$/.test(src) },
);

— qwen3.7-max via Qwen Code /review

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 (
Expand Down
Loading