From 026eb28cf44b2ddb3bb7afde70c905693df62ca2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:09:04 +0100 Subject: [PATCH] fix: support standalone binary CLI compilation --- CHANGELOG.md | 10 +++ package.json | 2 +- src/cli/generate/artifacts.ts | 59 ++++++++++++++++- tests/cli-generate-cli.integration.test.ts | 73 ++++++++++++++++++++++ 4 files changed, 142 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d58524..a1dbd3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ - Nothing yet. +## [0.10.1] - 2026-05-04 + +### CLI + +- Fix Bun-compiled standalone binaries so `generate-cli --compile` can compile generated CLIs from empty directories by staging the matching published `mcporter` package dependencies when no local package tree is available. + +### Tests + +- Add an opt-in standalone Bun release-binary smoke for the empty-directory generated CLI compile path. + ## [0.10.0] - 2026-05-04 ### CLI diff --git a/package.json b/package.json index fff95dd..4de5534 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcporter", - "version": "0.10.0", + "version": "0.10.1", "description": "TypeScript runtime and CLI for connecting to configured Model Context Protocol servers.", "keywords": [ "cli", diff --git a/src/cli/generate/artifacts.ts b/src/cli/generate/artifacts.ts index b79a00c..3f3b0ea 100644 --- a/src/cli/generate/artifacts.ts +++ b/src/cli/generate/artifacts.ts @@ -5,6 +5,7 @@ import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { RolldownPlugin } from 'rolldown'; +import { MCPORTER_VERSION } from '../../runtime.js'; import { markExecutable, safeCopyFile } from './fs-helpers.js'; import { verifyBunAvailable } from './runtime.js'; @@ -110,7 +111,7 @@ async function bundleWithBun({ args.push('--minify'); } await new Promise((resolve, reject) => { - execFile(bunBin, args, { cwd: packageRoot, env: process.env }, (error) => { + execFile(bunBin, args, { cwd: stagingDir, env: process.env }, (error) => { if (error) { reject(error); return; @@ -255,6 +256,62 @@ async function ensureBundlerDeps(stagingDir: string): Promise { await linkOrCopyDependency(sourceDir, target); }) ); + const missing = await findMissingBundlerDeps(stagingDir); + if (missing.length > 0) { + await installPublishedBundlerDeps(stagingDir); + } +} + +async function findMissingBundlerDeps(stagingDir: string): Promise { + const missing: string[] = []; + for (const specifier of BUNDLED_DEPENDENCIES) { + const pkgPath = path.join(stagingDir, 'node_modules', specifier, 'package.json'); + try { + await fs.access(pkgPath); + } catch { + missing.push(specifier); + } + } + return missing; +} + +async function installPublishedBundlerDeps(stagingDir: string): Promise { + const installSpec = process.env.MCPORTER_BUNDLER_DEP_PACKAGE ?? MCPORTER_VERSION; + if (installSpec === '0.0.0-dev') { + throw new Error( + 'Unable to resolve generated-CLI bundler dependencies from this standalone mcporter binary. Install mcporter via npm/Homebrew or publish the matching mcporter package before using --compile.' + ); + } + await fs.writeFile( + path.join(stagingDir, 'package.json'), + JSON.stringify({ private: true, type: 'module', dependencies: { mcporter: installSpec } }, null, 2), + 'utf8' + ); + await new Promise((resolve, reject) => { + execFile( + 'npm', + ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--min-release-age=0'], + { cwd: stagingDir, env: process.env }, + (error) => { + if (error) { + reject( + new Error( + `Unable to install ${formatMcporterInstallSpec(installSpec)} dependencies needed for Bun compilation from this standalone binary. Install mcporter via npm/Homebrew, or ensure npm can reach the registry.\n\n${error.message}` + ) + ); + return; + } + resolve(); + } + ); + }); +} + +function formatMcporterInstallSpec(installSpec: string): string { + if (installSpec === MCPORTER_VERSION) { + return `mcporter@${MCPORTER_VERSION}`; + } + return `mcporter from ${installSpec}`; } function resolveDependencyDirectory(specifier: (typeof BUNDLED_DEPENDENCIES)[number]): string | undefined { diff --git a/tests/cli-generate-cli.integration.test.ts b/tests/cli-generate-cli.integration.test.ts index 8748e63..dd95a7a 100644 --- a/tests/cli-generate-cli.integration.test.ts +++ b/tests/cli-generate-cli.integration.test.ts @@ -863,4 +863,77 @@ await new Promise((resolve) => { transport.onclose = resolve; }); await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); await fs.rm(callerCwd, { recursive: true, force: true }).catch(() => {}); }, 30_000); + + it('compiles a generated CLI from the standalone Bun release binary in an empty directory', async () => { + if (process.env.MCPORTER_STANDALONE_BINARY_TEST !== '1') { + console.warn('set MCPORTER_STANDALONE_BINARY_TEST=1 to run standalone Bun release binary smoke'); + return; + } + if (!(await ensureBunSupport('standalone Bun release binary smoke'))) { + return; + } + await new Promise((resolve, reject) => { + execFile(PNPM_COMMAND, pnpmArgs(['build:bun']), { cwd: process.cwd(), env: process.env }, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-standalone-bun-')); + const binaryPath = path.join(tempDir, 'context7-cli'); + const mcporterBinary = path.join(process.cwd(), 'dist-bun', 'mcporter'); + const packedTarball = await new Promise((resolve, reject) => { + execFile( + 'npm', + ['pack', '--ignore-scripts', '--pack-destination', tempDir], + { cwd: process.cwd(), env: process.env }, + (error, stdout, stderr) => { + if (error) { + reject(new Error(`${error.message}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`)); + return; + } + resolve(path.join(tempDir, stdout.trim().split('\n').at(-1) ?? '')); + } + ); + }); + + await new Promise((resolve, reject) => { + execFile( + mcporterBinary, + ['generate-cli', '--command', baseUrl.toString(), '--compile', binaryPath], + { + cwd: tempDir, + env: { + ...process.env, + MCPORTER_BUNDLER_DEP_PACKAGE: packedTarball, + MCPORTER_NO_FORCE_EXIT: '1', + }, + }, + (error, stdout, stderr) => { + if (error) { + reject(new Error(`${error.message}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`)); + return; + } + resolve(); + } + ); + }); + + const helpOutput = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + execFile(binaryPath, [], { env: process.env }, (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + resolve({ stdout, stderr }); + }); + }); + expect(helpOutput.stdout).toMatch(/Usage: .+ \[options]/); + expect(helpOutput.stdout).toContain('ping - Simple health check'); + + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + }, 90_000); });