@demigodmode/pi-web-agent 1.9.0 → 1.11.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.
@@ -0,0 +1,215 @@
1
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join, relative, sep } from 'node:path';
4
+ /**
5
+ * Works around two incompatibilities between pi's extension loader (a patched
6
+ * jiti) and jsdom's dependency tree:
7
+ *
8
+ * 1. jiti can't resolve the trailing-slash bare specifier require("punycode/")
9
+ * used by tr46.
10
+ * 2. jiti wraps `module.exports = new Set(...)` (cssstyle) in a Proxy, which
11
+ * breaks native Set methods on the exported value.
12
+ *
13
+ * Both files live in the shared `~/.pi/agent/npm/node_modules` tree, so
14
+ * installing or updating any *other* pi extension re-extracts them and reverts
15
+ * the patch. A postinstall hook alone therefore can't keep this healthy, which
16
+ * is why `ensureJitiCompat()` also runs on extension load, before jsdom is
17
+ * evaluated. See https://github.com/demigodmode/pi-web-agent/issues/34.
18
+ *
19
+ * scripts/patch-jiti-compat.mjs duplicates this logic for the postinstall
20
+ * hook. That hook runs before `npm run build`, so it cannot import dist/.
21
+ * Keep the two in sync.
22
+ */
23
+ const requireFromHere = createRequire(import.meta.url);
24
+ /**
25
+ * Write via a temp file + rename rather than in place. Two reasons, both of
26
+ * which bite harder now that this runs on every extension load instead of once
27
+ * per install:
28
+ *
29
+ * - `writeFileSync` truncates first. A crash, a full disk, or an OOM kill
30
+ * mid-write leaves a half-written file that still contains the marker
31
+ * comment, so every later run would skip it as "already patched" and the
32
+ * doctor would report a broken tree as healthy.
33
+ * - Another Pi session can be `require`-ing the same file while we write it.
34
+ * `rename` is atomic within a filesystem, so readers see the old file or
35
+ * the new one, never a partial one.
36
+ *
37
+ * The rename also breaks a pnpm-style hardlink instead of writing through it
38
+ * into the shared content-addressable store.
39
+ */
40
+ function writeFileAtomic(path, contents) {
41
+ const temp = `${path}.pi-web-agent-${process.pid}.tmp`;
42
+ try {
43
+ writeFileSync(temp, contents);
44
+ renameSync(temp, path);
45
+ }
46
+ catch (err) {
47
+ try {
48
+ if (existsSync(temp))
49
+ unlinkSync(temp);
50
+ }
51
+ catch {
52
+ // Best effort. Leaving a stray temp file is better than masking the
53
+ // original write failure.
54
+ }
55
+ throw err;
56
+ }
57
+ }
58
+ const defaultDeps = {
59
+ resolve: (specifier, fromFile) => (fromFile ? createRequire(fromFile) : requireFromHere).resolve(specifier),
60
+ existsSync,
61
+ readFileSync: (path) => readFileSync(path, 'utf8'),
62
+ writeFileSync: writeFileAtomic
63
+ };
64
+ const SET_SHIM_MARKER = 'pi/jiti workaround';
65
+ const SET_SHIM = `
66
+ // ${SET_SHIM_MARKER}: expose bound native Set methods as own properties so a
67
+ // Proxy wrapper around this export does not break Set brand checks.
68
+ for (const k of ["has", "add", "delete", "forEach", "keys", "values", "entries"]) {
69
+ module.exports[k] = Set.prototype[k].bind(module.exports);
70
+ }
71
+ module.exports[Symbol.iterator] = Set.prototype[Symbol.iterator].bind(module.exports);
72
+ `;
73
+ const PUNYCODE_SPECIFIER = 'require("punycode/")';
74
+ const PUNYCODE_REPLACEMENT = 'require("punycode/punycode.js")';
75
+ function findPackageRoot(deps, entryFile, packageName) {
76
+ let directory = dirname(entryFile);
77
+ for (;;) {
78
+ const manifestFile = join(directory, 'package.json');
79
+ if (deps.existsSync(manifestFile)) {
80
+ try {
81
+ const manifest = JSON.parse(deps.readFileSync(manifestFile));
82
+ if (manifest.name === packageName)
83
+ return directory;
84
+ }
85
+ catch {
86
+ // An unreadable or malformed package.json on the way up is not our
87
+ // problem to report. Keep walking.
88
+ }
89
+ }
90
+ const parent = dirname(directory);
91
+ if (parent === directory) {
92
+ throw new Error(`could not find the ${packageName} package root from ${entryFile}`);
93
+ }
94
+ directory = parent;
95
+ }
96
+ }
97
+ /**
98
+ * Resolve the copies jsdom actually loads, not whichever copy happens to sit
99
+ * highest in the tree.
100
+ *
101
+ * `~/.pi/agent/npm/node_modules` is shared by every pi extension, so version
102
+ * conflicts routinely push a second copy of a package into a nested
103
+ * `node_modules`. Resolving `cssstyle` or `tr46` from *this* module can then
104
+ * find a hoisted copy that jsdom never requires: the patch lands on the wrong
105
+ * file, the load still fails, and the doctor reports a healthy tree because it
106
+ * checked the same wrong file. Walk the real import chain instead
107
+ * (jsdom -> cssstyle, jsdom -> whatwg-url -> tr46) and only fall back to a
108
+ * direct resolve when jsdom is not resolvable at all.
109
+ */
110
+ function resolveFromJsdom(deps, specifier, via) {
111
+ try {
112
+ const jsdomEntry = deps.resolve('jsdom');
113
+ const importer = via ? deps.resolve(via, jsdomEntry) : jsdomEntry;
114
+ return deps.resolve(specifier, importer);
115
+ }
116
+ catch {
117
+ // jsdom (or the intermediate package) is not resolvable from here, e.g. a
118
+ // layout we do not recognize. Fall back to a plain resolve rather than
119
+ // giving up on the patch entirely.
120
+ return deps.resolve(specifier);
121
+ }
122
+ }
123
+ function tr46Entry(deps) {
124
+ return resolveFromJsdom(deps, 'tr46', 'whatwg-url');
125
+ }
126
+ function cssstyleTargets(deps) {
127
+ const packageRoot = findPackageRoot(deps, resolveFromJsdom(deps, 'cssstyle'), 'cssstyle');
128
+ return [
129
+ join(packageRoot, 'lib', 'allExtraProperties.js'),
130
+ join(packageRoot, 'lib', 'generated', 'allProperties.js'),
131
+ join(packageRoot, 'lib', 'generated', 'implementedProperties.js')
132
+ ].map((file) => ({
133
+ file,
134
+ label: `cssstyle/${relative(packageRoot, file).split(sep).join('/')}`
135
+ }));
136
+ }
137
+ /**
138
+ * Apply both patches if they are missing. Safe to call repeatedly: an already
139
+ * patched tree is a few `readFileSync` calls and no writes. Never throws.
140
+ */
141
+ export function ensureJitiCompat(deps = defaultDeps) {
142
+ const status = { pending: [], patched: [] };
143
+ try {
144
+ const file = tr46Entry(deps);
145
+ const contents = deps.readFileSync(file);
146
+ if (contents.includes(PUNYCODE_SPECIFIER)) {
147
+ deps.writeFileSync(file, contents.replaceAll(PUNYCODE_SPECIFIER, PUNYCODE_REPLACEMENT));
148
+ status.patched.push('tr46/index.js');
149
+ }
150
+ }
151
+ catch {
152
+ // A read-only install, a missing dependency, or a future dependency bump
153
+ // that changes the shape. Report it rather than blocking extension load.
154
+ status.pending.push('tr46/index.js');
155
+ }
156
+ try {
157
+ for (const { file, label } of cssstyleTargets(deps)) {
158
+ // Guard the read too: one unreadable file (EACCES, odd permissions) must
159
+ // not abandon the other two, which may be perfectly writable.
160
+ try {
161
+ if (!deps.existsSync(file))
162
+ continue;
163
+ const contents = deps.readFileSync(file);
164
+ if (contents.includes(SET_SHIM_MARKER))
165
+ continue;
166
+ if (!contents.includes('module.exports = new Set('))
167
+ continue;
168
+ deps.writeFileSync(file, contents + SET_SHIM);
169
+ status.patched.push(label);
170
+ }
171
+ catch {
172
+ status.pending.push(label);
173
+ }
174
+ }
175
+ }
176
+ catch {
177
+ status.pending.push('cssstyle');
178
+ }
179
+ return status;
180
+ }
181
+ /**
182
+ * Read-only view of the same checks, for `/web-agent doctor`. Never throws and
183
+ * never writes.
184
+ */
185
+ export function checkJitiCompat(deps = defaultDeps) {
186
+ const status = { pending: [], patched: [] };
187
+ try {
188
+ const contents = deps.readFileSync(tr46Entry(deps));
189
+ if (contents.includes(PUNYCODE_SPECIFIER))
190
+ status.pending.push('tr46/index.js');
191
+ }
192
+ catch {
193
+ status.pending.push('tr46/index.js');
194
+ }
195
+ try {
196
+ for (const { file, label } of cssstyleTargets(deps)) {
197
+ try {
198
+ if (!deps.existsSync(file))
199
+ continue;
200
+ const contents = deps.readFileSync(file);
201
+ if (contents.includes(SET_SHIM_MARKER))
202
+ continue;
203
+ if (contents.includes('module.exports = new Set('))
204
+ status.pending.push(label);
205
+ }
206
+ catch {
207
+ status.pending.push(label);
208
+ }
209
+ }
210
+ }
211
+ catch {
212
+ status.pending.push('cssstyle');
213
+ }
214
+ return status;
215
+ }
@@ -61,6 +61,10 @@ function serializeBackendConfigOverride(config) {
61
61
  if (config.headless && Object.keys(config.headless).length > 0) {
62
62
  backends.headless = { ...config.headless };
63
63
  }
64
+ if (config.proxy && Object.keys(config.proxy).length > 0) {
65
+ const { password: _password, ...proxy } = config.proxy;
66
+ backends.proxy = { ...proxy };
67
+ }
64
68
  return { backends };
65
69
  }
