@oh-my-pi/pi-utils 18.1.22 → 18.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -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 +10 -0
- package/dist/types/env.d.ts +2 -2
- package/dist/types/index.d.ts +0 -1
- package/dist/types/peek-file.d.ts +2 -3
- package/package.json +2 -2
- package/src/async.ts +37 -0
- package/src/browsers.ts +10 -169
- package/src/dirs.ts +17 -5
- package/src/env.ts +7 -33
- package/src/fetch-retry.ts +20 -1
- package/src/index.ts +0 -1
- package/src/logger.ts +14 -1
- package/src/peek-file.ts +17 -67
- package/src/postmortem.ts +6 -6
- package/src/stream.ts +29 -16
- package/dist/types/glob.d.ts +0 -28
- package/src/glob.ts +0 -189
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.2.0] - 2026-09-15
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Removed the unused `globPaths`, `loadGitignorePatterns`, and `GlobPathsOptions` exports.
|
|
10
|
+
- Browser helpers now manage Chrome only: removed `Browser`, `BrowserTag`, `resolveBuildId()`, `getInstalledBrowsers()`, and `browser` options/metadata; `getDownloadUrl()` now takes `(platform, buildId, baseUrl?)`.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Added `relativePathWithinNormalizedRoot()` for reusing canonical paths across containment checks.
|
|
15
|
+
- Added `sleepLong()` and `MAX_TIMER_DELAY_MS`: an abortable sleep that chunks delays past the signed 32-bit timer ceiling so day-scale provider waits elapse instead of overflowing the timer.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Log-retention cleanup now runs after logger construction without keeping short-lived commands alive.
|
|
20
|
+
- Debugger support now loads only when SIGUSR1 requests it.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Fixed `extractRetryHint` dropping OpenCode Go's `Resets in …` quota window (`45min`, `2hr 15min`, `3 days`): the `reset in` pattern now accepts `Resets` phrasing, `hr`/`day` units, and compound `2hr 15min` remainders, so exhausted Go credentials block for the server-stated window instead of the 60s heuristic guess. ([#12091](https://github.com/can1357/oh-my-pi/pull/12091) by [@H4vC](https://github.com/H4vC))
|
|
25
|
+
- Async file-peek callbacks now receive stable `Uint8Array` windows with copying `slice()` semantics.
|
|
26
|
+
- Dotenv loading now handles multiline values and escapes consistently with Bun, preventing project values from leaking into child-shell environments.
|
|
27
|
+
- SSE readers now support lone-CR line endings and CRLF split across chunks without merging or delaying events.
|
|
28
|
+
|
|
5
29
|
## [18.1.22] - 2026-09-14
|
|
6
30
|
|
|
7
31
|
### Fixed
|
package/THIRD-PARTY-NOTICES.txt
CHANGED
|
@@ -10843,7 +10843,7 @@ RUST RUNTIME DEPENDENCY LICENSES
|
|
|
10843
10843
|
|
|
10844
10844
|
Generated from Cargo.lock by cargo-about 0.8.2 in locked, offline, workspace, all-feature, all-target mode, then restricted using cargo metadata to normal and build edges reachable from the workspace (development-only edges are excluded). cargo-deny independently evaluates the complete all-target graph.
|
|
10845
10845
|
|
|
10846
|
-
Native Opus (
|
|
10846
|
+
Native Opus (opus/opusic-sys) and PCRE2 (pcre2/pcre2-sys) are covered by the exact crate license payloads below. inferno 0.12.8 is package-scoped as CDDL-1.0: its CDDL-covered source remains available in the locked crates.io source archive at https://crates.io/crates/inferno/0.12.8 and https://github.com/jonhoo/inferno. The full locked license text follows below.
|
|
10847
10847
|
|
|
10848
10848
|
-------------------------------------------------------------------------------
|
|
10849
10849
|
License: Apache License 2.0 (Apache-2.0)
|
|
@@ -12509,6 +12509,58 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
|
12509
12509
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
12510
12510
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
12511
12511
|
|
|
12512
|
+
-------------------------------------------------------------------------------
|
|
12513
|
+
License: BSD 3-Clause "New" or "Revised" License (BSD-3-Clause)
|
|
12514
|
+
Used by:
|
|
12515
|
+
- opusic-sys 0.7.5 (https://github.com/DoumanAsh/opusic-sys)
|
|
12516
|
+
|
|
12517
|
+
Full license text:
|
|
12518
|
+
|
|
12519
|
+
Copyright 2001-2023 Xiph.Org, Skype Limited, Octasic,
|
|
12520
|
+
Jean-Marc Valin, Timothy B. Terriberry,
|
|
12521
|
+
CSIRO, Gregory Maxwell, Mark Borgerding,
|
|
12522
|
+
Erik de Castro Lopo, Mozilla, Amazon
|
|
12523
|
+
|
|
12524
|
+
Redistribution and use in source and binary forms, with or without
|
|
12525
|
+
modification, are permitted provided that the following conditions
|
|
12526
|
+
are met:
|
|
12527
|
+
|
|
12528
|
+
- Redistributions of source code must retain the above copyright
|
|
12529
|
+
notice, this list of conditions and the following disclaimer.
|
|
12530
|
+
|
|
12531
|
+
- Redistributions in binary form must reproduce the above copyright
|
|
12532
|
+
notice, this list of conditions and the following disclaimer in the
|
|
12533
|
+
documentation and/or other materials provided with the distribution.
|
|
12534
|
+
|
|
12535
|
+
- Neither the name of Internet Society, IETF or IETF Trust, nor the
|
|
12536
|
+
names of specific contributors, may be used to endorse or promote
|
|
12537
|
+
products derived from this software without specific prior written
|
|
12538
|
+
permission.
|
|
12539
|
+
|
|
12540
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
12541
|
+
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
12542
|
+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
12543
|
+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
|
|
12544
|
+
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
12545
|
+
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
12546
|
+
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
|
12547
|
+
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
12548
|
+
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
12549
|
+
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
12550
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
12551
|
+
|
|
12552
|
+
Opus is subject to the royalty-free patent licenses which are
|
|
12553
|
+
specified at:
|
|
12554
|
+
|
|
12555
|
+
Xiph.Org Foundation:
|
|
12556
|
+
https://datatracker.ietf.org/ipr/1524/
|
|
12557
|
+
|
|
12558
|
+
Microsoft Corporation:
|
|
12559
|
+
https://datatracker.ietf.org/ipr/1914/
|
|
12560
|
+
|
|
12561
|
+
Broadcom Corporation:
|
|
12562
|
+
https://datatracker.ietf.org/ipr/1526/
|
|
12563
|
+
|
|
12512
12564
|
-------------------------------------------------------------------------------
|
|
12513
12565
|
License: BSD 3-Clause "New" or "Revised" License (BSD-3-Clause)
|
|
12514
12566
|
Used by:
|
|
@@ -13290,29 +13342,6 @@ jurisdiction of the Federal Courts of the Northern District of
|
|
|
13290
13342
|
California and the state courts of the State of California, with
|
|
13291
13343
|
venue lying in Santa Clara County, California.
|
|
13292
13344
|
|
|
13293
|
-
-------------------------------------------------------------------------------
|
|
13294
|
-
License: ISC License (ISC)
|
|
13295
|
-
Used by:
|
|
13296
|
-
- audiopus_sys 0.2.2 (https://github.com/lakelezz/audiopus_sys.git)
|
|
13297
|
-
|
|
13298
|
-
Full license text:
|
|
13299
|
-
|
|
13300
|
-
ISC License
|
|
13301
|
-
|
|
13302
|
-
Copyright (c) 2019, Lakelezz
|
|
13303
|
-
|
|
13304
|
-
Permission to use, copy, modify, and/or distribute this software for any
|
|
13305
|
-
purpose with or without fee is hereby granted, provided that the above
|
|
13306
|
-
copyright notice and this permission notice appear in all copies.
|
|
13307
|
-
|
|
13308
|
-
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
13309
|
-
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
13310
|
-
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
13311
|
-
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13312
|
-
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
13313
|
-
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
13314
|
-
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
13315
|
-
|
|
13316
13345
|
-------------------------------------------------------------------------------
|
|
13317
13346
|
License: ISC License (ISC)
|
|
13318
13347
|
Used by:
|
|
@@ -18734,7 +18763,7 @@ DEALINGS IN THE SOFTWARE.
|
|
|
18734
18763
|
-------------------------------------------------------------------------------
|
|
18735
18764
|
License: MIT License (MIT)
|
|
18736
18765
|
Used by:
|
|
18737
|
-
- opus 0.
|
|
18766
|
+
- opus 0.4.0 (https://github.com/SpaceManiac/opus-rs)
|
|
18738
18767
|
|
|
18739
18768
|
Full license text:
|
|
18740
18769
|
|
package/dist/types/async.d.ts
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Largest delay `setTimeout` (and `timers/promises` `scheduler.wait`)
|
|
3
|
+
* accepts without 32-bit signed overflow: larger values wrap and fire
|
|
4
|
+
* almost immediately instead of sleeping. Chunk day-scale provider waits
|
|
5
|
+
* (e.g. a monthly quota reset parsed from an error hint) so the full
|
|
6
|
+
* duration elapses instead of overflowing the timer.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MAX_TIMER_DELAY_MS = 2147483647;
|
|
9
|
+
/**
|
|
10
|
+
* Abortable sleep for arbitrarily long delays. Waits longer than
|
|
11
|
+
* {@link MAX_TIMER_DELAY_MS} chunk the sleep into back-to-back timer waits
|
|
12
|
+
* so no single timer overflows; an abort during any chunk rejects like
|
|
13
|
+
* `scheduler.wait`.
|
|
14
|
+
*
|
|
15
|
+
* The remainder is deliberately consumed by chunk, not recomputed from a
|
|
16
|
+
* monotonic deadline: deadline tracking never terminates under the repo's
|
|
17
|
+
* instant `scheduler.wait` mocks (retry-cap suites spy it to resolve
|
|
18
|
+
* immediately, so `deadline - performance.now()` never reaches zero and the
|
|
19
|
+
* loop spins forever). A premature native wake (Bun `uv_async_send`, see
|
|
20
|
+
* `sleepAtLeast` in `packages/agent/src/utils/yield.ts`) can therefore
|
|
21
|
+
* under-wait by the unelapsed chunk time — but that self-corrects downstream:
|
|
22
|
+
* credential blocks carry the true deadline independently of this sleep, so
|
|
23
|
+
* an early retry re-hits 429 and re-sleeps on a fresh server hint.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sleepLong(delayMs: number, signal?: AbortSignal): Promise<void>;
|
|
1
26
|
/**
|
|
2
27
|
* Wrap a promise with a timeout and optional abort signal.
|
|
3
28
|
* Rejects with the given error or a new error containing the given message if
|
package/dist/types/browsers.d.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/** Behavior-compatible reimplementation of @puppeteer/browsers' used surface. */
|
|
2
|
-
/** Supported browser products. */
|
|
3
|
-
export declare enum Browser {
|
|
4
|
-
CHROME = "chrome",
|
|
5
|
-
CHROMEHEADLESSSHELL = "chrome-headless-shell",
|
|
6
|
-
CHROMIUM = "chromium",
|
|
7
|
-
FIREFOX = "firefox",
|
|
8
|
-
CHROMEDRIVER = "chromedriver"
|
|
9
|
-
}
|
|
10
1
|
/** Browser download platform identifiers. */
|
|
11
2
|
export declare enum BrowserPlatform {
|
|
12
3
|
LINUX = "linux",
|
|
@@ -16,17 +7,6 @@ export declare enum BrowserPlatform {
|
|
|
16
7
|
WIN32 = "win32",
|
|
17
8
|
WIN64 = "win64"
|
|
18
9
|
}
|
|
19
|
-
/** Chrome-for-Testing release channel tags accepted by {@link resolveBuildId}. */
|
|
20
|
-
export declare enum BrowserTag {
|
|
21
|
-
CANARY = "canary",
|
|
22
|
-
NIGHTLY = "nightly",
|
|
23
|
-
BETA = "beta",
|
|
24
|
-
DEV = "dev",
|
|
25
|
-
DEVEDITION = "devedition",
|
|
26
|
-
STABLE = "stable",
|
|
27
|
-
ESR = "esr",
|
|
28
|
-
LATEST = "latest"
|
|
29
|
-
}
|
|
30
10
|
/** Download progress reported while a browser archive is streamed to disk. */
|
|
31
11
|
export interface BrowserDownloadProgress {
|
|
32
12
|
downloadedBytes: number;
|
|
@@ -34,7 +14,6 @@ export interface BrowserDownloadProgress {
|
|
|
34
14
|
}
|
|
35
15
|
/** Inputs used to locate an installed browser executable. */
|
|
36
16
|
export interface ComputeExecutablePathOptions {
|
|
37
|
-
browser: Browser;
|
|
38
17
|
buildId: string;
|
|
39
18
|
cacheDir: string;
|
|
40
19
|
platform?: BrowserPlatform;
|
|
@@ -44,9 +23,8 @@ export interface InstallOptions extends ComputeExecutablePathOptions {
|
|
|
44
23
|
baseUrl?: string;
|
|
45
24
|
downloadProgressCallback?: (progress: BrowserDownloadProgress) => void;
|
|
46
25
|
}
|
|
47
|
-
/** Metadata for
|
|
26
|
+
/** Metadata for a managed Chrome installation in Puppeteer's cache layout. */
|
|
48
27
|
export interface InstalledBrowser {
|
|
49
|
-
browser: Browser;
|
|
50
28
|
buildId: string;
|
|
51
29
|
platform: BrowserPlatform;
|
|
52
30
|
path: string;
|
|
@@ -54,15 +32,9 @@ export interface InstalledBrowser {
|
|
|
54
32
|
}
|
|
55
33
|
/** Detect the current host's Puppeteer browser platform. */
|
|
56
34
|
export declare function detectBrowserPlatform(): BrowserPlatform | undefined;
|
|
57
|
-
/** Resolve a Chrome-for-Testing channel, milestone, or build prefix to a full build ID. */
|
|
58
|
-
export declare function resolveBuildId(browser: Browser, _platform: BrowserPlatform, tag: string | BrowserTag): Promise<string>;
|
|
59
35
|
/** Return the Chrome-for-Testing archive URL for a browser build. */
|
|
60
|
-
export declare function getDownloadUrl(
|
|
36
|
+
export declare function getDownloadUrl(platform: BrowserPlatform, buildId: string, baseUrl?: string): URL;
|
|
61
37
|
/** Compute the executable path in Puppeteer's cache layout. */
|
|
62
38
|
export declare function computeExecutablePath(options: ComputeExecutablePathOptions): string;
|
|
63
|
-
/** Scan a Puppeteer cache for browser installation directories. */
|
|
64
|
-
export declare function getInstalledBrowsers(options: {
|
|
65
|
-
cacheDir: string;
|
|
66
|
-
}): Promise<InstalledBrowser[]>;
|
|
67
39
|
/** Download and unpack Chrome into Puppeteer's existing cache layout. */
|
|
68
40
|
export declare function install(options: InstallOptions): Promise<InstalledBrowser>;
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -44,6 +44,16 @@ export declare function resolveProfileEnv(omp: string | undefined, pi: string |
|
|
|
44
44
|
export declare function getBaseConfigRoot(): string;
|
|
45
45
|
export declare function resolveEquivalentPath(inputPath: string): string;
|
|
46
46
|
export declare function normalizePathForComparison(inputPath: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Compare paths already normalized by {@link normalizePathForComparison}.
|
|
49
|
+
*
|
|
50
|
+
* Returns the relative path (an empty string when the paths are equal), or
|
|
51
|
+
* `null` when the candidate is outside the root. Callers classifying one
|
|
52
|
+
* candidate against several static roots can normalize each side once and
|
|
53
|
+
* reuse the public helpers' containment semantics without repeating realpath
|
|
54
|
+
* work.
|
|
55
|
+
*/
|
|
56
|
+
export declare function relativePathWithinNormalizedRoot(normalizedRoot: string, normalizedCandidate: string): string | null;
|
|
47
57
|
export declare function pathIsWithin(root: string, candidate: string): boolean;
|
|
48
58
|
export declare function relativePathWithinRoot(root: string, candidate: string): string | null;
|
|
49
59
|
/** Get the project directory. */
|
package/dist/types/env.d.ts
CHANGED
|
@@ -29,8 +29,8 @@ export declare function filterProcessEnv(env: Record<string, string | undefined>
|
|
|
29
29
|
/** Filters process env for child shells without launch-cwd dotenv values. */
|
|
30
30
|
export declare function filterChildShellEnv(env: Record<string, string | undefined>, cwd?: string): Record<string, string>;
|
|
31
31
|
/**
|
|
32
|
-
* Parses a .env file
|
|
33
|
-
*
|
|
32
|
+
* Parses a complete .env file with the runtime's dotenv grammar, then retains
|
|
33
|
+
* only shell-identifier names and spawn-safe values before mirroring valid
|
|
34
34
|
* `OMP_` variables to their `PI_` aliases.
|
|
35
35
|
*/
|
|
36
36
|
export declare function parseEnvFile(filePath: string): Record<string, string>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -9,7 +9,6 @@ export * from "./file-lock.js";
|
|
|
9
9
|
export * from "./format.js";
|
|
10
10
|
export * from "./frontmatter.js";
|
|
11
11
|
export * from "./fs-error.js";
|
|
12
|
-
export * from "./glob.js";
|
|
13
12
|
export * from "./incoming-json.js";
|
|
14
13
|
export * from "./json.js";
|
|
15
14
|
export * from "./json-parse.js";
|
|
@@ -10,9 +10,8 @@ export declare function peekFile<T>(filePath: string, maxBytes: number, op: (hea
|
|
|
10
10
|
/**
|
|
11
11
|
* Read up to the last `maxBytes` of `filePath` and pass that slice to `op`.
|
|
12
12
|
*
|
|
13
|
-
* The tail mirror of {@link peekFile}:
|
|
14
|
-
*
|
|
15
|
-
* window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
|
|
13
|
+
* The tail mirror of {@link peekFile}: the read is positioned at `size - len` so
|
|
14
|
+
* the window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
|
|
16
15
|
* returned. A multi-byte codepoint straddling the leading cut decodes to a
|
|
17
16
|
* replacement char — callers that parse line-oriented tails drop the partial
|
|
18
17
|
* leading line anyway.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-utils",
|
|
3
|
-
"version": "18.
|
|
3
|
+
"version": "18.2.0",
|
|
4
4
|
"description": "Shared utilities for pi packages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@oh-my-pi/pi-natives": "18.
|
|
57
|
+
"@oh-my-pi/pi-natives": "18.2.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/bun": "^1.3.14"
|
package/src/async.ts
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
+
import { scheduler } from "node:timers/promises";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Largest delay `setTimeout` (and `timers/promises` `scheduler.wait`)
|
|
5
|
+
* accepts without 32-bit signed overflow: larger values wrap and fire
|
|
6
|
+
* almost immediately instead of sleeping. Chunk day-scale provider waits
|
|
7
|
+
* (e.g. a monthly quota reset parsed from an error hint) so the full
|
|
8
|
+
* duration elapses instead of overflowing the timer.
|
|
9
|
+
*/
|
|
10
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Abortable sleep for arbitrarily long delays. Waits longer than
|
|
14
|
+
* {@link MAX_TIMER_DELAY_MS} chunk the sleep into back-to-back timer waits
|
|
15
|
+
* so no single timer overflows; an abort during any chunk rejects like
|
|
16
|
+
* `scheduler.wait`.
|
|
17
|
+
*
|
|
18
|
+
* The remainder is deliberately consumed by chunk, not recomputed from a
|
|
19
|
+
* monotonic deadline: deadline tracking never terminates under the repo's
|
|
20
|
+
* instant `scheduler.wait` mocks (retry-cap suites spy it to resolve
|
|
21
|
+
* immediately, so `deadline - performance.now()` never reaches zero and the
|
|
22
|
+
* loop spins forever). A premature native wake (Bun `uv_async_send`, see
|
|
23
|
+
* `sleepAtLeast` in `packages/agent/src/utils/yield.ts`) can therefore
|
|
24
|
+
* under-wait by the unelapsed chunk time — but that self-corrects downstream:
|
|
25
|
+
* credential blocks carry the true deadline independently of this sleep, so
|
|
26
|
+
* an early retry re-hits 429 and re-sleeps on a fresh server hint.
|
|
27
|
+
*/
|
|
28
|
+
export async function sleepLong(delayMs: number, signal?: AbortSignal): Promise<void> {
|
|
29
|
+
signal?.throwIfAborted();
|
|
30
|
+
let remaining = delayMs;
|
|
31
|
+
while (remaining > 0) {
|
|
32
|
+
await scheduler.wait(Math.min(remaining, MAX_TIMER_DELAY_MS), { signal });
|
|
33
|
+
remaining -= MAX_TIMER_DELAY_MS;
|
|
34
|
+
signal?.throwIfAborted();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
1
38
|
/**
|
|
2
39
|
* Wrap a promise with a timeout and optional abort signal.
|
|
3
40
|
* Rejects with the given error or a new error containing the given message if
|
package/src/browsers.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
import type * as fs from "node:fs";
|
|
1
|
+
/** Managed Chrome-for-Testing installation with bounded downloads and atomic publication. */
|
|
4
2
|
import * as fsp from "node:fs/promises";
|
|
5
3
|
import * as os from "node:os";
|
|
6
4
|
import * as path from "node:path";
|
|
@@ -8,7 +6,6 @@ import { type ArchiveLimits, extractArchive } from "./ar";
|
|
|
8
6
|
import { withFileLock } from "./file-lock";
|
|
9
7
|
|
|
10
8
|
const CHROME_FOR_TESTING_BASE_URL = "https://storage.googleapis.com/chrome-for-testing-public";
|
|
11
|
-
const CHROME_METADATA_BASE_URL = "https://googlechromelabs.github.io/chrome-for-testing";
|
|
12
9
|
// Installation is outside browser/Eval operation deadlines, but a stalled CDN
|
|
13
10
|
// must still release the cached install promise and permit a later attempt.
|
|
14
11
|
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
|
|
@@ -25,15 +22,6 @@ const BROWSER_ARCHIVE_LIMITS: Partial<ArchiveLimits> = {
|
|
|
25
22
|
maxInMemorySize: 1024 * 1024 * 1024,
|
|
26
23
|
};
|
|
27
24
|
|
|
28
|
-
/** Supported browser products. */
|
|
29
|
-
export enum Browser {
|
|
30
|
-
CHROME = "chrome",
|
|
31
|
-
CHROMEHEADLESSSHELL = "chrome-headless-shell",
|
|
32
|
-
CHROMIUM = "chromium",
|
|
33
|
-
FIREFOX = "firefox",
|
|
34
|
-
CHROMEDRIVER = "chromedriver",
|
|
35
|
-
}
|
|
36
|
-
|
|
37
25
|
/** Browser download platform identifiers. */
|
|
38
26
|
export enum BrowserPlatform {
|
|
39
27
|
LINUX = "linux",
|
|
@@ -51,34 +39,6 @@ function requireChromeForTestingPlatform(platform: BrowserPlatform): ChromeForTe
|
|
|
51
39
|
return platform;
|
|
52
40
|
}
|
|
53
41
|
|
|
54
|
-
const BROWSERS = [
|
|
55
|
-
Browser.CHROME,
|
|
56
|
-
Browser.CHROMEHEADLESSSHELL,
|
|
57
|
-
Browser.CHROMIUM,
|
|
58
|
-
Browser.FIREFOX,
|
|
59
|
-
Browser.CHROMEDRIVER,
|
|
60
|
-
] as const;
|
|
61
|
-
const BROWSER_PLATFORMS = [
|
|
62
|
-
BrowserPlatform.LINUX,
|
|
63
|
-
BrowserPlatform.LINUX_ARM,
|
|
64
|
-
BrowserPlatform.MAC,
|
|
65
|
-
BrowserPlatform.MAC_ARM,
|
|
66
|
-
BrowserPlatform.WIN32,
|
|
67
|
-
BrowserPlatform.WIN64,
|
|
68
|
-
] as const;
|
|
69
|
-
|
|
70
|
-
/** Chrome-for-Testing release channel tags accepted by {@link resolveBuildId}. */
|
|
71
|
-
export enum BrowserTag {
|
|
72
|
-
CANARY = "canary",
|
|
73
|
-
NIGHTLY = "nightly",
|
|
74
|
-
BETA = "beta",
|
|
75
|
-
DEV = "dev",
|
|
76
|
-
DEVEDITION = "devedition",
|
|
77
|
-
STABLE = "stable",
|
|
78
|
-
ESR = "esr",
|
|
79
|
-
LATEST = "latest",
|
|
80
|
-
}
|
|
81
|
-
|
|
82
42
|
/** Download progress reported while a browser archive is streamed to disk. */
|
|
83
43
|
export interface BrowserDownloadProgress {
|
|
84
44
|
downloadedBytes: number;
|
|
@@ -87,7 +47,6 @@ export interface BrowserDownloadProgress {
|
|
|
87
47
|
|
|
88
48
|
/** Inputs used to locate an installed browser executable. */
|
|
89
49
|
export interface ComputeExecutablePathOptions {
|
|
90
|
-
browser: Browser;
|
|
91
50
|
buildId: string;
|
|
92
51
|
cacheDir: string;
|
|
93
52
|
platform?: BrowserPlatform;
|
|
@@ -99,27 +58,14 @@ export interface InstallOptions extends ComputeExecutablePathOptions {
|
|
|
99
58
|
downloadProgressCallback?: (progress: BrowserDownloadProgress) => void;
|
|
100
59
|
}
|
|
101
60
|
|
|
102
|
-
/** Metadata for
|
|
61
|
+
/** Metadata for a managed Chrome installation in Puppeteer's cache layout. */
|
|
103
62
|
export interface InstalledBrowser {
|
|
104
|
-
browser: Browser;
|
|
105
63
|
buildId: string;
|
|
106
64
|
platform: BrowserPlatform;
|
|
107
65
|
path: string;
|
|
108
66
|
executablePath: string;
|
|
109
67
|
}
|
|
110
68
|
|
|
111
|
-
interface LastKnownGoodVersions {
|
|
112
|
-
channels: Record<string, { version: string }>;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
interface MilestoneVersions {
|
|
116
|
-
milestones: Record<string, { version: string }>;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
interface PatchVersions {
|
|
120
|
-
builds: Record<string, { version: string }>;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
69
|
/** Detect the current host's Puppeteer browser platform. */
|
|
124
70
|
export function detectBrowserPlatform(): BrowserPlatform | undefined {
|
|
125
71
|
const platform = os.platform();
|
|
@@ -130,43 +76,8 @@ export function detectBrowserPlatform(): BrowserPlatform | undefined {
|
|
|
130
76
|
return undefined;
|
|
131
77
|
}
|
|
132
78
|
|
|
133
|
-
/** Resolve a Chrome-for-Testing channel, milestone, or build prefix to a full build ID. */
|
|
134
|
-
export async function resolveBuildId(
|
|
135
|
-
browser: Browser,
|
|
136
|
-
_platform: BrowserPlatform,
|
|
137
|
-
tag: string | BrowserTag,
|
|
138
|
-
): Promise<string> {
|
|
139
|
-
if (browser !== Browser.CHROME && browser !== Browser.CHROMEHEADLESSSHELL && browser !== Browser.CHROMEDRIVER) {
|
|
140
|
-
return tag;
|
|
141
|
-
}
|
|
142
|
-
if (/^\d+\.\d+\.\d+\.\d+$/.test(tag)) return tag;
|
|
143
|
-
|
|
144
|
-
const channel = tag === BrowserTag.LATEST ? "Canary" : chromeChannelName(tag);
|
|
145
|
-
if (channel) {
|
|
146
|
-
const metadata = await fetchMetadata<LastKnownGoodVersions>("last-known-good-versions.json");
|
|
147
|
-
const version = metadata.channels[channel]?.version;
|
|
148
|
-
if (!version) throw new Error(`Chrome channel ${tag} was not found in Chrome-for-Testing metadata`);
|
|
149
|
-
return version;
|
|
150
|
-
}
|
|
151
|
-
if (/^\d+$/.test(tag)) {
|
|
152
|
-
const metadata = await fetchMetadata<MilestoneVersions>("latest-versions-per-milestone.json");
|
|
153
|
-
return metadata.milestones[tag]?.version ?? tag;
|
|
154
|
-
}
|
|
155
|
-
if (/^\d+\.\d+\.\d+$/.test(tag)) {
|
|
156
|
-
const metadata = await fetchMetadata<PatchVersions>("latest-patch-versions-per-build.json");
|
|
157
|
-
return metadata.builds[tag]?.version ?? tag;
|
|
158
|
-
}
|
|
159
|
-
return tag;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
79
|
/** Return the Chrome-for-Testing archive URL for a browser build. */
|
|
163
|
-
export function getDownloadUrl(
|
|
164
|
-
browser: Browser,
|
|
165
|
-
platform: BrowserPlatform,
|
|
166
|
-
buildId: string,
|
|
167
|
-
baseUrl = CHROME_FOR_TESTING_BASE_URL,
|
|
168
|
-
): URL {
|
|
169
|
-
if (browser !== Browser.CHROME) throw new Error(`Unsupported browser download: ${browser}`);
|
|
80
|
+
export function getDownloadUrl(platform: BrowserPlatform, buildId: string, baseUrl = CHROME_FOR_TESTING_BASE_URL): URL {
|
|
170
81
|
const archivePlatform = chromeArchivePlatform(platform);
|
|
171
82
|
const root = baseUrl.replace(/\/$/, "");
|
|
172
83
|
return new URL(`${root}/${buildId}/${archivePlatform}/chrome-${archivePlatform}.zip`);
|
|
@@ -176,9 +87,8 @@ export function getDownloadUrl(
|
|
|
176
87
|
export function computeExecutablePath(options: ComputeExecutablePathOptions): string {
|
|
177
88
|
const detectedPlatform = options.platform ?? detectBrowserPlatform();
|
|
178
89
|
if (!detectedPlatform) throw new Error("Cannot determine a browser platform for this host");
|
|
179
|
-
if (options.browser !== Browser.CHROME) throw new Error(`Unsupported browser executable: ${options.browser}`);
|
|
180
90
|
const platform = requireChromeForTestingPlatform(detectedPlatform);
|
|
181
|
-
const installDir = installationDir(options.cacheDir,
|
|
91
|
+
const installDir = installationDir(options.cacheDir, platform, options.buildId);
|
|
182
92
|
switch (platform) {
|
|
183
93
|
case BrowserPlatform.LINUX:
|
|
184
94
|
return path.join(installDir, "chrome-linux64", "chrome");
|
|
@@ -207,52 +117,14 @@ export function computeExecutablePath(options: ComputeExecutablePathOptions): st
|
|
|
207
117
|
}
|
|
208
118
|
}
|
|
209
119
|
|
|
210
|
-
/** Scan a Puppeteer cache for browser installation directories. */
|
|
211
|
-
export async function getInstalledBrowsers(options: { cacheDir: string }): Promise<InstalledBrowser[]> {
|
|
212
|
-
const installed: InstalledBrowser[] = [];
|
|
213
|
-
for (const browser of BROWSERS) {
|
|
214
|
-
const browserDir = path.join(options.cacheDir, browser);
|
|
215
|
-
let entries: fs.Dirent[];
|
|
216
|
-
try {
|
|
217
|
-
entries = await fsp.readdir(browserDir, { withFileTypes: true });
|
|
218
|
-
} catch (error) {
|
|
219
|
-
if (isMissingPath(error)) continue;
|
|
220
|
-
throw error;
|
|
221
|
-
}
|
|
222
|
-
for (const entry of entries) {
|
|
223
|
-
if (!entry.isDirectory()) continue;
|
|
224
|
-
const parsed = parseInstallationName(entry.name);
|
|
225
|
-
if (!parsed) continue;
|
|
226
|
-
const installPath = path.join(browserDir, entry.name);
|
|
227
|
-
try {
|
|
228
|
-
installed.push({
|
|
229
|
-
browser,
|
|
230
|
-
buildId: parsed.buildId,
|
|
231
|
-
platform: parsed.platform,
|
|
232
|
-
path: installPath,
|
|
233
|
-
executablePath: computeExecutablePath({
|
|
234
|
-
browser,
|
|
235
|
-
buildId: parsed.buildId,
|
|
236
|
-
cacheDir: options.cacheDir,
|
|
237
|
-
platform: parsed.platform,
|
|
238
|
-
}),
|
|
239
|
-
});
|
|
240
|
-
} catch {
|
|
241
|
-
// Other browser products are not part of the surface used by OMP.
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return installed;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
120
|
/** Download and unpack Chrome into Puppeteer's existing cache layout. */
|
|
249
121
|
export async function install(options: InstallOptions): Promise<InstalledBrowser> {
|
|
250
122
|
const platform = options.platform ?? detectBrowserPlatform();
|
|
251
123
|
if (!platform) throw new Error("Cannot determine a browser platform for this host");
|
|
252
124
|
const executablePath = computeExecutablePath({ ...options, platform });
|
|
253
|
-
const installPath = installationDir(options.cacheDir,
|
|
125
|
+
const installPath = installationDir(options.cacheDir, platform, options.buildId);
|
|
254
126
|
if (await pathExists(executablePath)) {
|
|
255
|
-
return {
|
|
127
|
+
return { buildId: options.buildId, platform, path: installPath, executablePath };
|
|
256
128
|
}
|
|
257
129
|
|
|
258
130
|
await fsp.mkdir(path.dirname(installPath), { recursive: true });
|
|
@@ -267,7 +139,7 @@ export async function install(options: InstallOptions): Promise<InstalledBrowser
|
|
|
267
139
|
const stagingPath = path.join(options.cacheDir, `.browser-${nonce}`);
|
|
268
140
|
try {
|
|
269
141
|
await downloadArchive(
|
|
270
|
-
getDownloadUrl(
|
|
142
|
+
getDownloadUrl(platform, options.buildId, options.baseUrl),
|
|
271
143
|
archivePath,
|
|
272
144
|
options.downloadProgressCallback,
|
|
273
145
|
);
|
|
@@ -284,34 +156,12 @@ export async function install(options: InstallOptions): Promise<InstalledBrowser
|
|
|
284
156
|
]);
|
|
285
157
|
}
|
|
286
158
|
}
|
|
287
|
-
return {
|
|
159
|
+
return { buildId: options.buildId, platform, path: installPath, executablePath };
|
|
288
160
|
},
|
|
289
161
|
{ retries: Math.ceil(DOWNLOAD_TIMEOUT_MS / 100) + 1, retryDelayMs: 100 },
|
|
290
162
|
);
|
|
291
163
|
}
|
|
292
164
|
|
|
293
|
-
function chromeChannelName(tag: string): string | undefined {
|
|
294
|
-
switch (tag) {
|
|
295
|
-
case BrowserTag.STABLE:
|
|
296
|
-
return "Stable";
|
|
297
|
-
case BrowserTag.BETA:
|
|
298
|
-
return "Beta";
|
|
299
|
-
case BrowserTag.DEV:
|
|
300
|
-
return "Dev";
|
|
301
|
-
case BrowserTag.CANARY:
|
|
302
|
-
return "Canary";
|
|
303
|
-
default:
|
|
304
|
-
return undefined;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
async function fetchMetadata<T>(filename: string): Promise<T> {
|
|
309
|
-
const response = await fetch(`${CHROME_METADATA_BASE_URL}/${filename}`, { signal: AbortSignal.timeout(30_000) });
|
|
310
|
-
if (!response.ok)
|
|
311
|
-
throw new Error(`Failed to fetch Chrome-for-Testing metadata (${response.status} ${response.statusText})`);
|
|
312
|
-
return (await response.json()) as T;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
165
|
function chromeArchivePlatform(platform: BrowserPlatform): string {
|
|
316
166
|
switch (requireChromeForTestingPlatform(platform)) {
|
|
317
167
|
case BrowserPlatform.LINUX:
|
|
@@ -327,17 +177,8 @@ function chromeArchivePlatform(platform: BrowserPlatform): string {
|
|
|
327
177
|
}
|
|
328
178
|
}
|
|
329
179
|
|
|
330
|
-
function installationDir(cacheDir: string,
|
|
331
|
-
return path.join(cacheDir,
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function parseInstallationName(name: string): { platform: BrowserPlatform; buildId: string } | undefined {
|
|
335
|
-
for (const platform of BROWSER_PLATFORMS) {
|
|
336
|
-
const prefix = `${platform}-`;
|
|
337
|
-
if (name.startsWith(prefix) && name.length > prefix.length)
|
|
338
|
-
return { platform, buildId: name.slice(prefix.length) };
|
|
339
|
-
}
|
|
340
|
-
return undefined;
|
|
180
|
+
function installationDir(cacheDir: string, platform: BrowserPlatform, buildId: string): string {
|
|
181
|
+
return path.join(cacheDir, "chrome", `${platform}-${buildId}`);
|
|
341
182
|
}
|
|
342
183
|
|
|
343
184
|
function isMissingPath(error: unknown): boolean {
|
package/src/dirs.ts
CHANGED
|
@@ -165,19 +165,31 @@ export function normalizePathForComparison(inputPath: string): string {
|
|
|
165
165
|
return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Compare paths already normalized by {@link normalizePathForComparison}.
|
|
170
|
+
*
|
|
171
|
+
* Returns the relative path (an empty string when the paths are equal), or
|
|
172
|
+
* `null` when the candidate is outside the root. Callers classifying one
|
|
173
|
+
* candidate against several static roots can normalize each side once and
|
|
174
|
+
* reuse the public helpers' containment semantics without repeating realpath
|
|
175
|
+
* work.
|
|
176
|
+
*/
|
|
177
|
+
export function relativePathWithinNormalizedRoot(normalizedRoot: string, normalizedCandidate: string): string | null {
|
|
178
|
+
const relative = path.relative(normalizedRoot, normalizedCandidate);
|
|
179
|
+
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) return null;
|
|
180
|
+
return relative;
|
|
181
|
+
}
|
|
182
|
+
|
|
168
183
|
export function pathIsWithin(root: string, candidate: string): boolean {
|
|
169
184
|
const normalizedRoot = normalizePathForComparison(root);
|
|
170
185
|
const normalizedCandidate = normalizePathForComparison(candidate);
|
|
171
|
-
|
|
172
|
-
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
186
|
+
return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) !== null;
|
|
173
187
|
}
|
|
174
188
|
|
|
175
189
|
export function relativePathWithinRoot(root: string, candidate: string): string | null {
|
|
176
|
-
if (!pathIsWithin(root, candidate)) return null;
|
|
177
190
|
const normalizedRoot = normalizePathForComparison(root);
|
|
178
191
|
const normalizedCandidate = normalizePathForComparison(candidate);
|
|
179
|
-
|
|
180
|
-
return relative || null;
|
|
192
|
+
return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) || null;
|
|
181
193
|
}
|
|
182
194
|
|
|
183
195
|
let projectDir: string | undefined;
|
package/src/env.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { parseEnv } from "node:util";
|
|
4
5
|
import { getAgentDir, getConfigRootDir, getProjectDir, refreshDirsFromEnv } from "./dirs";
|
|
5
6
|
|
|
6
7
|
export * from "./worker-host";
|
|
@@ -184,44 +185,17 @@ export function filterChildShellEnv(
|
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
/**
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* unquoted values, and single/double/backtick quoting (a `#` inside quotes
|
|
190
|
-
* stays literal). Returns undefined for blank lines, comments, and malformed
|
|
191
|
-
* names.
|
|
192
|
-
*/
|
|
193
|
-
function parseEnvLine(line: string): { key: string; value: string } | undefined {
|
|
194
|
-
const trimmed = line.trim();
|
|
195
|
-
if (!trimmed || trimmed.startsWith("#")) return undefined;
|
|
196
|
-
const eqIndex = trimmed.indexOf("=");
|
|
197
|
-
if (eqIndex === -1) return undefined;
|
|
198
|
-
let key = trimmed.slice(0, eqIndex).trim();
|
|
199
|
-
const exported = key.match(/^export[ \t]+(.*)$/);
|
|
200
|
-
if (exported) key = exported[1].trim();
|
|
201
|
-
if (!isValidEnvName(key)) return undefined;
|
|
202
|
-
const raw = trimmed.slice(eqIndex + 1).replace(/^[ \t]+/, "");
|
|
203
|
-
const quote = raw[0];
|
|
204
|
-
if (quote === '"' || quote === "'" || quote === "`") {
|
|
205
|
-
let close = raw.indexOf(quote, 1);
|
|
206
|
-
while (close !== -1 && raw[close - 1] === "\\") close = raw.indexOf(quote, close + 1);
|
|
207
|
-
return { key, value: close === -1 ? raw.slice(1) : raw.slice(1, close) };
|
|
208
|
-
}
|
|
209
|
-
const commentIndex = raw.search(/[ \t]#/);
|
|
210
|
-
return { key, value: (commentIndex === -1 ? raw : raw.slice(0, commentIndex)).trimEnd() };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Parses a .env file synchronously into key-value string pairs using
|
|
215
|
-
* {@link parseEnvLine} for Bun-compatible line semantics, then mirrors valid
|
|
188
|
+
* Parses a complete .env file with the runtime's dotenv grammar, then retains
|
|
189
|
+
* only shell-identifier names and spawn-safe values before mirroring valid
|
|
216
190
|
* `OMP_` variables to their `PI_` aliases.
|
|
217
191
|
*/
|
|
218
192
|
export function parseEnvFile(filePath: string): Record<string, string> {
|
|
219
193
|
const result: Record<string, string> = {};
|
|
220
194
|
try {
|
|
221
|
-
const
|
|
222
|
-
for (const
|
|
223
|
-
const
|
|
224
|
-
if (
|
|
195
|
+
const parsed = parseEnv(fs.readFileSync(filePath, "utf-8"));
|
|
196
|
+
for (const key in parsed) {
|
|
197
|
+
const value = parsed[key];
|
|
198
|
+
if (value !== undefined && isValidEnvName(key) && isSafeEnvValue(value)) result[key] = value;
|
|
225
199
|
}
|
|
226
200
|
} catch {
|
|
227
201
|
// File doesn't exist or can't be read - return empty result
|
package/src/fetch-retry.ts
CHANGED
|
@@ -11,7 +11,12 @@ const RETRY_DELAY_FIELD_PATTERN = /"retryDelay":\s*"([0-9.]+)(ms|s)"/i;
|
|
|
11
11
|
// "try again in 90 minutes" / "try again in 1 hour"
|
|
12
12
|
const TRY_AGAIN_PATTERN = /try again in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i;
|
|
13
13
|
// "Your limit will reset in 13 minutes" / "reset in 13 minutes" / "will reset in 2h"
|
|
14
|
-
|
|
14
|
+
// OpenCode Go quota errors use "Resets in …" with day units and compound
|
|
15
|
+
// remainders ("Resets in 3 days", "Resets in 2hr 15min", "Resets in 45min").
|
|
16
|
+
const WILL_RESET_IN_PATTERN =
|
|
17
|
+
/(?:will\s+)?resets?\s+in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b/i;
|
|
18
|
+
// OpenCode Go compound remainder: "Resets in 2hr 15min".
|
|
19
|
+
const RESET_IN_HR_MIN_PATTERN = /resets?\s+in\s+~?\s*(\d+(?:\.\d+)?)\s*hr\s*(\d+(?:\.\d+)?)\s*min\b/i;
|
|
15
20
|
// "Your limit will reset at 2026-09-01 09:44:51" / "reset at 2026-09-01T09:44:51Z"
|
|
16
21
|
const WILL_RESET_AT_PATTERN =
|
|
17
22
|
/(?:will\s+)?reset at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:?[0-9]{2})?)/i;
|
|
@@ -158,6 +163,16 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
|
|
|
158
163
|
}
|
|
159
164
|
}
|
|
160
165
|
}
|
|
166
|
+
// OpenCode Go compound remainder ("Resets in 2hr 15min"): the generic
|
|
167
|
+
// pattern only captures the leading "2hr", so add the trailing minutes.
|
|
168
|
+
const compoundResetMatch = RESET_IN_HR_MIN_PATTERN.exec(body);
|
|
169
|
+
if (compoundResetMatch?.[1] && compoundResetMatch[2]) {
|
|
170
|
+
const hours = Number.parseFloat(compoundResetMatch[1]);
|
|
171
|
+
const minutes = Number.parseFloat(compoundResetMatch[2]);
|
|
172
|
+
if (Number.isFinite(hours) && Number.isFinite(minutes) && hours >= 0 && minutes > 0) {
|
|
173
|
+
consider(hours * 60 * 60_000 + minutes * 60_000);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
161
176
|
const accountResetMatch = WILL_RESET_IN_PATTERN.exec(body);
|
|
162
177
|
if (accountResetMatch?.[1]) {
|
|
163
178
|
const value = Number.parseFloat(accountResetMatch[1]);
|
|
@@ -241,6 +256,10 @@ function unitToMs(unit: string): number | undefined {
|
|
|
241
256
|
case "hour":
|
|
242
257
|
case "hours":
|
|
243
258
|
return 60 * 60_000;
|
|
259
|
+
case "d":
|
|
260
|
+
case "day":
|
|
261
|
+
case "days":
|
|
262
|
+
return 24 * 60 * 60_000;
|
|
244
263
|
default:
|
|
245
264
|
return undefined;
|
|
246
265
|
}
|
package/src/index.ts
CHANGED
package/src/logger.ts
CHANGED
|
@@ -152,6 +152,19 @@ function pruneStaleProcessLogs(dir: string): void {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
const scheduledPruneDirs = new Set<string>();
|
|
156
|
+
|
|
157
|
+
/** Run shared retention only after logger construction has returned to the event loop. */
|
|
158
|
+
function schedulePruneStaleProcessLogs(dir: string): void {
|
|
159
|
+
if (scheduledPruneDirs.has(dir)) return;
|
|
160
|
+
scheduledPruneDirs.add(dir);
|
|
161
|
+
const immediate = setImmediate(() => {
|
|
162
|
+
scheduledPruneDirs.delete(dir);
|
|
163
|
+
pruneStaleProcessLogs(dir);
|
|
164
|
+
});
|
|
165
|
+
immediate.unref();
|
|
166
|
+
}
|
|
167
|
+
|
|
155
168
|
/** Ensure a logs directory exists; return the resolved path. */
|
|
156
169
|
function ensureDir(dir: string): string {
|
|
157
170
|
if (!fs.existsSync(dir)) {
|
|
@@ -237,7 +250,7 @@ function formatLogInfo(info: NormalizedLogInfo): string {
|
|
|
237
250
|
/** Build a rotating file sink with process-local rotation and shared retention. */
|
|
238
251
|
function makeFileTransport(dir?: string): RotatingFileSink {
|
|
239
252
|
const logsDir = ensureDir(dir ?? getLogsDir());
|
|
240
|
-
|
|
253
|
+
schedulePruneStaleProcessLogs(logsDir);
|
|
241
254
|
return new RotatingFileSink({
|
|
242
255
|
directory: logsDir,
|
|
243
256
|
filenamePrefix: "omp",
|
package/src/peek-file.ts
CHANGED
|
@@ -1,67 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Read the first `maxBytes` of a file (offset 0) and pass that slice to `op`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* queue, falling back to a fresh allocation when the pool and queue are saturated
|
|
7
|
-
* or when `maxBytes` exceeds the pool slot size.
|
|
4
|
+
* Synchronous peeks reuse one growable `Uint8Array`; asynchronous peeks allocate
|
|
5
|
+
* their result window so callback-retained slices cannot be changed by a later read.
|
|
8
6
|
*/
|
|
9
7
|
import * as fs from "node:fs";
|
|
10
8
|
|
|
11
|
-
/** Async pool slot size; larger peeks allocate ad hoc. */
|
|
12
|
-
const POOLED_BUFFER_SIZE = 512;
|
|
13
|
-
const ASYNC_POOL_SIZE = 10;
|
|
14
|
-
/** Cap waiter queue so heavy concurrency does not queue unbounded; overflow uses alloc. */
|
|
15
|
-
const MAX_ASYNC_WAITERS = 4;
|
|
16
9
|
const INITIAL_SYNC_BUFFER_SIZE = 1024;
|
|
17
|
-
const EMPTY_BUFFER =
|
|
10
|
+
const EMPTY_BUFFER = new Uint8Array(0);
|
|
18
11
|
|
|
19
|
-
const asyncPool = Array.from({ length: ASYNC_POOL_SIZE }, () => Buffer.allocUnsafe(POOLED_BUFFER_SIZE));
|
|
20
|
-
const availableAsyncPoolIndexes = Array.from({ length: ASYNC_POOL_SIZE }, (_, index) => index);
|
|
21
|
-
const asyncPoolWaiters: Array<(index: number) => void> = [];
|
|
22
12
|
let syncPool = new Uint8Array(INITIAL_SYNC_BUFFER_SIZE);
|
|
23
13
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return index;
|
|
29
|
-
}
|
|
30
|
-
if (asyncPoolWaiters.length >= MAX_ASYNC_WAITERS) {
|
|
31
|
-
return -1;
|
|
32
|
-
}
|
|
33
|
-
const { promise, resolve } = Promise.withResolvers<number>();
|
|
34
|
-
asyncPoolWaiters.push(resolve);
|
|
35
|
-
return promise;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function releaseAsyncPoolIndex(index: number): void {
|
|
39
|
-
if (index < 0) {
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
const waiter = asyncPoolWaiters.shift();
|
|
43
|
-
if (waiter) {
|
|
44
|
-
waiter(index);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
availableAsyncPoolIndexes.push(index);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function withAsyncPoolBuffer<T>(maxBytes: number, op: (buffer: Buffer) => Promise<T>): Promise<T> {
|
|
51
|
-
if (maxBytes <= 0) {
|
|
52
|
-
return op(EMPTY_BUFFER);
|
|
53
|
-
}
|
|
54
|
-
if (maxBytes > POOLED_BUFFER_SIZE) {
|
|
55
|
-
return op(Buffer.allocUnsafe(maxBytes));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const poolIndex = await acquireAsyncPoolIndex();
|
|
59
|
-
const buffer = poolIndex >= 0 ? asyncPool[poolIndex] : Buffer.allocUnsafe(maxBytes);
|
|
60
|
-
try {
|
|
61
|
-
return await op(buffer.subarray(0, maxBytes));
|
|
62
|
-
} finally {
|
|
63
|
-
releaseAsyncPoolIndex(poolIndex);
|
|
64
|
-
}
|
|
14
|
+
function allocateWindow(length: number): Uint8Array {
|
|
15
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
16
|
+
// A plain view preserves Uint8Array.slice semantics without zeroing bytes the read replaces.
|
|
17
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
65
18
|
}
|
|
66
19
|
|
|
67
20
|
function withSyncPoolBuffer<T>(maxBytes: number, op: (buffer: Uint8Array) => T): T {
|
|
@@ -104,10 +57,9 @@ export async function peekFile<T>(filePath: string, maxBytes: number, op: (heade
|
|
|
104
57
|
|
|
105
58
|
const fileHandle = await fs.promises.open(filePath, "r");
|
|
106
59
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
});
|
|
60
|
+
const buffer = allocateWindow(maxBytes);
|
|
61
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, 0);
|
|
62
|
+
return op(buffer.subarray(0, bytesRead));
|
|
111
63
|
} finally {
|
|
112
64
|
await fileHandle.close();
|
|
113
65
|
}
|
|
@@ -116,9 +68,8 @@ export async function peekFile<T>(filePath: string, maxBytes: number, op: (heade
|
|
|
116
68
|
/**
|
|
117
69
|
* Read up to the last `maxBytes` of `filePath` and pass that slice to `op`.
|
|
118
70
|
*
|
|
119
|
-
* The tail mirror of {@link peekFile}:
|
|
120
|
-
*
|
|
121
|
-
* window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
|
|
71
|
+
* The tail mirror of {@link peekFile}: the read is positioned at `size - len` so
|
|
72
|
+
* the window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
|
|
122
73
|
* returned. A multi-byte codepoint straddling the leading cut decodes to a
|
|
123
74
|
* replacement char — callers that parse line-oriented tails drop the partial
|
|
124
75
|
* leading line anyway.
|
|
@@ -135,10 +86,9 @@ export async function peekFileTail<T>(filePath: string, maxBytes: number, op: (t
|
|
|
135
86
|
if (len <= 0) {
|
|
136
87
|
return op(EMPTY_BUFFER);
|
|
137
88
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
89
|
+
const buffer = allocateWindow(len);
|
|
90
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, size - len);
|
|
91
|
+
return op(buffer.subarray(0, bytesRead));
|
|
142
92
|
} finally {
|
|
143
93
|
await fileHandle.close();
|
|
144
94
|
}
|
|
@@ -168,7 +118,7 @@ export async function peekFileEnds<T>(
|
|
|
168
118
|
const headLen = prefixBytes > 0 ? Math.min(prefixBytes, size) : 0;
|
|
169
119
|
const tailLen = suffixBytes > 0 ? Math.min(suffixBytes, size) : 0;
|
|
170
120
|
|
|
171
|
-
const head = headLen > 0 ?
|
|
121
|
+
const head = headLen > 0 ? allocateWindow(headLen) : EMPTY_BUFFER;
|
|
172
122
|
const headBytesRead = headLen > 0 ? (await fileHandle.read(head, 0, head.byteLength, 0)).bytesRead : 0;
|
|
173
123
|
const headSlice = head.subarray(0, headBytesRead);
|
|
174
124
|
|
|
@@ -179,7 +129,7 @@ export async function peekFileEnds<T>(
|
|
|
179
129
|
return op(headSlice, headSlice.subarray(Math.max(0, headBytesRead - tailLen)));
|
|
180
130
|
}
|
|
181
131
|
|
|
182
|
-
const tail =
|
|
132
|
+
const tail = allocateWindow(tailLen);
|
|
183
133
|
const { bytesRead: tailBytesRead } = await fileHandle.read(tail, 0, tail.byteLength, size - tailLen);
|
|
184
134
|
return op(headSlice, tail.subarray(0, tailBytesRead));
|
|
185
135
|
} finally {
|
package/src/postmortem.ts
CHANGED
|
@@ -7,8 +7,6 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
|
-
import inspector from "node:inspector";
|
|
11
|
-
import { isMainThread } from "node:worker_threads";
|
|
12
10
|
import * as logger from "./logger";
|
|
13
11
|
import { restoreTerminalStderr } from "./stderr-guard";
|
|
14
12
|
|
|
@@ -422,22 +420,24 @@ async function exitAfterFatal(output: string, logMessage: string, err: Error, re
|
|
|
422
420
|
export async function fatal(error: unknown): Promise<never> {
|
|
423
421
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
424
422
|
const output = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n${formatFatalRecoveryHints()}`;
|
|
425
|
-
if (!isMainThread) {
|
|
423
|
+
if (!Bun.isMainThread) {
|
|
426
424
|
process.stderr.write(output);
|
|
427
425
|
process.exit(1);
|
|
428
426
|
}
|
|
429
427
|
return exitAfterFatal(output, "Fatal error", err, Reason.UNHANDLED_REJECTION);
|
|
430
428
|
}
|
|
431
429
|
|
|
432
|
-
if (isMainThread) {
|
|
430
|
+
if (Bun.isMainThread) {
|
|
433
431
|
process
|
|
434
432
|
.on("SIGINT", async () => {
|
|
435
433
|
await runCleanup(Reason.SIGINT);
|
|
436
434
|
exitProcess(130); // 128 + SIGINT (2)
|
|
437
435
|
})
|
|
438
|
-
.on("SIGUSR1", () => {
|
|
436
|
+
.on("SIGUSR1", async () => {
|
|
439
437
|
if (inspectorOpened) return;
|
|
440
438
|
inspectorOpened = true;
|
|
439
|
+
// Signal-only boundary: successful startup never constructs inspector.
|
|
440
|
+
const { default: inspector } = await import("node:inspector");
|
|
441
441
|
inspector.open(undefined, undefined, false);
|
|
442
442
|
const url = inspector.url();
|
|
443
443
|
process.stderr.write(`Inspector opened: ${url}\n`);
|
|
@@ -656,7 +656,7 @@ export async function drainStdout(): Promise<void> {
|
|
|
656
656
|
async function runQuit(code: number, exitMode: "guarded" | "native", options: QuitOptions = {}): Promise<void> {
|
|
657
657
|
await runCleanup(Reason.MANUAL);
|
|
658
658
|
|
|
659
|
-
if (!isMainThread) {
|
|
659
|
+
if (!Bun.isMainThread) {
|
|
660
660
|
return; // Workers: cleanup done, let worker exit naturally
|
|
661
661
|
}
|
|
662
662
|
|
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();
|
|
@@ -63,6 +64,7 @@ export async function* readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?:
|
|
|
63
64
|
class ConcatSink {
|
|
64
65
|
#space?: Buffer;
|
|
65
66
|
#length = 0;
|
|
67
|
+
#skipLeadingLf = false;
|
|
66
68
|
|
|
67
69
|
#ensureCapacity(size: number): Buffer {
|
|
68
70
|
const space = this.#space;
|
|
@@ -133,19 +135,27 @@ class ConcatSink {
|
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
appendAndFlushText(chunk: Uint8Array, decoder: TextDecoder): string | undefined {
|
|
136
|
-
|
|
137
|
-
if (
|
|
138
|
-
|
|
138
|
+
let start = 0;
|
|
139
|
+
if (this.#skipLeadingLf) {
|
|
140
|
+
if (chunk.length === 0) return undefined;
|
|
141
|
+
this.#skipLeadingLf = false;
|
|
142
|
+
if (chunk[0] === LF) start = 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const lastLineEnd = Math.max(chunk.lastIndexOf(LF), chunk.lastIndexOf(CR));
|
|
146
|
+
if (lastLineEnd < start) {
|
|
147
|
+
if (start < chunk.length) this.append(chunk.subarray(start));
|
|
139
148
|
return undefined;
|
|
140
149
|
}
|
|
141
150
|
|
|
142
|
-
const completeEnd =
|
|
151
|
+
const completeEnd = lastLineEnd + 1;
|
|
152
|
+
this.#skipLeadingLf = chunk[lastLineEnd] === CR && completeEnd === chunk.length;
|
|
143
153
|
let text: string;
|
|
144
154
|
if (this.isEmpty) {
|
|
145
|
-
const complete = completeEnd === chunk.length ? chunk : chunk.subarray(
|
|
155
|
+
const complete = start === 0 && completeEnd === chunk.length ? chunk : chunk.subarray(start, completeEnd);
|
|
146
156
|
text = decoder.decode(complete);
|
|
147
157
|
} else {
|
|
148
|
-
this.append(
|
|
158
|
+
this.append(chunk.subarray(start, completeEnd));
|
|
149
159
|
text = decoder.decode(this.flush());
|
|
150
160
|
this.clear();
|
|
151
161
|
}
|
|
@@ -294,7 +304,7 @@ interface SseEventState {
|
|
|
294
304
|
}
|
|
295
305
|
|
|
296
306
|
// Complete lines are decoded in one batch per source chunk. Each batch ends on
|
|
297
|
-
//
|
|
307
|
+
// an ASCII line-ending byte, which cannot split a multi-byte UTF-8 sequence.
|
|
298
308
|
const SSE_DECODER = new TextDecoder("utf-8");
|
|
299
309
|
|
|
300
310
|
function flushSseEvent(state: SseEventState): ServerSentEvent | null {
|
|
@@ -318,11 +328,6 @@ function flushSseEvent(state: SseEventState): ServerSentEvent | null {
|
|
|
318
328
|
}
|
|
319
329
|
|
|
320
330
|
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
331
|
if (line.length === 0) return flushSseEvent(state);
|
|
327
332
|
|
|
328
333
|
// Comment line: keep in `raw` for diagnostic context, skip parsing.
|
|
@@ -398,13 +403,21 @@ export async function* readSseEvents(
|
|
|
398
403
|
if (text === undefined) continue;
|
|
399
404
|
let start = 0;
|
|
400
405
|
while (start < text.length) {
|
|
401
|
-
|
|
402
|
-
|
|
406
|
+
let lineEnd = start;
|
|
407
|
+
while (lineEnd < text.length) {
|
|
408
|
+
const code = text.charCodeAt(lineEnd);
|
|
409
|
+
if (code === LF || code === CR) break;
|
|
410
|
+
lineEnd++;
|
|
411
|
+
}
|
|
412
|
+
const event = pushSseLine(text.slice(start, lineEnd), state);
|
|
403
413
|
if (event) yield event;
|
|
404
|
-
|
|
414
|
+
if (text.charCodeAt(lineEnd) === CR && text.charCodeAt(lineEnd + 1) === LF) {
|
|
415
|
+
lineEnd++;
|
|
416
|
+
}
|
|
417
|
+
start = lineEnd + 1;
|
|
405
418
|
}
|
|
406
419
|
}
|
|
407
|
-
// Treat any trailing partial line (no terminating
|
|
420
|
+
// Treat any trailing partial line (no terminating line ending) as complete.
|
|
408
421
|
if (!lineBuffer.isEmpty) {
|
|
409
422
|
const tail = lineBuffer.flush();
|
|
410
423
|
if (tail) {
|
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
|
-
}
|