Add Bun binary build and improve list UX

This commit is contained in:
Peter Steinberger 2025-11-06 05:21:04 +00:00
parent 0a8c4008e3
commit d19f8eb6d8
6 changed files with 246 additions and 42 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
node_modules
.DS_Store
dist
dist-bun
# Logs
npm-debug.log*

View File

@ -13,6 +13,8 @@ _TypeScript runtime + CLI generator for the Model Context Protocol._
## Installation
### npm / pnpm / yarn
```bash
pnpm add mcporter
# or
@ -21,6 +23,15 @@ yarn add mcporter
npm install mcporter
```
### Homebrew (macOS arm64)
```bash
brew tap steipete/tap
brew install steipete/tap/mcporter
```
> Note: Homebrew installation currently ships the Bun-compiled arm64 binary. Intel Macs should use the npm install method or Rosetta.
## Quick Start
```ts

View File

@ -5,8 +5,20 @@
3. pnpm check
4. pnpm test
5. pnpm build
6. npm pack --dry-run to inspect the tarball.
7. Verify git status is clean.
8. git commit && git push.
9. pnpm publish --tag latest
10. Tag the release (git tag v<version> && git push --tags).
6. pnpm build:bun
7. tar -C dist-bun -czf dist-bun/mcporter-macos-arm64-v<version>.tar.gz mcporter
8. shasum -a 256 dist-bun/mcporter-macos-arm64-v<version>.tar.gz
9. npm pack --dry-run to inspect the npm tarball.
10. Verify git status is clean.
11. git commit && git push.
12. pnpm publish --tag latest
13. Create a GitHub release, upload mcporter-macos-arm64-v<version>.tar.gz (with the SHA from step 8), and record the release URL.
14. Tag the release (git tag v<version> && git push --tags).
15. Update `steipete/homebrew-tap``Formula/mcporter.rb` with the new version, tarball URL, and SHA256; adjust tap README highlights if needed.
16. Commit and push the tap update.
17. Verify the Homebrew flow (after GitHub release assets propagate):
```bash
brew update
brew install steipete/tap/mcporter
mcporter list --help
```

View File

@ -21,6 +21,7 @@
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"build:bun": "bun scripts/build-bun.ts",
"check": "pnpm lint:biome && pnpm lint:oxlint && pnpm typecheck",
"lint": "pnpm check",
"lint:biome": "biome check",

128
scripts/build-bun.ts Normal file
View File

@ -0,0 +1,128 @@
#!/usr/bin/env bun
/**
* Build a self-contained mcporter binary using Bun's --compile mode.
* Mirrors the poltergeist release flow so we can dual-publish via npm + Homebrew.
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
interface Options {
readonly target?: string;
readonly bytecode: boolean;
readonly minify: boolean;
readonly output?: string;
}
function parseArgs(argv: string[]): Options {
let target: string | undefined;
let bytecode = false;
let minify = true;
let output: string | undefined;
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token) {
continue;
}
if (token === '--target') {
target = requireValue(token, argv[++index]);
continue;
}
if (token === '--bytecode') {
bytecode = true;
continue;
}
if (token === '--no-minify') {
minify = false;
continue;
}
if (token === '--output') {
output = requireValue(token, argv[++index]);
continue;
}
throw new Error(`Unknown flag "${token}"`);
}
return { target, bytecode, minify, output };
}
function requireValue(flag: string, value: string | undefined): string {
if (!value) {
throw new Error(`${flag} requires a value`);
}
return value;
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
const projectRoot = path.join(import.meta.dir, '..');
const distDir = path.join(projectRoot, 'dist-bun');
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
const outputPath = options.output
? path.resolve(options.output)
: path.join(distDir, options.target ? `mcporter-${options.target}` : 'mcporter');
const buildArgs = [
'build',
path.join(projectRoot, 'src/cli.ts'),
'--compile',
'--outfile',
outputPath,
];
if (options.minify) {
buildArgs.push('--minify');
}
if (options.bytecode) {
buildArgs.push('--bytecode');
}
if (options.target) {
buildArgs.push('--target', options.target);
}
console.log(`Building mcporter binary → ${outputPath}`);
const result = spawnSync('bun', buildArgs, { stdio: 'inherit' });
if (result.status !== 0) {
throw new Error(`bun build exited with status ${result.status ?? 'unknown'}`);
}
if (process.platform !== 'win32') {
fs.chmodSync(outputPath, 0o755);
}
try {
const sizeBytes = fs.statSync(outputPath).size;
const human = formatSize(sizeBytes);
console.log(`✅ Built ${outputPath} (${human})`);
} catch {
console.log(`✅ Built ${outputPath}`);
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 ** 2) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
if (bytes < 1024 ** 3) {
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
}
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
}
main().catch((error) => {
console.error('mcporter Bun build failed.');
if (error instanceof Error) {
console.error(error.message);
} else {
console.error(error);
}
process.exit(1);
});

View File

@ -7,9 +7,9 @@ import path from 'node:path';
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
import type { CliArtifactMetadata, SerializedServerDefinition } from './cli-metadata.js';
import { readCliMetadata } from './cli-metadata.js';
import type { ServerSource } from './config.js';
import type { ServerDefinition, ServerSource } from './config.js';
import { generateCli } from './generate-cli.js';
import { createRuntime } from './runtime.js';
import { createRuntime, type ServerToolInfo } from './runtime.js';
import {
createPrefixedConsoleLogger,
parseLogLevel,
@ -43,6 +43,26 @@ function logError(message: string, error?: unknown) {
activeLogger.error(message, error);
}
const forceColorRaw = process.env.FORCE_COLOR?.toLowerCase();
const forceDisableColor = forceColorRaw === '0' || forceColorRaw === 'false';
const forceEnableColor =
forceColorRaw === '1' || forceColorRaw === 'true' || forceColorRaw === '2' || forceColorRaw === '3';
const hasNoColor = process.env.NO_COLOR !== undefined;
const stdoutStream = process.stdout as NodeJS.WriteStream | undefined;
const supportsAnsiColor =
!hasNoColor && (forceEnableColor || (!forceDisableColor && Boolean(stdoutStream?.isTTY)));
function colorize(code: number, text: string): string {
if (!supportsAnsiColor) {
return text;
}
return `\u001B[${code}m${text}\u001B[0m`;
}
const dimText = (text: string): string => colorize(90, text);
const yellowText = (text: string): string => colorize(33, text);
const redText = (text: string): string => colorize(31, text);
// main parses CLI flags and dispatches to list/call commands.
async function main(): Promise<void> {
const argv = process.argv.slice(2);
@ -684,56 +704,38 @@ export async function handleList(runtime: Awaited<ReturnType<typeof createRuntim
console.log(`Listing ${servers.length} server(s) (per-server timeout: ${perServerTimeoutSeconds}s)`);
const results = await Promise.all(
servers.map(async (server) => {
const tasks = servers.map((server) => {
const task = (async (): Promise<ListSummaryResult> => {
const startedAt = Date.now();
try {
const tools = await withTimeout(runtime.listTools(server.name, { autoAuthorize: false }), perServerTimeoutMs);
const durationMs = Date.now() - startedAt;
const tools = await withTimeout(
runtime.listTools(server.name, { autoAuthorize: false }),
perServerTimeoutMs
);
return {
server,
status: 'ok' as const,
tools,
durationMs,
durationMs: Date.now() - startedAt,
};
} catch (error) {
const durationMs = Date.now() - startedAt;
return {
server,
status: 'error' as const,
error,
durationMs,
durationMs: Date.now() - startedAt,
};
}
})
);
})();
for (const result of results) {
const description = result.server.description ? `${result.server.description}` : '';
const durationSeconds = (result.durationMs / 1000).toFixed(1);
const sourceSuffix = formatSourceSuffix(result.server.source);
if (result.status === 'ok') {
const toolSuffix =
result.tools.length === 0
? 'no tools reported'
: `${result.tools.length === 1 ? '1 tool' : `${result.tools.length} tools`}`;
console.log(`- ${result.server.name}${description} (${toolSuffix}, ${durationSeconds}s)${sourceSuffix}`);
continue;
}
task.then((result) => {
printServerListResult(result, perServerTimeoutMs);
});
const { error } = result;
let note: string;
if (error instanceof UnauthorizedError) {
note = `auth required — run 'mcporter auth ${result.server.name}' to complete the OAuth flow`;
} else if (error instanceof Error && error.message === 'Timeout') {
note = `timed out after ${perServerTimeoutSeconds}s`;
} else if (error instanceof Error) {
note = error.message;
} else {
note = String(error);
}
console.log(`- ${result.server.name}${description} (${note}, ${durationSeconds}s)${sourceSuffix}`);
}
return task;
});
await Promise.all(tasks);
return;
}
@ -766,6 +768,53 @@ export async function handleList(runtime: Awaited<ReturnType<typeof createRuntim
}
}
type ListSummaryResult =
| {
status: 'ok';
server: ServerDefinition;
tools: ServerToolInfo[];
durationMs: number;
}
| {
status: 'error';
server: ServerDefinition;
error: unknown;
durationMs: number;
};
function printServerListResult(result: ListSummaryResult, timeoutMs: number): void {
const description = result.server.description ? `${result.server.description}` : '';
const durationLabel = dimText(`${(result.durationMs / 1000).toFixed(1)}s`);
const sourceSuffix = formatSourceSuffix(result.server.source);
const prefix = `- ${result.server.name}${description}`;
if (result.status === 'ok') {
const toolSuffix =
result.tools.length === 0
? 'no tools reported'
: `${result.tools.length === 1 ? '1 tool' : `${result.tools.length} tools`}`;
console.log(`${prefix} (${toolSuffix}, ${durationLabel})${sourceSuffix}`);
return;
}
const timeoutSeconds = Math.round(timeoutMs / 1000);
let formatter: (text: string) => string = redText;
let note: string;
const { error } = result;
if (error instanceof UnauthorizedError) {
note = `auth required — run 'mcporter auth ${result.server.name}' to complete the OAuth flow`;
formatter = yellowText;
} else if (error instanceof Error && error.message === 'Timeout') {
note = `timed out after ${timeoutSeconds}s`;
} else if (error instanceof Error) {
note = error.message;
} else {
note = String(error);
}
console.log(`${prefix} (${formatter(note)}, ${durationLabel})${sourceSuffix}`);
}
// handleCall invokes a tool, prints JSON, and optionally tails logs.
export async function handleCall(runtime: Awaited<ReturnType<typeof createRuntime>>, args: string[]): Promise<void> {
const parsed = parseCallArguments(args);
@ -859,7 +908,9 @@ function formatSourceSuffix(source: ServerSource | undefined, inline = false): s
return '';
}
const formatted = formatPathForDisplay(source.path);
return inline ? formatted : ` [source: ${formatted}]`;
const text = inline ? formatted : `[source: ${formatted}]`;
const tinted = dimText(text);
return inline ? tinted : ` ${tinted}`;
}
function formatPathForDisplay(filePath: string): string {