chrome-devtools-mcp-for-extension 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/src/McpContext.js +45 -26
- package/build/src/browser-connection-manager.js +72 -53
- package/build/src/browser.js +29 -12
- package/build/src/profile-resolver.js +190 -0
- package/build/src/project-detector.js +66 -0
- package/package.json +1 -1
package/build/src/McpContext.js
CHANGED
|
@@ -44,6 +44,9 @@ export class McpContext {
|
|
|
44
44
|
#dialog;
|
|
45
45
|
#nextSnapshotId = 1;
|
|
46
46
|
#traceResults = [];
|
|
47
|
+
// CDP Session management (v0.8.4+)
|
|
48
|
+
#browserSession;
|
|
49
|
+
#pageSessions = new Map();
|
|
47
50
|
constructor(browser, logger, browserFactory, connectionOptions) {
|
|
48
51
|
this.browser = browser;
|
|
49
52
|
this.logger = logger;
|
|
@@ -77,29 +80,38 @@ export class McpContext {
|
|
|
77
80
|
await context.#init();
|
|
78
81
|
return context;
|
|
79
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Dispose all CDP sessions to prevent memory leaks
|
|
85
|
+
*/
|
|
86
|
+
async #disposeSessions() {
|
|
87
|
+
await Promise.allSettled([
|
|
88
|
+
this.#browserSession?.detach(),
|
|
89
|
+
...[...this.#pageSessions.values()].map(s => s.detach())
|
|
90
|
+
]);
|
|
91
|
+
this.#browserSession = undefined;
|
|
92
|
+
this.#pageSessions.clear();
|
|
93
|
+
}
|
|
80
94
|
/**
|
|
81
95
|
* Reinitialize CDP protocol domains after reconnection
|
|
82
96
|
* This ensures proper CDP event handling after browser restart
|
|
83
97
|
*/
|
|
84
98
|
async reinitializeCDP() {
|
|
85
99
|
try {
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// 1. Enable Target discovery and auto-attach
|
|
93
|
-
// This is critical for multi-target scenarios (iframes, workers, etc.)
|
|
100
|
+
// 1. Dispose old sessions to prevent leaks
|
|
101
|
+
await this.#disposeSessions();
|
|
102
|
+
// 2. Create browser-level CDP session (public API, not _client())
|
|
103
|
+
this.#browserSession = await this.browser.target().createCDPSession();
|
|
104
|
+
this.logger('CDP: Browser-level session created');
|
|
105
|
+
// 3. Enable Target discovery and auto-attach
|
|
94
106
|
try {
|
|
95
|
-
await
|
|
107
|
+
await this.#browserSession.send('Target.setDiscoverTargets', { discover: true });
|
|
96
108
|
this.logger('CDP: Target discovery enabled');
|
|
97
109
|
}
|
|
98
110
|
catch (err) {
|
|
99
111
|
this.logger('Warning: Failed to enable target discovery:', err);
|
|
100
112
|
}
|
|
101
113
|
try {
|
|
102
|
-
await
|
|
114
|
+
await this.#browserSession.send('Target.setAutoAttach', {
|
|
103
115
|
autoAttach: true,
|
|
104
116
|
waitForDebuggerOnStart: false,
|
|
105
117
|
flatten: true
|
|
@@ -109,24 +121,29 @@ export class McpContext {
|
|
|
109
121
|
catch (err) {
|
|
110
122
|
this.logger('Warning: Failed to configure auto-attach:', err);
|
|
111
123
|
}
|
|
112
|
-
//
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
await
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
// 4. Enable essential CDP domains for all targets
|
|
125
|
+
for (const target of this.browser.targets()) {
|
|
126
|
+
const type = target.type();
|
|
127
|
+
if (type === 'page') {
|
|
128
|
+
const page = await target.page();
|
|
129
|
+
if (!page)
|
|
130
|
+
continue;
|
|
131
|
+
try {
|
|
132
|
+
const session = await page.createCDPSession();
|
|
133
|
+
await Promise.allSettled([
|
|
134
|
+
session.send('Network.enable'),
|
|
135
|
+
session.send('Runtime.enable'),
|
|
136
|
+
session.send('Log.enable'),
|
|
137
|
+
]);
|
|
138
|
+
this.#pageSessions.set(page, session);
|
|
139
|
+
this.logger(`CDP domains enabled for ${type}: ${page.url()}`);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
this.logger(`Warning: Failed to enable CDP domains for ${type} ${page.url()}:`, err);
|
|
125
143
|
}
|
|
126
144
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
145
|
+
// Service Worker / Worker support can be added here if needed
|
|
146
|
+
// if (type === 'service_worker' || type === 'worker') { ... }
|
|
130
147
|
}
|
|
131
148
|
this.logger('CDP protocol reinitialization completed');
|
|
132
149
|
}
|
|
@@ -140,6 +157,8 @@ export class McpContext {
|
|
|
140
157
|
* Update browser instance after reconnection
|
|
141
158
|
*/
|
|
142
159
|
async updateBrowser(newBrowser) {
|
|
160
|
+
// Dispose old sessions first
|
|
161
|
+
await this.#disposeSessions();
|
|
143
162
|
this.browser = newBrowser;
|
|
144
163
|
this.connectionManager.setBrowser(newBrowser, async () => newBrowser);
|
|
145
164
|
// Reinitialize CDP protocol domains BEFORE collectors
|
|
@@ -11,6 +11,7 @@ const DEFAULT_OPTIONS = {
|
|
|
11
11
|
maxReconnectAttempts: 3,
|
|
12
12
|
initialRetryDelay: 1000, // 1 second
|
|
13
13
|
maxRetryDelay: 10000, // 10 seconds
|
|
14
|
+
reconnectOverallTimeoutMs: 30000, // 30 seconds
|
|
14
15
|
enableLogging: true,
|
|
15
16
|
onReconnect: undefined,
|
|
16
17
|
};
|
|
@@ -43,6 +44,13 @@ export class BrowserConnectionManager {
|
|
|
43
44
|
reconnectSequenceInFlight = null;
|
|
44
45
|
/** Current connection state */
|
|
45
46
|
state = ConnectionState.CLOSED;
|
|
47
|
+
/** Disconnected event handler (arrow function to preserve 'this') */
|
|
48
|
+
onDisconnected = () => {
|
|
49
|
+
this.log('Browser disconnected');
|
|
50
|
+
this.setState(ConnectionState.RECONNECTING);
|
|
51
|
+
// Trigger immediate reconnection (single-flight prevents duplicates)
|
|
52
|
+
void this.triggerReconnect('event:disconnected');
|
|
53
|
+
};
|
|
46
54
|
constructor(options = {}) {
|
|
47
55
|
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
48
56
|
}
|
|
@@ -53,15 +61,15 @@ export class BrowserConnectionManager {
|
|
|
53
61
|
* @param factory - Factory function to create new browser instances on reconnection
|
|
54
62
|
*/
|
|
55
63
|
setBrowser(browser, factory) {
|
|
64
|
+
// Remove old listener to prevent memory leak
|
|
65
|
+
if (this.browser) {
|
|
66
|
+
this.browser.off('disconnected', this.onDisconnected);
|
|
67
|
+
}
|
|
56
68
|
this.browser = browser;
|
|
57
69
|
this.browserFactory = factory;
|
|
58
70
|
this.state = ConnectionState.CONNECTED;
|
|
59
71
|
// Event-driven detection: hook into browser 'disconnected' event
|
|
60
|
-
this.browser.on('disconnected',
|
|
61
|
-
this.log('Browser disconnected event fired');
|
|
62
|
-
this.setState(ConnectionState.CLOSED);
|
|
63
|
-
// Trigger immediate reconnection (will be handled by next operation)
|
|
64
|
-
});
|
|
72
|
+
this.browser.on('disconnected', this.onDisconnected);
|
|
65
73
|
this.log('Browser instance set, state: CONNECTED');
|
|
66
74
|
}
|
|
67
75
|
/**
|
|
@@ -80,71 +88,81 @@ export class BrowserConnectionManager {
|
|
|
80
88
|
}
|
|
81
89
|
}
|
|
82
90
|
/**
|
|
83
|
-
*
|
|
91
|
+
* Trigger reconnection sequence
|
|
84
92
|
*
|
|
85
|
-
*
|
|
86
|
-
* -
|
|
87
|
-
* - Only one reconnection sequence runs at a time
|
|
88
|
-
* - All waiting operations retry after the shared sequence completes
|
|
93
|
+
* Can be called from events (disconnected) or operations (executeWithRetry).
|
|
94
|
+
* Uses single-flight pattern to prevent duplicate reconnections.
|
|
89
95
|
*
|
|
90
|
-
*
|
|
96
|
+
* @param reason - Reason for triggering reconnection
|
|
97
|
+
* @returns Promise that resolves when reconnection completes
|
|
98
|
+
*/
|
|
99
|
+
async triggerReconnect(reason) {
|
|
100
|
+
// Single-flight: return existing sequence if already running
|
|
101
|
+
if (this.reconnectSequenceInFlight) {
|
|
102
|
+
this.log(`Reconnection already in progress (${reason}), waiting...`);
|
|
103
|
+
return this.reconnectSequenceInFlight;
|
|
104
|
+
}
|
|
105
|
+
this.log(`Triggering reconnection: ${reason}`);
|
|
106
|
+
// Overall timeout with AbortController
|
|
107
|
+
const abortController = new AbortController();
|
|
108
|
+
const overallTimeout = this.options.reconnectOverallTimeoutMs ?? 30000;
|
|
109
|
+
const timeoutId = setTimeout(() => {
|
|
110
|
+
abortController.abort();
|
|
111
|
+
this.log(`Reconnection overall timeout (${overallTimeout}ms) exceeded`);
|
|
112
|
+
}, overallTimeout);
|
|
113
|
+
// Start reconnection sequence
|
|
114
|
+
this.reconnectSequenceInFlight = this._runReconnectionSequence(abortController.signal)
|
|
115
|
+
.finally(() => {
|
|
116
|
+
clearTimeout(timeoutId);
|
|
117
|
+
this.reconnectSequenceInFlight = null;
|
|
118
|
+
});
|
|
119
|
+
return this.reconnectSequenceInFlight;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Retry operation with automatic reconnection
|
|
91
123
|
*
|
|
92
124
|
* @param operation - Operation to retry
|
|
93
125
|
* @param operationName - Name of operation for logging
|
|
94
126
|
* @returns Result of operation
|
|
95
127
|
*/
|
|
96
128
|
async retryWithReconnect(operation, operationName) {
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
await this.reconnectSequenceInFlight;
|
|
103
|
-
this.log(`${operationName}: Reconnection sequence completed, retrying operation...`);
|
|
104
|
-
return await operation();
|
|
105
|
-
}
|
|
106
|
-
catch (error) {
|
|
107
|
-
// Reconnection sequence failed, propagate the error
|
|
108
|
-
throw error;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
// Start a new reconnection sequence
|
|
112
|
-
this.reconnectSequenceInFlight = this._runReconnectionSequence();
|
|
113
|
-
try {
|
|
114
|
-
await this.reconnectSequenceInFlight;
|
|
115
|
-
this.log(`${operationName}: Reconnection sequence completed, retrying operation...`);
|
|
116
|
-
return await operation();
|
|
117
|
-
}
|
|
118
|
-
catch (error) {
|
|
119
|
-
throw error;
|
|
120
|
-
}
|
|
121
|
-
finally {
|
|
122
|
-
// Clear the in-flight sequence
|
|
123
|
-
this.reconnectSequenceInFlight = null;
|
|
124
|
-
}
|
|
129
|
+
// Use triggerReconnect for unified reconnection logic
|
|
130
|
+
await this.triggerReconnect(`operation:${operationName}`);
|
|
131
|
+
this.log(`${operationName}: Reconnection completed, retrying operation...`);
|
|
132
|
+
return await operation();
|
|
125
133
|
}
|
|
126
134
|
/**
|
|
127
135
|
* Run a full reconnection sequence with exponential backoff
|
|
128
136
|
*
|
|
129
137
|
* This is the actual retry loop that multiple operations can share.
|
|
130
138
|
*
|
|
139
|
+
* @param signal - AbortSignal to cancel reconnection
|
|
131
140
|
* @private
|
|
132
141
|
*/
|
|
133
|
-
async _runReconnectionSequence() {
|
|
142
|
+
async _runReconnectionSequence(signal) {
|
|
134
143
|
const maxAttempts = this.options.maxReconnectAttempts ?? 3;
|
|
135
144
|
const initialDelay = this.options.initialRetryDelay ?? 1000;
|
|
136
145
|
const maxDelay = this.options.maxRetryDelay ?? 10000;
|
|
146
|
+
const random = this.options.rng ?? Math.random;
|
|
137
147
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
148
|
+
// Check abort signal
|
|
149
|
+
if (signal?.aborted) {
|
|
150
|
+
throw new Error('Reconnection aborted: overall timeout exceeded');
|
|
151
|
+
}
|
|
138
152
|
this.reconnectAttempts++;
|
|
139
153
|
const attemptNum = attempt + 1;
|
|
140
154
|
this.log(`Reconnect attempt ${attemptNum}/${maxAttempts}...`);
|
|
141
155
|
// Exponential backoff with max delay
|
|
142
156
|
const baseDelay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
|
|
143
157
|
// Add jitter: ±20% randomness to prevent thundering herd
|
|
144
|
-
const jitter = baseDelay * 0.2 * (
|
|
158
|
+
const jitter = baseDelay * 0.2 * (random() * 2 - 1);
|
|
145
159
|
const delay = Math.max(0, baseDelay + jitter);
|
|
146
160
|
this.log(`Waiting ${delay.toFixed(0)}ms before reconnect attempt...`);
|
|
147
161
|
await this.sleep(delay);
|
|
162
|
+
// Check abort signal after sleep
|
|
163
|
+
if (signal?.aborted) {
|
|
164
|
+
throw new Error('Reconnection aborted: overall timeout exceeded');
|
|
165
|
+
}
|
|
148
166
|
try {
|
|
149
167
|
await this.reconnectBrowser();
|
|
150
168
|
this.log(`Reconnection successful`);
|
|
@@ -164,7 +182,7 @@ export class BrowserConnectionManager {
|
|
|
164
182
|
* Check if error is a CDP connection error
|
|
165
183
|
*
|
|
166
184
|
* Uses type-safe error detection with instanceof checks and falls back
|
|
167
|
-
* to string matching for compatibility.
|
|
185
|
+
* to string matching for compatibility. Also checks method hints.
|
|
168
186
|
*
|
|
169
187
|
* @param error - Error to check
|
|
170
188
|
* @returns true if error is a CDP connection error
|
|
@@ -174,13 +192,17 @@ export class BrowserConnectionManager {
|
|
|
174
192
|
if (error instanceof ProtocolError || error instanceof TimeoutError) {
|
|
175
193
|
return true;
|
|
176
194
|
}
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
195
|
+
const msg = String(error?.message ?? '').toLowerCase();
|
|
196
|
+
const method = error?.method?.toString?.().toLowerCase?.() ?? '';
|
|
197
|
+
// Message hints
|
|
198
|
+
if (/connection closed|session closed|target closed|websocket is not open/.test(msg)) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
// Method hints (CDP methods like 'Target.*')
|
|
202
|
+
if (/^target\./.test(method)) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
184
206
|
}
|
|
185
207
|
/**
|
|
186
208
|
* Reconnect browser instance with single-flight pattern
|
|
@@ -231,10 +253,7 @@ export class BrowserConnectionManager {
|
|
|
231
253
|
const newBrowser = await this.browserFactory();
|
|
232
254
|
this.browser = newBrowser;
|
|
233
255
|
// Re-attach disconnected event handler
|
|
234
|
-
this.browser.on('disconnected',
|
|
235
|
-
this.log('Browser disconnected event fired');
|
|
236
|
-
this.setState(ConnectionState.CLOSED);
|
|
237
|
-
});
|
|
256
|
+
this.browser.on('disconnected', this.onDisconnected);
|
|
238
257
|
this.setState(ConnectionState.CONNECTED);
|
|
239
258
|
this.log('Browser reconnected successfully');
|
|
240
259
|
// Notify callback if provided
|
package/build/src/browser.js
CHANGED
|
@@ -7,6 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import os from 'node:os';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import puppeteer from 'puppeteer-core';
|
|
10
|
+
import { resolveUserDataDir } from './profile-resolver.js';
|
|
10
11
|
let browser;
|
|
11
12
|
const ignoredPrefixes = new Set([
|
|
12
13
|
'chrome://',
|
|
@@ -418,20 +419,36 @@ export async function launch(options) {
|
|
|
418
419
|
const { channel, executablePath, customDevTools, headless, isolated, loadExtension, loadExtensionsDir, loadSystemExtensions, chromeProfile, } = options;
|
|
419
420
|
// Reset development extension paths
|
|
420
421
|
developmentExtensionPaths = [];
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
:
|
|
424
|
-
|
|
422
|
+
// Resolve user data directory using new profile resolver (v0.15.0+)
|
|
423
|
+
const resolved = resolveUserDataDir({
|
|
424
|
+
cliUserDataDir: options.userDataDir,
|
|
425
|
+
env: process.env,
|
|
426
|
+
cwd: process.cwd(),
|
|
427
|
+
channel: channel || 'stable',
|
|
428
|
+
});
|
|
429
|
+
const userDataDir = resolved.path;
|
|
430
|
+
await fs.promises.mkdir(userDataDir, { recursive: true });
|
|
431
|
+
// Legacy profile warning (shown if legacy path exists)
|
|
432
|
+
try {
|
|
433
|
+
const legacy = path.join(os.homedir(), '.cache', 'chrome-devtools-mcp', 'chrome-profile');
|
|
434
|
+
if (fs.existsSync(legacy)) {
|
|
435
|
+
console.error(`⚠️ Legacy profile detected: ${legacy}\n` +
|
|
436
|
+
`ℹ️ New profile location: ${path.join(os.homedir(), '.cache', 'chrome-devtools-mcp', 'profiles', 'project-default', 'stable')}\n` +
|
|
437
|
+
`💡 To continue using the legacy profile, set: MCP_USER_DATA_DIR=${legacy}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
/* ignore */
|
|
442
|
+
}
|
|
443
|
+
// Profile resolution logs
|
|
444
|
+
console.error(`[profiles] Using: ${userDataDir}`);
|
|
445
|
+
console.error(` Reason: ${resolved.reason}`);
|
|
446
|
+
console.error(` Project: ${resolved.projectName} (${resolved.hash})`);
|
|
447
|
+
if (resolved.reason === 'AUTO') {
|
|
448
|
+
console.error(` Root: ${process.cwd()}`);
|
|
449
|
+
}
|
|
425
450
|
let usingSystemProfile = false;
|
|
426
451
|
let profileDirectory = 'Default';
|
|
427
|
-
if (!userDataDir) {
|
|
428
|
-
// Use isolated profile (independent from system Chrome)
|
|
429
|
-
userDataDir = path.join(os.homedir(), '.cache', 'chrome-devtools-mcp', profileDirName);
|
|
430
|
-
await fs.promises.mkdir(userDataDir, {
|
|
431
|
-
recursive: true,
|
|
432
|
-
});
|
|
433
|
-
console.error(`📁 Using isolated profile: ${userDataDir}`);
|
|
434
|
-
}
|
|
435
452
|
const args = [
|
|
436
453
|
'--hide-crash-restore-bubble',
|
|
437
454
|
`--profile-directory=${profileDirectory}`,
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// src/profile-resolver.ts
|
|
2
|
+
// Phase 1 (v0.15.0)
|
|
3
|
+
// - Hybrid priority (CLI > MCP_USER_DATA_DIR > MCP_PROJECT_ID > AUTO > DEFAULT)
|
|
4
|
+
// - Auto-detection (git root -> nearest package.json -> cwd)
|
|
5
|
+
// - Realpath normalization
|
|
6
|
+
// - Tilde (~) expansion
|
|
7
|
+
// - Short SHA-256 hash (8 chars)
|
|
8
|
+
// - CI detection => ephemeral session profile (unless MCP_PERSIST_PROFILES)
|
|
9
|
+
// - Minimal console.error() logging of decision
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import { detectProjectName, detectProjectRoot } from './project-detector.js';
|
|
15
|
+
const CACHE_ROOT = path.join(os.homedir(), '.cache', 'chrome-devtools-mcp');
|
|
16
|
+
// --- Public API ---
|
|
17
|
+
export function resolveUserDataDir(opts) {
|
|
18
|
+
const channel = opts.channel || 'stable';
|
|
19
|
+
// 0) CI detection → ephemeral session directory (unless MCP_PERSIST_PROFILES)
|
|
20
|
+
// - This happens before other priorities to keep CI clean by default.
|
|
21
|
+
if (isCI(opts.env) && !opts.env.MCP_PERSIST_PROFILES) {
|
|
22
|
+
const sessionId = `${process.pid}-${Date.now()}`;
|
|
23
|
+
const tempPath = realpathSafe(path.join(CACHE_ROOT, 'sessions', sessionId, channel));
|
|
24
|
+
// best-effort cleanup on exit
|
|
25
|
+
process.on('exit', () => {
|
|
26
|
+
try {
|
|
27
|
+
fs.rmSync(tempPath, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
/* ignore */
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const result = {
|
|
34
|
+
path: tempPath,
|
|
35
|
+
reason: 'AUTO', // keep enum as specified (no EPHEMERAL type in Phase 1)
|
|
36
|
+
projectKey: `session-${sessionId}`,
|
|
37
|
+
projectName: 'ci-session',
|
|
38
|
+
hash: sessionId,
|
|
39
|
+
channel,
|
|
40
|
+
};
|
|
41
|
+
// concise decision log (resolver-side)
|
|
42
|
+
console.error(`[profiles] resolved(AUTO, ci-ephemeral): ${result.path} (session=${sessionId})`);
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
// 1) CLI explicit userDataDir
|
|
46
|
+
if (opts.cliUserDataDir && opts.cliUserDataDir.trim().length > 0) {
|
|
47
|
+
const p = realpathOrExpand(opts.cliUserDataDir);
|
|
48
|
+
const result = {
|
|
49
|
+
path: p,
|
|
50
|
+
reason: 'CLI',
|
|
51
|
+
projectKey: stripHomeForKey(p),
|
|
52
|
+
projectName: 'cli',
|
|
53
|
+
hash: shortHash(p),
|
|
54
|
+
channel,
|
|
55
|
+
};
|
|
56
|
+
console.error(`[profiles] resolved(CLI): ${result.path}`);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
// 2) ENV: MCP_USER_DATA_DIR (full path)
|
|
60
|
+
const envUserData = opts.env.MCP_USER_DATA_DIR?.trim();
|
|
61
|
+
if (envUserData) {
|
|
62
|
+
const p = realpathOrExpand(envUserData);
|
|
63
|
+
const result = {
|
|
64
|
+
path: p,
|
|
65
|
+
reason: 'MCP_USER_DATA_DIR',
|
|
66
|
+
projectKey: stripHomeForKey(p),
|
|
67
|
+
projectName: 'env',
|
|
68
|
+
hash: shortHash(p),
|
|
69
|
+
channel,
|
|
70
|
+
};
|
|
71
|
+
console.error(`[profiles] resolved(MCP_USER_DATA_DIR): ${result.path}`);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
// 3) ENV: MCP_PROJECT_ID (project-scoped persistent profile)
|
|
75
|
+
const projectId = sanitize(opts.env.MCP_PROJECT_ID || '');
|
|
76
|
+
if (projectId) {
|
|
77
|
+
const p = projectProfilePath(projectId, channel);
|
|
78
|
+
const result = {
|
|
79
|
+
path: p,
|
|
80
|
+
reason: 'MCP_PROJECT_ID',
|
|
81
|
+
projectKey: projectId,
|
|
82
|
+
projectName: projectId,
|
|
83
|
+
hash: shortHash(projectId),
|
|
84
|
+
channel,
|
|
85
|
+
};
|
|
86
|
+
console.error(`[profiles] resolved(MCP_PROJECT_ID): ${result.path} (projectId=${projectId})`);
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
// 4) AUTO: detect by root -> name -> hash
|
|
90
|
+
try {
|
|
91
|
+
const root = detectProjectRoot(opts.cwd);
|
|
92
|
+
const name = detectProjectName(root);
|
|
93
|
+
const realRoot = realpathSafe(root);
|
|
94
|
+
const hash = shortHash(realRoot);
|
|
95
|
+
const key = `${sanitize(name)}_${hash}`;
|
|
96
|
+
const p = projectProfilePath(key, channel);
|
|
97
|
+
const result = {
|
|
98
|
+
path: p,
|
|
99
|
+
reason: 'AUTO',
|
|
100
|
+
projectKey: key,
|
|
101
|
+
projectName: sanitize(name),
|
|
102
|
+
hash,
|
|
103
|
+
channel,
|
|
104
|
+
};
|
|
105
|
+
console.error(`[profiles] resolved(AUTO): ${result.path} (root=${root}, name=${name}, hash=${hash})`);
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
// 5) DEFAULT fallback
|
|
110
|
+
const key = 'project-default';
|
|
111
|
+
const p = projectProfilePath(key, channel);
|
|
112
|
+
const result = {
|
|
113
|
+
path: p,
|
|
114
|
+
reason: 'DEFAULT',
|
|
115
|
+
projectKey: key,
|
|
116
|
+
projectName: key,
|
|
117
|
+
hash: '00000000',
|
|
118
|
+
channel,
|
|
119
|
+
};
|
|
120
|
+
console.error(`[profiles] resolved(DEFAULT): ${result.path} (reason=${e?.message || 'fallback'})`);
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// --- Helpers ---
|
|
125
|
+
function isCI(env) {
|
|
126
|
+
// Common CI signals
|
|
127
|
+
return env.CI === 'true' || env.GITHUB_ACTIONS === 'true';
|
|
128
|
+
}
|
|
129
|
+
function projectProfilePath(projectKey, channel) {
|
|
130
|
+
const base = path.join(CACHE_ROOT, 'profiles', projectKey, channel);
|
|
131
|
+
return pathNormalize(base);
|
|
132
|
+
}
|
|
133
|
+
function shortHash(s) {
|
|
134
|
+
try {
|
|
135
|
+
return crypto.createHash('sha256').update(s).digest('hex').slice(0, 8);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Extremely unlikely; fallback for robustness
|
|
139
|
+
return '00000000';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function sanitize(s) {
|
|
143
|
+
const base = (s || 'project').toLowerCase().replace(/[^a-z0-9-_]/g, '-');
|
|
144
|
+
// Avoid empty string after sanitize
|
|
145
|
+
return base.length ? base : 'project';
|
|
146
|
+
}
|
|
147
|
+
function expandTilde(p) {
|
|
148
|
+
if (!p)
|
|
149
|
+
return p;
|
|
150
|
+
if (p.startsWith('~')) {
|
|
151
|
+
return path.join(os.homedir(), p.slice(1));
|
|
152
|
+
}
|
|
153
|
+
return p;
|
|
154
|
+
}
|
|
155
|
+
function realpathSafe(p) {
|
|
156
|
+
try {
|
|
157
|
+
// Use native realpath if available for speed/behavior
|
|
158
|
+
// (Node 18+ has fs.realpathSync.native)
|
|
159
|
+
const rp = fs.realpathSync.native
|
|
160
|
+
? fs.realpathSync.native(p)
|
|
161
|
+
: fs.realpathSync(p);
|
|
162
|
+
return rp;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// The path may not exist yet; normalize current form
|
|
166
|
+
return pathNormalize(p);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function realpathOrExpand(p) {
|
|
170
|
+
const expanded = expandTilde(p);
|
|
171
|
+
return realpathSafe(expanded);
|
|
172
|
+
}
|
|
173
|
+
function pathNormalize(p) {
|
|
174
|
+
// Normalize but do not resolve symlinks here
|
|
175
|
+
// (realpathSafe is used where resolution is desired)
|
|
176
|
+
return path.normalize(p);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Best-effort readable key when user supplied an absolute path.
|
|
180
|
+
* We do not want slashes in the key, so hash + tail dir name.
|
|
181
|
+
*/
|
|
182
|
+
function stripHomeForKey(absPath) {
|
|
183
|
+
try {
|
|
184
|
+
const name = path.basename(absPath);
|
|
185
|
+
return `${sanitize(name)}_${shortHash(absPath)}`;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return `abs_${shortHash(absPath)}`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/project-detector.ts
|
|
2
|
+
// Phase 1 (v0.15.0)
|
|
3
|
+
// - detectProjectRoot: git root → nearest package.json → cwd
|
|
4
|
+
// - detectProjectName: package.json "name" → dirname
|
|
5
|
+
// - Use spawnSync with 500ms timeout to avoid blocking long
|
|
6
|
+
// - Realpath normalization
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
export function detectProjectRoot(cwd) {
|
|
11
|
+
const realCwd = realpathSafe(cwd);
|
|
12
|
+
// 1) Try 'git rev-parse --show-toplevel' with 500ms timeout
|
|
13
|
+
const git = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
14
|
+
cwd: realCwd,
|
|
15
|
+
timeout: 500,
|
|
16
|
+
encoding: 'utf8',
|
|
17
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
18
|
+
windowsHide: true,
|
|
19
|
+
});
|
|
20
|
+
if (git.status === 0 && git.stdout) {
|
|
21
|
+
const out = git.stdout.toString().trim();
|
|
22
|
+
if (out)
|
|
23
|
+
return realpathSafe(out);
|
|
24
|
+
}
|
|
25
|
+
// If git is not available or command failed/timed out → fall through
|
|
26
|
+
// 2) Walk up to nearest package.json
|
|
27
|
+
let cur = realCwd;
|
|
28
|
+
while (true) {
|
|
29
|
+
if (fs.existsSync(path.join(cur, 'package.json')))
|
|
30
|
+
return cur;
|
|
31
|
+
const next = path.dirname(cur);
|
|
32
|
+
if (next === cur)
|
|
33
|
+
break; // reached filesystem root
|
|
34
|
+
cur = next;
|
|
35
|
+
}
|
|
36
|
+
// 3) Fallback to cwd
|
|
37
|
+
return realCwd;
|
|
38
|
+
}
|
|
39
|
+
export function detectProjectName(root) {
|
|
40
|
+
const realRoot = realpathSafe(root);
|
|
41
|
+
const pj = path.join(realRoot, 'package.json');
|
|
42
|
+
if (fs.existsSync(pj)) {
|
|
43
|
+
try {
|
|
44
|
+
const pkg = JSON.parse(fs.readFileSync(pj, 'utf8'));
|
|
45
|
+
if (pkg && typeof pkg.name === 'string' && pkg.name.trim()) {
|
|
46
|
+
return String(pkg.name).trim();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// ignore parse errors and fall back to directory name
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return path.basename(realRoot);
|
|
54
|
+
}
|
|
55
|
+
// --- helpers ---
|
|
56
|
+
function realpathSafe(p) {
|
|
57
|
+
try {
|
|
58
|
+
const rp = fs.realpathSync.native
|
|
59
|
+
? fs.realpathSync.native(p)
|
|
60
|
+
: fs.realpathSync(p);
|
|
61
|
+
return rp;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return path.normalize(p);
|
|
65
|
+
}
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-devtools-mcp-for-extension",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "MCP server for Chrome extension development with Web Store automation. Fork of chrome-devtools-mcp with extension-specific tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": "./build/src/index.js",
|