fix: harden generated cli bundles

This commit is contained in:
Peter Steinberger 2026-05-14 19:22:37 +01:00
parent dd000bdec4
commit 46cc31cafe
No known key found for this signature in database
11 changed files with 133 additions and 15 deletions

View File

@ -1,8 +1,16 @@
# mcporter Changelog
## [0.11.1] - Unreleased
## [0.11.1] - 2026-05-14
- Nothing yet.
### CLI
- Make `generate-cli --runtime node --bundle <name>.mjs` emit an ES module bundle with a local `require` shim, fixing `.mjs` artifacts that previously crashed at startup.
- Classify generated `.mjs` and `.cjs` outputs as bundle artifacts in embedded metadata instead of reporting them as binaries.
- Avoid leaving implicit `<server>.ts` template files in the current directory when generating bundle-only artifacts without `--output`.
- Print generated CLI help with a trailing newline so subsequent shell output no longer glues onto the help footer.
- Point generated CLI metadata and npm package metadata at `openclaw/mcporter`.
- Document the existing `generate-cli --timeout`, `--minify`, and `--no-minify` flags in `generate-cli --help`.
- Suppress expected Rolldown unresolved-import warnings for Node built-ins during successful generated CLI bundling.
## [0.11.0] - 2026-05-14

View File

@ -70,7 +70,7 @@ Shipping a release means **all** of:
After the release is live, always update the Homebrew tap and re-verify both installers. The tap formula should install the npm `.tgz`, not the Bun-compiled macOS tarball, because `generate-cli --compile` needs the installed package tree so Bun can resolve `mcporter`, `commander`, and related dependencies when compiling from an empty directory. Keep the macOS tarball on the GitHub release as a direct binary asset, but point Homebrew at `mcporter-<version>.tgz`.
1. Update `steipete/homebrew-tap` -> `Formula/mcporter.rb` with:
- URL `https://github.com/steipete/mcporter/releases/download/v<version>/mcporter-<version>.tgz`
- URL `https://github.com/openclaw/mcporter/releases/download/v<version>/mcporter-<version>.tgz`
- SHA256 from `mcporter-<version>.tgz.sha256`
- `require "language/node"`, `depends_on "node"`, and `system "npm", "install", *std_npm_args, "--min-release-age=0"` so same-day releases with fresh npm dependencies can install immediately.
Refresh the tap README highlight so Homebrew users see the new version callout.

View File

