Skip to content

Commit bb8f44e

Browse files
committed
chore: add update notification to both binaries
Both `chrome-devtools` and `chrome-devtools-mcp` now log a notification when a newer version is detected to be available.
1 parent c7948cf commit bb8f44e

6 files changed

Lines changed: 219 additions & 0 deletions

File tree

.npmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package-lock=true

src/bin/check-latest-version.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import fs from 'node:fs/promises';
8+
import path from 'node:path';
9+
import process from 'node:process';
10+
11+
const cachePath = process.argv[2];
12+
13+
if (cachePath) {
14+
try {
15+
const response = await fetch(
16+
'https://registry.npmjs.org/chrome-devtools-mcp/latest',
17+
);
18+
const data = response.ok ? await response.json() : null;
19+
20+
if (
21+
data &&
22+
typeof data === 'object' &&
23+
'version' in data &&
24+
typeof data.version === 'string'
25+
) {
26+
await fs.mkdir(path.dirname(cachePath), {recursive: true});
27+
await fs.writeFile(
28+
cachePath,
29+
JSON.stringify({version: data.version, timestamp: Date.now()}),
30+
);
31+
}
32+
} catch {
33+
// Ignore errors.
34+
}
35+
}

src/bin/chrome-devtools-mcp-main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ import {createMcpServer, logDisclaimers} from '../index.js';
1212
import {logger, saveLogsToFile} from '../logger.js';
1313
import {computeFlagUsage} from '../telemetry/flagUtils.js';
1414
import {StdioServerTransport} from '../third_party/index.js';
15+
import {checkForUpdates} from '../utils/check-for-updates.js';
1516
import {VERSION} from '../version.js';
1617

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

20+
await checkForUpdates('Run `npm install chrome-devtools-mcp@latest` to update.');
21+
1922
export const args = parseArguments(VERSION);
2023

2124
const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;

