mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* fix(cli): restore bounded OpenTUI dialogs and add migration gates
* fix(cli): preserve runtime boundaries during standalone updates
Preserve Node/Bun archive flavor and validate actual CLI loading, package
identity, version and prepared installation identity before activation.
Keep Node npm transport and restrict Bun registry lookup to trusted config.
Integrates chiga0's update stack through ce67edaba5,
with isolated fixtures and real Node/Bun runtime regression coverage.
Co-authored-by: gary.gq <[email protected]>
* fix(cli): preserve OpenTUI input and attachment ownership
* test(cli): bound cancellation test waits and cleanup
* feat(cli): ship private Bun for opt-in npm OpenTUI sessions
* feat(cli): align OpenTUI status lines with Ink
* fix(cli): align OpenTUI editor and cancellation transactions
* fix(tui): harden migration cleanup and evidence after review
* test(tui): fail acceptance on unhandled errors
* fix(cli): validate standalone launchers and registry settings
* docs(cli): clarify consolidated OpenTUI delivery status
* test(cli): isolate system settings in TUI parity fixtures
* test(cli): isolate compile cache in standalone runtime checks
* test(cli): reduce standalone runtime fixture compression cost
* fix(cli): settle OpenTUI cancellation when replay resets a session
* feat(cli): align OpenTUI Vim, history and session output
Reuse the shared editing and history contracts, preserve cancellation ownership, and connect accepted interactive turns to the existing output bridge.
* style(cli): format merged OpenTUI test callbacks
* test(core): control terminal conversion in PTY parking regression
* test(cli): preserve visible input across session rotation
* test(cli): cover shell recall provenance and streaming search selection
* test(cli): await cancellation before releasing held response
* fix(cli): preserve imported history ownership and active search
* docs(cli): synchronize consolidated OpenTUI validation status
* test(cli): await AppContainer configuration cleanup
* test(cli): mock Vim clipboard imports before module loading
* test(cli): check standalone asset roots for npm subpaths
* test(cli): share clipboard mocks across module export views
* test(cli): verify finalized MOD parts and shared JSON projection
* fix(cli): reuse Ink editing history for OpenTUI Unicode input
* test(cli): keep native Vim input callbacks void
* test(cli): preserve Escape boundaries in Unicode Vim fixtures
* test(cli): read the current composer across Vim remounts
* test(cli): align Unicode deletion cursor with Ink oracle
* fix(cli): preserve completion and escape precedence and validate status colors
* fix(cli): use boundary selection for the OpenTUI composer
* fix(cli): preserve attachment navigation with one completion
* test(cli): preserve Shift in the native redo key sequence
* fix(cli): display session titles in the OpenTUI composer border
* fix(cli): discard generated command work after cancellation
* test(cli): align Unicode unit cursor with Ink oracle
* test(cli): model native selection key delegation
* fix(cli): preserve Vim Escape ownership across listeners
* fix(cli): preserve shared Vim command messages in OpenTUI
* test(cli): observe rendered Vim mode during parity checks
* docs(cli): record integrated OpenTUI validation checkpoint
* fix(ci): account for dedicated TUI parity coverage
* fix(cli): preserve collapsed paste payloads across Vim undo
* docs(cli): refresh OpenTUI distribution acceptance status
---------
Co-authored-by: gary.gq <[email protected]>
551 lines
18 KiB
JavaScript
551 lines
18 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';
|
|
import os from 'node:os';
|
|
import {
|
|
NPM_BUN_OPTIONAL_DEPENDENCIES,
|
|
NPM_OPENTUI_NATIVE_ASSETS,
|
|
prepareNpmOpenTuiAssets,
|
|
stageOpenTuiPackages,
|
|
} from './opentui-package-assets.js';
|
|
|
|
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 = 160 * 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,
|
|
opentuiModulesDir,
|
|
npmDistribution = true,
|
|
} = {}) {
|
|
const distDir = path.join(rootDir, 'dist');
|
|
|
|
verifyBundleArtifacts(rootDir, distDir);
|
|
if (npmDistribution) prepareNpmOpenTuiAssets(distDir, opentuiModulesDir);
|
|
copyDocumentationFiles(rootDir, distDir);
|
|
copyLocales(rootDir, distDir);
|
|
copyExtensionExamples(rootDir, distDir);
|
|
verifyNativeAudioCapturePackage(rootDir, distDir, {
|
|
required: requireNativeAudioCapture,
|
|
});
|
|
writeDistPackageJson(rootDir, distDir, npmDistribution);
|
|
assertNoSensitivePackageScanLiterals(distDir);
|
|
assertPreparedPackageSize(distDir, maxPackageUnpackedBytes);
|
|
printPackageStructure(distDir);
|
|
}
|
|
|
|
if (isDirectRun()) {
|
|
if (process.argv.includes('--host-runtime')) {
|
|
preparePackage({ npmDistribution: false });
|
|
} else {
|
|
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-npm-opentui-'));
|
|
try {
|
|
const opentuiModulesDir = stageOpenTuiPackages(
|
|
defaultRootDir,
|
|
staging,
|
|
Object.keys(NPM_OPENTUI_NATIVE_ASSETS),
|
|
);
|
|
preparePackage({ opentuiModulesDir });
|
|
} finally {
|
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
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, 'execution-worker.js'),
|
|
path.join(distDir, 'mem0', 'main.js'),
|
|
path.join(distDir, 'mem0', 'write-confirmation.js'),
|
|
path.join(distDir, 'generated-ui', 'main.js'),
|
|
path.join(distDir, 'generated-ui', 'app.html'),
|
|
path.join(distDir, 'generated-ui', 'skills', 'generated-ui', 'SKILL.md'),
|
|
path.join(distDir, 'generated-ui', 'THIRD_PARTY_NOTICES.txt'),
|
|
path.join(distDir, 'sandboxBwrapRelay.js'),
|
|
path.join(distDir, 'sandboxLandlockRelay.js'),
|
|
path.join(distDir, 'sandboxFileWorker.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, 'web-shell', 'manifest.webmanifest'),
|
|
path.join(distDir, 'web-shell', 'sw.js'),
|
|
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, npmDistribution) {
|
|
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'),
|
|
);
|
|
const coreManifest = JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(rootDir, 'packages', 'core', 'package.json'),
|
|
'utf-8',
|
|
),
|
|
);
|
|
// Resolve sharp the way core does at runtime — the nearest
|
|
// node_modules/sharp above packages/core — so the published manifest pins
|
|
// the version this release was built against, whichever package manager
|
|
// installed the tree.
|
|
let sharpVersion;
|
|
for (
|
|
let dir = path.join(rootDir, 'packages', 'core');
|
|
;
|
|
dir = path.dirname(dir)
|
|
) {
|
|
const manifest = path.join(dir, 'node_modules', 'sharp', 'package.json');
|
|
if (fs.existsSync(manifest)) {
|
|
sharpVersion = JSON.parse(fs.readFileSync(manifest, 'utf-8')).version;
|
|
break;
|
|
}
|
|
if (dir === rootDir || path.dirname(dir) === dir) break;
|
|
}
|
|
const declared = coreManifest.dependencies?.sharp;
|
|
if (!sharpVersion || !declared || !semver.satisfies(sharpVersion, declared)) {
|
|
throw new Error(
|
|
`sharp is not installed at a version packages/core accepts ` +
|
|
`(installed ${sharpVersion ?? 'none'}, ` +
|
|
`packages/core declares ${declared ?? 'none'})`,
|
|
);
|
|
}
|
|
// The six `@lydell/node-pty*` platform pins ship in the tarball's
|
|
// optionalDependencies and decide which bundled ConPTY `conpty.dll` /
|
|
// `OpenConsole.exe` real Windows users get. Derive them from
|
|
// packages/core/package.json — the single source conpty-host.ts verifies its
|
|
// release path against (and its pin tripwire asserts) — instead of a third
|
|
// hardcoded table, so a bump of the native backend in one manifest cannot
|
|
// silently ship an unverified pin here. Mirrors the sharp / audio-capture
|
|
// derivations above.
|
|
const nodePtyPins = Object.fromEntries(
|
|
Object.entries(coreManifest.optionalDependencies ?? {}).filter(([name]) =>
|
|
name.startsWith('@lydell/node-pty'),
|
|
),
|
|
);
|
|
|
|
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',
|
|
'execution-worker.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',
|
|
'glob-search-worker.js',
|
|
'codeModeHost.js',
|
|
'mod-worker.js',
|
|
'sandboxBwrapRelay.js',
|
|
'sandboxLandlockRelay.js',
|
|
'sandboxFileWorker.js',
|
|
'mem0',
|
|
'generated-ui',
|
|
'chunks',
|
|
'vendor',
|
|
'*.sb',
|
|
'README.md',
|
|
'LICENSE',
|
|
'locales',
|
|
'examples',
|
|
'bundled',
|
|
'web-shell',
|
|
'export-transcript-document.js',
|
|
'export-transcript-document.css',
|
|
...(npmDistribution
|
|
? [
|
|
'opentui-assets/@opentui/core',
|
|
'opentui-assets/web-tree-sitter',
|
|
...Object.keys(NPM_OPENTUI_NATIVE_ASSETS).map(
|
|
(name) => `opentui-assets/${name}`,
|
|
),
|
|
]
|
|
: []),
|
|
],
|
|
config: rootPackageJson.config,
|
|
dependencies: {},
|
|
optionalDependencies: {
|
|
...(npmDistribution ? NPM_BUN_OPTIONAL_DEPENDENCIES : {}),
|
|
'@qwen-code/audio-capture': rootPackageJson.version,
|
|
...nodePtyPins,
|
|
'@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`;
|
|
}
|