feat: add npx/bunx binary distribution support

Adds infrastructure for distributing Hapi via npm as a scoped package
(@twsxtd/hapi) with platform-specific optional dependencies. This enables
installation via `npx @twsxtd/hapi` or `bunx @twsxtd/hapi`.

- Add bin/hapi.js wrapper script for platform detection and binary selection
- Create npm/ directory structure for platform-specific packages (darwin-{arm64,x64},
  linux-{arm64,x64}, win32-x64)
- Add scripts/prepare-npm-packages.ts to generate platform-specific package.json files
  and copy binaries to npm packages
- Add scripts/publish-npm.ts for automated publishing of platform packages
- Update package.json with new scoped name, bin entry, optionalDependencies, and
  prepack/publish scripts
- Update .gitignore to ignore generated npm packages and build outputs
- Update .npmignore to only include bin/hapi.js wrapper, excluding source and build files
This commit is contained in:
weishu
2025-12-24 14:39:05 +08:00
parent bb9c5b66d3
commit 85c2dc10ea
12 changed files with 429 additions and 11 deletions
+177
View File
@@ -0,0 +1,177 @@
/**
* Prepare npm platform packages for publishing.
*
* This script:
* 1. Reads the version from cli/package.json
* 2. Generates package.json for each platform package
* 3. Copies binaries from dist-exe to npm package directories
* 4. Updates optionalDependencies versions in main package.json
*
* Run after `bun run build:exe:all`
*/
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, '..');
// Platform configurations
// Maps npm platform name to build target info
const PLATFORMS = [
{
name: 'darwin-arm64',
os: 'darwin',
cpu: 'arm64',
buildTarget: 'bun-darwin-arm64',
binName: 'hapi'
},
{
name: 'darwin-x64',
os: 'darwin',
cpu: 'x64',
buildTarget: 'bun-darwin-x64',
binName: 'hapi'
},
{
name: 'linux-arm64',
os: 'linux',
cpu: 'arm64',
buildTarget: 'bun-linux-arm64',
binName: 'hapi'
},
{
name: 'linux-x64',
os: 'linux',
cpu: 'x64',
buildTarget: 'bun-linux-x64',
binName: 'hapi'
},
{
name: 'win32-x64',
os: 'win32',
cpu: 'x64',
buildTarget: 'bun-windows-x64',
binName: 'hapi.exe'
}
] as const;
interface MainPackageJson {
version: string;
license?: string;
repository?: {
type: string;
url: string;
};
}
async function readMainPackageJson(): Promise<MainPackageJson> {
const pkgPath = join(projectRoot, 'package.json');
const content = await Bun.file(pkgPath).text();
return JSON.parse(content);
}
function generatePlatformPackageJson(
platform: typeof PLATFORMS[number],
mainPkg: MainPackageJson
): object {
return {
name: `@twsxtd/hapi-${platform.name}`,
version: mainPkg.version,
description: `hapi binary for ${platform.os} ${platform.cpu}`,
os: [platform.os],
cpu: [platform.cpu],
bin: {
hapi: `bin/${platform.binName}`
},
files: [`bin/${platform.binName}`],
license: mainPkg.license ?? 'MIT',
repository: mainPkg.repository
};
}
async function preparePlatform(
platform: typeof PLATFORMS[number],
mainPkg: MainPackageJson,
distExeDir: string,
npmDir: string
): Promise<void> {
const platformDir = join(npmDir, platform.name);
const binDir = join(platformDir, 'bin');
// Ensure bin directory exists
mkdirSync(binDir, { recursive: true });
// Generate package.json
const pkgJson = generatePlatformPackageJson(platform, mainPkg);
const pkgJsonPath = join(platformDir, 'package.json');
writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 4) + '\n');
console.log(`Generated: ${pkgJsonPath}`);
// Copy binary
const srcBin = join(distExeDir, platform.buildTarget, platform.binName);
const destBin = join(binDir, platform.binName);
if (!existsSync(srcBin)) {
console.warn(`Warning: Binary not found: ${srcBin}`);
console.warn(` Run 'bun run build:exe:all' first to build binaries.`);
return;
}
copyFileSync(srcBin, destBin);
console.log(`Copied: ${srcBin} -> ${destBin}`);
}
function updateMainPackageOptionalDeps(version: string): void {
const pkgPath = join(projectRoot, 'package.json');
const content = readFileSync(pkgPath, 'utf-8');
const pkg = JSON.parse(content);
// Update optionalDependencies versions
if (!pkg.optionalDependencies) {
pkg.optionalDependencies = {};
}
for (const platform of PLATFORMS) {
pkg.optionalDependencies[`@twsxtd/hapi-${platform.name}`] = version;
}
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
console.log(`Updated optionalDependencies in package.json to version ${version}`);
}
async function main(): Promise<void> {
console.log('Preparing npm platform packages...\n');
const mainPkg = await readMainPackageJson();
console.log(`Version: ${mainPkg.version}\n`);
// Update optionalDependencies in main package.json
updateMainPackageOptionalDeps(mainPkg.version);
const distExeDir = join(projectRoot, 'dist-exe');
const npmDir = join(projectRoot, 'npm');
let hasErrors = false;
for (const platform of PLATFORMS) {
try {
await preparePlatform(platform, mainPkg, distExeDir, npmDir);
} catch (error) {
console.error(`Error preparing ${platform.name}:`, error);
hasErrors = true;
}
}
console.log('\nDone!');
if (hasErrors) {
process.exit(1);
}
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
+144
View File
@@ -0,0 +1,144 @@
/**
* Publish all npm packages (platform packages + main package).
*
* Usage:
* bun run scripts/publish-npm.ts # Publish all packages
* bun run scripts/publish-npm.ts --dry-run # Preview without publishing
* bun run scripts/publish-npm.ts --skip-build # Skip building binaries
*
* Prerequisites:
* - npm login (must be logged in to npm)
* - For scoped packages: npm access must be set to public
*/
import { execSync, spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, '..');
const PLATFORMS = [
'darwin-arm64',
'darwin-x64',
'linux-arm64',
'linux-x64',
'win32-x64'
];
function parseArgs(): { dryRun: boolean; skipBuild: boolean } {
const args = process.argv.slice(2);
return {
dryRun: args.includes('--dry-run'),
skipBuild: args.includes('--skip-build')
};
}
function run(cmd: string, cwd: string = projectRoot): void {
console.log(`\n$ ${cmd}`);
execSync(cmd, { cwd, stdio: 'inherit' });
}
function checkNpmLogin(): boolean {
try {
const result = spawnSync('npm', ['whoami'], { encoding: 'utf-8' });
if (result.status === 0) {
console.log(`Logged in as: ${result.stdout.trim()}`);
return true;
}
} catch {
// ignore
}
return false;
}
function checkBinariesExist(): boolean {
const binaries = [
join(projectRoot, 'dist-exe', 'bun-darwin-arm64', 'hapi'),
join(projectRoot, 'dist-exe', 'bun-darwin-x64', 'hapi'),
join(projectRoot, 'dist-exe', 'bun-linux-arm64', 'hapi'),
join(projectRoot, 'dist-exe', 'bun-linux-x64', 'hapi'),
join(projectRoot, 'dist-exe', 'bun-windows-x64', 'hapi.exe')
];
for (const bin of binaries) {
if (!existsSync(bin)) {
return false;
}
}
return true;
}
function publishPackage(pkgDir: string, dryRun: boolean): void {
const cmd = dryRun
? 'npm publish --access public --dry-run'
: 'npm publish --access public';
run(cmd, pkgDir);
}
async function main(): Promise<void> {
const { dryRun, skipBuild } = parseArgs();
console.log('='.repeat(60));
console.log(dryRun ? ' DRY RUN - No packages will be published' : ' PUBLISHING PACKAGES');
console.log('='.repeat(60));
// Check npm login
console.log('\n[1/5] Checking npm login...');
if (!checkNpmLogin()) {
console.error('Error: Not logged in to npm. Run `npm login` first.');
process.exit(1);
}
// Build binaries
if (!skipBuild) {
console.log('\n[2/5] Building binaries for all platforms...');
run('bun run build:exe:all');
} else {
console.log('\n[2/5] Skipping build (--skip-build)');
if (!checkBinariesExist()) {
console.error('Error: Binaries not found. Run without --skip-build first.');
process.exit(1);
}
}
// Prepare npm packages
console.log('\n[3/5] Preparing npm packages...');
run('bun run prepare-npm-packages');
// Publish platform packages
console.log('\n[4/5] Publishing platform packages...');
for (const platform of PLATFORMS) {
const pkgDir = join(projectRoot, 'npm', platform);
console.log(`\nPublishing @twsxtd/hapi-${platform}...`);
try {
publishPackage(pkgDir, dryRun);
} catch (error) {
console.error(`Failed to publish @twsxtd/hapi-${platform}`);
throw error;
}
}
// Publish main package
console.log('\n[5/5] Publishing main package...');
console.log('\nPublishing @twsxtd/hapi...');
publishPackage(projectRoot, dryRun);
console.log('\n' + '='.repeat(60));
console.log(dryRun ? ' DRY RUN COMPLETE' : ' ALL PACKAGES PUBLISHED SUCCESSFULLY');
console.log('='.repeat(60));
if (!dryRun) {
const pkg = await Bun.file(join(projectRoot, 'package.json')).json();
console.log(`\nVersion ${pkg.version} published!`);
console.log('\nUsers can now run:');
console.log(' npx @twsxtd/hapi');
console.log(' bunx @twsxtd/hapi');
}
}
main().catch((error) => {
console.error('\nPublish failed:', error.message);
process.exit(1);
});