@narumitw/pi-chrome-devtools 0.49.4 → 0.50.1

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/src/runtime.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ChildProcess } from "node:child_process";
2
+ import type { EffectiveBrowserSettings } from "./settings.js";
2
3
 
3
4
  export const DEFAULT_HOST = "127.0.0.1";
4
5
  export const DEFAULT_PORT = 9222;
@@ -26,6 +27,12 @@ export interface ChromeDevToolsState {
26
27
  portConfigured: boolean;
27
28
  autoLaunchEnabled: boolean;
28
29
  browserExecutable?: string;
30
+ extensionPaths: string[];
31
+ browserExecutableSource: EffectiveBrowserSettings["executablePathSource"];
32
+ extensionPathsSource: EffectiveBrowserSettings["extensionPathsSource"];
33
+ settingsFilePath?: string;
34
+ projectSettingsFilePath?: string;
35
+ projectSettingsTrusted: boolean;
29
36
  activePageId?: string;
30
37
  managedBrowser?: ManagedBrowser;
31
38
  launchPromise?: Promise<void>;
@@ -42,6 +49,8 @@ export interface ManagedBrowser {
42
49
  port?: number;
43
50
  exited: boolean;
44
51
  ready: boolean;
52
+ ownerGeneration: number;
53
+ cleanupPromise?: Promise<void>;
45
54
  }
46
55
 
47
56
  export interface BrowserLaunchAttempt {
@@ -83,7 +92,25 @@ export const state: ChromeDevToolsState = {
83
92
  portConfigured: configuredPortOverride !== undefined,
84
93
  autoLaunchEnabled: process.env.PI_CHROME_DEVTOOLS_AUTO_LAUNCH !== "0",
85
94
  browserExecutable: process.env.PI_CHROME_DEVTOOLS_BROWSER,
95
+ extensionPaths: [],
96
+ browserExecutableSource: process.env.PI_CHROME_DEVTOOLS_BROWSER ? "environment" : "default",
97
+ extensionPathsSource: "default",
98
+ projectSettingsTrusted: false,
86
99
  shuttingDown: false,
87
100
  sessionGeneration: 0,
88
101
  sessionController: new AbortController(),
89
102
  };
103
+
104
+ export function applyRuntimeBrowserSettings(
105
+ browser: EffectiveBrowserSettings,
106
+ paths: { user: string; project?: string },
107
+ projectTrusted: boolean,
108
+ ) {
109
+ state.browserExecutable = browser.executablePath;
110
+ state.extensionPaths = [...browser.extensionPaths];
111
+ state.browserExecutableSource = browser.executablePathSource;
112
+ state.extensionPathsSource = browser.extensionPathsSource;
113
+ state.settingsFilePath = paths.user;
114
+ state.projectSettingsFilePath = paths.project;
115
+ state.projectSettingsTrusted = projectTrusted;
116
+ }
package/src/settings.ts CHANGED
@@ -1,135 +1,367 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { access, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
- import { dirname, join } from "node:path";
5
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ access,
5
+ lstat,
6
+ mkdir,
7
+ readFile,
8
+ realpath,
9
+ rename,
10
+ rm,
11
+ stat,
12
+ writeFile,
13
+ } from "node:fs/promises";
14
+ import { dirname, isAbsolute, join, resolve } from "node:path";
15
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
6
16
  import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-names.js";
7
17
 
8
18
  const NEW_SETTINGS_FILE_NAME = "pi-chrome-devtools.json";
9
19
  const LEGACY_SETTINGS_FILE_NAME = "pi-chrome-devtools-settings.json";
20
+
10
21
  export interface ChromeDevToolsSettings {
11
22
  tools: ChromeDevToolsToolName[];
12
23
  updatedAt: number;
13
24
  }
14
25
 
26
+ export type BrowserSettingsSource = "default" | "environment" | "project" | "user";
27
+
28
+ export interface EffectiveBrowserSettings {
29
+ executablePath?: string;
30
+ extensionPaths: string[];
31
+ executablePathSource: BrowserSettingsSource;
32
+ extensionPathsSource: BrowserSettingsSource;
33
+ }
34
+
35
+ export interface ResolvedChromeDevToolsSettings {
36
+ tools?: ChromeDevToolsToolName[];
37
+ updatedAt?: number;
38
+ browser: EffectiveBrowserSettings;
39
+ }
40
+
41
+ export interface SettingsLoadOptions {
42
+ cwd?: string;
43
+ projectTrusted?: boolean;
44
+ }
45
+
46
+ interface SettingsLoadBase {
47
+ effectiveBrowser: EffectiveBrowserSettings;
48
+ paths: { user: string; project?: string };
49
+ warnings: string[];
50
+ notice?: string;
51
+ }
52
+
53
+ export type SettingsLoadResult =
54
+ | (SettingsLoadBase & { kind: "missing"; settings?: undefined })
55
+ | (SettingsLoadBase & { kind: "invalid"; reason: string; settings?: undefined })
56
+ | (SettingsLoadBase & { kind: "loaded"; settings: ResolvedChromeDevToolsSettings });
57
+
15
58
  export interface SettingsFileOperations {
16
59
  write(path: string, data: string): Promise<void>;
17
60
  rename(source: string, destination: string): Promise<void>;
18
61
  }
19
62
 
63
+ interface NormalizedBrowserSection {
64
+ executablePath?: string;
65
+ extensionPaths?: string[];
66
+ }
67
+
68
+ interface NormalizedSettingsDocument {
69
+ tools?: ChromeDevToolsToolName[];
70
+ updatedAt?: number;
71
+ browser?: NormalizedBrowserSection;
72
+ }
73
+
74
+ interface SettingsDocumentResult {
75
+ kind: "missing" | "invalid" | "valid";
76
+ reason?: string;
77
+ document?: Record<string, unknown>;
78
+ normalized?: NormalizedSettingsDocument;
79
+ warnings: string[];
80
+ }
81
+
20
82
  const DEFAULT_FILE_OPERATIONS: SettingsFileOperations = {
21
83
  write: (path, data) => writeFile(path, data, "utf8").then(() => undefined),
22
84
  rename,
23
85
  };
24
86
 
25
- export type SettingsLoadResult =
26
- | { kind: "missing"; notice?: string }
27
- | { kind: "invalid"; reason: string; notice?: string }
28
- | { kind: "loaded"; settings: ChromeDevToolsSettings; notice?: string };
87
+ let settingsSaveQueue = Promise.resolve();
29
88
 
30
- export async function loadSettings(): Promise<SettingsLoadResult> {
89
+ export async function loadSettings(options: SettingsLoadOptions = {}): Promise<SettingsLoadResult> {
31
90
  await settingsSaveQueue;
32
- const newPath = settingsFilePath();
33
- const newSettings = await readSettingsFile(newPath);
34
- if (newSettings.kind !== "missing") {
35
- return withLegacyIgnoredNotice(newSettings);
91
+ const userPath = settingsFilePath();
92
+ const projectPath = options.cwd ? projectSettingsFilePath(options.cwd) : undefined;
93
+ const paths = { user: userPath, ...(projectPath ? { project: projectPath } : {}) };
94
+ const warnings: string[] = [];
95
+
96
+ let user = await readSettingsDocument(userPath, "user");
97
+ const legacyExists = await fileExists(legacySettingsFilePath());
98
+ if (user.kind !== "missing" && legacyExists) {
99
+ warnings.push(
100
+ `Chrome DevTools legacy settings ignored: ${legacySettingsFilePath()} exists, but ${userPath} takes precedence. Delete ${LEGACY_SETTINGS_FILE_NAME} after confirming your settings.`,
101
+ );
36
102
  }
103
+ if (user.kind === "missing") {
104
+ const legacy = await readSettingsDocument(legacySettingsFilePath(), "user");
105
+ const concurrentlyCreated = await readSettingsDocument(userPath, "user");
106
+ if (concurrentlyCreated.kind !== "missing") {
107
+ user = concurrentlyCreated;
108
+ if (legacy.kind !== "missing") {
109
+ warnings.push(
110
+ `Chrome DevTools legacy settings ignored: ${legacySettingsFilePath()} exists, but ${userPath} takes precedence. Delete ${LEGACY_SETTINGS_FILE_NAME} after confirming your settings.`,
111
+ );
112
+ }
113
+ } else if (legacy.kind !== "missing") {
114
+ user = legacy;
115
+ if (legacy.kind === "valid") {
116
+ warnings.push(
117
+ `Using legacy ${LEGACY_SETTINGS_FILE_NAME}; rename it to ${NEW_SETTINGS_FILE_NAME}. Future saves write ${NEW_SETTINGS_FILE_NAME} without modifying the legacy file.`,
118
+ );
119
+ }
120
+ }
121
+ }
122
+ warnings.push(...user.warnings);
37
123
 
38
- const legacyPath = legacySettingsFilePath();
39
- const legacySettings = await readSettingsFile(legacyPath);
40
- const concurrentlyCreatedSettings = await readSettingsFile(newPath);
41
- if (concurrentlyCreatedSettings.kind !== "missing") {
42
- return withLegacyIgnoredNotice(concurrentlyCreatedSettings);
124
+ let project: SettingsDocumentResult = { kind: "missing", warnings: [] };
125
+ if (projectPath && options.projectTrusted) {
126
+ project = await readSettingsDocument(projectPath, "project", options.cwd);
127
+ warnings.push(...project.warnings);
43
128
  }
44
- if (legacySettings.kind === "missing") return { kind: "missing" };
45
- if (legacySettings.kind === "invalid") return legacySettings;
46
129
 
130
+ const effectiveBrowser = resolveEffectiveBrowser(
131
+ user.normalized?.browser,
132
+ project.normalized?.browser,
133
+ );
134
+ const settings = resolveSettings(user.normalized, project.normalized, effectiveBrowser);
135
+ const recognized =
136
+ settings.tools !== undefined ||
137
+ user.normalized?.browser !== undefined ||
138
+ project.normalized?.browser !== undefined;
139
+ const invalidReasons = [user, project]
140
+ .filter((result) => result.kind === "invalid")
141
+ .map((result) => result.reason)
142
+ .filter((reason): reason is string => Boolean(reason));
143
+ const base = {
144
+ effectiveBrowser,
145
+ paths,
146
+ warnings,
147
+ ...(warnings.length > 0 ? { notice: warnings.join("\n") } : {}),
148
+ };
149
+
150
+ if (recognized) return { ...base, kind: "loaded", settings };
151
+ if (invalidReasons.length > 0) {
152
+ return { ...base, kind: "invalid", reason: invalidReasons.join("; ") };
153
+ }
154
+ return { ...base, kind: "missing" };
155
+ }
156
+
157
+ function resolveSettings(
158
+ user: NormalizedSettingsDocument | undefined,
159
+ _project: NormalizedSettingsDocument | undefined,
160
+ browser: EffectiveBrowserSettings,
161
+ ): ResolvedChromeDevToolsSettings {
47
162
  return {
48
- ...legacySettings,
49
- notice: `Using legacy ${LEGACY_SETTINGS_FILE_NAME}; rename it to ${NEW_SETTINGS_FILE_NAME}. Future saves write ${NEW_SETTINGS_FILE_NAME} without modifying the legacy file.`,
163
+ ...(user?.tools ? { tools: user.tools, updatedAt: user.updatedAt } : {}),
164
+ browser,
50
165
  };
51
166
  }
52
167
 
53
- interface SettingsDocumentResult {
54
- result: SettingsLoadResult;
55
- document?: Record<string, unknown>;
168
+ function resolveEffectiveBrowser(
169
+ user: NormalizedBrowserSection | undefined,
170
+ project: NormalizedBrowserSection | undefined,
171
+ ): EffectiveBrowserSettings {
172
+ const environmentExecutable = process.env.PI_CHROME_DEVTOOLS_BROWSER;
173
+ const executablePath = environmentExecutable || user?.executablePath;
174
+ const extensionPaths = project?.extensionPaths ?? user?.extensionPaths ?? [];
175
+ return {
176
+ ...(executablePath ? { executablePath } : {}),
177
+ extensionPaths: [...extensionPaths],
178
+ executablePathSource: environmentExecutable
179
+ ? "environment"
180
+ : user?.executablePath
181
+ ? "user"
182
+ : "default",
183
+ extensionPathsSource: project?.extensionPaths
184
+ ? "project"
185
+ : user?.extensionPaths
186
+ ? "user"
187
+ : "default",
188
+ };
56
189
  }
57
190
 
58
- async function readSettingsDocument(filePath: string): Promise<SettingsDocumentResult> {
191
+ async function readSettingsDocument(
192
+ filePath: string,
193
+ scope: "project" | "user",
194
+ cwd?: string,
195
+ ): Promise<SettingsDocumentResult> {
59
196
  let text: string;
60
197
  try {
61
198
  text = await readFile(filePath, "utf8");
62
199
  } catch (error) {
63
- if (isNodeError(error) && error.code === "ENOENT") return { result: { kind: "missing" } };
64
- return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
200
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing", warnings: [] };
201
+ const reason = `${filePath}: ${formatError(error)}`;
202
+ return { kind: "invalid", reason, warnings: [`Chrome DevTools settings ignored: ${reason}`] };
65
203
  }
66
204
 
205
+ let parsed: unknown;
67
206
  try {
68
- const parsed = JSON.parse(text) as unknown;
69
- const settings = normalizeChromeDevtoolsSettings(parsed);
70
- if (settings) {
71
- return {
72
- result: { kind: "loaded", settings },
73
- document: { ...(parsed as Record<string, unknown>) },
74
- };
75
- }
207
+ parsed = JSON.parse(text) as unknown;
208
+ } catch {
209
+ const reason = `${filePath}: invalid JSON`;
210
+ return { kind: "invalid", reason, warnings: [`Chrome DevTools settings ignored: ${reason}`] };
211
+ }
212
+ if (!isRecord(parsed)) {
213
+ const reason = `${filePath}: expected a JSON object`;
214
+ return { kind: "invalid", reason, warnings: [`Chrome DevTools settings ignored: ${reason}`] };
215
+ }
216
+
217
+ const scopeWarnings = projectExecutableWarnings(parsed, scope, filePath);
218
+ try {
219
+ const normalized = await normalizeSettingsDocument(parsed, scope, filePath, cwd);
220
+ return { kind: "valid", document: { ...parsed }, normalized, warnings: scopeWarnings };
221
+ } catch (error) {
222
+ const reason = `${filePath}: ${formatError(error)}`;
76
223
  return {
77
- result: {
78
- kind: "invalid",
79
- reason: `${filePath}: expected tools to be an array of Chrome DevTools tool names`,
80
- },
224
+ kind: "invalid",
225
+ reason,
226
+ warnings: [...scopeWarnings, `Chrome DevTools settings ignored: ${reason}`],
81
227
  };
82
- } catch (error) {
83
- return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
84
228
  }
85
229
  }
86
230
 
87
- async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
88
- return (await readSettingsDocument(filePath)).result;
231
+ function projectExecutableWarnings(
232
+ document: Record<string, unknown>,
233
+ scope: "project" | "user",
234
+ filePath: string,
235
+ ) {
236
+ if (
237
+ scope !== "project" ||
238
+ !isRecord(document.browser) ||
239
+ document.browser.executablePath === undefined
240
+ ) {
241
+ return [];
242
+ }
243
+ return [
244
+ `Chrome DevTools project browser.executablePath ignored in ${filePath}; configure the machine-owned executable in ${settingsFilePath()}.`,
245
+ ];
89
246
  }
90
247
 
91
- async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<SettingsLoadResult> {
92
- if (!(await fileExists(legacySettingsFilePath()))) return settings;
93
- return {
94
- ...settings,
95
- notice: `Chrome DevTools legacy settings ignored: ${legacySettingsFilePath()} exists, but ${settingsFilePath()} takes precedence. Delete ${LEGACY_SETTINGS_FILE_NAME} after confirming your settings.`,
96
- };
248
+ async function normalizeSettingsDocument(
249
+ document: Record<string, unknown>,
250
+ scope: "project" | "user",
251
+ filePath: string,
252
+ cwd?: string,
253
+ ): Promise<NormalizedSettingsDocument> {
254
+ const normalized: NormalizedSettingsDocument = {};
255
+
256
+ if (scope === "user" && (document.tools !== undefined || document.updatedAt !== undefined)) {
257
+ const toolSettings = normalizeChromeDevtoolsSettings(document);
258
+ if (!toolSettings) {
259
+ throw new Error(
260
+ "expected tools to be an array of Chrome DevTools tool names with numeric updatedAt",
261
+ );
262
+ }
263
+ normalized.tools = toolSettings.tools;
264
+ normalized.updatedAt = toolSettings.updatedAt;
265
+ }
266
+
267
+ if (document.browser !== undefined) {
268
+ if (!isRecord(document.browser)) throw new Error("expected browser to be an object");
269
+ normalized.browser = await normalizeBrowserSection(document.browser, scope, filePath, cwd);
270
+ }
271
+
272
+ return normalized;
97
273
  }
98
274
 
99
- async function fileExists(filePath: string) {
100
- try {
101
- await access(filePath, constants.F_OK);
102
- return true;
103
- } catch {
104
- return false;
275
+ async function normalizeBrowserSection(
276
+ browser: Record<string, unknown>,
277
+ scope: "project" | "user",
278
+ _filePath: string,
279
+ cwd?: string,
280
+ ): Promise<NormalizedBrowserSection> {
281
+ const normalized: NormalizedBrowserSection = {};
282
+ if (scope === "user" && browser.executablePath !== undefined) {
283
+ if (typeof browser.executablePath !== "string" || browser.executablePath.length === 0) {
284
+ throw new Error("expected browser.executablePath to be a non-empty absolute path");
285
+ }
286
+ if (!isAbsolute(browser.executablePath)) {
287
+ throw new Error("browser.executablePath in user settings must be absolute");
288
+ }
289
+ normalized.executablePath = resolve(browser.executablePath);
105
290
  }
291
+
292
+ if (browser.extensionPaths !== undefined) {
293
+ if (
294
+ !Array.isArray(browser.extensionPaths) ||
295
+ !browser.extensionPaths.every((entry) => typeof entry === "string" && entry.length > 0)
296
+ ) {
297
+ throw new Error(`expected ${scope} browser.extensionPaths to be an array of non-empty paths`);
298
+ }
299
+ const resolvedPaths: string[] = [];
300
+ for (const configuredPath of browser.extensionPaths as string[]) {
301
+ if (scope === "user" && !isAbsolute(configuredPath)) {
302
+ throw new Error("browser.extensionPaths in user settings must contain only absolute paths");
303
+ }
304
+ const absolutePath = isAbsolute(configuredPath)
305
+ ? resolve(configuredPath)
306
+ : resolve(cwd ?? process.cwd(), configuredPath);
307
+ resolvedPaths.push(await validateUnpackedExtensionPath(absolutePath));
308
+ }
309
+ normalized.extensionPaths = orderedUnique(resolvedPaths);
310
+ }
311
+ return normalized;
106
312
  }
107
313
 
108
- async function pathEntryExists(filePath: string) {
314
+ async function validateUnpackedExtensionPath(extensionPath: string) {
315
+ let canonicalPath: string;
109
316
  try {
110
- await lstat(filePath);
111
- return true;
317
+ canonicalPath = await realpath(extensionPath);
318
+ const extensionStat = await stat(canonicalPath);
319
+ if (!extensionStat.isDirectory()) throw new Error("not a directory");
112
320
  } catch (error) {
113
- if (isNodeError(error) && error.code === "ENOENT") return false;
114
- throw error;
321
+ throw new Error(`unpacked extension path ${extensionPath} is invalid: ${formatError(error)}`);
322
+ }
323
+
324
+ const manifestPath = join(canonicalPath, "manifest.json");
325
+ let manifest: unknown;
326
+ try {
327
+ const manifestStat = await lstat(manifestPath);
328
+ if (!manifestStat.isFile()) throw new Error("not a regular file");
329
+ manifest = JSON.parse(await readFile(manifestPath, "utf8")) as unknown;
330
+ } catch (error) {
331
+ throw new Error(
332
+ `unpacked extension ${canonicalPath} has invalid manifest.json: ${formatError(error)}`,
333
+ );
115
334
  }
335
+ if (canonicalPath.includes(",")) {
336
+ throw new Error(
337
+ `unpacked extension path ${canonicalPath} cannot contain a comma because Chrome separates multiple --load-extension paths with commas`,
338
+ );
339
+ }
340
+ if (
341
+ !isRecord(manifest) ||
342
+ ![2, 3].includes(manifest.manifest_version as number) ||
343
+ typeof manifest.name !== "string" ||
344
+ typeof manifest.version !== "string"
345
+ ) {
346
+ throw new Error(
347
+ `unpacked extension ${canonicalPath} has invalid manifest.json: expected manifest_version, name, and version`,
348
+ );
349
+ }
350
+ return canonicalPath;
116
351
  }
117
352
 
118
353
  export function normalizeChromeDevtoolsSettings(
119
354
  value: unknown,
120
355
  ): ChromeDevToolsSettings | undefined {
121
- if (!value || typeof value !== "object") return undefined;
122
- const settings = value as { tools?: unknown; updatedAt?: unknown };
123
- if (typeof settings.updatedAt !== "number") return undefined;
356
+ if (!isRecord(value)) return undefined;
357
+ if (typeof value.updatedAt !== "number" || !Number.isFinite(value.updatedAt)) return undefined;
124
358
 
125
- if (settings.tools === "enabled") {
126
- return { tools: [...CHROME_DEVTOOLS_TOOL_NAMES], updatedAt: settings.updatedAt };
359
+ if (value.tools === "enabled") {
360
+ return { tools: [...CHROME_DEVTOOLS_TOOL_NAMES], updatedAt: value.updatedAt };
127
361
  }
128
- if (settings.tools === "disabled") return { tools: [], updatedAt: settings.updatedAt };
129
-
130
- if (!Array.isArray(settings.tools)) return undefined;
131
- if (!settings.tools.every(isChromeDevtoolsToolName)) return undefined;
132
- return { tools: orderedUniqueChromeDevtoolsTools(settings.tools), updatedAt: settings.updatedAt };
362
+ if (value.tools === "disabled") return { tools: [], updatedAt: value.updatedAt };
363
+ if (!Array.isArray(value.tools) || !value.tools.every(isChromeDevtoolsToolName)) return undefined;
364
+ return { tools: orderedUniqueChromeDevtoolsTools(value.tools), updatedAt: value.updatedAt };
133
365
  }
134
366
 
135
367
  function isChromeDevtoolsToolName(value: unknown): value is ChromeDevToolsToolName {
@@ -141,7 +373,9 @@ function orderedUniqueChromeDevtoolsTools(tools: readonly ChromeDevToolsToolName
141
373
  return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => selectedTools.has(toolName));
142
374
  }
143
375
 
144
- let settingsSaveQueue = Promise.resolve();
376
+ function orderedUnique(values: readonly string[]) {
377
+ return [...new Set(values)];
378
+ }
145
379
 
146
380
  export function saveSettings(
147
381
  settings: ChromeDevToolsSettings,
@@ -157,13 +391,11 @@ async function saveSettingsNow(
157
391
  operations: Partial<SettingsFileOperations>,
158
392
  ): Promise<void> {
159
393
  const filePath = settingsFilePath();
160
- let current = await readSettingsDocument(filePath);
161
- const replaceCanonical = current.result.kind !== "missing";
162
- if (!replaceCanonical) current = await readSettingsDocument(legacySettingsFilePath());
163
- if (current.result.kind === "invalid") {
164
- throw new Error(
165
- `Cannot save Chrome DevTools settings until you repair ${current.result.reason}`,
166
- );
394
+ let current = await readSettingsDocument(filePath, "user");
395
+ const replaceCanonical = current.kind !== "missing";
396
+ if (!replaceCanonical) current = await readSettingsDocument(legacySettingsFilePath(), "user");
397
+ if (current.kind === "invalid") {
398
+ throw new Error(`Cannot save Chrome DevTools settings until you repair ${current.reason}`);
167
399
  }
168
400
  const nextDocument = {
169
401
  ...(current.document ?? {}),
@@ -190,15 +422,38 @@ async function saveSettingsNow(
190
422
  }
191
423
 
192
424
  export function settingsFilePath() {
193
- return join(agentDir(), NEW_SETTINGS_FILE_NAME);
425
+ return join(getAgentDir(), NEW_SETTINGS_FILE_NAME);
426
+ }
427
+
428
+ export function projectSettingsFilePath(cwd: string) {
429
+ return join(cwd, CONFIG_DIR_NAME, NEW_SETTINGS_FILE_NAME);
194
430
  }
195
431
 
196
432
  function legacySettingsFilePath() {
197
- return join(agentDir(), LEGACY_SETTINGS_FILE_NAME);
433
+ return join(getAgentDir(), LEGACY_SETTINGS_FILE_NAME);
434
+ }
435
+
436
+ async function fileExists(filePath: string) {
437
+ try {
438
+ await access(filePath, constants.F_OK);
439
+ return true;
440
+ } catch {
441
+ return false;
442
+ }
443
+ }
444
+
445
+ async function pathEntryExists(filePath: string) {
446
+ try {
447
+ await lstat(filePath);
448
+ return true;
449
+ } catch (error) {
450
+ if (isNodeError(error) && error.code === "ENOENT") return false;
451
+ throw error;
452
+ }
198
453
  }
199
454
 
200
- function agentDir() {
201
- return getAgentDir();
455
+ function isRecord(value: unknown): value is Record<string, unknown> {
456
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
202
457
  }
203
458
 
204
459
  function isNodeError(error: unknown): error is NodeJS.ErrnoException {