Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: implement update command and improve error handling #55

Merged
merged 4 commits into from
Dec 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"test:list": "node --loader ts-node/esm src/index.ts list",
"test:install": "node --loader ts-node/esm src/index.ts install",
"test:installed": "node --loader ts-node/esm src/index.ts installed",
"test:update": "node --loader ts-node/esm src/index.ts update",
"extract": "node --loader ts-node/esm src/extractors/modelcontextprotocol-extractor.ts",
"test:uninstall": "node --loader ts-node/esm src/index.ts uninstall",
"version:patch": "npm version patch",
Expand Down
134 changes: 134 additions & 0 deletions src/__tests__/auto-update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { jest } from '@jest/globals';
import { describe, it, expect, beforeEach } from '@jest/globals';
import type { ExecOptions, ChildProcess, ExecException } from 'child_process';

// Type definitions
type ExecResult = { stdout: string; stderr: string };

// Setup mocks
const mockExecPromise = jest.fn().mockName('execPromise') as jest.MockedFunction<
(command: string) => Promise<ExecResult>
>;

// Create a properly typed mock for exec
const mockExec = jest.fn((
command: string,
options: ExecOptions | undefined | null,
callback?: (error: ExecException | null, stdout: string, stderr: string) => void
): ChildProcess => {
return {
on: jest.fn(),
stdout: { on: jest.fn() },
stderr: { on: jest.fn() }
} as unknown as ChildProcess;
});

// Mock chalk module
await jest.unstable_mockModule('chalk', () => ({
default: {
yellow: jest.fn(str => str),
cyan: jest.fn(str => str),
green: jest.fn(str => str),
red: jest.fn(str => str),
}
}));

// Mock child_process module
await jest.unstable_mockModule('child_process', () => ({
exec: mockExec
}));

// Mock util module
await jest.unstable_mockModule('util', () => ({
promisify: jest.fn(() => mockExecPromise)
}));

// Mock fs module
await jest.unstable_mockModule('fs', () => ({
readFileSync: jest.fn(() => JSON.stringify({ version: '1.0.48' }))
}));

// Import after mocking
const { updatePackage } = await import('../auto-update.js');

// Helper to create exec result
const createExecResult = (stdout: string, stderr: string = ''): ExecResult => ({ stdout, stderr });

describe('updatePackage', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

it('should check for updates and install if available', async () => {
mockExecPromise
.mockResolvedValueOnce(createExecResult('1.0.50\n'))
.mockResolvedValueOnce(createExecResult('success'));

await updatePackage();

expect(mockExecPromise).toHaveBeenNthCalledWith(1, 'npm show @michaellatman/mcp-get version');
expect(mockExecPromise).toHaveBeenNthCalledWith(2, 'npm install -g @michaellatman/mcp-get@latest');

expect(console.log).toHaveBeenNthCalledWith(1,
'\nA new version of mcp-get is available: 1.0.50 (current: 1.0.48)'
);
expect(console.log).toHaveBeenNthCalledWith(2,
'Installing update...'
);
expect(console.log).toHaveBeenNthCalledWith(3,
'success'
);
expect(console.log).toHaveBeenNthCalledWith(4,
'✓ Update complete\n'
);
});

it('should handle version check errors gracefully', async () => {
const error = new Error('Failed to check version');
mockExecPromise.mockRejectedValueOnce(error);

await updatePackage();

expect(console.error).toHaveBeenCalledWith(
'Failed to check for updates:',
'Failed to check version'
);
});

it('should handle installation errors gracefully', async () => {
mockExecPromise
.mockResolvedValueOnce(createExecResult('1.0.50\n'))
.mockRejectedValueOnce(new Error('Installation failed'));

await updatePackage();

expect(console.error).toHaveBeenCalledWith(
'Failed to install update:',
'Installation failed'
);
});

describe('silent mode', () => {
it('should not log messages in silent mode when update is available', async () => {
mockExecPromise
.mockResolvedValueOnce(createExecResult('1.0.50\n'))
.mockResolvedValueOnce(createExecResult('success'));

await updatePackage(true);
expect(console.log).not.toHaveBeenCalled();
});

it('should not log errors in silent mode on failure', async () => {
mockExecPromise.mockRejectedValueOnce(new Error('Failed to check version'));

await updatePackage(true);
expect(console.error).not.toHaveBeenCalled();
});
});
});
49 changes: 34 additions & 15 deletions src/auto-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import chalk from 'chalk';
const execAsync = promisify(exec);

async function getCurrentVersion(): Promise<string> {
const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
const packageJsonPath = new URL('../package.json', import.meta.url).pathname;
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
return packageJson.version;
}

Expand All @@ -16,26 +17,44 @@ async function getLatestVersion(): Promise<string> {
return stdout.trim();
}

export async function updatePackage(): Promise<void> {
export async function updatePackage(silent: boolean = false): Promise<void> {
try {
const currentVersion = await getCurrentVersion();
const latestVersion = await getLatestVersion();

if (currentVersion !== latestVersion) {
console.log(chalk.yellow(`\nA new version of mcp-get is available: ${latestVersion} (current: ${currentVersion})`));
console.log(chalk.cyan('Installing update...'));

// Use npx to ensure we get the latest version
await execAsync('npx --yes @michaellatman/mcp-get@latest');

console.log(chalk.green('✓ Update complete\n'));

// Exit after update to ensure the new version is used
process.exit(0);
if (!silent) {
console.log(chalk.yellow(`\nA new version of mcp-get is available: ${latestVersion} (current: ${currentVersion})`));
console.log(chalk.cyan('Installing update...'));
}

try {
const { stdout, stderr } = await execAsync('npm install -g @michaellatman/mcp-get@latest');
if (!silent) {
if (stdout) console.log(stdout);
if (stderr) console.error(chalk.yellow('Update process output:'), stderr);
console.log(chalk.green('✓ Update complete\n'));
}
} catch (installError: any) {
if (!silent) {
console.error(chalk.red('Failed to install update:'), installError.message);
if (installError.stdout) console.log('stdout:', installError.stdout);
if (installError.stderr) console.error('stderr:', installError.stderr);
console.error(chalk.yellow('Try running the update manually with sudo:'));
console.error(chalk.cyan(' sudo npm install -g @michaellatman/mcp-get@latest'));
}
return;
}
} else {
if (!silent) console.log(chalk.green('✓ mcp-get is already up to date\n'));
}
} catch (error: any) {
if (!silent) {
console.error(chalk.red('Failed to check for updates:'), error.message);
if (error.stdout) console.log('stdout:', error.stdout);
if (error.stderr) console.error('stderr:', error.stderr);
}
} catch (error) {
// Log update check failure but continue with execution
console.log(chalk.yellow('\nFailed to check for updates. Continuing with current version.'));
return;
}
}

5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { list } from './commands/list.js';
import { install } from './commands/install.js';
import { uninstall } from './commands/uninstall.js';
import { listInstalledPackages } from './commands/installed.js';
import { updatePackage } from './auto-update.js';

const command = process.argv[2];
const packageName = process.argv[3];
Expand All @@ -26,12 +27,16 @@ async function main() {
case 'installed':
await listInstalledPackages();
break;
case 'update':
await updatePackage();
break;
default:
console.log('Available commands:');
console.log(' list List all available packages');
console.log(' install <package> Install a package');
console.log(' uninstall [package] Uninstall a package');
console.log(' installed List installed packages');
console.log(' update Update mcp-get to latest version');
process.exit(1);
}
}
Expand Down
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
Expand Down
Loading