mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-09-10 19:01:58 +00:00
* perf(export): split the transcript renderer's embedded CSS into a versioned asset The export renderer carried the web-shell component stylesheet as a ~2.3 MB string literal, so every reader parsed and compiled 4.1 MB of JS (56% of it dead CSS) before a transcript could render. Lift that literal out at export build time into a version-pinned, SRI-protected export-transcript-document.css served from unpkg and loaded via a nonce-bearing <link>, dropping the renderer JS to ~1.83 MB. The transform is an esbuild onLoad plugin in the web-templates export build that strips the injected CSS constant from web-shell's dist/transcript.js; web-shell source and runtime behavior are untouched. The document's fail-closed load-error path is extended to the stylesheet so a missing CSS asset fails the same way as a missing renderer. * fix(export): match the transcript CSS entry on Windows paths too esbuild hands plugin callbacks the platform-native absolute path, so the extract-transcript-css `onLoad` filter never matched on Windows: the callback did not run, `extractedTranscriptCss.css` stayed undefined, and the mandatory extraction guard below aborted the build. That build is not platform-gated — `scripts/prepare.js` runs it from `prepare`, so `npm ci` itself would fail on every Windows contributor and on the windows-latest legs of test_windows and desktop-release. Widen the separator to `[\\/]`, keeping the `transcript\.js$` tail so the barred `web-shell/dist/index.js` package root still does not match. The filter moves to transcript-css-entry.mjs because build.mjs is a top-level-await script with no harness — the same reason scripts/sdk-node-exporter-stub.js exists — so scripts/tests/transcript-css-entry-filter.test.js can pin both separators. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-closeout/jmtufhms2u4 * fix(export): latch a stylesheet failure ahead of the parser block The `<link id="transcript-stylesheet">` sits in `<head>` while the `window` error listener that catches its failure is registered by an inline script in `<body>`. Chromium parser-blocks that script on the pending stylesheet, so when the CSS failure settles first the error event is dispatched with no listener to receive it: nothing marks the render as failed, both renderComplete guards in document-main.tsx pass, React mounts the transcript without any of the component CSS, and the requestAnimationFrame stamps `data-render-complete="true"`. The reviewer measured this fail-open above roughly 2.1 MB of document HTML (272 of the 1,000 permitted blocks) for a 404, an SRI rejection, a truncated body and a destroyed socket alike, and fail-closed for a *late* failure — so size, not failure kind, decides it. Latch the failure in `<head>` before the `<link>` is parsed and act on the latch from the existing body IIFE. The head script only records: `showLoadError()` writes `document.body.dataset` and `#app`, neither of which exists while the parser is still in `<head>`. It carries `nonce="__EXPORT_NONCE__"` because the document CSP allows no inline script, which is safe — `formatters/html.ts:53` replaces every occurrence. The listener is capture-phase because resource error events do not bubble. Not the `link.sheet === null` variant: the reviewer measured `sheet` non-null for a 404, a truncated body and a destroyed socket, so it only detects SRI rejection. scripts/tests/export-transcript-document-template.test.js pins the position, the nonce, the capture phase and the record-only shape; all five cases go red against the unpatched template. The behavioural witness (real Chromium, large document, instant CSS abort) belongs to the playwright transcript gate, which is out of budget on this host. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-closeout/jmtufhms2u4 * fix(scripts): name the missing export renderer assets, pin the CSS gate The bundle copy became all-or-nothing over two artifacts but its `else` warning still named only the renderer, so the one new way to reach that branch — a tree built before the split, then `npm run bundle`d without rebuilding web-templates, which has the JS and no CSS — told the operator to go looking for a `export-transcript-document.js` that was sitting right there, and silently discarded it. List the paths that are actually absent, matching the sibling Web Shell warning twenty lines above. Stays warn-and-skip: prepare-package.js is the release gate. Also pin that release gate. Every fixture that reached `preparePackage` staged `dist/export-transcript-document.css` unconditionally, so deleting the new required-path entry left the whole test:scripts lane green; a release built with `npm ci --ignore-scripts` would then publish documents whose stylesheet 404s on unpkg for that version. `verifyBundleArtifacts` reports through console.error + process.exit(1) rather than a throw, so the new case stubs exit instead of copying the audio-capture sibling's `toThrow` idiom. Both cases were flip-checked: restoring the old warning text, and deleting the CSS line from prepare-package.js, each turn their case red. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-closeout/jmtufhms2u4 * fix(export): pin the stylesheet-failure id contract and sync the design docs The <head> latch, the body listener and the <link> each spell 'transcript-stylesheet' independently and nothing compared them, so renaming either listener's id left the whole suite green while the latch recorded nothing - reinstating the fail-open the latch was added to close. Derive the id from the <link> and assert both listeners compare against it. Verified red under both mutations: latch id -> 'transcript-renderer' (1 failed | 5 passed), and the mirror with the body listener's id wrong and the latch intact (same). Both design docs still specified the two shapes the previous round replaced: the forward-slash-only onLoad filter that never matches on Windows, and the body-listener-only fail-closed extension. Section 1 now quotes the shipped TRANSCRIPT_CSS_ENTRY_FILTER and names transcript-css-entry.mjs, section 2 describes the <head> latch (position, nonce, capture phase, record-only), section 3 names the module-level render guard, and "Files affected" lists the three omitted files. EN and zh-CN are updated in the same commit. Also correct the shape-guard comment in build.mjs: the document nonces every <style> created through document.createElement, so the CSP would not block an un-stripped duplicate, and a 367-byte regrowth stays inside both byte budgets. That throw is the only guard on the duplicate-injection path. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-closeout/jmtuo29vduf * fix(export): close out transcript CSS review comments * fix(export): tighten transcript CSS closeout Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
492 lines
16 KiB
JavaScript
492 lines
16 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Prepares the bundled CLI package for npm publishing
|
|
* This script adds publishing metadata (package.json, README, LICENSE) to dist/
|
|
* All runtime assets (cli.js, vendor/, *.sb) are already in dist/ from the bundle step
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import semver from 'semver';
|
|
|
|
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)?$/;
|
|
// The docker-sandbox E2E leg builds its image by running this script, so an
|
|
// over-budget package turns main's E2E run red as well as blocking publishing.
|
|
const DEFAULT_MAX_NPM_PACKAGE_UNPACKED_BYTES = 128 * 1024 * 1024;
|
|
const PACKAGE_TEXT_FILE_RE =
|
|
/\.(?:[cm]?[jt]sx?|json|md|html|css|txt|ya?ml|sh|svg|map)$/i;
|
|
const PACKAGE_SCAN_FORBIDDEN_LITERALS = [
|
|
['chrome', 'devtools', 'mcp'].join('-'),
|
|
['puppeteer', 'core'].join('-'),
|
|
['oastify', 'com'].join('.'),
|
|
['webhook', 'site'].join('.'),
|
|
['ngrok', 'io'].join('.'),
|
|
['ngrok-free', 'app'].join('.'),
|
|
];
|
|
|
|
export function preparePackage({
|
|
rootDir = defaultRootDir,
|
|
requireNativeAudioCapture = process.env
|
|
.QWEN_REQUIRE_AUDIO_CAPTURE_PREBUILD === '1',
|
|
maxPackageUnpackedBytes = DEFAULT_MAX_NPM_PACKAGE_UNPACKED_BYTES,
|
|
} = {}) {
|
|
const distDir = path.join(rootDir, 'dist');
|
|
|
|
verifyBundleArtifacts(rootDir, distDir);
|
|
copyDocumentationFiles(rootDir, distDir);
|
|
copyLocales(rootDir, distDir);
|
|
copyExtensionExamples(rootDir, distDir);
|
|
verifyNativeAudioCapturePackage(rootDir, distDir, {
|
|
required: requireNativeAudioCapture,
|
|
});
|
|
writeDistPackageJson(rootDir, distDir);
|
|
assertNoSensitivePackageScanLiterals(distDir);
|
|
assertPreparedPackageSize(distDir, maxPackageUnpackedBytes);
|
|
printPackageStructure(distDir);
|
|
}
|
|
|
|
if (isDirectRun()) {
|
|
preparePackage();
|
|
}
|
|
|
|
function isDirectRun() {
|
|
return process.argv[1]
|
|
? fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
|
|
: false;
|
|
}
|
|
|
|
function verifyBundleArtifacts(rootDir, distDir) {
|
|
const requiredPaths = [
|
|
path.join(distDir, 'cli.js'),
|
|
path.join(distDir, 'vendor'),
|
|
path.join(distDir, 'bundled', 'qc-helper', 'docs'),
|
|
// The Web Shell ships with the published package ("Web Shell out of the
|
|
// box"). Gate on it here so a build that skipped the web-shell workspace
|
|
// (e.g. `npm ci --ignore-scripts` bypassing the root `prepare`) fails
|
|
// loudly during packaging instead of silently publishing an API-only CLI
|
|
// whose `GET /` 404s. copy_bundle_assets.js stays warn-and-skip for
|
|
// --cli-only dev bundles; this is the release gate.
|
|
path.join(distDir, 'web-shell', 'index.html'),
|
|
path.join(distDir, 'web-shell', 'assets'),
|
|
path.join(distDir, 'export-transcript-document.js'),
|
|
path.join(distDir, 'export-transcript-document.css'),
|
|
];
|
|
|
|
if (!fs.existsSync(distDir)) {
|
|
console.error('Error: dist/ directory not found');
|
|
console.error('Please run "npm run bundle" first');
|
|
process.exit(1);
|
|
}
|
|
|
|
for (const requiredPath of requiredPaths) {
|
|
if (!fs.existsSync(requiredPath)) {
|
|
console.error(
|
|
`Error: Required package artifact not found: ${requiredPath}`,
|
|
);
|
|
console.error('Please run "npm run bundle" first');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function copyDocumentationFiles(rootDir, distDir) {
|
|
console.log('Copying documentation files...');
|
|
const filesToCopy = ['README.md', 'LICENSE'];
|
|
for (const file of filesToCopy) {
|
|
const sourcePath = path.join(rootDir, file);
|
|
const destPath = path.join(distDir, file);
|
|
if (fs.existsSync(sourcePath)) {
|
|
fs.copyFileSync(sourcePath, destPath);
|
|
console.log(`Copied ${file}`);
|
|
} else {
|
|
console.warn(`Warning: ${file} not found at ${sourcePath}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function copyLocales(rootDir, distDir) {
|
|
console.log('Copying locales folder...');
|
|
const localesSourceDir = path.join(
|
|
rootDir,
|
|
'packages',
|
|
'cli',
|
|
'src',
|
|
'i18n',
|
|
'locales',
|
|
);
|
|
const localesDestDir = path.join(distDir, 'locales');
|
|
|
|
if (fs.existsSync(localesSourceDir)) {
|
|
copyRecursiveSync(localesSourceDir, localesDestDir);
|
|
console.log('Copied locales folder');
|
|
} else {
|
|
console.warn(`Warning: locales folder not found at ${localesSourceDir}`);
|
|
}
|
|
}
|
|
|
|
function copyExtensionExamples(rootDir, distDir) {
|
|
console.log('Copying extension examples folder...');
|
|
const extensionExamplesDir = path.join(
|
|
rootDir,
|
|
'packages',
|
|
'cli',
|
|
'src',
|
|
'commands',
|
|
'extensions',
|
|
'examples',
|
|
);
|
|
const extensionExamplesDestDir = path.join(distDir, 'examples');
|
|
|
|
if (fs.existsSync(extensionExamplesDir)) {
|
|
copyRecursiveSync(extensionExamplesDir, extensionExamplesDestDir);
|
|
console.log('Copied extension examples folder');
|
|
} else {
|
|
console.warn(
|
|
`Warning: extension examples folder not found at ${extensionExamplesDir}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function verifyNativeAudioCapturePackage(rootDir, distDir, { required } = {}) {
|
|
console.log('Verifying 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;
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
const addonRequire = createRequire(path.join(addonSrc, 'package.json'));
|
|
for (const dependencyName of Object.keys(addonPkg.dependencies ?? {})) {
|
|
try {
|
|
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;
|
|
}
|
|
}
|
|
|
|
console.log('Verified native audio capture package');
|
|
}
|
|
|
|
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) {
|
|
console.log('Creating package.json for distribution...');
|
|
|
|
const cliEntryPath = path.join(distDir, 'cli-entry.js');
|
|
fs.copyFileSync(path.join(__dirname, 'cli-entry.js'), cliEntryPath);
|
|
fs.chmodSync(cliEntryPath, 0o755);
|
|
console.log('Created dist cli-entry.js wrapper');
|
|
|
|
const rootPackageJson = JSON.parse(
|
|
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'),
|
|
);
|
|
let lockfile;
|
|
try {
|
|
lockfile = JSON.parse(
|
|
fs.readFileSync(path.join(rootDir, 'package-lock.json'), 'utf-8'),
|
|
);
|
|
} catch (error) {
|
|
throw new Error(`Cannot read package-lock.json: ${error.message}`);
|
|
}
|
|
const coreManifest = JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(rootDir, 'packages', 'core', 'package.json'),
|
|
'utf-8',
|
|
),
|
|
);
|
|
const sharpVersion =
|
|
lockfile.packages?.['packages/core/node_modules/sharp']?.version ??
|
|
lockfile.packages?.['node_modules/sharp']?.version;
|
|
const declared = coreManifest.dependencies?.sharp;
|
|
if (!sharpVersion || !declared || !semver.satisfies(sharpVersion, declared)) {
|
|
throw new Error(
|
|
`sharp version is not locked in package-lock.json ` +
|
|
`(resolved ${sharpVersion ?? 'none'}, ` +
|
|
`packages/core declares ${declared ?? 'none'})`,
|
|
);
|
|
}
|
|
|
|
const distPackageJson = {
|
|
name: rootPackageJson.name,
|
|
version: rootPackageJson.version,
|
|
description:
|
|
rootPackageJson.description || 'Qwen Code - AI-powered coding assistant',
|
|
repository: rootPackageJson.repository,
|
|
type: 'module',
|
|
main: 'cli.js',
|
|
bin: {
|
|
qwen: 'cli-entry.js',
|
|
},
|
|
files: [
|
|
'cli-entry.js',
|
|
'cli.js',
|
|
// Worker thread entry loaded by FzfWorkerHandle at runtime via
|
|
// `resolveBundleDir(import.meta.url)` + `path.join(dir, 'fzfWorker.js')`.
|
|
// Must ship in the tarball or the @-picker silently falls back to the
|
|
// in-thread AsyncFzf path on big workspaces in npm-installed CLIs.
|
|
'fzfWorker.js',
|
|
'chunks',
|
|
'vendor',
|
|
'*.sb',
|
|
'README.md',
|
|
'LICENSE',
|
|
'locales',
|
|
'examples',
|
|
'bundled',
|
|
'web-shell',
|
|
'export-transcript-document.js',
|
|
'export-transcript-document.css',
|
|
// OpenTUI renderer runtime assets (tree-sitter grammars, parser worker,
|
|
// web-tree-sitter wasm, native render library) are intentionally NOT
|
|
// published in the npm package — a multi-megabyte tree dominated by the
|
|
// native render library — against the unpacked-size budget. An npm
|
|
// install also has no @opentui/core dependency to resolve the assets
|
|
// against: below the runtime floor (Node < 26.4.0) the renderer gate
|
|
// never selects opentui, and at or above it, QWEN_TUI_RENDERER=opentui
|
|
// selects the renderer, the boot fails on the missing asset tree, and
|
|
// a stderr warning falls back to ink on every startup. The opt-in Bun
|
|
// standalone flavor (--runtime=bun) carries the assets via
|
|
// create-standalone-package.js instead.
|
|
],
|
|
config: rootPackageJson.config,
|
|
dependencies: {},
|
|
optionalDependencies: {
|
|
'@qwen-code/audio-capture': rootPackageJson.version,
|
|
'@lydell/node-pty': '1.2.0-beta.10',
|
|
'@lydell/node-pty-darwin-arm64': '1.2.0-beta.10',
|
|
'@lydell/node-pty-darwin-x64': '1.2.0-beta.10',
|
|
'@lydell/node-pty-linux-x64': '1.2.0-beta.10',
|
|
'@lydell/node-pty-win32-arm64': '1.2.0-beta.10',
|
|
'@lydell/node-pty-win32-x64': '1.2.0-beta.10',
|
|
'@teddyzhu/clipboard': '0.0.5',
|
|
'@teddyzhu/clipboard-darwin-arm64': '0.0.5',
|
|
'@teddyzhu/clipboard-darwin-x64': '0.0.5',
|
|
'@teddyzhu/clipboard-linux-x64-gnu': '0.0.5',
|
|
'@teddyzhu/clipboard-linux-arm64-gnu': '0.0.5',
|
|
'@teddyzhu/clipboard-win32-x64-msvc': '0.0.5',
|
|
'@teddyzhu/clipboard-win32-arm64-msvc': '0.0.5',
|
|
// sharp is a native module externalized in esbuild. Declaring sharp alone
|
|
// is sufficient: its own optionalDependencies pull in the matching @img
|
|
// platform binary for every OS/arch npm installs onto, so the platform
|
|
// packages are not pinned here (pinning them drifts on a sharp bump).
|
|
// The version is exact-pinned like all other native optional deps in this
|
|
// manifest — a project-wide convention that keeps the published tarball
|
|
// reproducible. Sharp's recurring CVE stream means users must wait for a
|
|
// CLI release to pick up libvips fixes; nightly releases keep the
|
|
// turnaround short.
|
|
sharp: sharpVersion,
|
|
},
|
|
engines: rootPackageJson.engines,
|
|
};
|
|
|
|
fs.writeFileSync(
|
|
path.join(distDir, 'package.json'),
|
|
JSON.stringify(distPackageJson, null, 2) + '\n',
|
|
);
|
|
}
|
|
|
|
function assertNoSensitivePackageScanLiterals(distDir) {
|
|
for (const filePath of listTextPackageFiles(distDir)) {
|
|
const contents = fs.readFileSync(filePath, 'utf8');
|
|
const lowerContents = contents.toLowerCase();
|
|
for (const literal of PACKAGE_SCAN_FORBIDDEN_LITERALS) {
|
|
if (!lowerContents.includes(literal.toLowerCase())) continue;
|
|
const relativePath = path.relative(distDir, filePath);
|
|
throw new Error(
|
|
`Prepared package contains forbidden string "${literal}" in ${relativePath}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function listTextPackageFiles(dir) {
|
|
const files = [];
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const entryPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...listTextPackageFiles(entryPath));
|
|
} else if (entry.isFile() && PACKAGE_TEXT_FILE_RE.test(entry.name)) {
|
|
files.push(entryPath);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function assertPreparedPackageSize(distDir, maxUnpackedBytes) {
|
|
const packageFiles = collectPreparedPackageFiles(distDir);
|
|
let unpackedBytes = 0;
|
|
for (const filePath of packageFiles) {
|
|
unpackedBytes += fs.statSync(filePath).size;
|
|
}
|
|
if (unpackedBytes <= maxUnpackedBytes) return;
|
|
throw new Error(
|
|
`Prepared package unpacked size ${unpackedBytes} bytes exceeds ${maxUnpackedBytes} bytes`,
|
|
);
|
|
}
|
|
|
|
function collectPreparedPackageFiles(distDir) {
|
|
const distPackageJson = JSON.parse(
|
|
fs.readFileSync(path.join(distDir, 'package.json'), 'utf8'),
|
|
);
|
|
const packageFiles = new Set([path.join(distDir, 'package.json')]);
|
|
|
|
for (const entry of distPackageJson.files ?? []) {
|
|
if (entry === '*.sb') {
|
|
for (const fileName of fs.readdirSync(distDir)) {
|
|
if (fileName.endsWith('.sb')) {
|
|
packageFiles.add(path.join(distDir, fileName));
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const entryPath = path.join(distDir, entry);
|
|
if (!fs.existsSync(entryPath)) continue;
|
|
collectFiles(entryPath, packageFiles);
|
|
}
|
|
|
|
return packageFiles;
|
|
}
|
|
|
|
function collectFiles(entryPath, output) {
|
|
const stat = fs.statSync(entryPath);
|
|
if (stat.isDirectory()) {
|
|
for (const entry of fs.readdirSync(entryPath)) {
|
|
collectFiles(path.join(entryPath, entry), output);
|
|
}
|
|
} else if (stat.isFile()) {
|
|
output.add(entryPath);
|
|
}
|
|
}
|
|
|
|
function printPackageStructure(distDir) {
|
|
console.log('\n✅ Package prepared for publishing at dist/');
|
|
console.log('\nPackage structure:');
|
|
// Use Node.js to list directory contents (cross-platform)
|
|
const distFiles = fs.readdirSync(distDir);
|
|
for (const file of distFiles) {
|
|
const filePath = path.join(distDir, file);
|
|
const stats = fs.statSync(filePath);
|
|
const size = stats.isDirectory() ? '<DIR>' : formatBytes(stats.size);
|
|
console.log(` ${size.padEnd(12)} ${file}`);
|
|
}
|
|
}
|
|
|
|
function copyRecursiveSync(src, dest) {
|
|
const stats = fs.statSync(src);
|
|
if (stats.isDirectory()) {
|
|
if (!fs.existsSync(dest)) {
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
}
|
|
const entries = fs.readdirSync(src);
|
|
for (const entry of entries) {
|
|
const srcPath = path.join(src, entry);
|
|
const destPath = path.join(dest, entry);
|
|
copyRecursiveSync(srcPath, destPath);
|
|
}
|
|
} else {
|
|
fs.copyFileSync(src, dest);
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes < 1024) return `${bytes}B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
}
|