fix: harden generated cli bundles
This commit is contained in:
parent
dd000bdec4
commit
46cc31cafe
12
CHANGELOG.md
12
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 <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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user