From 0e819534b783880e638342b2b19bdc0d03e37a90 Mon Sep 17 00:00:00 2001 From: rowkav09 Date: Mon, 5 Oct 2026 06:03:07 +0100 Subject: [PATCH] fix: protect implicitly loaded config from output overwrite I retain the default config path when the file exists and include it in output safety checks, matching explicitly selected config files. I added a fail-first CLI regression for output targeting the directory containing the implicitly loaded project settings. --- src/cli.ts | 11 ++++++----- test/generate.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 3fa104c..559f0f2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,7 +17,7 @@ import { buildManifest, DEFAULT_31_DIALECT } from './manifest.js'; import { createMcpTransformer } from './transformer.js'; import { watchSpec } from './watch.js'; import type { ManifestOptions } from './manifest.js'; -import { readProjectConfig, resolveConfig, type ProjectConfig } from './config.js'; +import { CONFIG_FILE, readProjectConfig, resolveConfig, type ProjectConfig } from './config.js'; import { assertOutputDoesNotContainInputs } from './output-safety.js'; const VERSION = '0.1.0'; @@ -181,7 +181,8 @@ async function main(): Promise { } const spec = positionals[0]; if (!spec) fail(`missing argument\n\n${HELP}`); - const { options, config } = resolveConfig(await readProjectConfig(values.config), { name: values.name, baseUrl: values['base-url'], envPrefix: values['env-prefix'], overlays: values.overlay, include: values.include, exclude: values.exclude }); + const configPath = values.config ?? await access(CONFIG_FILE).then(() => CONFIG_FILE, () => undefined); + const { options, config } = resolveConfig(await readProjectConfig(configPath), { name: values.name, baseUrl: values['base-url'], envPrefix: values['env-prefix'], overlays: values.overlay, include: values.include, exclude: values.exclude }); const flags = { out: values.out, name: options.serverName, baseUrl: options.baseUrl, envPrefix: options.envPrefix, transport: values.transport, port: values.port, include: options.include, exclude: options.exclude }; if (flags.transport && !['stdio', 'http'].includes(flags.transport)) fail('--transport must be stdio or http'); if (command !== 'serve' && (flags.transport || flags.port)) fail('--transport and --port are only valid with serve'); @@ -194,17 +195,17 @@ async function main(): Promise { } if (values.watch && command === 'serve') fail('--watch is only supported by generate'); if (values.watch) { - if (flags.out) await assertOutputDoesNotContainInputs(flags.out, [{ label: 'spec', path: spec }, ...((config.overlays ?? []).map((path) => ({ label: 'overlay', path }))), { label: 'config', path: values.config }]); + if (flags.out) await assertOutputDoesNotContainInputs(flags.out, [{ label: 'spec', path: spec }, ...((config.overlays ?? []).map((path) => ({ label: 'overlay', path }))), { label: 'config', path: configPath }]); const seconds = values['poll-interval'] === undefined ? 30 : Number(values['poll-interval']); // Node clamps timer delays above 2^31-1 ms to 1 ms, which would poll the URL continuously (#298). if (!Number.isFinite(seconds) || seconds < MIN_POLL_SECONDS || seconds > MAX_POLL_SECONDS) fail(`--poll-interval must be between ${MIN_POLL_SECONDS} and ${MAX_POLL_SECONDS} seconds`); - const handle = await watchSpec(spec, () => cmdGenerate(spec, flags, config, values.config), { pollIntervalMs: seconds * 1000, additionalInputs: config.overlays ?? [], discoverInputs: () => localRefDependencies(spec) }); + const handle = await watchSpec(spec, () => cmdGenerate(spec, flags, config, configPath), { pollIntervalMs: seconds * 1000, additionalInputs: config.overlays ?? [], discoverInputs: () => localRefDependencies(spec) }); process.once('SIGINT', () => { handle.close(); process.exit(0); }); process.once('SIGTERM', () => { handle.close(); process.exit(0); }); } else if (command === 'serve') { await cmdServe(spec, flags, config); } else { - await cmdGenerate(spec, flags, config, values.config); + await cmdGenerate(spec, flags, config, configPath); } } diff --git a/test/generate.test.ts b/test/generate.test.ts index bd3077e..28804e9 100644 --- a/test/generate.test.ts +++ b/test/generate.test.ts @@ -283,3 +283,15 @@ test('overlay reference protection uses the spec folder when the overlay lives e assert.equal(await readFile(dependency, 'utf8'), '{}'); } finally { await rm(dir, { recursive: true, force: true }); } }); + +test('generation protects the implicitly loaded default config from overwrite', async () => { + const dir = await mkdtemp(join(tmpdir(), 'spec2mcp-default-config-')); + try { + const config = join(dir, 'spec2mcp.config.json'); + const original = JSON.stringify({ name: 'keep-my-settings', include: ['list*'] }); + await writeFile(config, original); + await assert.rejects(run(process.execPath, [TSX, CLI, 'generate', PETSTORE, '--out', dir], { cwd: dir }), + (error: unknown) => /output directory contains config input/i.test((error as { stderr: string }).stderr)); + assert.equal(await readFile(config, 'utf8'), original); + } finally { await rm(dir, { recursive: true, force: true }); } +});