Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=true
35 changes: 35 additions & 0 deletions src/bin/check-latest-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';

const cachePath = process.argv[2];

if (cachePath) {
try {
const response = await fetch(
'https://registry.npmjs.org/chrome-devtools-mcp/latest',
);
const data = response.ok ? await response.json() : null;

if (
data &&
typeof data === 'object' &&
'version' in data &&
typeof data.version === 'string'
) {
await fs.mkdir(path.dirname(cachePath), {recursive: true});
await fs.writeFile(
cachePath,
JSON.stringify({version: data.version, timestamp: Date.now()}),
);
}
} catch {
// Ignore errors.
}
}
3 changes: 3 additions & 0 deletions src/bin/chrome-devtools-mcp-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ import {createMcpServer, logDisclaimers} from '../index.js';
import {logger, saveLogsToFile} from '../logger.js';
import {computeFlagUsage} from '../telemetry/flagUtils.js';
import {StdioServerTransport} from '../third_party/index.js';
import {checkForUpdates} from '../utils/check-for-updates.js';
import {VERSION} from '../version.js';

import {cliOptions, parseArguments} from './chrome-devtools-mcp-cli-options.js';

await checkForUpdates('Run `npm install chrome-devtools-mcp@latest` to update.');

export const args = parseArguments(VERSION);

const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
Expand Down
5 changes: 5 additions & 0 deletions src/bin/chrome-devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ import {
import {isDaemonRunning, serializeArgs} from '../daemon/utils.js';
import {logDisclaimers} from '../index.js';
import {hideBin, yargs, type CallToolResult} from '../third_party/index.js';
import {checkForUpdates} from '../utils/check-for-updates.js';
import {VERSION} from '../version.js';

import {commands} from './chrome-devtools-cli-options.js';
import {cliOptions, parseArguments} from './chrome-devtools-mcp-cli-options.js';

await checkForUpdates(
'Run `npm install -g chrome-devtools-mcp@latest` and `chrome-devtools start` to update and restart the daemon.',
);

async function start(args: string[]) {
const combinedArgs = [...args, ...defaultArgs];
await startDaemon(combinedArgs);
Expand Down
63 changes: 63 additions & 0 deletions src/utils/check-for-updates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import child_process from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';

import {VERSION} from '../version.js';

/**
* Notifies the user if an update is available.
* @param message The message to display in the update notification.
*/
export async function checkForUpdates(message: string) {
Comment thread
mathiasbynens marked this conversation as resolved.
const cachePath = path.join(
os.homedir(),
'.cache',
'chrome-devtools-mcp',
'latest.json',
);

let cache: {version: string; timestamp: number} | undefined;
Comment thread
mathiasbynens marked this conversation as resolved.
Outdated
try {
const data = await fs.readFile(cachePath, 'utf8');
cache = JSON.parse(data);
} catch {
// Ignore errors reading cache.
}

if (cache && typeof cache.version === 'string' && cache.version !== VERSION) {
console.warn(
`\nUpdate available: ${VERSION} -> ${cache.version}\n${message}\n`,
);
}

const now = Date.now();
if (cache && now - cache.timestamp < 24 * 60 * 60 * 1000) {
return;
}

// In a separate process, check the latest available version number
// and update the local snapshot accordingly.
const scriptPath = path.join(import.meta.dirname, '..', 'bin', 'check-latest-version.js');

try {
const child = child_process.spawn(
process.execPath,
[scriptPath, cachePath],
{
detached: true,
stdio: 'ignore',
},
);
child.unref();
} catch {
// Fail silently in case of any errors.
}
}
112 changes: 112 additions & 0 deletions tests/check-for-updates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import assert from 'node:assert';
import child_process from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import {afterEach, describe, it} from 'node:test';

import sinon from 'sinon';

import {checkForUpdates} from '../src/utils/check-for-updates.js';
import {VERSION} from '../src/version.js';

describe('checkForUpdates', () => {
afterEach(() => {
sinon.restore();
});

it('notifies if cache exists and version is different', async () => {
sinon.stub(os, 'homedir').returns('/home/user');
sinon.stub(fs, 'readFile').callsFake(async filePath => {
if (filePath.toString().includes('latest.json')) {
return JSON.stringify({
version: '99.9.9',
timestamp: Date.now(),
});
}
throw new Error(`File not found: ${filePath}`);
});
const warnStub = sinon.stub(console, 'warn');
const spawnStub = sinon.stub(child_process, 'spawn');

await checkForUpdates('Run `npm update` to update.');

assert.ok(
warnStub.calledWith(
sinon.match('Update available: ' + VERSION + ' -> 99.9.9'),
),
);
assert.ok(spawnStub.notCalled);
});

it('does not spawn fetch process if cache is fresh', async () => {
sinon.stub(os, 'homedir').returns('/home/user');
sinon.stub(fs, 'readFile').callsFake(async filePath => {
if (filePath.toString().includes('latest.json')) {
return JSON.stringify({
version: VERSION,
timestamp: Date.now(),
});
}
throw new Error(`File not found: ${filePath}`);
});
const spawnStub = sinon.stub(child_process, 'spawn');

await checkForUpdates('Run `npm update` to update.');

assert.ok(spawnStub.notCalled);
});

it('spawns detached process if cache is stale', async () => {
sinon.stub(os, 'homedir').returns('/home/user');
sinon.stub(fs, 'readFile').callsFake(async filePath => {
if (filePath.toString().includes('latest.json')) {
return JSON.stringify({
version: VERSION,
timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago
});
}
throw new Error(`File not found: ${filePath}`);
});

const unrefSpy = sinon.spy();
const spawnStub = sinon.stub(child_process, 'spawn').returns({
unref: unrefSpy,
} as unknown as child_process.ChildProcess);

await checkForUpdates('Run `npm update` to update.');

assert.ok(spawnStub.calledOnce);
assert.strictEqual(spawnStub.firstCall.args[0], process.execPath);
assert.ok(spawnStub.firstCall.args[1][0]?.includes('check-latest-version.js'));
assert.ok(spawnStub.firstCall.args[1][1]?.includes('latest.json'));
assert.strictEqual(spawnStub.firstCall.args[2]?.detached, true);
assert.ok(unrefSpy.calledOnce);
});

it('spawns detached process if cache is missing', async () => {
sinon.stub(os, 'homedir').returns('/home/user');
sinon.stub(fs, 'readFile').callsFake(async filePath => {
throw new Error(`File not found: ${filePath}`);
});

const unrefSpy = sinon.spy();
const spawnStub = sinon.stub(child_process, 'spawn').returns({
unref: unrefSpy,
} as unknown as child_process.ChildProcess);

await checkForUpdates('Run `npm update` to update.');

assert.ok(spawnStub.calledOnce);
assert.strictEqual(spawnStub.firstCall.args[0], process.execPath);
assert.ok(spawnStub.firstCall.args[1][0]?.includes('check-latest-version.js'));
assert.ok(spawnStub.firstCall.args[1][1]?.includes('latest.json'));
assert.strictEqual(spawnStub.firstCall.args[2]?.detached, true);
assert.ok(unrefSpy.calledOnce);
});
});
Loading