From 46cc31cafe1ac3d936358392a32a35624a67739b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 19:22:37 +0100 Subject: [PATCH] fix: harden generated cli bundles --- CHANGELOG.md | 12 +++++-- docs/RELEASE.md | 2 +- package.json | 4 +-- src/cli/generate-cli-runner.ts | 2 ++ src/cli/generate/artifacts.ts | 37 +++++++++++++++++-- src/cli/generate/output.ts | 4 ++- src/cli/generate/template-help.ts | 2 +- src/cli/generate/template.ts | 4 +-- src/generate-cli.ts | 5 ++- tests/cli-generate-runner.test.ts | 16 ++++++++- tests/generate-cli.test.ts | 60 +++++++++++++++++++++++++++++++ 11 files changed, 133 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a676a06..e36f068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 .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 `.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 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 00bad43..f199a1c 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -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-.tgz`. 1. Update `steipete/homebrew-tap` -> `Formula/mcporter.rb` with: - - URL `https://github.com/steipete/mcporter/releases/download/v/mcporter-.tgz` + - URL `https://github.com/openclaw/mcporter/releases/download/v/mcporter-.tgz` - SHA256 from `mcporter-.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. diff --git a/package.json b/package.json index 813abb2..f0b8ecd 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/cli/generate-cli-runner.ts b/src/cli/generate-cli-runner.ts index d6eecc9..cb18a72 100644 --- a/src/cli/generate-cli-runner.ts +++ b/src/cli/generate-cli-runner.ts @@ -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 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.', diff --git a/src/cli/generate/artifacts.ts b/src/cli/generate/artifacts.ts index a00b2f1..f3e1596 100644 --- a/src/cli/generate/artifacts.ts +++ b/src/cli/generate/artifacts.ts @@ -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, diff --git a/src/cli/generate/output.ts b/src/cli/generate/output.ts index 14cf956..13e5cc0 100644 --- a/src/cli/generate/output.ts +++ b/src/cli/generate/output.ts @@ -17,7 +17,9 @@ export async function performGenerateFromArtifact( export async function performGenerateFromRequest(request: GenerateCliOptions): Promise { 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}`); } diff --git a/src/cli/generate/template-help.ts b/src/cli/generate/template-help.ts index 853bf23..23e41f1 100644 --- a/src/cli/generate/template-help.ts +++ b/src/cli/generate/template-help.ts @@ -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(); diff --git a/src/cli/generate/template.ts b/src/cli/generate/template.ts index ea7a6c2..1cfaf5c 100644 --- a/src/cli/generate/template.ts +++ b/src/cli/generate/template.ts @@ -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'; diff --git a/src/generate-cli.ts b/src/generate-cli.ts index 72fa614..4922b90 100644 --- a/src/generate-cli.ts +++ b/src/generate-cli.ts @@ -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; diff --git a/tests/cli-generate-runner.test.ts b/tests/cli-generate-runner.test.ts index 17dd5c2..02193e2 100644 --- a/tests/cli-generate-runner.test.ts +++ b/tests/cli-generate-runner.test.ts @@ -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 '); + 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]); diff --git a/tests/generate-cli.test.ts b/tests/generate-cli.test.ts index 3d1dd75..2f44467 100644 --- a/tests/generate-cli.test.ts +++ b/tests/generate-cli.test.ts @@ -408,6 +408,66 @@ describeGenerateCli('generateCli', () => { expect(stdout).not.toContain(' 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',