qwen-code/scripts/copy_bundle_assets.js
顾盼 a791e47a54
feat(cua): streamline computer use and port native reliability fixes (#14846)
* feat(cua): preload the JavaScript computer use runtime

* refactor(cua): remove the native Wayland backend

* feat(cua): move app computer use into the driver

* feat(cua): preserve background delivery and port platform fixes

* feat(cua): add explicit desktop input and browser runtime facade

* feat(cua): package the unified 0.26.0 computer use runtime

* fix(cua): finalize cross-platform native reliability

* fix(cua): isolate runtime releases from CLI versioning

* fix(cua): keep Electron out of root workspace installs

* fix(cua): synchronize consumers and async cleanup tests

* fix(browser): migrate smoke to preloaded cua runtime

* docs(cua): document MCP setup for Qwen Code Claude Code and Codex
2026-10-21 14:03:31 +08:00

771 lines
28 KiB
JavaScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import {
dirname,
join,
basename,
resolve,
relative,
sep,
extname,
} from 'node:path';
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { glob } from 'glob';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const defaultRoot = join(__dirname, '..');
// Exported for `scripts/tests/review-source-digest.test.ts`, which holds the
// skill root's digest allowlist up to everything this rule lets the copier
// ship — a file the copier would carry but the digest cannot see is a
// staleness check with a blind spot.
export const BUNDLED_SKILL_TEST_FILE_RE =
/\.(?:test|spec)\.(?:d\.)?[cm]?[jt]sx?(?:\.map)?$/;
/**
* The digest of every review source this bundle was built from.
*
* A staleness hint, not an integrity control: an unsigned file beside the
* bundle, which anyone who can write `dist/` can write.
*
* Kept in step with `stale-bundle.ts`, which re-derives it the same way — and
* duplicated rather than shared, because this script runs before the package
* it would import has been built. `scripts/tests/review-source-digest.test.ts`
* is what holds the two equal; nothing here is imported from there.
*
* Tests and fixtures are excluded on both sides: esbuild follows imports from
* the CLI entry, neither is reachable that way, and a warning fired by an
* edit to a file the bundle cannot contain is the false positive this check
* exists not to produce. DESIGN.md is excluded for the same reason: the
* copier below deliberately does not ship it.
*/
// Mirrors NOT_BUNDLED_RE / NOT_BUNDLED_DIR / NOT_BUNDLED_FILE /
// NOT_BUNDLED_SKILL_FILE / DIGESTED_EXTENSIONS in stale-bundle.ts; the
// parity test keeps them equal.
const NOT_BUNDLED_RE = /\.(?:test|spec)\.(?:d\.)?[cm]?[jt]sx?$/;
const NOT_BUNDLED_DIR = new Set(['__fixtures__', '__snapshots__']);
const NOT_BUNDLED_FILE = new Set(['test-utils.ts']);
const NOT_BUNDLED_SKILL_FILE = new Set(['DESIGN.md']);
const DIGESTED_EXTENSIONS = {
code: new Set([
'.ts',
'.tsx',
'.mts',
'.cts',
'.js',
'.mjs',
'.json',
'.jsx',
]),
skill: new Set(['.md']),
};
function isDigestedFile(kind, name) {
if (!DIGESTED_EXTENSIONS[kind].has(extname(name))) return false;
if (kind !== 'code') return !NOT_BUNDLED_SKILL_FILE.has(name);
return !NOT_BUNDLED_RE.test(name) && !NOT_BUNDLED_FILE.has(name);
}
export function reviewSourceDigestForBuild(root) {
const cliCommands = join(root, 'packages', 'cli', 'src', 'commands');
const roots = [
{ path: join(cliCommands, 'review'), kind: 'code' },
{ path: join(cliCommands, 'review.ts'), kind: 'code' },
// Mirrors the lease root `reviewSourceRoots` (stale-bundle.ts) adds; the
// repo-tree case in review-source-digest.test.ts holds the two equal.
{
path: join(
root,
'packages',
'cli',
'src',
'services',
'review-worktree-lease.ts',
),
kind: 'code',
},
// Mirrors the utils-helpers roots `reviewSourceRoots` (stale-bundle.ts)
// adds; the repo-tree case in review-source-digest.test.ts holds the two
// equal.
{
path: join(root, 'packages', 'cli', 'src', 'utils', 'shell-args.ts'),
kind: 'code',
},
{
path: join(root, 'packages', 'cli', 'src', 'utils', 'paths.ts'),
kind: 'code',
},
{
path: join(
root,
'packages',
'core',
'src',
'skills',
'bundled',
'review',
),
kind: 'skill',
},
];
const files = [];
// `listed` is true when the parent walk saw this path as a directory a
// moment ago: if it cannot be listed now, the tree changed underneath the
// walk and the measurement is incomplete, where a root that was never there
// has simply nothing to say.
const walk = (dir, kind, listed) => {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
// `readdirSync` failing says only "this path did not list" — the reason
// decides everything, and `statSync` states it directly, where inferring
// "this is a file" from `ENOTDIR` assumes every platform's libuv maps
// the case the same way, and a one-sided divergence would drop
// `review.ts` from one digest and not the other — a bundle that is
// byte-for-byte correct warning on every review, forever, on that
// platform alone. A directory that could not be listed (EACCES, or
// vanished after the parent listed it) throws instead of being skipped:
// a digest over the survivors would be stamped as the truth, and every
// review from then on would report stale against it.
let stats;
try {
stats = fs.statSync(dir);
} catch (error) {
if (listed) {
throw new Error(`${dir} vanished while being walked`);
}
// A root that was never there is silent only on ENOENT — EACCES/EPERM
// is a tree that IS there but cannot be measured, and skipping it
// would stamp a digest over the surviving roots, which the runtime
// twin (`sourceFilesUnder` in stale-bundle.ts) marks incomplete: a
// parity gap that would accuse a byte-for-byte correct bundle once
// the tree becomes readable again.
if (error?.code !== 'ENOENT') {
throw new Error(`${dir} could not be measured`);
}
return;
}
if (stats.isDirectory()) {
throw new Error(`${dir} could not be listed`);
}
if (stats.isFile() && isDigestedFile(kind, basename(dir))) {
files.push(dir);
}
return;
}
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
if (kind !== 'code' || !NOT_BUNDLED_DIR.has(e.name))
walk(full, kind, true);
} else if (e.isFile() && isDigestedFile(kind, e.name)) {
files.push(full);
}
}
};
for (const r of roots) walk(r.path, r.kind, false);
if (files.length === 0)
return { digest: undefined, count: 0, newest: undefined };
const hash = createHash('sha256');
let newest;
for (const file of files.sort()) {
const { mtimeMs } = fs.statSync(file);
if (!newest || mtimeMs > newest.mtimeMs) newest = { file, mtimeMs };
hash.update(relative(root, file).split(sep).join('/'));
hash.update('\0');
hash.update(fs.readFileSync(file));
hash.update('\0');
}
return { digest: hash.digest('hex'), count: files.length, newest };
}
function stampReviewSourceDigest(root, distDir) {
const stampPath = join(distDir, 'review-sources.sha256');
// Every refusal below removes an existing stamp first. Leaving an older
// stamp beside a newer bundle is a weaker form of the misdescription the
// refusal exists to avoid, and `unmeasured` is the state each refusal means.
const refuse = (why) => {
try {
fs.rmSync(stampPath, { recursive: true, force: true });
} catch (error) {
// `force` swallows only ENOENT; a permission or lock error on the old
// stamp must not kill the bundle step after every asset is in place.
// Leaving it is the lesser outcome: at worst a later "stale" notice
// whose rebuild advice is still correct.
console.warn(
`Could not remove the stale source digest at ${stampPath}: ` +
(error instanceof Error ? error.message : error),
);
}
// `warn`, not `log`: the runtime message sends its reader back to this
// build's output, and a refusal hidden among plain logs is not what they
// are being sent to find.
console.warn(why);
};
// Never fatal. This is the last step of the copier, so a file vanishing
// mid-walk would fail the bundle after every asset was already in place —
// and a missing stamp is only `unmeasured`, which the runtime check already
// treats as an acceptable answer.
let digest, count, newest;
try {
({ digest, count, newest } = reviewSourceDigestForBuild(root));
} catch (error) {
refuse(
`Could not read the review sources; skipped the source digest: ${
error instanceof Error ? error.message : error
}`,
);
return;
}
if (!digest) {
refuse('No review sources found; skipped the source digest.');
return;
}
// The digest describes the tree as the COPIER sees it, and the copier runs
// after esbuild — so a source edited after the bundle was written, or this
// script run on its own, would stamp a `cli.js` built from something else.
// That is the only direction where silence is affirmatively wrong instead
// of merely uninformative: every other gap here degrades to `unmeasured`.
// Timestamps are the wrong tool for judging staleness and the right one for
// judging whether this stamp can be honest at all, so refuse rather than
// stamp. The anchor is the bundle's WRITE time, not the build's start:
// esbuild reads the sources during the graph walk, before it writes, so an
// edit landing while the build is in flight is older than this anchor,
// passes the gate, and is what the copier digests — a stamp honest about
// the tree and not about the bundle. That window is not covered here.
const bundlePath = join(distDir, 'cli.js');
let builtAt;
try {
builtAt = fs.statSync(bundlePath).mtimeMs;
} catch {
refuse('No bundle found to stamp; skipped the source digest.');
return;
}
const newestSource = newest.mtimeMs;
if (newestSource > builtAt) {
refuse(
`A review source is newer than ${bundlePath}; skipped the source digest ` +
`rather than stamp a bundle it may not describe. Run \`npm run bundle\` ` +
`to rebuild the bundle and the stamp together.`,
);
return;
}
try {
fs.writeFileSync(stampPath, digest);
} catch (error) {
refuse(
`Could not write the source digest; skipped the stamp: ${
error instanceof Error ? error.message : error
}`,
);
return;
}
console.log(`Stamped the review source digest over ${count} files.`);
}
/**
* Whether this host's Node.js links against musl rather than glibc. The
* report header only carries `glibcVersionRuntime` on a glibc runtime.
*/
function hostUsesMusl() {
if (process.platform !== 'linux') return false;
try {
return !process.report?.getReport?.()?.header?.glibcVersionRuntime;
} catch {
return false;
}
}
/**
* Whether an `@opentui/core-*` package holds a native library this host
* cannot load: the Linux packages ship one library per libc, and npm
* installs both flavors on a glibc host because only `libc` (not `os`/`cpu`)
* separates them.
*
* Shipping the foreign one is not merely dead weight. Both are ELF files, so
* AppImage packaging walks them with `ldd` while deploying dependencies; the
* musl library needs `libc.so`, which no glibc system resolves, so `ldd`
* exits non-zero and aborts linuxdeploy. That is what broke the Linux job of
* desktop release 0.2.3-preview.0, which bundles this dist tree verbatim.
*/
function isForeignLibcPackage(entry, musl = hostUsesMusl()) {
if (!entry.startsWith('core-linux-')) return false;
return entry.endsWith('-musl') !== musl;
}
/**
* OpenTUI renderer runtime assets (tree-sitter grammars, the parser worker,
* `web-tree-sitter` runtime and the native render library) relocated next to
* the bundle.
*
* In the esbuild single-file bundle @opentui/core's package-relative asset
* fallback (`new URL('./assets/…', import.meta.url)`) points into `dist/`
* and misses, so code-block syntax highlighting degrades to single color.
* The runtime (`packages/cli/src/ui/opentui/opentui-assets.ts`) therefore
* points `OTUI_ASSET_ROOT` at `dist/opentui-assets/` — but only when the tree
* there is complete, because @opentui/core throws on any missing key under a
* configured root (including the native library). This copier writes the
* assets under exactly the keys that module checks:
*
* opentui-assets/@opentui/core/parser.worker.js
* opentui-assets/@opentui/core/assets/<lang>/<file>
* opentui-assets/@opentui/core-<platform>-<arch>[ -musl]/libopentui.*
* opentui-assets/web-tree-sitter/tree-sitter.wasm
*
* Returns the list of destination-relative keys copied (empty on skip) so
* callers/tests can assert on it. Never fails the bundle: a missing
* @opentui/core (e.g. an install without the renderer) only warns.
*/
export function copyOpenTuiAssets({
root = defaultRoot,
distDir,
musl = hostUsesMusl(),
} = {}) {
distDir ??= join(root, 'dist');
const destRoot = join(distDir, 'opentui-assets');
// Start from a clean tree: the runtime gate below checks key existence
// only, so a tree left by an earlier bundle would still satisfy it and
// ship stale native libraries after the installed platform set changed.
fs.rmSync(destRoot, { recursive: true, force: true });
// @opentui/core may sit in the root node_modules or be hoisted into
// packages/cli/node_modules (it is a cli dependency); try both anchors.
const anchors = [
join(root, 'package.json'),
join(root, 'packages', 'cli', 'package.json'),
];
let coreEntry;
for (const anchor of anchors) {
if (!existsSync(anchor)) continue;
try {
coreEntry = createRequire(anchor).resolve('@opentui/core');
break;
} catch {
// try the next anchor
}
}
if (!coreEntry) {
console.warn(
'Warning: @opentui/core is not resolvable; skipped OpenTUI assets',
);
return [];
}
const coreDir = dirname(coreEntry);
const coreRequire = createRequire(join(coreDir, 'package.json'));
const copied = [];
const copyToKey = (source, key) => {
if (!existsSync(source) || !statSync(source).isFile()) {
console.warn(`Warning: OpenTUI asset missing at ${source}; skipped`);
return;
}
const destPath = join(destRoot, key);
mkdirSync(dirname(destPath), { recursive: true });
copyFileSync(source, destPath);
copied.push(key);
};
// 1. The tree-sitter parser worker (self-contained bundle).
copyToKey(
join(coreDir, 'parser.worker.js'),
'@opentui/core/parser.worker.js',
);
// 2. Grammar assets: assets/<lang>/{highlights.scm,injections.scm,*.wasm}.
const assetsDir = join(coreDir, 'assets');
if (existsSync(assetsDir)) {
for (const lang of fs.readdirSync(assetsDir)) {
const langDir = join(assetsDir, lang);
if (!statSync(langDir).isDirectory()) continue;
for (const file of fs.readdirSync(langDir)) {
copyToKey(join(langDir, file), `@opentui/core/assets/${lang}/${file}`);
}
}
} else {
console.warn(`Warning: OpenTUI grammar assets not found at ${assetsDir}`);
}
// 3. The web-tree-sitter runtime wasm (peer dependency of @opentui/core).
let wasmSource;
try {
wasmSource = coreRequire.resolve('web-tree-sitter/tree-sitter.wasm');
} catch {
try {
wasmSource = join(
dirname(coreRequire.resolve('web-tree-sitter')),
'tree-sitter.wasm',
);
} catch {
wasmSource = undefined;
}
}
if (wasmSource) {
copyToKey(wasmSource, 'web-tree-sitter/tree-sitter.wasm');
} else {
console.warn(
'Warning: web-tree-sitter is not resolvable; skipped its wasm asset',
);
}
// 4. Native render libraries for every installed @opentui platform package
// (core-darwin-arm64, core-win32-x64, …), minus the Linux packages built
// for the other libc (see isForeignLibcPackage). The runtime only needs
// the current platform's, but shipping the rest of the installed set
// keeps the completeness check satisfiable wherever the bundle runs
// next. The platform packages live beside @opentui/core or hoisted at
// the repo root; scan both scope directories.
const scopeDirs = [dirname(coreDir), join(root, 'node_modules', '@opentui')];
const seenPackages = new Set();
for (const scopeDir of scopeDirs) {
if (!existsSync(scopeDir)) continue;
for (const entry of fs.readdirSync(scopeDir)) {
if (!entry.startsWith('core-') || seenPackages.has(entry)) continue;
if (isForeignLibcPackage(entry, musl)) continue;
const packageDir = join(scopeDir, entry);
if (!statSync(packageDir).isDirectory()) continue;
let copiedFromPackage = false;
for (const file of fs.readdirSync(packageDir)) {
if (!/\.(dylib|so|dll)$/.test(file)) continue;
copyToKey(join(packageDir, file), `@opentui/${entry}/${file}`);
copiedFromPackage = true;
}
if (copiedFromPackage) seenPackages.add(entry);
}
}
if (copied.length > 0) {
console.log(
`Copied ${copied.length} OpenTUI runtime assets to dist/opentui-assets/`,
);
} else {
console.warn('Warning: no OpenTUI runtime assets were copied');
}
return copied;
}
export function copyGeneratedUiAssets({
root = defaultRoot,
distDir = join(root, 'dist'),
} = {}) {
const sourceDir = join(root, 'integrations', 'generated-ui', 'dist');
if (!existsSync(join(sourceDir, '..', 'package.json'))) return;
const files = [
['bundled-main.js', 'main.js'],
['app.html', 'app.html'],
['skills/generated-ui/SKILL.md', 'skills/generated-ui/SKILL.md'],
['THIRD_PARTY_NOTICES.txt', 'THIRD_PARTY_NOTICES.txt'],
];
for (const [source] of files) {
if (
!statSync(join(sourceDir, source), { throwIfNoEntry: false })?.isFile()
) {
throw new Error(
`Generated UI asset not found: ${join(sourceDir, source)}. ` +
'Run npm run build --workspace=@qwen-code/generated-ui first.',
);
}
}
const targetDir = join(distDir, 'generated-ui');
fs.rmSync(targetDir, { recursive: true, force: true });
for (const [source, target] of files) {
const destination = join(targetDir, target);
mkdirSync(dirname(destination), { recursive: true });
copyFileSync(join(sourceDir, source), destination);
}
}
export function copyBundleAssets({ root = defaultRoot } = {}) {
const distDir = join(root, 'dist');
const coreVendorDir = join(root, 'packages', 'core', 'vendor');
// Create the dist directory if it doesn't exist
if (!existsSync(distDir)) {
mkdirSync(distDir);
}
copyGeneratedUiAssets({ root, distDir });
// Find and copy all .sb files from packages to the root of the dist directory
const sbFiles = glob.sync('packages/**/*.sb', { cwd: root });
for (const file of sbFiles) {
copyFileSync(join(root, file), join(distDir, basename(file)));
}
console.log('Copied sandbox profiles to dist/');
// Copy vendor directory (contains ripgrep binaries)
console.log('Copying vendor directory...');
if (existsSync(coreVendorDir)) {
const destVendorDir = join(distDir, 'vendor');
copyRecursiveSync(coreVendorDir, destVendorDir);
console.log('Copied vendor directory to dist/');
} else {
console.warn(`Warning: Vendor directory not found at ${coreVendorDir}`);
}
// Copy bundled skills (e.g. /review) so they are available at runtime.
// In the esbuild bundle, import.meta.url resolves to dist/cli.js, so
// SkillManager looks for bundled skills at dist/bundled/.
const bundledSkillsDir = join(
root,
'packages',
'core',
'src',
'skills',
'bundled',
);
if (existsSync(bundledSkillsDir)) {
const destBundledDir = join(distDir, 'bundled');
fs.rmSync(destBundledDir, { recursive: true, force: true });
copyRecursiveSync(bundledSkillsDir, destBundledDir, {
// DESIGN.md files are maintainer design narratives, not runtime inputs;
// shipping one would hand a review a ~125 KB read_file target that
// outweighs the context the slimmed skill saves.
skipEntry: (entry, sourcePath) =>
isBundledSkillTestFile(entry) ||
entry === 'DESIGN.md' ||
sourcePath === join(bundledSkillsDir, 'browser-use') ||
sourcePath === join(bundledSkillsDir, 'computer-use'),
});
console.log('Copied bundled skills to dist/bundled/');
} else {
console.warn(
`Warning: Bundled skills directory not found at ${bundledSkillsDir}`,
);
}
// Copy user docs into qc-helper bundled skill so it can reference them at runtime.
// The qc-helper skill reads docs from a `docs/` subdirectory relative to its own
// directory. In the esbuild bundle this becomes dist/bundled/qc-helper/docs/.
const userDocsDir = join(root, 'docs', 'users');
if (existsSync(userDocsDir)) {
const destDocsDir = join(distDir, 'bundled', 'qc-helper', 'docs');
copyRecursiveSync(userDocsDir, destDocsDir);
console.log('Copied docs/users/ to dist/bundled/qc-helper/docs/');
} else {
console.warn(`Warning: User docs directory not found at ${userDocsDir}`);
}
// Copy builtin locales so bundled dist/cli.js can load UI translations at runtime.
// Published packages already include these via prepare-package.js; bundle output
// should mirror that behavior for local `node dist/cli.js` runs.
const localesDir = join(root, 'packages', 'cli', 'src', 'i18n', 'locales');
if (existsSync(localesDir)) {
const destLocalesDir = join(distDir, 'locales');
copyRecursiveSync(localesDir, destLocalesDir);
console.log('Copied builtin locales to dist/locales/');
} else {
console.warn(`Warning: Locales directory not found at ${localesDir}`);
}
// Copy the OpenTUI renderer's runtime assets (tree-sitter grammars, parser
// worker, web-tree-sitter wasm, native render library) so the bundled
// renderer can point OTUI_ASSET_ROOT at dist/opentui-assets/ and keep
// code-block syntax highlighting. Warn-and-skip when unavailable.
copyOpenTuiAssets({ root, distDir });
// Copy extension templates so bundled dist/cli.js can scaffold
// `/extensions new` from the runtime examples directory.
const extensionExamplesDir = join(
root,
'packages',
'cli',
'src',
'commands',
'extensions',
'examples',
);
if (existsSync(extensionExamplesDir)) {
const destExtensionExamplesDir = join(distDir, 'examples');
copyRecursiveSync(extensionExamplesDir, destExtensionExamplesDir);
console.log('Copied extension examples to dist/examples/');
} else {
console.warn(
`Warning: Extension examples directory not found at ${extensionExamplesDir}`,
);
}
// Copy the built Web Shell SPA (index.html + assets/) so the bundled
// `qwen serve` can serve the browser UI at its root path. The library
// build outputs (dist/index.js, dist/types) are for npm consumers and are
// intentionally NOT copied. Source only exists after the web-shell
// workspace is built (npm run build); when absent (e.g. a --cli-only
// build, or bundling without a prior full build) we warn and skip so the
// bundle step never fails — the daemon then runs API-only at runtime.
const webShellDistDir = join(root, 'packages', 'web-shell', 'dist');
const webShellIndexHtml = join(webShellDistDir, 'index.html');
const webShellAssetsDir = join(webShellDistDir, 'assets');
if (existsSync(webShellIndexHtml) && existsSync(webShellAssetsDir)) {
const destWebShellDir = join(distDir, 'web-shell');
mkdirSync(destWebShellDir, { recursive: true });
copyFileSync(webShellIndexHtml, join(destWebShellDir, 'index.html'));
copyRecursiveSync(webShellAssetsDir, join(destWebShellDir, 'assets'));
for (const file of ['manifest.webmanifest', 'sw.js']) {
const source = join(webShellDistDir, file);
if (existsSync(source)) {
copyFileSync(source, join(destWebShellDir, file));
} else {
console.warn(`Warning: Web Shell PWA asset not found: ${source}`);
}
}
console.log('Copied Web Shell UI to dist/web-shell/');
} else {
console.warn(
`Warning: Web Shell assets not found at ${webShellDistDir}; ` +
'dist/web-shell/ will be absent and `qwen serve` runs API-only. ' +
'Run a full `npm run build` before bundling to include the UI.',
);
}
const exportTranscriptRenderer = join(
root,
'packages',
'web-templates',
'src',
'export-html',
'dist',
'export-transcript-document.js',
);
const exportTranscriptCss = join(
root,
'packages',
'web-templates',
'src',
'export-html',
'dist',
'export-transcript-document.css',
);
if (existsSync(exportTranscriptRenderer) && existsSync(exportTranscriptCss)) {
copyFileSync(
exportTranscriptRenderer,
join(distDir, 'export-transcript-document.js'),
);
copyFileSync(
exportTranscriptCss,
join(distDir, 'export-transcript-document.css'),
);
console.log('Copied HTML export renderer to dist/');
} else {
const missingExportTranscriptAssets = [
exportTranscriptRenderer,
exportTranscriptCss,
].filter((assetPath) => !existsSync(assetPath));
console.warn(
`Warning: HTML export renderer assets not found at ${missingExportTranscriptAssets.join(', ')}; ` +
'dist/ will carry no HTML export renderer. ' +
'Run a full `npm run build` before bundling to include it.',
);
}
// Stamp what the review sources looked like at build time. `/review` drives
// the bundle, not the working tree, so a review command edited after this
// point takes no effect — and without a record of what was built, the run
// cannot tell and neither can its reader. Compared, not trusted: the check
// reads this and re-derives the digest from the tree.
stampReviewSourceDigest(root, distDir);
// Make dist/cli.js directly executable: shellContextEnv blanks a
// QWEN_CODE_CLI whose entry a POSIX shell cannot exec (no shebang, or no
// execute bit), and the `"${QWEN_CODE_CLI:-qwen}"` fallback then silently
// runs whatever `qwen` the PATH resolves — a different install for every
// `review` subcommand of a session launched from this bundle. Measured on
// three live `review run`s: every agent-issued subcommand ran the machine's
// global install instead of the freshly bundled tree.
const cliEntry = join(distDir, 'cli.js');
if (existsSync(cliEntry)) {
const source = readFileSync(cliEntry, 'utf8');
if (!source.startsWith('#!')) {
// Preserve the bundle's write time across the rewrite. The digest stamp
// above reads this mtime as "when the bundle was built", and a bumped
// one certifies a bundle as newer than review sources edited before it
// — the staleness warning the skill's Step 0 stops on then never fires.
// (Only a standalone run of this script is exposed, since a full bundle
// stamps before reaching here, but that is a flow the gate contemplates.)
const { atime, mtime } = statSync(cliEntry);
writeFileSync(cliEntry, `#!/usr/bin/env node\n${source}`);
fs.utimesSync(cliEntry, atime, mtime);
}
// chmod does not touch mtime, so it stays outside the guard: a bundle
// that already carries a shebang may still arrive without the exec bit.
fs.chmodSync(cliEntry, 0o755);
}
console.log('\n✅ All bundle assets copied to dist/');
}
if (isDirectRun()) {
copyBundleAssets();
}
function isDirectRun() {
return process.argv[1]
? fileURLToPath(import.meta.url) === resolve(process.argv[1])
: false;
}
/**
* Recursively copy directory
*/
function copyRecursiveSync(src, dest, options = {}) {
if (!existsSync(src)) {
return;
}
const stats = statSync(src);
if (stats.isDirectory()) {
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src);
for (const entry of entries) {
const srcPath = join(src, entry);
if (entry === '.DS_Store' || options.skipEntry?.(entry, srcPath)) {
continue;
}
const destPath = join(dest, entry);
copyRecursiveSync(srcPath, destPath, options);
}
} else {
copyFileSync(src, dest);
// Preserve execute permissions for binaries
const srcStats = statSync(src);
if (srcStats.mode & 0o111) {
fs.chmodSync(dest, srcStats.mode);
}
}
}
function isBundledSkillTestFile(fileName) {
return BUNDLED_SKILL_TEST_FILE_RE.test(fileName);
}