@oh-my-pi/pi-utils 18.1.22 → 18.2.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/CHANGELOG.md +40 -0
- package/THIRD-PARTY-NOTICES.txt +54 -25
- package/dist/types/async.d.ts +25 -0
- package/dist/types/browsers.d.ts +2 -30
- package/dist/types/dirs.d.ts +17 -0
- package/dist/types/env.d.ts +10 -2
- package/dist/types/executable.d.ts +4 -0
- package/dist/types/fetch-retry.d.ts +8 -3
- package/dist/types/file-lock.d.ts +2 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mime.d.ts +1 -0
- package/dist/types/path.d.ts +6 -0
- package/dist/types/peek-file.d.ts +2 -3
- package/dist/types/postmortem.d.ts +26 -5
- package/dist/types/procmgr.d.ts +2 -4
- package/dist/types/snowflake.d.ts +1 -0
- package/dist/types/stream.d.ts +45 -0
- package/dist/types/which.d.ts +6 -0
- package/package.json +2 -2
- package/src/acp/transport.ts +37 -3
- package/src/async.ts +37 -0
- package/src/browsers.ts +10 -169
- package/src/dirs.ts +27 -5
- package/src/env.ts +52 -33
- package/src/executable.ts +17 -0
- package/src/fetch-retry.ts +46 -18
- package/src/file-lock.ts +23 -0
- package/src/index.ts +1 -1
- package/src/logger.ts +16 -4
- package/src/mime.ts +3 -7
- package/src/path.ts +14 -0
- package/src/peek-file.ts +17 -67
- package/src/postmortem.ts +95 -47
- package/src/procmgr.ts +2 -12
- package/src/ptree.ts +36 -5
- package/src/snowflake.ts +12 -1
- package/src/stream.ts +118 -24
- package/src/which.ts +27 -1
- package/src/xml.ts +16 -0
- package/dist/types/glob.d.ts +0 -28
- package/src/glob.ts +0 -189
package/src/stream.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { abortableSource } from "./abortable";
|
|
|
4
4
|
import { parseStreamingJson } from "./json-parse";
|
|
5
5
|
|
|
6
6
|
const LF = 0x0a;
|
|
7
|
+
const CR = 0x0d;
|
|
7
8
|
|
|
8
9
|
export async function* readLines(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<Uint8Array> {
|
|
9
10
|
const buffer = new ConcatSink();
|
|
@@ -56,13 +57,19 @@ export async function* readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?:
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Amortized byte accumulator for chunked stream readers.
|
|
62
|
+
*
|
|
63
|
+
* Holds the unconsumed tail of a stream in a single growing `Buffer` so that
|
|
64
|
+
* appending N chunks costs O(total bytes) instead of re-copying the whole
|
|
65
|
+
* prefix per chunk. Backs {@link readLines}, {@link readJsonl} and
|
|
66
|
+
* {@link readSseEvents}; also usable directly when a reader needs its own
|
|
67
|
+
* framing loop (see `consume` and `flush`).
|
|
68
|
+
*/
|
|
69
|
+
export class ConcatSink {
|
|
64
70
|
#space?: Buffer;
|
|
65
71
|
#length = 0;
|
|
72
|
+
#skipLeadingLf = false;
|
|
66
73
|
|
|
67
74
|
#ensureCapacity(size: number): Buffer {
|
|
68
75
|
const space = this.#space;
|
|
@@ -100,11 +107,26 @@ class ConcatSink {
|
|
|
100
107
|
return this.#length === 0;
|
|
101
108
|
}
|
|
102
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The buffered bytes as a live view — invalidated by the next `append`,
|
|
112
|
+
* `reset` or `consume`.
|
|
113
|
+
*/
|
|
103
114
|
flush(): Uint8Array | undefined {
|
|
104
115
|
if (!this.#length) return undefined;
|
|
105
116
|
return this.#space!.subarray(0, this.#length);
|
|
106
117
|
}
|
|
107
118
|
|
|
119
|
+
/** Drop the first `count` buffered bytes, keeping the remainder. */
|
|
120
|
+
consume(count: number) {
|
|
121
|
+
if (count <= 0) return;
|
|
122
|
+
if (count >= this.#length) {
|
|
123
|
+
this.#length = 0;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
this.#space!.copyWithin(0, count, this.#length);
|
|
127
|
+
this.#length -= count;
|
|
128
|
+
}
|
|
129
|
+
|
|
108
130
|
clear() {
|
|
109
131
|
this.#length = 0;
|
|
110
132
|
}
|
|
@@ -133,19 +155,27 @@ class ConcatSink {
|
|
|
133
155
|
}
|
|
134
156
|
|
|
135
157
|
appendAndFlushText(chunk: Uint8Array, decoder: TextDecoder): string | undefined {
|
|
136
|
-
|
|
137
|
-
if (
|
|
138
|
-
|
|
158
|
+
let start = 0;
|
|
159
|
+
if (this.#skipLeadingLf) {
|
|
160
|
+
if (chunk.length === 0) return undefined;
|
|
161
|
+
this.#skipLeadingLf = false;
|
|
162
|
+
if (chunk[0] === LF) start = 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const lastLineEnd = Math.max(chunk.lastIndexOf(LF), chunk.lastIndexOf(CR));
|
|
166
|
+
if (lastLineEnd < start) {
|
|
167
|
+
if (start < chunk.length) this.append(chunk.subarray(start));
|
|
139
168
|
return undefined;
|
|
140
169
|
}
|
|
141
170
|
|
|
142
|
-
const completeEnd =
|
|
171
|
+
const completeEnd = lastLineEnd + 1;
|
|
172
|
+
this.#skipLeadingLf = chunk[lastLineEnd] === CR && completeEnd === chunk.length;
|
|
143
173
|
let text: string;
|
|
144
174
|
if (this.isEmpty) {
|
|
145
|
-
const complete = completeEnd === chunk.length ? chunk : chunk.subarray(
|
|
175
|
+
const complete = start === 0 && completeEnd === chunk.length ? chunk : chunk.subarray(start, completeEnd);
|
|
146
176
|
text = decoder.decode(complete);
|
|
147
177
|
} else {
|
|
148
|
-
this.append(
|
|
178
|
+
this.append(chunk.subarray(start, completeEnd));
|
|
149
179
|
text = decoder.decode(this.flush());
|
|
150
180
|
this.clear();
|
|
151
181
|
}
|
|
@@ -197,6 +227,10 @@ class ConcatSink {
|
|
|
197
227
|
}
|
|
198
228
|
}
|
|
199
229
|
|
|
230
|
+
// =============================================================================
|
|
231
|
+
// SSE (Server-Sent Events)
|
|
232
|
+
// =============================================================================
|
|
233
|
+
|
|
200
234
|
/**
|
|
201
235
|
* Stream parsed JSON objects from SSE `data:` lines.
|
|
202
236
|
*
|
|
@@ -236,11 +270,24 @@ function isRecoverableTrailingJson(data: string): boolean {
|
|
|
236
270
|
return typeof recovered === "object" && recovered !== null;
|
|
237
271
|
}
|
|
238
272
|
|
|
239
|
-
|
|
273
|
+
/**
|
|
274
|
+
* One dispatched `data:` frame from {@link readSseFrames}: either the parsed JSON
|
|
275
|
+
* value, or the text of a frame `JSON.parse` rejected together with the
|
|
276
|
+
* `SyntaxError` it raised (so the strict reader can rethrow it unchanged).
|
|
277
|
+
*/
|
|
278
|
+
type SseFrame<T> = { ok: true; value: T } | { ok: false; raw: string; error: SyntaxError };
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Shared `data:`-line framing for {@link readSseJson} and
|
|
282
|
+
* {@link readSseJsonOrText}: skips empty events, stops at the OpenAI `[DONE]`
|
|
283
|
+
* sentinel, notifies the diagnostic observer, and treats a container-shaped
|
|
284
|
+
* stream tail as a clean end of iteration.
|
|
285
|
+
*/
|
|
286
|
+
async function* readSseFrames<T>(
|
|
240
287
|
stream: ReadableStream<Uint8Array>,
|
|
241
288
|
signal?: AbortSignal,
|
|
242
289
|
onEvent?: SseEventObserver,
|
|
243
|
-
): AsyncGenerator<T
|
|
290
|
+
): AsyncGenerator<SseFrame<T>> {
|
|
244
291
|
for await (const sse of readSseEvents(stream, signal)) {
|
|
245
292
|
const isTrailing = trailingEvents.has(sse);
|
|
246
293
|
notifySseEventObserver(onEvent, sse);
|
|
@@ -250,16 +297,60 @@ export async function* readSseJson<T>(
|
|
|
250
297
|
continue;
|
|
251
298
|
}
|
|
252
299
|
try {
|
|
253
|
-
yield JSON.parse(data) as T;
|
|
300
|
+
yield { ok: true, value: JSON.parse(data) as T };
|
|
254
301
|
} catch (err) {
|
|
255
302
|
if (err instanceof SyntaxError && isTrailing && isRecoverableTrailingJson(data)) {
|
|
256
303
|
return;
|
|
257
304
|
}
|
|
305
|
+
if (err instanceof SyntaxError) {
|
|
306
|
+
yield { ok: false, raw: data, error: err };
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
258
309
|
throw err;
|
|
259
310
|
}
|
|
260
311
|
}
|
|
261
312
|
}
|
|
262
313
|
|
|
314
|
+
export async function* readSseJson<T>(
|
|
315
|
+
stream: ReadableStream<Uint8Array>,
|
|
316
|
+
signal?: AbortSignal,
|
|
317
|
+
onEvent?: SseEventObserver,
|
|
318
|
+
): AsyncGenerator<T> {
|
|
319
|
+
for await (const frame of readSseFrames<T>(stream, signal, onEvent)) {
|
|
320
|
+
if (!frame.ok) throw frame.error;
|
|
321
|
+
yield frame.value;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Like {@link readSseJson}, but a `data:` frame that is not valid JSON is yielded
|
|
327
|
+
* as its raw text instead of raising a `SyntaxError`. Cut-off container-shaped
|
|
328
|
+
* stream tails stay recoverable, exactly as they are in {@link readSseJson}.
|
|
329
|
+
*
|
|
330
|
+
* Consumers that only understand objects must treat a `string` yield as a
|
|
331
|
+
* transport-level failure (for example a `429 Too Many Requests` or an HTML
|
|
332
|
+
* throttle page from a reverse proxy that already committed to the stream). This
|
|
333
|
+
* exists because `readSseJson`'s baseline consumers span unrelated transports
|
|
334
|
+
* whose error handling a text yield would subtly change; new call sites opt in.
|
|
335
|
+
*
|
|
336
|
+
* Note that the text lane is only the frames `JSON.parse` *rejected*: a frame
|
|
337
|
+
* carrying a JSON-encoded string (`data: "429 Too Many Requests"`) parses, so it
|
|
338
|
+
* is yielded as that string and is indistinguishable from a rejected frame by
|
|
339
|
+
* type alone. Consumers branching on `typeof === "string"` therefore see both,
|
|
340
|
+
* which is the safe direction — each is classified as text rather than trusted as
|
|
341
|
+
* an event object.
|
|
342
|
+
*/
|
|
343
|
+
export async function* readSseJsonOrText<T>(
|
|
344
|
+
stream: ReadableStream<Uint8Array>,
|
|
345
|
+
signal?: AbortSignal,
|
|
346
|
+
onEvent?: SseEventObserver,
|
|
347
|
+
): AsyncGenerator<T | string> {
|
|
348
|
+
for await (const frame of readSseFrames<T>(stream, signal, onEvent)) {
|
|
349
|
+
if (!frame.ok) yield frame.raw;
|
|
350
|
+
else yield frame.value;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
263
354
|
/**
|
|
264
355
|
* A single Server-Sent Event dispatched on a blank-line boundary.
|
|
265
356
|
*
|
|
@@ -294,7 +385,7 @@ interface SseEventState {
|
|
|
294
385
|
}
|
|
295
386
|
|
|
296
387
|
// Complete lines are decoded in one batch per source chunk. Each batch ends on
|
|
297
|
-
//
|
|
388
|
+
// an ASCII line-ending byte, which cannot split a multi-byte UTF-8 sequence.
|
|
298
389
|
const SSE_DECODER = new TextDecoder("utf-8");
|
|
299
390
|
|
|
300
391
|
function flushSseEvent(state: SseEventState): ServerSentEvent | null {
|
|
@@ -318,11 +409,6 @@ function flushSseEvent(state: SseEventState): ServerSentEvent | null {
|
|
|
318
409
|
}
|
|
319
410
|
|
|
320
411
|
function pushSseLine(line: string, state: SseEventState): ServerSentEvent | null {
|
|
321
|
-
// Complete-line batches split on LF only; strip a trailing CR so CRLF sources
|
|
322
|
-
// don't leak `\r` into field values.
|
|
323
|
-
if (line.charCodeAt(line.length - 1) === 0x0d /* '\r' */) {
|
|
324
|
-
line = line.slice(0, -1);
|
|
325
|
-
}
|
|
326
412
|
if (line.length === 0) return flushSseEvent(state);
|
|
327
413
|
|
|
328
414
|
// Comment line: keep in `raw` for diagnostic context, skip parsing.
|
|
@@ -398,13 +484,21 @@ export async function* readSseEvents(
|
|
|
398
484
|
if (text === undefined) continue;
|
|
399
485
|
let start = 0;
|
|
400
486
|
while (start < text.length) {
|
|
401
|
-
|
|
402
|
-
|
|
487
|
+
let lineEnd = start;
|
|
488
|
+
while (lineEnd < text.length) {
|
|
489
|
+
const code = text.charCodeAt(lineEnd);
|
|
490
|
+
if (code === LF || code === CR) break;
|
|
491
|
+
lineEnd++;
|
|
492
|
+
}
|
|
493
|
+
const event = pushSseLine(text.slice(start, lineEnd), state);
|
|
403
494
|
if (event) yield event;
|
|
404
|
-
|
|
495
|
+
if (text.charCodeAt(lineEnd) === CR && text.charCodeAt(lineEnd + 1) === LF) {
|
|
496
|
+
lineEnd++;
|
|
497
|
+
}
|
|
498
|
+
start = lineEnd + 1;
|
|
405
499
|
}
|
|
406
500
|
}
|
|
407
|
-
// Treat any trailing partial line (no terminating
|
|
501
|
+
// Treat any trailing partial line (no terminating line ending) as complete.
|
|
408
502
|
if (!lineBuffer.isEmpty) {
|
|
409
503
|
const tail = lineBuffer.flush();
|
|
410
504
|
if (tail) {
|
package/src/which.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as os from "node:os";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
+
import { isFullyQualifiedPath } from "./path";
|
|
14
15
|
|
|
15
16
|
type CacheKey = string | bigint | number;
|
|
16
17
|
|
|
@@ -179,6 +180,12 @@ export interface WhichOptions extends Bun.WhichOptions {
|
|
|
179
180
|
* Defaults to `WhichCachePolicy.Fresh`.
|
|
180
181
|
*/
|
|
181
182
|
cache?: WhichCachePolicy;
|
|
183
|
+
/**
|
|
184
|
+
* Only search absolute directory entries in PATH, ignoring relative entries
|
|
185
|
+
* (e.g. `.` or `./bin`) and empty components to prevent resolving against
|
|
186
|
+
* an untrusted working directory.
|
|
187
|
+
*/
|
|
188
|
+
requireAbsolutePaths?: boolean;
|
|
182
189
|
}
|
|
183
190
|
|
|
184
191
|
// Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH.
|
|
@@ -192,6 +199,15 @@ function darwinWhich(command: string, options?: Bun.WhichOptions): string | null
|
|
|
192
199
|
return null;
|
|
193
200
|
}
|
|
194
201
|
|
|
202
|
+
function filterAbsoluteSearchPath(rawPath: string | undefined): string | null {
|
|
203
|
+
if (!rawPath) return null;
|
|
204
|
+
const safePath = rawPath
|
|
205
|
+
.split(path.delimiter)
|
|
206
|
+
.filter(dir => dir.length > 0 && isFullyQualifiedPath(dir))
|
|
207
|
+
.join(path.delimiter);
|
|
208
|
+
return safePath || null;
|
|
209
|
+
}
|
|
210
|
+
|
|
195
211
|
// Which function that incorporates Darwin Xcode logic if platform reports as 'darwin'.
|
|
196
212
|
// Look `Bun.which` up per call rather than capturing it at import, so a `Bun.which`
|
|
197
213
|
// stub installed later (the per-test seam) is honoured on every platform.
|
|
@@ -219,8 +235,15 @@ function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey {
|
|
|
219
235
|
*/
|
|
220
236
|
export function $which(command: string, options?: WhichOptions): string | null {
|
|
221
237
|
const cachePolicy = options?.cache ?? WhichCachePolicy.Cached;
|
|
222
|
-
|
|
238
|
+
let lookupOptions =
|
|
223
239
|
options?.PATH !== undefined || process.env.PATH === undefined ? options : { ...options, PATH: process.env.PATH };
|
|
240
|
+
|
|
241
|
+
if (options?.requireAbsolutePaths) {
|
|
242
|
+
const safePath = filterAbsoluteSearchPath(lookupOptions?.PATH);
|
|
243
|
+
if (!safePath) return null;
|
|
244
|
+
lookupOptions = { ...lookupOptions, PATH: safePath };
|
|
245
|
+
}
|
|
246
|
+
|
|
224
247
|
let key: CacheKey | undefined;
|
|
225
248
|
|
|
226
249
|
if (cachePolicy !== WhichCachePolicy.Bypass) {
|
|
@@ -232,6 +255,9 @@ export function $which(command: string, options?: WhichOptions): string | null {
|
|
|
232
255
|
}
|
|
233
256
|
|
|
234
257
|
const result = whichFresh(command, lookupOptions);
|
|
258
|
+
if (result && options?.requireAbsolutePaths && !isFullyQualifiedPath(result)) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
235
261
|
if (key != null && cachePolicy !== WhichCachePolicy.ReadOnly) {
|
|
236
262
|
toolCache.set(key, result);
|
|
237
263
|
}
|
package/src/xml.ts
CHANGED
|
@@ -104,6 +104,12 @@ class XmlReader {
|
|
|
104
104
|
this.#readProcessingInstruction(document);
|
|
105
105
|
continue;
|
|
106
106
|
}
|
|
107
|
+
if (this.#xml.startsWith("</", this.#position)) {
|
|
108
|
+
// Stray end tag with no element open: skip it instead of parsing it as a new element,
|
|
109
|
+
// whose empty name would leave #readElement stuck on the `/`.
|
|
110
|
+
this.#skipThrough(">");
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
107
113
|
if (this.#xml[this.#position] === "<") {
|
|
108
114
|
const element = this.#readElement("");
|
|
109
115
|
this.#addValue(document, element.name, element.name, element.value, element.leaf, null);
|
|
@@ -138,6 +144,11 @@ class XmlReader {
|
|
|
138
144
|
this.#position++;
|
|
139
145
|
this.#skipWhitespace();
|
|
140
146
|
value = this.#readAttributeValue();
|
|
147
|
+
} else if (attributeName === "") {
|
|
148
|
+
// Markup #readName cannot consume (the stray `/` in `<a / >`): skip the character
|
|
149
|
+
// so the loop always makes progress.
|
|
150
|
+
this.#position++;
|
|
151
|
+
continue;
|
|
141
152
|
}
|
|
142
153
|
attributes.push([attributeName, value]);
|
|
143
154
|
}
|
|
@@ -153,6 +164,11 @@ class XmlReader {
|
|
|
153
164
|
this.#position = close < 0 ? this.#xml.length : close + 1;
|
|
154
165
|
break;
|
|
155
166
|
}
|
|
167
|
+
if (this.#xml.startsWith("</", this.#position)) {
|
|
168
|
+
// End tag for an ancestor (an unclosed `<br>` inside `<p>`, or plain mismatched
|
|
169
|
+
// markup): close this element implicitly and leave the tag to its owner.
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
156
172
|
if (this.#xml.startsWith("<![CDATA[", this.#position)) {
|
|
157
173
|
const end = this.#xml.indexOf("]]>", this.#position + 9);
|
|
158
174
|
const raw = this.#xml.slice(this.#position + 9, end < 0 ? this.#xml.length : end);
|
package/dist/types/glob.d.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
export interface GlobPathsOptions {
|
|
2
|
-
/** Base directory for glob patterns. Defaults to getProjectDir(). */
|
|
3
|
-
cwd?: string;
|
|
4
|
-
/** Glob exclusion patterns. */
|
|
5
|
-
exclude?: string[];
|
|
6
|
-
/** Abort signal to cancel the glob. */
|
|
7
|
-
signal?: AbortSignal;
|
|
8
|
-
/** Timeout in milliseconds for the glob operation. */
|
|
9
|
-
timeoutMs?: number;
|
|
10
|
-
/** Include dotfiles when true. */
|
|
11
|
-
dot?: boolean;
|
|
12
|
-
/** Only return files (skip directories). Default: true. */
|
|
13
|
-
onlyFiles?: boolean;
|
|
14
|
-
/** Respect .gitignore files when true. Walks up directory tree to find all applicable .gitignore files. */
|
|
15
|
-
gitignore?: boolean;
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Load .gitignore patterns from a directory and its parents.
|
|
19
|
-
* Walks up the directory tree to find all applicable .gitignore files.
|
|
20
|
-
* Returns glob-compatible exclude patterns.
|
|
21
|
-
*/
|
|
22
|
-
export declare function loadGitignorePatterns(baseDir: string): Promise<string[]>;
|
|
23
|
-
/**
|
|
24
|
-
* Resolve filesystem paths matching glob patterns with optional exclude filters.
|
|
25
|
-
* Returns paths relative to the provided cwd (or getProjectDir()).
|
|
26
|
-
* Errors and abort/timeouts are surfaced to the caller.
|
|
27
|
-
*/
|
|
28
|
-
export declare function globPaths(patterns: string | string[], options?: GlobPathsOptions): Promise<string[]>;
|
package/src/glob.ts
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import * as path from "node:path";
|
|
2
|
-
import { Glob } from "bun";
|
|
3
|
-
import { getProjectDir } from "./dirs";
|
|
4
|
-
|
|
5
|
-
export interface GlobPathsOptions {
|
|
6
|
-
/** Base directory for glob patterns. Defaults to getProjectDir(). */
|
|
7
|
-
cwd?: string;
|
|
8
|
-
/** Glob exclusion patterns. */
|
|
9
|
-
exclude?: string[];
|
|
10
|
-
/** Abort signal to cancel the glob. */
|
|
11
|
-
signal?: AbortSignal;
|
|
12
|
-
/** Timeout in milliseconds for the glob operation. */
|
|
13
|
-
timeoutMs?: number;
|
|
14
|
-
/** Include dotfiles when true. */
|
|
15
|
-
dot?: boolean;
|
|
16
|
-
/** Only return files (skip directories). Default: true. */
|
|
17
|
-
onlyFiles?: boolean;
|
|
18
|
-
/** Respect .gitignore files when true. Walks up directory tree to find all applicable .gitignore files. */
|
|
19
|
-
gitignore?: boolean;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Patterns always excluded (.git is never useful in glob results). */
|
|
23
|
-
const ALWAYS_IGNORED = ["**/.git", "**/.git/**"];
|
|
24
|
-
|
|
25
|
-
/** node_modules exclusion patterns (skipped if pattern explicitly references node_modules). */
|
|
26
|
-
const NODE_MODULES_IGNORED = ["**/node_modules", "**/node_modules/**"];
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Parse a single .gitignore file and return glob-compatible exclude patterns.
|
|
30
|
-
* @param content - Raw content of the .gitignore file
|
|
31
|
-
* @param gitignoreDir - Absolute path to the directory containing the .gitignore
|
|
32
|
-
* @param baseDir - Absolute path to the glob's cwd (for relativizing rooted patterns)
|
|
33
|
-
*/
|
|
34
|
-
function parseGitignorePatterns(content: string, gitignoreDir: string, baseDir: string): string[] {
|
|
35
|
-
const patterns: string[] = [];
|
|
36
|
-
|
|
37
|
-
for (const rawLine of content.split("\n")) {
|
|
38
|
-
const line = rawLine.trim();
|
|
39
|
-
// Skip empty lines and comments
|
|
40
|
-
if (!line || line.startsWith("#")) {
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
// Skip negation patterns (unsupported for simple exclude)
|
|
44
|
-
if (line.startsWith("!")) {
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
let pattern = line;
|
|
49
|
-
|
|
50
|
-
// Handle trailing slash (directory-only match)
|
|
51
|
-
// For glob exclude, we treat it as matching the dir and its contents
|
|
52
|
-
const isDirectoryOnly = pattern.endsWith("/");
|
|
53
|
-
if (isDirectoryOnly) {
|
|
54
|
-
pattern = pattern.slice(0, -1);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Handle rooted patterns (start with /)
|
|
58
|
-
if (pattern.startsWith("/")) {
|
|
59
|
-
// Rooted pattern: relative to the .gitignore location
|
|
60
|
-
const absolutePattern = path.join(gitignoreDir, pattern.slice(1));
|
|
61
|
-
const relativeToBase = path.relative(baseDir, absolutePattern);
|
|
62
|
-
if (relativeToBase.startsWith("..")) {
|
|
63
|
-
// Pattern is outside the search directory, skip
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
pattern = relativeToBase.replace(/\\/g, "/");
|
|
67
|
-
if (isDirectoryOnly) {
|
|
68
|
-
patterns.push(pattern);
|
|
69
|
-
patterns.push(`${pattern}/**`);
|
|
70
|
-
} else {
|
|
71
|
-
patterns.push(pattern);
|
|
72
|
-
}
|
|
73
|
-
} else {
|
|
74
|
-
// Unrooted pattern: match anywhere in the tree
|
|
75
|
-
if (pattern.includes("/")) {
|
|
76
|
-
// Contains slash: match from any directory level
|
|
77
|
-
patterns.push(`**/${pattern}`);
|
|
78
|
-
if (isDirectoryOnly) {
|
|
79
|
-
patterns.push(`**/${pattern}/**`);
|
|
80
|
-
}
|
|
81
|
-
} else {
|
|
82
|
-
// No slash: match file/dir name anywhere
|
|
83
|
-
patterns.push(`**/${pattern}`);
|
|
84
|
-
if (isDirectoryOnly) {
|
|
85
|
-
patterns.push(`**/${pattern}/**`);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return patterns;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Load .gitignore patterns from a directory and its parents.
|
|
96
|
-
* Walks up the directory tree to find all applicable .gitignore files.
|
|
97
|
-
* Returns glob-compatible exclude patterns.
|
|
98
|
-
*/
|
|
99
|
-
export async function loadGitignorePatterns(baseDir: string): Promise<string[]> {
|
|
100
|
-
const patterns: string[] = [];
|
|
101
|
-
const absoluteBase = path.resolve(baseDir);
|
|
102
|
-
|
|
103
|
-
let current = absoluteBase;
|
|
104
|
-
const maxDepth = 50; // Prevent infinite loops
|
|
105
|
-
|
|
106
|
-
for (let i = 0; i < maxDepth; i++) {
|
|
107
|
-
const gitignorePath = path.join(current, ".gitignore");
|
|
108
|
-
|
|
109
|
-
try {
|
|
110
|
-
const content = await Bun.file(gitignorePath).text();
|
|
111
|
-
const filePatterns = parseGitignorePatterns(content, current, absoluteBase);
|
|
112
|
-
patterns.push(...filePatterns);
|
|
113
|
-
} catch {
|
|
114
|
-
// .gitignore doesn't exist or can't be read, continue
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const parent = path.dirname(current);
|
|
118
|
-
if (parent === current) {
|
|
119
|
-
// Reached filesystem root
|
|
120
|
-
break;
|
|
121
|
-
}
|
|
122
|
-
current = parent;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return patterns;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Resolve filesystem paths matching glob patterns with optional exclude filters.
|
|
130
|
-
* Returns paths relative to the provided cwd (or getProjectDir()).
|
|
131
|
-
* Errors and abort/timeouts are surfaced to the caller.
|
|
132
|
-
*/
|
|
133
|
-
export async function globPaths(patterns: string | string[], options: GlobPathsOptions = {}): Promise<string[]> {
|
|
134
|
-
const { cwd, exclude, signal, timeoutMs, dot, onlyFiles = true, gitignore } = options;
|
|
135
|
-
|
|
136
|
-
// Build exclude list: always exclude .git, exclude node_modules unless pattern references it
|
|
137
|
-
const patternArray = Array.isArray(patterns) ? patterns : [patterns];
|
|
138
|
-
const mentionsNodeModules = patternArray.some(p => p.includes("node_modules"));
|
|
139
|
-
|
|
140
|
-
const baseExclude = mentionsNodeModules ? [...ALWAYS_IGNORED] : [...ALWAYS_IGNORED, ...NODE_MODULES_IGNORED];
|
|
141
|
-
let effectiveExclude = exclude ? [...baseExclude, ...exclude] : baseExclude;
|
|
142
|
-
|
|
143
|
-
if (gitignore) {
|
|
144
|
-
const gitignorePatterns = await loadGitignorePatterns(cwd ?? getProjectDir());
|
|
145
|
-
effectiveExclude = [...effectiveExclude, ...gitignorePatterns];
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const base = cwd ?? getProjectDir();
|
|
149
|
-
const allResults: string[] = [];
|
|
150
|
-
|
|
151
|
-
// Combine timeout and abort signals
|
|
152
|
-
const timeoutSignal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
|
|
153
|
-
const combinedSignal =
|
|
154
|
-
signal && timeoutSignal ? AbortSignal.any([signal, timeoutSignal]) : (signal ?? timeoutSignal);
|
|
155
|
-
|
|
156
|
-
for (const pattern of patternArray) {
|
|
157
|
-
const glob = new Glob(pattern);
|
|
158
|
-
const scanOptions = {
|
|
159
|
-
cwd: base,
|
|
160
|
-
dot,
|
|
161
|
-
onlyFiles,
|
|
162
|
-
throwErrorOnBrokenSymlink: false,
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
for await (const entry of glob.scan(scanOptions)) {
|
|
166
|
-
if (combinedSignal?.aborted) {
|
|
167
|
-
const reason = combinedSignal.reason;
|
|
168
|
-
if (reason instanceof Error) throw reason;
|
|
169
|
-
throw new DOMException("Aborted", "AbortError");
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Check exclusion patterns
|
|
173
|
-
const normalized = entry.replace(/\\/g, "/");
|
|
174
|
-
let excluded = false;
|
|
175
|
-
for (const excludePattern of effectiveExclude) {
|
|
176
|
-
const excludeGlob = new Glob(excludePattern);
|
|
177
|
-
if (excludeGlob.match(normalized)) {
|
|
178
|
-
excluded = true;
|
|
179
|
-
break;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
if (!excluded) {
|
|
183
|
-
allResults.push(normalized);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return allResults;
|
|
189
|
-
}
|