66
70
  async function readConfigFileForWrite(filePath) {
@@ -5,6 +5,8 @@ type Subtitle = {
5
5
  text: string;
6
6
  };
7
7
  type YoutubeReaderDeps = {
8
+ /** Fetch implementation used for YouTube's requests (defaults to global fetch). */
9
+ fetchImpl?: typeof fetch;
8
10
  fetchSubtitles?: (input: {
9
11
  videoID: string;
10
12
  lang: string;
@@ -17,5 +19,5 @@ type YoutubeReaderDeps = {
17
19
  description?: string;
18
20
  }>;
19
21
  };
20
- export declare function createYoutubeReader({ fetchSubtitles, fetchDetails }?: YoutubeReaderDeps): SpecialContentReader;
22
+ export declare function createYoutubeReader({ fetchImpl, fetchSubtitles, fetchDetails }?: YoutubeReaderDeps): SpecialContentReader;
21
23
  export {};
@@ -21,7 +21,15 @@ function extractVideoId(url) {
21
21
  }
22
22
  return undefined;
23
23
  }
24
- export function createYoutubeReader({ fetchSubtitles = getSubtitles, fetchDetails = getVideoDetails } = {}) {
24
+ export function createYoutubeReader({ fetchImpl = fetch, fetchSubtitles, fetchDetails } = {}) {
25
+ // Route YouTube's caption/metadata requests through the configured fetch
26
+ // client (e.g. the proxy) unless an explicit override is provided.
27
+ const doFetchSubtitles = fetchSubtitles ?? ((input) => getSubtitles({ ...input, fetch: fetchImpl }));
28
+ const doFetchDetails = fetchDetails ??
29
+ ((input) => getVideoDetails({ ...input, fetch: fetchImpl }).then((details) => ({
30
+ title: details.title,
31
+ description: details.description
32
+ })));
25
33
  return {
26
34
  name: 'youtube',
27
35
  canHandle(url) {
@@ -39,8 +47,8 @@ export function createYoutubeReader({ fetchSubtitles = getSubtitles, fetchDetail
39
47
  }
40
48
  try {
41
49
  const [subtitles, details] = await Promise.all([
42
- fetchSubtitles({ videoID, lang: 'en' }),
43
- fetchDetails({ videoID, lang: 'en' }).catch(() => ({ title: undefined }))
50
+ doFetchSubtitles({ videoID, lang: 'en' }),
51
+ doFetchDetails({ videoID, lang: 'en' }).catch(() => ({ title: undefined }))
44
52
  ]);
45
53
  if (!subtitles || subtitles.length === 0) {
46
54
  return {
@@ -5,5 +5,14 @@ export type ParsedDuckDuckGoResults = {
5
5
  hasResultContainers: boolean;
6
6
  };
7
7
  export declare function buildSearchUrl(query: string): string;
8
- export declare function fetchDuckDuckGoHtml(query: string): Promise<string>;
8
+ export declare const DUCKDUCKGO_HEADERS: {
9
+ readonly 'User-Agent': "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
10
+ readonly Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
11
+ readonly 'Accept-Language': "en-US,en;q=0.9";
12
+ };
13
+ export declare function fetchDuckDuckGoHtml(query: string, { fetchImpl, retries, sleep }?: {
14
+ fetchImpl?: typeof fetch;
15
+ retries?: number;
16
+ sleep?: (ms: number) => Promise<void>;
17
+ }): Promise<string>;
9
18
  export declare function parseDuckDuckGoResults(html: string): ParsedDuckDuckGoResults;
@@ -21,12 +21,30 @@ export function buildSearchUrl(query) {
21
21
  const params = new URLSearchParams({ q: query });
22
22
  return `https://html.duckduckgo.com/html/?${params.toString()}`;
23
23
  }
24
- export async function fetchDuckDuckGoHtml(query) {
25
- const response = await fetch(buildSearchUrl(query));
26
- if (!response.ok) {
27
- throw new Error(`DuckDuckGo request failed with ${response.status}`);
24
+ export const DUCKDUCKGO_HEADERS = {
25
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
26
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
27
+ 'Accept-Language': 'en-US,en;q=0.9'
28
+ };
29
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
30
+ export async function fetchDuckDuckGoHtml(query, { fetchImpl = fetch, retries = 1, sleep = defaultSleep } = {}) {
31
+ let lastError;
32
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
33
+ try {
34
+ const response = await fetchImpl(buildSearchUrl(query), { headers: { ...DUCKDUCKGO_HEADERS } });
35
+ if (!response.ok) {
36
+ throw new Error(`DuckDuckGo request failed with ${response.status}`);
37
+ }
38
+ return response.text();
39
+ }
40
+ catch (error) {
41
+ lastError = error;
42
+ if (attempt < retries) {
43
+ await sleep(500);
44
+ }
45
+ }
28
46
  }
29
- return response.text();
47
+ throw lastError instanceof Error ? lastError : new Error('DuckDuckGo request failed');
30
48
  }
31
49
  export function parseDuckDuckGoResults(html) {
32
50
  const $ = cheerio.load(html);
@@ -1,6 +1,7 @@
1
1
  import type { WebSearchResponse } from '../types.js';
2
- export declare function createTavilySearchTool({ apiKey, fetchImpl }: {
2
+ export declare function createTavilySearchTool({ apiKey, keyless, fetchImpl }: {
3
3
  apiKey?: string;
4
+ keyless?: boolean;
4
5
  fetchImpl?: typeof fetch;
5
6
  }): ({ query }: {
6
7
  query: string;
@@ -17,7 +17,7 @@ function normalizeResults(response) {
17
17
  ];
18
18
  });
19
19
  }
20
- export function createTavilySearchTool({ apiKey, fetchImpl = fetch }) {
20
+ export function createTavilySearchTool({ apiKey, keyless = false, fetchImpl = fetch }) {
21
21
  return async function tavilySearch({ query }) {
22
22
  const normalizedQuery = query.trim();
23
23
  if (!normalizedQuery) {
@@ -28,7 +28,7 @@ export function createTavilySearchTool({ apiKey, fetchImpl = fetch }) {
28
28
  error: { code: 'INVALID_QUERY', message: 'Query must not be empty.' }
29
29
  });
30
30
  }
31
- if (!apiKey?.trim()) {
31
+ if (!apiKey?.trim() && !keyless) {
32
32
  return resultWithPresentation({
33
33
  status: 'error',
34
34
  results: [],
@@ -45,7 +45,9 @@ export function createTavilySearchTool({ apiKey, fetchImpl = fetch }) {
45
45
  headers: {
46
46
  Accept: 'application/json',
47
47
  'Content-Type': 'application/json',
48
- Authorization: `Bearer ${apiKey}`
48
+ ...(apiKey?.trim()
49
+ ? { Authorization: `Bearer ${apiKey}` }
50
+ : { 'X-Tavily-Access-Mode': 'keyless' })
49
51
  },
50
52
  body: JSON.stringify({ query: normalizedQuery, max_results: 10 })
51
53
  });
@@ -27,7 +27,11 @@ function htmlLooksBlocked(html) {
27
27
  normalized.includes('challenge') ||
28
28
  normalized.includes('verify you are human') ||
29
29
  normalized.includes('are you a robot') ||
30
- normalized.includes('unusual traffic'));
30
+ normalized.includes('unusual traffic') ||
31
+ normalized.includes('automated requests') ||
32
+ normalized.includes('automated queries') ||
33
+ normalized.includes('detected unusual') ||
34
+ normalized.includes('too many requests'));
31
35
  }
32
36
  export function createWebSearchTool({ searchHtml = fetchDuckDuckGoHtml, cache = createTtlCache({ ttlMs: 30_000 }) } = {}) {
33
37
  return async function webSearch({ query }) {
@@ -57,8 +61,14 @@ export function createWebSearchTool({ searchHtml = fetchDuckDuckGoHtml, cache =
57
61
  };
58
62
  }
59
63
  try {
60
- const html = await searchHtml(normalizedQuery);
61
- const parsed = parseDuckDuckGoResults(html);
64
+ let html = await searchHtml(normalizedQuery);
65
+ let parsed = parseDuckDuckGoResults(html);
66
+ // A 200-OK bot-wall reads as a successful fetch, so the fetch-layer retry never sees it.
67
+ // Give a page that looks blocked one more shot here before we classify it.
68
+ if (parsed.results.length === 0 && htmlLooksBlocked(html)) {
69
+ html = await searchHtml(normalizedQuery);
70
+ parsed = parseDuckDuckGoResults(html);
71
+ }
62
72
  if (parsed.results.length > 0) {
63
73
  const result = {
64
74
  status: 'ok',
@@ -71,14 +81,16 @@ export function createWebSearchTool({ searchHtml = fetchDuckDuckGoHtml, cache =
71
81
  presentation: buildSearchPresentation(result)
72
82
  };
73
83
  }
74
- if (parsed.noResults) {
84
+ // Check for a bot-wall before "no results": a page can carry both markers, and BLOCKED is
85
+ // the honest call since it routes to the fallback instead of a dead end.
86
+ if (htmlLooksBlocked(html)) {
75
87
  const result = {
76
88
  status: 'error',
77
89
  results: [],
78
90
  metadata: { backend: 'duckduckgo', cacheHit: false },
79
91
  error: {
80
- code: 'NO_RESULTS',
81
- message: 'DuckDuckGo returned no usable results for this query.'
92
+ code: 'BLOCKED',
93
+ message: 'DuckDuckGo search appears to be blocked or rate limited.'
82
94
  }
83
95
  };
84
96
  return {
@@ -86,14 +98,14 @@ export function createWebSearchTool({ searchHtml = fetchDuckDuckGoHtml, cache =
86
98
  presentation: buildSearchPresentation(result)
87
99
  };
88
100
  }
89
- if (htmlLooksBlocked(html)) {
101
+ if (parsed.noResults) {
90
102
  const result = {
91
103
  status: 'error',
92
104
  results: [],
93
105
  metadata: { backend: 'duckduckgo', cacheHit: false },
94
106
  error: {
95
- code: 'BLOCKED',
96
- message: 'DuckDuckGo search appears to be blocked or rate limited.'
107
+ code: 'NO_RESULTS',
108
+ message: 'DuckDuckGo returned no usable results for this query.'
97
109
  }
98
110
  };
99
111
  return {
package/dist/types.d.ts CHANGED
@@ -20,7 +20,7 @@ export type ToolError = {
20
20
  export type SearchMetadata = {
21
21
  backend: 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
22
22
  cacheHit: boolean;
23
- fallbackFrom?: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
23
+ fallbackFrom?: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'duckduckgo';
24
24
  fallbackReason?: string;
25
25
  fanout?: FanoutMetadata;
26
26
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@demigodmode/pi-web-agent",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Pi package for reliable web access with explicit search, fetch, and headless boundaries.",
5
5
  "type": "module",
6
6
  "main": "./dist/extension.js",
@@ -68,6 +68,7 @@
68
68
  "jsdom": "^26.0.0",
69
69
  "playwright": "^1.60.0",
70
70
  "typebox": "^1.1.37",
71
+ "undici": "^8.10.2",
71
72
  "unpdf": "^1.8.1",
72
73
  "youtube-caption-extractor": "^1.10.2"
73
74
  },
@@ -10,19 +10,63 @@
10
10
  // when this package is installed as a pi extension via `pi install npm:...`.
11
11
  // Safe to run multiple times and safe to no-op if the target files or
12
12
  // patterns are missing (e.g. a future dependency bump changes the shape).
13
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
13
+ //
14
+ // src/jiti-compat.ts does the same two patches at extension load time, because
15
+ // the shared ~/.pi/agent/npm tree means another extension's install can revert
16
+ // them long after this hook ran (#34). The logic is deliberately duplicated
17
+ // rather than shared: postinstall runs before `npm run build` in CI and in a
18
+ // fresh clone, so this file cannot depend on dist/. Keep the two in sync.
19
+ import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync } from "node:fs";
14
20
  import { createRequire } from "node:module";
15
21
  import { dirname, join, relative, sep } from "node:path";
16
22
 
17
- const resolveFromHere = createRequire(import.meta.url).resolve;
23
+ const requireFromHere = createRequire(import.meta.url);
24
+ const resolveFromHere = requireFromHere.resolve;
25
+
26
+ // Resolve the copies jsdom actually loads. The shared ~/.pi/agent/npm tree
27
+ // often holds a second, nested copy of a package when extensions disagree on
28
+ // versions, and patching a hoisted copy jsdom never requires would fix
29
+ // nothing. Mirrors resolveFromJsdom in src/jiti-compat.ts.
30
+ function resolveFromJsdom(specifier, via) {
31
+ try {
32
+ const jsdomEntry = resolveFromHere("jsdom");
33
+ const importer = via ? createRequire(jsdomEntry).resolve(via) : jsdomEntry;
34
+ return createRequire(importer).resolve(specifier);
35
+ } catch {
36
+ return resolveFromHere(specifier);
37
+ }
38
+ }
39
+
40
+ // Temp file + rename, not an in-place write. `writeFileSync` truncates first,
41
+ // so an interrupted write would leave a half-patched file that still contains
42
+ // the marker comment and would be skipped as "already patched" forever.
43
+ // Mirrors writeFileAtomic in src/jiti-compat.ts.
44
+ function writeFileAtomic(path, contents) {
45
+ const temp = `${path}.pi-web-agent-${process.pid}.tmp`;
46
+ try {
47
+ writeFileSync(temp, contents);
48
+ renameSync(temp, path);
49
+ } catch (err) {
50
+ try {
51
+ if (existsSync(temp)) unlinkSync(temp);
52
+ } catch {
53
+ // Best effort.
54
+ }
55
+ throw err;
56
+ }
57
+ }
18
58
 
19
59
  function findPackageRoot(entryFile, packageName) {
20
60
  let directory = dirname(entryFile);
21
61
  while (true) {
22
62
  const manifestFile = join(directory, "package.json");
23
63
  if (existsSync(manifestFile)) {
24
- const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
25
- if (manifest.name === packageName) return directory;
64
+ try {
65
+ const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
66
+ if (manifest.name === packageName) return directory;
67
+ } catch {
68
+ // Malformed package.json on the way up. Keep walking.
69
+ }
26
70
  }
27
71
 
28
72
  const parent = dirname(directory);
@@ -34,7 +78,7 @@ function findPackageRoot(entryFile, packageName) {
34
78
  }
35
79
 
36
80
  function patchTr46() {
37
- const file = resolveFromHere("tr46");
81
+ const file = resolveFromJsdom("tr46", "whatwg-url");
38
82
  if (!existsSync(file)) {
39
83
  console.debug("patch-jiti-compat: tr46/index.js not found, skipping");
40
84
  return;
@@ -44,7 +88,7 @@ function patchTr46() {
44
88
  console.debug("patch-jiti-compat: tr46/index.js does not match expected pattern, skipping");
45
89
  return;
46
90
  }
47
- writeFileSync(
91
+ writeFileAtomic(
48
92
  file,
49
93
  contents.replaceAll('require("punycode/")', 'require("punycode/punycode.js")'),
50
94
  );
@@ -61,7 +105,7 @@ module.exports[Symbol.iterator] = Set.prototype[Symbol.iterator].bind(module.exp
61
105
  `;
62
106
 
63
107
  function patchCssstyleSetExports() {
64
- const packageRoot = findPackageRoot(resolveFromHere("cssstyle"), "cssstyle");
108
+ const packageRoot = findPackageRoot(resolveFromJsdom("cssstyle"), "cssstyle");
65
109
  const files = [
66
110
  join(packageRoot, "lib", "allExtraProperties.js"),
67
111
  join(packageRoot, "lib", "generated", "allProperties.js"),
@@ -79,7 +123,7 @@ function patchCssstyleSetExports() {
79
123
  console.debug(`patch-jiti-compat: ${label} does not match expected pattern, skipping`);
80
124
  continue;
81
125
  }
82
- writeFileSync(file, contents + SET_SHIM);
126
+ writeFileAtomic(file, contents + SET_SHIM);
83
127
  console.log(`patch-jiti-compat: patched ${label}`);
84
128
  }
85
129
  }