Add Bun binary build and improve list UX
This commit is contained in:
parent
0a8c4008e3
commit
d19f8eb6d8
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-bun
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
|
||||
11
README.md
11
README.md
@ -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
|
||||
|
||||
22
RELEASE.md
22
RELEASE.md
@ -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
|
||||
```
|
||||
|
||||
@ -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
128
scripts/build-bun.ts
Normal 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);
|
||||
});
|
||||
125
src/cli.ts
125
src/cli.ts
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user