mcp-browser-extension 0.9.28 → 0.9.30

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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  **Website and docs:** [mcp-browser-extension.com](https://mcp-browser-extension.com) ·
8
8
  [Quickstart](https://mcp-browser-extension.com/docs/quickstart) ·
9
- [All 42 tools](https://mcp-browser-extension.com/docs/tools) ·
9
+ [All 43 tools](https://mcp-browser-extension.com/docs/tools) ·
10
10
  [Install the extension from the Chrome Web Store](https://chromewebstore.google.com/detail/mcp-browser-extension/jelfhdlkhbfmlpbghoeaepijllcnplgh)
11
11
 
12
12
  **Let Claude use the Chrome you are already logged into.** Not a fresh
@@ -447,7 +447,7 @@ Reading the DOM tells you what a page looks like after it failed, not why. With
447
447
  and `fetch`/`XMLHttpRequest` traffic, and intercepts native dialogs:
448
448
 
449
449
  ```jsonc
450
- console_logs { "level": "error" } // the exception the page swallowed
450
+ console_logs { "level": "exception" } // the uncaught error behind a broken page
451
451
  network_log { "failedOnly": true } // the 500 behind the blank screen
452
452
  dialogs { "policy": "accept" } // answer confirm() with true from here on
453
453
  ```
@@ -487,6 +487,24 @@ refs renumber in document order on every snapshot, so diffing on them would
487
487
  report an unchanged button as removed-and-re-added the moment anything above it
488
488
  appears.
489
489
 
490
+ ### Remember a page and catch changes later — `page_watch`
491
+
492
+ Save a named accessibility checkpoint, then compare the current page against it
493
+ in a later session. Checkpoints live under the active Chrome profile, so work
494
+ and personal browser identities stay separate:
495
+
496
+ ```jsonc
497
+ page_watch { "action": "create", "name": "Vendor pricing" }
498
+ page_watch { "action": "compare", "watchId": "watch_…" }
499
+ page_watch { "action": "list" }
500
+ page_watch { "action": "delete", "watchId": "watch_…" }
501
+ ```
502
+
503
+ The result shows added, removed and changed controls. It stores accessibility
504
+ labels and control state, strips URL query strings, applies configured redaction,
505
+ and never saves live form values. Creating or comparing a checkpoint still
506
+ requires the page's domain to be allowed.
507
+
490
508
  ### Targeting by role and name
491
509
 
492
510
  Actions accept a locator instead of a CSS selector, so you don't need a snapshot
@@ -16,12 +16,18 @@
16
16
  * One resolver, used by every op, is what closes it.
17
17
  */
18
18
  /** Ops the page-side dispatcher understands. */
19
- export type PageOpName = 'probe' | 'text' | 'html' | 'click' | 'type' | 'focus' | 'point' | 'hover' | 'select' | 'measure' | 'scroll' | 'storage' | 'waitSelector' | 'waitFor';
19
+ export type PageOpName = 'probe' | 'text' | 'html' | 'click' | 'check' | 'type' | 'focus' | 'point' | 'hover' | 'select' | 'measure' | 'scroll' | 'storage' | 'waitSelector' | 'waitFor';
20
20
  export interface PageOpArgs {
21
21
  op: PageOpName;
22
22
  selector?: string | null;
23
23
  text?: string;
24
24
  clear?: boolean;
25
+ /** type: send keydown/keypress/keyup around each character. */
26
+ keyEvents?: boolean;
27
+ /** type: press Enter after the text (submits the field's form like a person would). */
28
+ pressEnter?: boolean;
29
+ /** check: the state a checkbox/radio should end up in. */
30
+ checked?: boolean;
25
31
  outer?: boolean;
26
32
  values?: string[];
27
33
  x?: number | null;
@@ -208,6 +208,18 @@ function pageOp(a) {
208
208
  el.click();
209
209
  return { found: true };
210
210
  }
211
+ case 'check': {
212
+ if (!el)
213
+ return { found: false };
214
+ // Click only when the state differs: a click toggles, so clicking an
215
+ // already-checked box to "check" it would clear it.
216
+ const box = el;
217
+ if (box.checked !== (a.checked !== false)) {
218
+ el.scrollIntoView({ block: 'center' });
219
+ el.click();
220
+ }
221
+ return { found: true, checked: box.checked };
222
+ }
211
223
  case 'type': {
212
224
  if (!el)
213
225
  return { found: false };
@@ -226,9 +238,32 @@ function pageOp(a) {
226
238
  if (!node)
227
239
  return { found: false };
228
240
  node.focus();
229
- const next = (a.clear ? '' : node.value ?? '') + (a.text ?? '');
230
- setValue(node, next);
241
+ if (a.keyEvents) {
242
+ // One character at a time, each inside its own key events, for pages
243
+ // that react to keystrokes rather than to the value.
244
+ if (a.clear)
245
+ setValue(node, '');
246
+ for (const ch of a.text ?? '') {
247
+ const ev = { key: ch, bubbles: true, cancelable: true };
248
+ const go = node.dispatchEvent(new KeyboardEvent('keydown', ev));
249
+ if (go && node.dispatchEvent(new KeyboardEvent('keypress', ev)))
250
+ setValue(node, (node.value ?? '') + ch);
251
+ node.dispatchEvent(new KeyboardEvent('keyup', ev));
252
+ }
253
+ }
254
+ else {
255
+ setValue(node, (a.clear ? '' : node.value ?? '') + (a.text ?? ''));
256
+ }
231
257
  node.dispatchEvent(new Event('change', { bubbles: true }));
258
+ if (a.pressEnter) {
259
+ const ev = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
260
+ const go = node.dispatchEvent(new KeyboardEvent('keydown', ev)) && node.dispatchEvent(new KeyboardEvent('keypress', ev));
261
+ node.dispatchEvent(new KeyboardEvent('keyup', ev));
262
+ // Synthetic key events never trigger implicit submission; do what the
263
+ // browser would for Enter in a single-line field of a form.
264
+ if (go && node instanceof HTMLInputElement && node.form)
265
+ node.form.requestSubmit();
266
+ }
232
267
  return { found: true };
233
268
  }
234
269
  case 'focus': {
@@ -34,6 +34,12 @@ const READ_CONTENT = new Set([
34
34
  'frames_list',
35
35
  'observers',
36
36
  'print_pdf',
37
+ // The page's accessibility tree, its cookies (session secrets), and its
38
+ // web storage are content too. A storage write is refused in safe mode by
39
+ // the extension's storage handler; the domain gate here covers every op.
40
+ 'snapshot',
41
+ 'get_cookies',
42
+ 'storage',
37
43
  ]);
38
44
  /** Content-mutating actions — URL-gated AND mutation-gated. */
39
45
  const MUTATE_CONTENT = new Set([
@@ -44,6 +50,7 @@ const MUTATE_CONTENT = new Set([
44
50
  'hover',
45
51
  'scroll',
46
52
  'fill_form',
53
+ 'select_option',
47
54
  ]);
48
55
  /** Navigation — URL-gated by the DESTINATION url, and mutation-gated. */
49
56
  const NAVIGATION = new Set([
@@ -72,7 +79,9 @@ function isUrlGated(method) {
72
79
  MUTATE_CONTENT.has(method) ||
73
80
  NAVIGATION.has(method) ||
74
81
  method === 'eval' ||
75
- method === 'upload_file');
82
+ method === 'upload_file' ||
83
+ // Gated on where the bytes come from: the URL when one is given, else the tab.
84
+ method === 'download_file');
76
85
  }
77
86
  // ---------------------------------------------------------------------------
78
87
  // Domain matching
@@ -72,6 +72,13 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
72
72
  if (t)
73
73
  return t;
74
74
  }
75
+ // <label for> or a wrapping <label>: how ordinary forms name their fields.
76
+ const labels = el.labels;
77
+ if (labels && labels.length > 0) {
78
+ const t = Array.from(labels).map((l) => l.innerText).join(' ').replace(/\s+/g, ' ').trim();
79
+ if (t)
80
+ return t;
81
+ }
75
82
  const ph = el.getAttribute('placeholder');
76
83
  if (ph)
77
84
  return ph.trim();
@@ -113,8 +120,11 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
113
120
  // recursively. Defensive against null/closed shadow roots and re-visits.
114
121
  const seen = new Set();
115
122
  const candidates = [];
123
+ // Gather past `max` so hidden candidates dropped below still leave enough to
124
+ // fill it, and so a page with more than `max` can be reported as truncated.
125
+ const cap = Math.max(max * 2, max + 50);
116
126
  const collect = (root) => {
117
- if (candidates.length >= max)
127
+ if (candidates.length >= cap)
118
128
  return;
119
129
  let matched;
120
130
  try {
@@ -124,7 +134,7 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
124
134
  matched = [];
125
135
  }
126
136
  for (let i = 0; i < matched.length; i++) {
127
- if (candidates.length >= max)
137
+ if (candidates.length >= cap)
128
138
  break;
129
139
  const el = matched[i];
130
140
  if (seen.has(el))
@@ -142,7 +152,7 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
142
152
  hosts = [];
143
153
  }
144
154
  for (let i = 0; i < hosts.length; i++) {
145
- if (candidates.length >= max)
155
+ if (candidates.length >= cap)
146
156
  break;
147
157
  const sr = hosts[i].shadowRoot;
148
158
  if (sr)
@@ -230,7 +240,8 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
230
240
  break;
231
241
  picked.push(read(el));
232
242
  }
233
- truncated = els.length > picked.length;
243
+ // At the cap there may be more on the page than were even looked at.
244
+ truncated = els.length > picked.length || candidates.length >= cap;
234
245
  }
235
246
  // -- phase 2: writes (one attribute per returned node) ----------------------
236
247
  const nodes = [];
@@ -0,0 +1,17 @@
1
+ /**
2
+ * src/bridge/host-claim.ts — retire a server its MCP host has replaced.
3
+ *
4
+ * Some hosts (Claude Code's /mcp "Reconnect" among them) spawn a fresh child
5
+ * without closing the old one's stdin or signalling it. The old server then
6
+ * lingers with its OLD flags and, if it holds the port, keeps serving the
7
+ * browser: a reconnect that was meant to apply a new flag silently doesn't.
8
+ *
9
+ * Each stdio server claims `hosts/<parentPid>-<profile>` on start. The newest
10
+ * claim wins; an older server that finds someone else's pid in its claim file
11
+ * knows the same host replaced it and shuts itself down. Nothing is ever killed
12
+ * here, so a recycled pid cannot cause a wrong kill.
13
+ */
14
+ /** Record this process as the current server for (parent, profile). */
15
+ export declare function claimHost(dataDir: string, parentPid: number, profile: string, pid?: number): void;
16
+ /** True when a newer server from the same parent has claimed (parent, profile). */
17
+ export declare function supersededBy(dataDir: string, parentPid: number, profile: string, pid?: number): number | null;
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ /**
3
+ * src/bridge/host-claim.ts — retire a server its MCP host has replaced.
4
+ *
5
+ * Some hosts (Claude Code's /mcp "Reconnect" among them) spawn a fresh child
6
+ * without closing the old one's stdin or signalling it. The old server then
7
+ * lingers with its OLD flags and, if it holds the port, keeps serving the
8
+ * browser: a reconnect that was meant to apply a new flag silently doesn't.
9
+ *
10
+ * Each stdio server claims `hosts/<parentPid>-<profile>` on start. The newest
11
+ * claim wins; an older server that finds someone else's pid in its claim file
12
+ * knows the same host replaced it and shuts itself down. Nothing is ever killed
13
+ * here, so a recycled pid cannot cause a wrong kill.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.claimHost = claimHost;
17
+ exports.supersededBy = supersededBy;
18
+ const node_fs_1 = require("node:fs");
19
+ const node_path_1 = require("node:path");
20
+ const atomic_file_1 = require("../atomic-file");
21
+ function claimPath(dataDir, parentPid, profile) {
22
+ const safe = profile.replace(/[^A-Za-z0-9._-]/g, '_') || 'default';
23
+ return (0, node_path_1.join)(dataDir, 'hosts', `${parentPid}-${safe}`);
24
+ }
25
+ /** Record this process as the current server for (parent, profile). */
26
+ function claimHost(dataDir, parentPid, profile, pid = process.pid) {
27
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(dataDir, 'hosts'), { recursive: true, mode: 0o700 });
28
+ (0, atomic_file_1.writeFileAtomic)(claimPath(dataDir, parentPid, profile), `${pid}\n`, 0o600);
29
+ }
30
+ /** True when a newer server from the same parent has claimed (parent, profile). */
31
+ function supersededBy(dataDir, parentPid, profile, pid = process.pid) {
32
+ let text;
33
+ try {
34
+ text = (0, node_fs_1.readFileSync)(claimPath(dataDir, parentPid, profile), 'utf8');
35
+ }
36
+ catch {
37
+ return null; // missing or unreadable: never retire on doubt
38
+ }
39
+ const owner = Number.parseInt(text.trim(), 10);
40
+ return Number.isInteger(owner) && owner > 0 && owner !== pid ? owner : null;
41
+ }
42
+ //# sourceMappingURL=host-claim.js.map
package/dist/src/cli.js CHANGED
@@ -19,6 +19,8 @@ const tasks_1 = require("./bridge/tasks");
19
19
  const manager_1 = require("./executor/manager");
20
20
  const select_1 = require("./executor/select");
21
21
  const server_1 = require("./bridge/server");
22
+ const policy_1 = require("./security/policy");
23
+ const host_claim_1 = require("./bridge/host-claim");
22
24
  const datadir_1 = require("./bridge/datadir");
23
25
  const workspace_1 = require("./bridge/workspace");
24
26
  const auth_1 = require("./bridge/auth");
@@ -260,14 +262,10 @@ async function main() {
260
262
  (0, tools_1.setToolAllowlist)(cfg.tools);
261
263
  const dataDir = (0, datadir_1.ensureDataDir)(cfg.dataDir);
262
264
  const token = (0, auth_1.resolveToken)(dataDir, { persist: cfg.persistToken });
263
- const { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations } = cfg.policy;
264
265
  const bridge = new server_1.BridgeServer({
265
266
  token,
266
267
  serverVersion: version(),
267
- // Wire-serializable policy subset (no local uploadsDir) so the extension mirrors the gate.
268
- // `allowDomains` is passed BY REFERENCE: sites allowed from the extension's
269
- // Options are spliced into this same array, which the tool gate reads too.
270
- policy: { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations },
268
+ policy: (0, policy_1.toWirePolicy)(cfg.policy),
271
269
  siteGrants: !cfg.noSiteGrants,
272
270
  latestExtensionVersion: bundledExtensionVersion(),
273
271
  port: cfg.wsPort,
@@ -436,9 +434,23 @@ async function main() {
436
434
  // holding the port, and its usage is never reported. Notice instead when the
437
435
  // parent is gone, or when writing to it fails.
438
436
  const parentPid = process.ppid;
437
+ // A host that "reconnects" by spawning a new child may leave us running with
438
+ // our old flags; the newest server from the same parent claims the slot.
439
+ const hostProfile = cfg.profile ?? 'default';
440
+ try {
441
+ (0, host_claim_1.claimHost)(dataDir, parentPid, hostProfile);
442
+ }
443
+ catch (err) {
444
+ (0, server_2.logDebug)(`host claim skipped: ${err.message}`);
445
+ }
439
446
  const watchdog = setInterval(() => {
440
447
  if (process.ppid !== parentPid)
441
448
  return shutdown(); // reparented: the parent exited
449
+ const newer = (0, host_claim_1.supersededBy)(dataDir, parentPid, hostProfile);
450
+ if (newer !== null) {
451
+ (0, server_2.logErr)(`replaced by a newer server (pid ${newer}) from the same MCP host; exiting`);
452
+ return shutdown();
453
+ }
442
454
  try {
443
455
  process.kill(parentPid, 0);
444
456
  }
@@ -97,6 +97,9 @@ export declare class CdpExecutor implements Executor {
97
97
  selectOption(t: Target, values: string[], opts?: {
98
98
  tabId?: TabId;
99
99
  }): Promise<ActionOk>;
100
+ setChecked(t: Target, checked: boolean, opts?: {
101
+ tabId?: TabId;
102
+ }): Promise<ActionOk>;
100
103
  fill(t: Target, value: string, opts?: {
101
104
  tabId?: TabId;
102
105
  }): Promise<ActionOk>;
@@ -272,8 +272,15 @@ class CdpExecutor {
272
272
  catch (err) {
273
273
  const msg = err.message ?? '';
274
274
  if (CLOSED_TARGET.test(msg)) {
275
+ const stale = this.context;
275
276
  this.context = null;
276
277
  this.tabs.clear();
278
+ // A browser we launched is ours to close; otherwise the relaunch on the
279
+ // next call leaves this one running. Never close the user's Chrome.
280
+ if (this.opts.mode !== 'connect' && this.launched) {
281
+ this.launched = false;
282
+ void stale?.close().catch(() => undefined);
283
+ }
277
284
  throw new types_1.ExecutorError('DETACHED', 'browser target closed or crashed; reconnect and retry');
278
285
  }
279
286
  throw err;
@@ -397,6 +404,13 @@ class CdpExecutor {
397
404
  return this.ok;
398
405
  });
399
406
  }
407
+ async setChecked(t, checked, opts) {
408
+ return this.guard(async () => {
409
+ const p = await this.resolveTab(opts?.tabId);
410
+ await this.locator(p, t).setChecked(checked);
411
+ return this.ok;
412
+ });
413
+ }
400
414
  async fill(t, value, opts) {
401
415
  return this.guard(async () => {
402
416
  const p = await this.resolveTab(opts?.tabId);
@@ -168,10 +168,20 @@ class ExtensionExecutor {
168
168
  return (await this.send('type', { ...targetParams(t), ...frameParams(opts), text: value, clear: true, keyEvents: false }, { tabId: opts?.tabId }));
169
169
  }
170
170
  async fillFields(fields, opts) {
171
- // An extension that predates the op never advertised it: let the caller go field by field.
172
- if (!this.bridge.hasCap(this.activeProfile(), protocol_1.WIRE_CAP_FILL_FORM))
173
- return null;
174
- const res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId, timeoutMs: (0, protocol_1.fillFormTimeoutMs)(fields.length) }));
171
+ // No capability pre-check alone: a server relaying through another one (a
172
+ // second AI session) cannot see the extension's caps, so ask and read the
173
+ // answer. An extension that predates the op says it has no such method:
174
+ // then let the caller go field by field.
175
+ let res;
176
+ try {
177
+ res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId, timeoutMs: (0, protocol_1.fillFormTimeoutMs)(fields.length) }));
178
+ }
179
+ catch (err) {
180
+ if (!this.bridge.hasCap(this.activeProfile(), protocol_1.WIRE_CAP_FILL_FORM) && err instanceof types_1.ExecutorError && /unhandled method/.test(err.message)) {
181
+ return null;
182
+ }
183
+ throw err;
184
+ }
175
185
  if (res.error) {
176
186
  // Say how far the batch got: the fields before this one DID land, and a
177
187
  // blind retry of the whole form would write them a second time.
@@ -361,6 +361,10 @@ export interface Executor {
361
361
  } & FrameOpts): Promise<{
362
362
  filled: number;
363
363
  } | null>;
364
+ /** Put a checkbox/radio into `checked` (no-op when it already is). */
365
+ setChecked?(t: Target, checked: boolean, opts?: {
366
+ tabId?: TabId;
367
+ } & FrameOpts): Promise<ActionOk>;
364
368
  /** Every frame of a tab that the extension can inject into, with its URL. */
365
369
  framesList?(opts?: {
366
370
  tabId?: TabId;
@@ -166,8 +166,17 @@ async function fillForm(ex, args) {
166
166
  for (const { selector, value } of ops) {
167
167
  const target = { selector };
168
168
  if (typeof value === 'boolean') {
169
- // Checkbox/radio: a click toggles it.
170
- await ex.click(target, opts);
169
+ // Checkbox/radio: set the state. A bare click toggles, so it is only
170
+ // right for a backend that cannot set it, and only to turn it on.
171
+ if (ex.setChecked)
172
+ await ex.setChecked(target, value, opts);
173
+ else if (value)
174
+ await ex.click(target, opts);
175
+ else {
176
+ // Unticking needs the current state, which this backend cannot read;
177
+ // a silent no-op would report a field as filled that was not.
178
+ throw new types_1.ExecutorError('UNSUPPORTED', `cannot set ${selector} to unchecked with this browser backend; update the MCP Browser Extension`);
179
+ }
171
180
  }
172
181
  else {
173
182
  await ex.fill(target, value, opts);
@@ -0,0 +1,12 @@
1
+ import type { SnapshotResult } from '../executor/types';
2
+ import type { SnapshotDiff, StoredSnapshot } from './snapdiff';
3
+ export interface PageWatch extends StoredSnapshot {
4
+ name: string;
5
+ }
6
+ /** Keep enough structure for useful diffs while never persisting live form values. */
7
+ export declare function safeSnapshot(snap: SnapshotResult, redact: (s: string) => string): SnapshotResult;
8
+ export declare function createWatch(profileDir: string, name: string, snap: SnapshotResult): PageWatch;
9
+ export declare function readWatch(profileDir: string, id: string): PageWatch;
10
+ export declare function listWatches(profileDir: string): Array<Pick<PageWatch, 'id' | 'name' | 'ts' | 'url'>>;
11
+ export declare function removeWatch(profileDir: string, id: string): void;
12
+ export declare function compareWatch(previous: PageWatch, current: SnapshotResult, redact: (s: string) => string): SnapshotDiff;
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.safeSnapshot = safeSnapshot;
4
+ exports.createWatch = createWatch;
5
+ exports.readWatch = readWatch;
6
+ exports.listWatches = listWatches;
7
+ exports.removeWatch = removeWatch;
8
+ exports.compareWatch = compareWatch;
9
+ /** Persistent, profile-scoped accessibility checkpoints for pages the user allows. */
10
+ const node_fs_1 = require("node:fs");
11
+ const node_path_1 = require("node:path");
12
+ const node_crypto_1 = require("node:crypto");
13
+ const snapdiff_1 = require("./snapdiff");
14
+ function root(profileDir) {
15
+ const dir = (0, node_path_1.join)(profileDir, 'page-watches');
16
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true, mode: 0o700 });
17
+ return dir;
18
+ }
19
+ function watchPath(profileDir, id) {
20
+ if (!/^watch_[0-9a-f-]{36}$/i.test(id))
21
+ throw new Error('invalid watchId');
22
+ return (0, node_path_1.join)(root(profileDir), `${id}.json`);
23
+ }
24
+ /** Keep enough structure for useful diffs while never persisting live form values. */
25
+ function safeSnapshot(snap, redact) {
26
+ const nodes = snap.nodes.slice(0, 500).map((node) => {
27
+ const { value: _privateValue, ...safe } = node;
28
+ return { ...safe, name: redact(safe.name) };
29
+ });
30
+ let url = snap.url;
31
+ try {
32
+ const parsed = new URL(url);
33
+ parsed.search = '';
34
+ parsed.hash = '';
35
+ url = parsed.toString();
36
+ }
37
+ catch { /* keep non-URL test fixtures as given */ }
38
+ return { url, title: redact(snap.title), nodes, truncated: snap.truncated };
39
+ }
40
+ function createWatch(profileDir, name, snap) {
41
+ const id = `watch_${(0, node_crypto_1.randomUUID)()}`;
42
+ const watch = { id, name, ts: Date.now(), url: snap.url, nodes: snap.nodes };
43
+ const dir = root(profileDir);
44
+ const temp = (0, node_path_1.join)(dir, `.${id}.${process.pid}.tmp`);
45
+ (0, node_fs_1.writeFileSync)(temp, JSON.stringify(watch), { mode: 0o600, flag: 'wx' });
46
+ (0, node_fs_1.renameSync)(temp, watchPath(profileDir, id));
47
+ return watch;
48
+ }
49
+ function readWatch(profileDir, id) {
50
+ const path = watchPath(profileDir, id);
51
+ if (!(0, node_fs_1.existsSync)(path))
52
+ throw new Error(`page watch not found: ${id}`);
53
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
54
+ if (parsed.id !== id || !Array.isArray(parsed.nodes))
55
+ throw new Error('page watch file is invalid');
56
+ return parsed;
57
+ }
58
+ function listWatches(profileDir) {
59
+ return (0, node_fs_1.readdirSync)(root(profileDir)).filter((f) => /^watch_[0-9a-f-]{36}\.json$/i.test(f)).flatMap((file) => {
60
+ try {
61
+ const { id, name, ts, url } = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(root(profileDir), file), 'utf8'));
62
+ return [{ id, name, ts, url }];
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ }).sort((a, b) => b.ts - a.ts);
68
+ }
69
+ function removeWatch(profileDir, id) {
70
+ const path = watchPath(profileDir, id);
71
+ if (!(0, node_fs_1.existsSync)(path))
72
+ throw new Error(`page watch not found: ${id}`);
73
+ (0, node_fs_1.rmSync)(path);
74
+ }
75
+ function compareWatch(previous, current, redact) {
76
+ const clean = safeSnapshot(current, redact);
77
+ return (0, snapdiff_1.diffSnapshots)(previous, { ...clean, nodes: clean.nodes });
78
+ }
79
+ //# sourceMappingURL=page-watch.js.map