import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MCPORTER_VERSION } from '../src/runtime.js'; process.env.MCPORTER_DISABLE_AUTORUN = '1'; const cliModulePromise = import('../src/cli.js'); describe('mcporter CLI config fallback', () => { let tempDir: string; let originalCwd: string; let previousNoForceExit: string | undefined; beforeEach(async () => { originalCwd = process.cwd(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-config-')); process.chdir(tempDir); previousNoForceExit = process.env.MCPORTER_NO_FORCE_EXIT; process.env.MCPORTER_NO_FORCE_EXIT = '1'; }); afterEach(async () => { process.chdir(originalCwd); await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); process.exitCode = undefined; if (previousNoForceExit === undefined) { delete process.env.MCPORTER_NO_FORCE_EXIT; } else { process.env.MCPORTER_NO_FORCE_EXIT = previousNoForceExit; } }); it('lists servers even when the config directory is missing', async () => { const { runCli } = await cliModulePromise; const logs: string[] = []; const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { if (typeof value === 'string') { logs.push(value); } }); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); await expect(runCli(['config', 'list'])).resolves.not.toThrow(); expect(warnSpy).not.toHaveBeenCalled(); logSpy.mockRestore(); warnSpy.mockRestore(); }); it('warns once and continues when the default config is corrupt', async () => { const { runCli } = await cliModulePromise; await fs.mkdir(path.join(tempDir, 'config'), { recursive: true }); const configPath = path.join(tempDir, 'config', 'mcporter.json'); await fs.writeFile(configPath, '{ invalid : json', 'utf8'); const logs: string[] = []; const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { if (typeof value === 'string') { logs.push(value); } }); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); await expect(runCli(['config', 'list'])).resolves.not.toThrow(); expect(warnSpy).toHaveBeenCalledTimes(1); const message = warnSpy.mock.calls[0]?.[0]?.toString() ?? ''; expect(message).toContain('Ignoring config'); expect(message).toContain(configPath); logSpy.mockRestore(); warnSpy.mockRestore(); }); it('prints the doctor banner even when config is missing', async () => { const { runCli } = await cliModulePromise; const logs: string[] = []; const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { if (typeof value === 'string') { logs.push(value); } }); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); await expect(runCli(['config', 'doctor'])).resolves.not.toThrow(); expect(logs[0]).toBe(`MCPorter ${MCPORTER_VERSION}`); expect(warnSpy).not.toHaveBeenCalled(); logSpy.mockRestore(); warnSpy.mockRestore(); }); it('doctor warns once and keeps running when the config is corrupt', async () => { const { runCli } = await cliModulePromise; await fs.mkdir(path.join(tempDir, 'config'), { recursive: true }); const configPath = path.join(tempDir, 'config', 'mcporter.json'); await fs.writeFile(configPath, '{ not valid }', 'utf8'); const logs: string[] = []; const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { if (typeof value === 'string') { logs.push(value); } }); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); await expect(runCli(['config', 'doctor'])).resolves.not.toThrow(); expect(logs[0]).toBe(`MCPorter ${MCPORTER_VERSION}`); expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy.mock.calls[0]?.[0]).toContain(configPath); logSpy.mockRestore(); warnSpy.mockRestore(); }); }); describe('mcporter CLI with completely empty environment (ENOENT regression)', () => { let tempDir: string; let originalCwd: string; let previousNoForceExit: string | undefined; let homedirSpy: { mockRestore(): void } | undefined; let previousEnv: Record = {}; beforeEach(async () => { originalCwd = process.cwd(); // Create a completely empty temp directory - no config files anywhere tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-empty-env-')); process.chdir(tempDir); previousNoForceExit = process.env.MCPORTER_NO_FORCE_EXIT; process.env.MCPORTER_NO_FORCE_EXIT = '1'; // Mock homedir to an empty directory to ensure no home config exists homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tempDir); // Save and override env vars that could point to config files previousEnv = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, APPDATA: process.env.APPDATA, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, MCPORTER_CONFIG: process.env.MCPORTER_CONFIG, }; process.env.HOME = tempDir; process.env.USERPROFILE = tempDir; process.env.APPDATA = path.join(tempDir, 'AppData', 'Roaming'); process.env.XDG_CONFIG_HOME = path.join(tempDir, '.config'); delete process.env.MCPORTER_CONFIG; }); afterEach(async () => { homedirSpy?.mockRestore(); process.chdir(originalCwd); await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); process.exitCode = undefined; if (previousNoForceExit === undefined) { delete process.env.MCPORTER_NO_FORCE_EXIT; } else { process.env.MCPORTER_NO_FORCE_EXIT = previousNoForceExit; } // Restore env vars for (const [key, value] of Object.entries(previousEnv)) { if (value === undefined) { delete process.env[key]; } else { process.env[key] = value; } } }); it('list command succeeds with no config files anywhere (regression test)', async () => { // This is the key regression test: before the fix, this would crash with: // ENOENT: no such file or directory, open '.../config/mcporter.json' const { runCli } = await cliModulePromise; const logs: string[] = []; const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { if (typeof value === 'string') { logs.push(value); } }); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); // This should NOT throw - it should gracefully handle missing config await expect(runCli(['list'])).resolves.not.toThrow(); // Should not have any warnings about missing config (since it's optional) expect(warnSpy).not.toHaveBeenCalled(); // Should show "No MCP servers configured" or similar empty state const output = logs.join('\n'); expect(output).toContain('No MCP servers configured'); logSpy.mockRestore(); warnSpy.mockRestore(); errorSpy.mockRestore(); }); it('list --json succeeds with no config files anywhere', async () => { const { runCli } = await cliModulePromise; const logs: string[] = []; const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { if (typeof value === 'string') { logs.push(value); } }); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); await expect(runCli(['list', '--json'])).resolves.not.toThrow(); expect(warnSpy).not.toHaveBeenCalled(); // Should output valid JSON with empty servers array const jsonOutput = JSON.parse(logs[logs.length - 1] ?? '{}'); expect(jsonOutput).toHaveProperty('servers'); expect(jsonOutput.servers).toEqual([]); logSpy.mockRestore(); warnSpy.mockRestore(); }); it('config list succeeds with no config files anywhere', async () => { const { runCli } = await cliModulePromise; const logs: string[] = []; const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { if (typeof value === 'string') { logs.push(value); } }); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); await expect(runCli(['config', 'list'])).resolves.not.toThrow(); expect(warnSpy).not.toHaveBeenCalled(); logSpy.mockRestore(); warnSpy.mockRestore(); }); it('config doctor succeeds with no config files anywhere', async () => { const { runCli } = await cliModulePromise; const logs: string[] = []; const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { if (typeof value === 'string') { logs.push(value); } }); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); await expect(runCli(['config', 'doctor'])).resolves.not.toThrow(); expect(logs[0]).toBe(`MCPorter ${MCPORTER_VERSION}`); expect(warnSpy).not.toHaveBeenCalled(); logSpy.mockRestore(); warnSpy.mockRestore(); }); it('daemon start no-ops gracefully with no config files anywhere', async () => { const { runCli } = await cliModulePromise; const logs: string[] = []; const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { if (typeof value === 'string') { logs.push(value); } }); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); await expect(runCli(['daemon', 'start'])).resolves.not.toThrow(); expect(warnSpy).not.toHaveBeenCalled(); expect(logs.join('\n')).toContain('No MCP servers are configured for keep-alive; daemon not started.'); logSpy.mockRestore(); warnSpy.mockRestore(); }); });