@ -1,6 +1,6 @@
{
"name": "mcporter",
"version": "0.11.0",
"version": "0.11.1",
"description": "TypeScript runtime and CLI for connecting to configured Model Context Protocol servers.",
"keywords": [
"cli",
@ -12,7 +12,7 @@
"author": "Sweetistics",
"repository": {
"type": "git",
"url": "git+https://github.com/steipete/mcporter.git"
"url": "git+https://github.com/openclaw/mcporter.git"
},
"bin": {
"mcporter": "dist/cli.js"

View File

@ -99,6 +99,8 @@ export function printGenerateCliHelp(): void {
' --compile [path] Emit a Bun-compiled binary.',
' --runtime node|bun Runtime for generated code.',
' --bundler rolldown|bun Bundler for JavaScript output.',
' --timeout <ms> Discovery/call timeout in milliseconds.',
' --minify / --no-minify Toggle bundle minification.',
' --include-tools a,b Generate only these tools.',
' --exclude-tools a,b Omit these tools.',
' --dry-run Print regeneration command for --from.',

View File

@ -1,7 +1,7 @@
import { execFile } from 'node:child_process';
import fsSync from 'node:fs';
import fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import { builtinModules, createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { RolldownPlugin } from 'rolldown';
@ -16,6 +16,7 @@ const packageRoot = fileURLToPath(new URL('../../..', import.meta.url));
// even in empty temp dirs (fixes #1).
const BUNDLED_DEPENDENCIES = ['commander', 'mcporter', 'jsonc-parser'] as const;
const dependencyAliasPlugin = createLocalDependencyAliasPlugin([...BUNDLED_DEPENDENCIES]);
const NODE_BUILTIN_SPECIFIERS = new Set(builtinModules.flatMap((specifier) => [specifier, `node:${specifier}`]));
export async function bundleOutput({
sourcePath,
@ -70,20 +71,52 @@ async function bundleWithRolldown({
if (typeof (log as { code?: string }).code === 'string' && (log as { code?: string }).code === 'EVAL') {
return;
}
if (isExpectedNodeBuiltinWarning(log)) {
return;
}
handler(level, log);
},
});
const format = outputFormatForTarget(absTarget, runtimeKind);
await bundle.write({
file: absTarget,
format: runtimeKind === 'bun' ? 'esm' : 'cjs',
format,
codeSplitting: false,
sourcemap: false,
minify,
...(format === 'esm' ? { banner: buildEsmRequireBanner() } : {}),
});
await markExecutable(absTarget);
return absTarget;
}
function isExpectedNodeBuiltinWarning(log: unknown): boolean {
const record = log as { code?: string; message?: string };
if (record.code !== 'UNRESOLVED_IMPORT' || typeof record.message !== 'string') {
return false;
}
const match = record.message.match(/Could not resolve ['"]([^'"]+)['"]/);
return Boolean(match?.[1] && NODE_BUILTIN_SPECIFIERS.has(match[1]));
}
function buildEsmRequireBanner(): string {
return [
'import { createRequire as __mcporterCreateRequire } from "node:module";',
'const require = __mcporterCreateRequire(import.meta.url);',
].join('\n');
}
function outputFormatForTarget(targetPath: string, runtimeKind: 'node' | 'bun'): 'cjs' | 'esm' {
const extension = path.extname(targetPath).toLowerCase();
if (extension === '.mjs') {
return 'esm';
}
if (extension === '.cjs') {
return 'cjs';
}
return runtimeKind === 'bun' ? 'esm' : 'cjs';
}
async function bundleWithBun({
sourcePath,
targetPath,

View File

@ -17,7 +17,9 @@ export async function performGenerateFromArtifact(
export async function performGenerateFromRequest(request: GenerateCliOptions): Promise<void> {
const { outputPath, bundlePath, compilePath } = await generateCli(request);
console.log(`Generated CLI at ${outputPath}`);
if (request.outputPath || (!bundlePath && !compilePath)) {
console.log(`Generated CLI at ${outputPath}`);
}
if (bundlePath) {
console.log(`Bundled executable created at ${bundlePath}`);
}

View File

@ -37,7 +37,7 @@ function renderStandaloneHelp(): string {
\tif (generatorInfo) {
\t\tlines.push('', tint.extraDim(generatorInfo));
\t}
\treturn lines.join('\\n');
\treturn lines.join('\\n') + '\\n';
}
program.helpInformation = () => renderStandaloneHelp();

View File

@ -77,7 +77,7 @@ export function renderTemplate({
].join('\n');
const embedded = JSON.stringify(definition, (_key, value) => (value instanceof URL ? value.toString() : value), 2);
const relativeStdioCwd = computeRelativeStdioCwd(definition, runtimeScriptPath ?? outputPath);
const generatorHeader = `Generated by ${generator.name}@${generator.version} — https://github.com/steipete/mcporter`;
const generatorHeader = `Generated by ${generator.name}@${generator.version} — https://github.com/openclaw/mcporter`;
const toolDocs = tools.map((tool) => ({
tool,
doc: buildToolDoc({
@ -281,7 +281,7 @@ function determineArtifactKind(): 'template' | 'bundle' | 'binary' {
\tif (scriptPath.endsWith('.ts')) {
\t\treturn 'template';
\t}
\tif (scriptPath.endsWith('.js')) {
\tif (scriptPath.endsWith('.js') || scriptPath.endsWith('.mjs') || scriptPath.endsWith('.cjs')) {
\t\treturn 'bundle';
\t}
\treturn 'binary';

View File

@ -90,16 +90,15 @@ export async function generateCli(
invocation: baseInvocation,
};
const shouldBundle = Boolean(options.bundle ?? options.compile);
let templateTmpDir: string | undefined;
let templateOutputPath = options.outputPath;
if (!templateOutputPath && options.compile) {
if (!templateOutputPath && shouldBundle) {
const tmpPrefix = path.join(process.cwd(), 'tmp', 'mcporter-cli-');
await fs.mkdir(path.dirname(tmpPrefix), { recursive: true });
templateTmpDir = await fs.mkdtemp(tmpPrefix);
templateOutputPath = path.join(templateTmpDir, `${name}.ts`);
}
const shouldBundle = Boolean(options.bundle ?? options.compile);
const templateSourcePath = path.resolve(templateOutputPath ?? path.resolve(process.cwd(), `${name}.ts`));
let resolvedBundleTarget: string | undefined;
let resolvedCompileTarget: string | undefined;

View File

@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { parseGenerateFlags } from '../src/cli/generate/flags.js';
import { inferNameFromCommand } from '../src/cli/generate/name-utils.js';
import { normalizeDefinition } from '../src/cli/generate/definition.js';
import { buildGenerateCliCommand } from '../src/cli/generate/template-data.js';
import { printGenerateCliHelp } from '../src/cli/generate-cli-runner.js';
import { serializeDefinition } from '../src/cli-metadata.js';
import type { SerializedServerDefinition } from '../src/cli-metadata.js';
@ -25,6 +26,19 @@ describe('generate-cli runner internals', () => {
expect(parsed.minify).toBe(true);
});
it('documents timeout and minify flags in generate-cli help', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
let output = '';
try {
printGenerateCliHelp();
output = String(spy.mock.calls[0]?.[0] ?? '');
} finally {
spy.mockRestore();
}
expect(output).toContain('--timeout <ms>');
expect(output).toContain('--minify / --no-minify');
});
it('normalizes inferred names from URLs', () => {
const args = ['--command', 'https://api.linear.app/mcp.getComponents'];
const parsed = parseGenerateFlags([...args]);

View File

@ -408,6 +408,66 @@ describeGenerateCli('generateCli', () => {
expect(stdout).not.toContain('<tool> key=value');
}, 30_000);
it('runs node .mjs bundles without leaving an implicit template in cwd', async () => {
const inline = JSON.stringify({
name: 'integration-mjs',
description: 'MJS bundle integration server',
command: baseUrl.toString(),
});
const bundlePath = path.join(tmpDir, 'integration-mjs.mjs');
await fs.rm(bundlePath, { force: true });
await ensureDistBuilt();
const { outputPath: generated, bundlePath: bundled } = await generateCli({
serverRef: inline,
runtime: 'node',
timeoutMs: 5_000,
bundle: bundlePath,
});
expect(bundled).toBe(bundlePath);
expect(await exists(bundlePath)).toBe(true);
expect(await exists(generated)).toBe(false);
const { execFile } = await import('node:child_process');
const { stdout: helpStdout } = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFile(
process.execPath,
[bundlePath, '--help'],
execOptions(),
(error: import('node:child_process').ExecFileException | null, stdout: string, stderr: string) => {
if (error) {
reject(error);
return;
}
resolve({ stdout, stderr });
}
);
});
expect(helpStdout.endsWith('\n')).toBe(true);
expect(helpStdout).toContain('https://github.com/openclaw/mcporter');
const metadata = await readCliMetadata(bundlePath);
expect(metadata.artifact.kind).toBe('bundle');
expect(metadata.invocation.bundle).toBe(bundlePath);
const { stdout: callStdout } = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFile(
process.execPath,
[bundlePath, 'add', '--a', '20', '--b', '22', '--output', 'json'],
execOptions(),
(error: import('node:child_process').ExecFileException | null, stdout: string, stderr: string) => {
if (error) {
reject(error);
return;
}
resolve({ stdout, stderr });
}
);
});
expect(JSON.parse(callStdout)).toEqual({ result: 42 });
}, 60_000);
it('maps CLI options to Commander camelCase properties', async () => {
const inline = JSON.stringify({
name: 'case-options',