src/bin/chrome-devtools.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@ import {
1919
import {isDaemonRunning, serializeArgs} from '../daemon/utils.js';
2020
import {logDisclaimers} from '../index.js';
2121
import {hideBin, yargs, type CallToolResult} from '../third_party/index.js';
22+
import {checkForUpdates} from '../utils/check-for-updates.js';
2223
import {VERSION} from '../version.js';
2324

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

28+
await checkForUpdates(
29+
'Run `npm install -g chrome-devtools-mcp@latest` and `chrome-devtools start` to update and restart the daemon.',
30+
);
31+
2732
async function start(args: string[]) {
2833
const combinedArgs = [...args, ...defaultArgs];
2934
await startDaemon(combinedArgs);

src/utils/check-for-updates.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import child_process from 'node:child_process';
8+
import fs from 'node:fs/promises';
9+
import os from 'node:os';
10+
import path from 'node:path';
11+
import process from 'node:process';
12+
13+
import {VERSION} from '../version.js';
14+
15+
/**
16+
* Notifies the user if an update is available.
17+
* @param message The message to display in the update notification.
18+
*/
19+
export async function checkForUpdates(message: string) {
20+
const cachePath = path.join(
21+
os.homedir(),
22+
'.cache',
23+
'chrome-devtools-mcp',
24+
'latest.json',
25+
);
26+
27+
let cache: {version: string; timestamp: number} | undefined;
28+
try {
29+
const data = await fs.readFile(cachePath, 'utf8');
30+
cache = JSON.parse(data);
31+
} catch {
32+
// Ignore errors reading cache.
33+
}
34+
35+
if (cache && typeof cache.version === 'string' && cache.version !== VERSION) {
36+
console.warn(
37+
`\nUpdate available: ${VERSION} -> ${cache.version}\n${message}\n`,
38+
);
39+
}
40+
41+
const now = Date.now();
42+
if (cache && now - cache.timestamp < 24 * 60 * 60 * 1000) {
43+
return;
44+
}
45+
46+
// In a separate process, check the latest available version number
47+
// and update the local snapshot accordingly.
48+
const scriptPath = path.join(import.meta.dirname, '..', 'bin', 'check-latest-version.js');
49+
50+
try {
51+
const child = child_process.spawn(
52+
process.execPath,
53+
[scriptPath, cachePath],
54+
{
55+
detached: true,
56+
stdio: 'ignore',
57+
},
58+
);
59+
child.unref();
60+
} catch {
61+
// Fail silently in case of any errors.
62+
}
63+
}

tests/check-for-updates.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
import child_process from 'node:child_process';
9+
import fs from 'node:fs/promises';
10+
import os from 'node:os';
11+
import {afterEach, describe, it} from 'node:test';
12+
13+
import sinon from 'sinon';
14+
15+
import {checkForUpdates} from '../src/utils/check-for-updates.js';
16+
import {VERSION} from '../src/version.js';
17+
18+
describe('checkForUpdates', () => {
19+
afterEach(() => {
20+
sinon.restore();
21+
});
22+
23+
it('notifies if cache exists and version is different', async () => {
24+
sinon.stub(os, 'homedir').returns('/home/user');
25+
sinon.stub(fs, 'readFile').callsFake(async filePath => {
26+
if (filePath.toString().includes('latest.json')) {
27+
return JSON.stringify({
28+
version: '99.9.9',
29+
timestamp: Date.now(),
30+
});
31+
}
32+
throw new Error(`File not found: ${filePath}`);
33+
});
34+
const warnStub = sinon.stub(console, 'warn');
35+
const spawnStub = sinon.stub(child_process, 'spawn');
36+
37+
await checkForUpdates('Run `npm update` to update.');
38+
39+
assert.ok(
40+
warnStub.calledWith(
41+
sinon.match('Update available: ' + VERSION + ' -> 99.9.9'),
42+
),
43+
);
44+
assert.ok(spawnStub.notCalled);
45+
});
46+
47+
it('does not spawn fetch process if cache is fresh', async () => {
48+
sinon.stub(os, 'homedir').returns('/home/user');
49+
sinon.stub(fs, 'readFile').callsFake(async filePath => {
50+
if (filePath.toString().includes('latest.json')) {
51+
return JSON.stringify({
52+
version: VERSION,
53+
timestamp: Date.now(),
54+
});
55+
}
56+
throw new Error(`File not found: ${filePath}`);
57+
});
58+
const spawnStub = sinon.stub(child_process, 'spawn');
59+
60+
await checkForUpdates('Run `npm update` to update.');
61+
62+
assert.ok(spawnStub.notCalled);
63+
});
64+
65+
it('spawns detached process if cache is stale', async () => {
66+
sinon.stub(os, 'homedir').returns('/home/user');
67+
sinon.stub(fs, 'readFile').callsFake(async filePath => {
68+
if (filePath.toString().includes('latest.json')) {
69+
return JSON.stringify({
70+
version: VERSION,
71+
timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago
72+
});
73+
}
74+
throw new Error(`File not found: ${filePath}`);
75+
});
76+
77+
const unrefSpy = sinon.spy();
78+
const spawnStub = sinon.stub(child_process, 'spawn').returns({
79+
unref: unrefSpy,
80+
} as unknown as child_process.ChildProcess);
81+
82+
await checkForUpdates('Run `npm update` to update.');
83+
84+
assert.ok(spawnStub.calledOnce);
85+
assert.strictEqual(spawnStub.firstCall.args[0], process.execPath);
86+
assert.ok(spawnStub.firstCall.args[1][0]?.includes('check-latest-version.js'));
87+
assert.ok(spawnStub.firstCall.args[1][1]?.includes('latest.json'));
88+
assert.strictEqual(spawnStub.firstCall.args[2]?.detached, true);
89+
assert.ok(unrefSpy.calledOnce);
90+
});
91+
92+
it('spawns detached process if cache is missing', async () => {
93+
sinon.stub(os, 'homedir').returns('/home/user');
94+
sinon.stub(fs, 'readFile').callsFake(async filePath => {
95+
throw new Error(`File not found: ${filePath}`);
96+
});
97+
98+
const unrefSpy = sinon.spy();
99+
const spawnStub = sinon.stub(child_process, 'spawn').returns({
100+
unref: unrefSpy,
101+
} as unknown as child_process.ChildProcess);
102+
103+
await checkForUpdates('Run `npm update` to update.');
104+
105+
assert.ok(spawnStub.calledOnce);
106+
assert.strictEqual(spawnStub.firstCall.args[0], process.execPath);
107+
assert.ok(spawnStub.firstCall.args[1][0]?.includes('check-latest-version.js'));
108+
assert.ok(spawnStub.firstCall.args[1][1]?.includes('latest.json'));
109+
assert.strictEqual(spawnStub.firstCall.args[2]?.detached, true);
110+
assert.ok(unrefSpy.calledOnce);
111+
});
112+
});

0 commit comments

Comments
 (0)