@linxiraos/pi-utils 1.1.14 → 1.1.16
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 +2 -52
- package/THIRD-PARTY-NOTICES.txt +54 -25
- package/dist/types/async.d.ts +18 -0
- package/dist/types/browsers.d.ts +2 -30
- package/dist/types/color.d.ts +2 -0
- package/dist/types/dirs.d.ts +21 -0
- package/dist/types/env.d.ts +12 -2
- package/dist/types/executable.d.ts +4 -0
- package/dist/types/fetch-retry.d.ts +8 -2
- package/dist/types/file-lock.d.ts +12 -0
- package/dist/types/format.d.ts +7 -0
- package/dist/types/index.d.ts +2 -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/sqlite.d.ts +27 -7
- package/dist/types/stream.d.ts +76 -1
- package/dist/types/which.d.ts +8 -2
- package/dist/types/yaml-config.d.ts +2 -0
- package/package.json +2 -2
- package/src/acp/transport.ts +37 -3
- package/src/async.ts +31 -0
- package/src/browsers.ts +46 -191
- package/src/color.ts +1 -1
- package/src/dirs.ts +34 -6
- package/src/env.ts +81 -34
- package/src/executable.ts +17 -0
- package/src/fetch-retry.ts +72 -13
- package/src/file-lock.ts +36 -2
- package/src/format.ts +16 -0
- package/src/index.ts +2 -1
- package/src/json.ts +12 -1
- package/src/logger/rotating-file.ts +40 -3
- 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/sqlite.ts +242 -7
- package/src/stream.ts +167 -35
- package/src/which.ts +38 -14
- package/src/xml.ts +16 -0
- package/src/yaml-config.ts +8 -0
- package/dist/types/glob.d.ts +0 -28
- package/src/glob.ts +0 -189
package/dist/types/stream.d.ts
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split a byte stream on LF boundaries.
|
|
3
|
+
*
|
|
4
|
+
* Every yielded line owns its bytes and remains unchanged after the generator
|
|
5
|
+
* advances or drains. Line terminators are excluded.
|
|
6
|
+
*/
|
|
1
7
|
export declare function readLines(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<Uint8Array>;
|
|
2
8
|
export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<T>;
|
|
9
|
+
/**
|
|
10
|
+
* Amortized byte accumulator for chunked stream readers.
|
|
11
|
+
*
|
|
12
|
+
* Holds the unconsumed tail of a stream in a single growing `Buffer` so that
|
|
13
|
+
* appending N chunks costs O(total bytes) instead of re-copying the whole
|
|
14
|
+
* prefix per chunk. Backs {@link readLines}, {@link readJsonl} and
|
|
15
|
+
* {@link readSseEvents}; also usable directly when a reader needs its own
|
|
16
|
+
* framing loop (see `consume` and `flush`).
|
|
17
|
+
*/
|
|
18
|
+
export declare class ConcatSink {
|
|
19
|
+
#private;
|
|
20
|
+
append(chunk: Uint8Array): void;
|
|
21
|
+
reset(chunk: Uint8Array): void;
|
|
22
|
+
get isEmpty(): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* The buffered bytes as a live view — invalidated by the next `append`,
|
|
25
|
+
* `reset` or `consume`.
|
|
26
|
+
*/
|
|
27
|
+
flush(): Uint8Array | undefined;
|
|
28
|
+
/** Drop the first `count` buffered bytes, keeping the remainder. */
|
|
29
|
+
consume(count: number): void;
|
|
30
|
+
clear(): void;
|
|
31
|
+
/**
|
|
32
|
+
* Append a chunk and yield each complete LF-delimited line.
|
|
33
|
+
*
|
|
34
|
+
* Yielded lines are owned snapshots. Unlike {@link flush}, they remain
|
|
35
|
+
* valid after this sink or the input chunk is mutated.
|
|
36
|
+
*/
|
|
37
|
+
appendAndFlushLines(chunk: Uint8Array): Generator<Uint8Array>;
|
|
38
|
+
appendAndFlushText(chunk: Uint8Array, decoder: TextDecoder): string | undefined;
|
|
39
|
+
pullJSONL<T>(chunk: Uint8Array, beg: number, end: number): Generator<T, void, unknown>;
|
|
40
|
+
}
|
|
3
41
|
/**
|
|
4
42
|
* Stream parsed JSON objects from SSE `data:` lines.
|
|
5
43
|
*
|
|
@@ -18,6 +56,25 @@ export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?
|
|
|
18
56
|
*/
|
|
19
57
|
export type SseEventObserver = (event: ServerSentEvent) => void;
|
|
20
58
|
export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver): AsyncGenerator<T>;
|
|
59
|
+
/**
|
|
60
|
+
* Like {@link readSseJson}, but a `data:` frame that is not valid JSON is yielded
|
|
61
|
+
* as its raw text instead of raising a `SyntaxError`. Cut-off container-shaped
|
|
62
|
+
* stream tails stay recoverable, exactly as they are in {@link readSseJson}.
|
|
63
|
+
*
|
|
64
|
+
* Consumers that only understand objects must treat a `string` yield as a
|
|
65
|
+
* transport-level failure (for example a `429 Too Many Requests` or an HTML
|
|
66
|
+
* throttle page from a reverse proxy that already committed to the stream). This
|
|
67
|
+
* exists because `readSseJson`'s baseline consumers span unrelated transports
|
|
68
|
+
* whose error handling a text yield would subtly change; new call sites opt in.
|
|
69
|
+
*
|
|
70
|
+
* Note that the text lane is only the frames `JSON.parse` *rejected*: a frame
|
|
71
|
+
* carrying a JSON-encoded string (`data: "429 Too Many Requests"`) parses, so it
|
|
72
|
+
* is yielded as that string and is indistinguishable from a rejected frame by
|
|
73
|
+
* type alone. Consumers branching on `typeof === "string"` therefore see both,
|
|
74
|
+
* which is the safe direction — each is classified as text rather than trusted as
|
|
75
|
+
* an event object.
|
|
76
|
+
*/
|
|
77
|
+
export declare function readSseJsonOrText<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver): AsyncGenerator<T | string>;
|
|
21
78
|
/**
|
|
22
79
|
* A single Server-Sent Event dispatched on a blank-line boundary.
|
|
23
80
|
*
|
|
@@ -34,6 +91,15 @@ export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signa
|
|
|
34
91
|
export interface ServerSentEvent {
|
|
35
92
|
event: string | null;
|
|
36
93
|
data: string;
|
|
94
|
+
/**
|
|
95
|
+
* Decoded wire lines for this event (`event:`/`data:`/etc.), for the
|
|
96
|
+
* diagnostic pipeline. Populated only when the reader opts in via
|
|
97
|
+
* {@link ReadSseEventsOptions.captureRaw} (or attaches an `onSseEvent`
|
|
98
|
+
* observer to the JSON readers, which opt in automatically); otherwise
|
|
99
|
+
* `[]`. Direct `readSseEvents` callers that need wire text must pass
|
|
100
|
+
* `{ captureRaw: true }` — the field is allocation-free by default so
|
|
101
|
+
* the token path pays no per-frame array/slice cost.
|
|
102
|
+
*/
|
|
37
103
|
raw: string[];
|
|
38
104
|
id?: string;
|
|
39
105
|
retry?: number;
|
|
@@ -57,7 +123,16 @@ export interface ServerSentEvent {
|
|
|
57
123
|
* }
|
|
58
124
|
* ```
|
|
59
125
|
*/
|
|
60
|
-
export
|
|
126
|
+
export interface ReadSseEventsOptions {
|
|
127
|
+
/**
|
|
128
|
+
* Capture per-line wire text into `event.raw` for the diagnostic
|
|
129
|
+
* pipeline (`onSseEvent` observers, raw-SSE viewer). Off by default:
|
|
130
|
+
* every frame otherwise pays an array allocation plus one string slice
|
|
131
|
+
* per line on the token path.
|
|
132
|
+
*/
|
|
133
|
+
captureRaw?: boolean;
|
|
134
|
+
}
|
|
135
|
+
export declare function readSseEvents(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, options?: ReadSseEventsOptions): AsyncGenerator<ServerSentEvent>;
|
|
61
136
|
/**
|
|
62
137
|
* Parse a complete JSONL string, skipping malformed lines instead of throwing.
|
|
63
138
|
*
|
package/dist/types/which.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cache policy for which lookups.
|
|
3
3
|
*/
|
|
4
|
-
export declare
|
|
4
|
+
export declare enum WhichCachePolicy {
|
|
5
5
|
/**
|
|
6
6
|
* Use cached result if available.
|
|
7
7
|
*/
|
|
@@ -22,9 +22,15 @@ export declare const enum WhichCachePolicy {
|
|
|
22
22
|
export interface WhichOptions extends Bun.WhichOptions {
|
|
23
23
|
/**
|
|
24
24
|
* Cache policy for the lookup.
|
|
25
|
-
* Defaults to `WhichCachePolicy.
|
|
25
|
+
* Defaults to `WhichCachePolicy.Cached`.
|
|
26
26
|
*/
|
|
27
27
|
cache?: WhichCachePolicy;
|
|
28
|
+
/**
|
|
29
|
+
* Only search absolute directory entries in PATH, ignoring relative entries
|
|
30
|
+
* (e.g. `.` or `./bin`) and empty components to prevent resolving against
|
|
31
|
+
* an untrusted working directory.
|
|
32
|
+
*/
|
|
33
|
+
requireAbsolutePaths?: boolean;
|
|
28
34
|
}
|
|
29
35
|
declare function darwinWhich(command: string, options?: Bun.WhichOptions): string | null;
|
|
30
36
|
export declare const whichFresh: typeof darwinWhich;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxiraos/pi-utils",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.16",
|
|
4
4
|
"description": "Shared utilities for pi packages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"utilities",
|
|
@@ -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
|
-
"@linxiraos/pi-natives": "1.1.
|
|
57
|
+
"@linxiraos/pi-natives": "1.1.16"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/bun": "^1.3.14"
|
package/src/acp/transport.ts
CHANGED
|
@@ -108,10 +108,15 @@ function createStandardError(
|
|
|
108
108
|
type Dispatcher = (method: string, params: unknown, notification: boolean) => MaybePromise<unknown>;
|
|
109
109
|
type Pending = { resolve(value: unknown): void; reject(reason: unknown): void };
|
|
110
110
|
|
|
111
|
+
/** Bound on clean-EOF inbound drain so `closed` cannot hang if a handler never settles. */
|
|
112
|
+
const INBOUND_DRAIN_TIMEOUT_MS = 30_000;
|
|
113
|
+
|
|
111
114
|
/** Correlated bidirectional JSON-RPC connection. */
|
|
112
115
|
export class RpcConnection {
|
|
113
116
|
#nextId = 0;
|
|
114
117
|
#pending = new Map<JsonRpcId, Pending>();
|
|
118
|
+
#inbound = new Set<Promise<void>>();
|
|
119
|
+
#openInboundIds = new Set<JsonRpcId>();
|
|
115
120
|
#writable: WritableStream<AnyMessage>;
|
|
116
121
|
#writeTail: Promise<void> = Promise.resolve();
|
|
117
122
|
#abort = new AbortController();
|
|
@@ -179,8 +184,9 @@ export class RpcConnection {
|
|
|
179
184
|
while (true) {
|
|
180
185
|
const next = await reader.read();
|
|
181
186
|
if (next.done) break;
|
|
182
|
-
|
|
187
|
+
this.#dispatch(next.value);
|
|
183
188
|
}
|
|
189
|
+
await this.#drainInbound();
|
|
184
190
|
this.close();
|
|
185
191
|
} catch (error) {
|
|
186
192
|
this.close(error);
|
|
@@ -189,6 +195,34 @@ export class RpcConnection {
|
|
|
189
195
|
}
|
|
190
196
|
}
|
|
191
197
|
|
|
198
|
+
#dispatch(message: AnyMessage): void {
|
|
199
|
+
if ("method" in message && "id" in message) this.#openInboundIds.add(message.id);
|
|
200
|
+
const task = this.#handle(message).catch(error => this.close(error));
|
|
201
|
+
this.#inbound.add(task);
|
|
202
|
+
void task.finally(() => this.#inbound.delete(task));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async #drainInbound(): Promise<void> {
|
|
206
|
+
if (this.#inbound.size === 0) return;
|
|
207
|
+
const drained = Promise.allSettled(this.#inbound).then(() => {});
|
|
208
|
+
let timer: Timer | undefined;
|
|
209
|
+
const timedOut = await Promise.race([
|
|
210
|
+
drained.then(() => false),
|
|
211
|
+
new Promise<boolean>(resolve => {
|
|
212
|
+
timer = setTimeout(() => resolve(true), INBOUND_DRAIN_TIMEOUT_MS);
|
|
213
|
+
}),
|
|
214
|
+
]);
|
|
215
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
216
|
+
if (!timedOut) return;
|
|
217
|
+
const error = RequestError.internalError(undefined, "Inbound request drain timed out").toErrorResponse();
|
|
218
|
+
await Promise.allSettled([...this.#openInboundIds].map(id => this.#respond(id, { error })));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#respond(id: JsonRpcId, body: { result: unknown } | { error: ErrorResponse }): Promise<void> {
|
|
222
|
+
if (!this.#openInboundIds.delete(id)) return Promise.resolve();
|
|
223
|
+
return this.#write({ jsonrpc: "2.0", id, ...body });
|
|
224
|
+
}
|
|
225
|
+
|
|
192
226
|
async #handle(message: AnyMessage): Promise<void> {
|
|
193
227
|
if ("id" in message && !("method" in message)) {
|
|
194
228
|
const pending = this.#pending.get(message.id);
|
|
@@ -210,13 +244,13 @@ export class RpcConnection {
|
|
|
210
244
|
}
|
|
211
245
|
try {
|
|
212
246
|
const result = await this.#dispatcher(message.method, message.params, false);
|
|
213
|
-
await this.#
|
|
247
|
+
await this.#respond(message.id, { result: result ?? {} });
|
|
214
248
|
} catch (error) {
|
|
215
249
|
const protocolError =
|
|
216
250
|
error instanceof RequestError
|
|
217
251
|
? error
|
|
218
252
|
: RequestError.internalError({ details: error instanceof Error ? error.message : String(error) });
|
|
219
|
-
await this.#
|
|
253
|
+
await this.#respond(message.id, { error: protocolError.toErrorResponse() });
|
|
220
254
|
}
|
|
221
255
|
}
|
|
222
256
|
}
|
package/src/async.ts
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
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
|
+
* Uses a monotonic deadline so a timer that wakes prematurely is re-armed for
|
|
19
|
+
* the unelapsed duration instead of shortening the requested sleep.
|
|
20
|
+
*/
|
|
21
|
+
export async function sleepLong(delayMs: number, signal?: AbortSignal): Promise<void> {
|
|
22
|
+
signal?.throwIfAborted();
|
|
23
|
+
const deadline = performance.now() + delayMs;
|
|
24
|
+
while (true) {
|
|
25
|
+
const remaining = deadline - performance.now();
|
|
26
|
+
if (!(remaining > 0)) return;
|
|
27
|
+
await scheduler.wait(Math.min(remaining, MAX_TIMER_DELAY_MS), { signal });
|
|
28
|
+
signal?.throwIfAborted();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
1
32
|
/**
|
|
2
33
|
* Wrap a promise with a timeout and optional abort signal.
|
|
3
34
|
* Rejects with the given error or a new error containing the given message if
|
package/src/browsers.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
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";
|
|
7
5
|
import { type ArchiveLimits, extractArchive } from "./ar";
|
|
6
|
+
import { withFileLock } from "./file-lock";
|
|
8
7
|
|
|
9
8
|
const CHROME_FOR_TESTING_BASE_URL = "https://storage.googleapis.com/chrome-for-testing-public";
|
|
10
|
-
|
|
9
|
+
// Installation is outside browser/Eval operation deadlines, but a stalled CDN
|
|
10
|
+
// must still release the cached install promise and permit a later attempt.
|
|
11
|
+
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Archive ceilings for the managed browser download. The default archive
|
|
@@ -21,15 +22,6 @@ const BROWSER_ARCHIVE_LIMITS: Partial<ArchiveLimits> = {
|
|
|
21
22
|
maxInMemorySize: 1024 * 1024 * 1024,
|
|
22
23
|
};
|
|
23
24
|
|
|
24
|
-
/** Supported browser products. */
|
|
25
|
-
export enum Browser {
|
|
26
|
-
CHROME = "chrome",
|
|
27
|
-
CHROMEHEADLESSSHELL = "chrome-headless-shell",
|
|
28
|
-
CHROMIUM = "chromium",
|
|
29
|
-
FIREFOX = "firefox",
|
|
30
|
-
CHROMEDRIVER = "chromedriver",
|
|
31
|
-
}
|
|
32
|
-
|
|
33
25
|
/** Browser download platform identifiers. */
|
|
34
26
|
export enum BrowserPlatform {
|
|
35
27
|
LINUX = "linux",
|
|
@@ -47,34 +39,6 @@ function requireChromeForTestingPlatform(platform: BrowserPlatform): ChromeForTe
|
|
|
47
39
|
return platform;
|
|
48
40
|
}
|
|
49
41
|
|
|
50
|
-
const BROWSERS = [
|
|
51
|
-
Browser.CHROME,
|
|
52
|
-
Browser.CHROMEHEADLESSSHELL,
|
|
53
|
-
Browser.CHROMIUM,
|
|
54
|
-
Browser.FIREFOX,
|
|
55
|
-
Browser.CHROMEDRIVER,
|
|
56
|
-
] as const;
|
|
57
|
-
const BROWSER_PLATFORMS = [
|
|
58
|
-
BrowserPlatform.LINUX,
|
|
59
|
-
BrowserPlatform.LINUX_ARM,
|
|
60
|
-
BrowserPlatform.MAC,
|
|
61
|
-
BrowserPlatform.MAC_ARM,
|
|
62
|
-
BrowserPlatform.WIN32,
|
|
63
|
-
BrowserPlatform.WIN64,
|
|
64
|
-
] as const;
|
|
65
|
-
|
|
66
|
-
/** Chrome-for-Testing release channel tags accepted by {@link resolveBuildId}. */
|
|
67
|
-
export enum BrowserTag {
|
|
68
|
-
CANARY = "canary",
|
|
69
|
-
NIGHTLY = "nightly",
|
|
70
|
-
BETA = "beta",
|
|
71
|
-
DEV = "dev",
|
|
72
|
-
DEVEDITION = "devedition",
|
|
73
|
-
STABLE = "stable",
|
|
74
|
-
ESR = "esr",
|
|
75
|
-
LATEST = "latest",
|
|
76
|
-
}
|
|
77
|
-
|
|
78
42
|
/** Download progress reported while a browser archive is streamed to disk. */
|
|
79
43
|
export interface BrowserDownloadProgress {
|
|
80
44
|
downloadedBytes: number;
|
|
@@ -83,7 +47,6 @@ export interface BrowserDownloadProgress {
|
|
|
83
47
|
|
|
84
48
|
/** Inputs used to locate an installed browser executable. */
|
|
85
49
|
export interface ComputeExecutablePathOptions {
|
|
86
|
-
browser: Browser;
|
|
87
50
|
buildId: string;
|
|
88
51
|
cacheDir: string;
|
|
89
52
|
platform?: BrowserPlatform;
|
|
@@ -95,27 +58,14 @@ export interface InstallOptions extends ComputeExecutablePathOptions {
|
|
|
95
58
|
downloadProgressCallback?: (progress: BrowserDownloadProgress) => void;
|
|
96
59
|
}
|
|
97
60
|
|
|
98
|
-
/** Metadata for
|
|
61
|
+
/** Metadata for a managed Chrome installation in Puppeteer's cache layout. */
|
|
99
62
|
export interface InstalledBrowser {
|
|
100
|
-
browser: Browser;
|
|
101
63
|
buildId: string;
|
|
102
64
|
platform: BrowserPlatform;
|
|
103
65
|
path: string;
|
|
104
66
|
executablePath: string;
|
|
105
67
|
}
|
|
106
68
|
|
|
107
|
-
interface LastKnownGoodVersions {
|
|
108
|
-
channels: Record<string, { version: string }>;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
interface MilestoneVersions {
|
|
112
|
-
milestones: Record<string, { version: string }>;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
interface PatchVersions {
|
|
116
|
-
builds: Record<string, { version: string }>;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
69
|
/** Detect the current host's Puppeteer browser platform. */
|
|
120
70
|
export function detectBrowserPlatform(): BrowserPlatform | undefined {
|
|
121
71
|
const platform = os.platform();
|
|
@@ -126,43 +76,8 @@ export function detectBrowserPlatform(): BrowserPlatform | undefined {
|
|
|
126
76
|
return undefined;
|
|
127
77
|
}
|
|
128
78
|
|
|
129
|
-
/** Resolve a Chrome-for-Testing channel, milestone, or build prefix to a full build ID. */
|
|
130
|
-
export async function resolveBuildId(
|
|
131
|
-
browser: Browser,
|
|
132
|
-
_platform: BrowserPlatform,
|
|
133
|
-
tag: string | BrowserTag,
|
|
134
|
-
): Promise<string> {
|
|
135
|
-
if (browser !== Browser.CHROME && browser !== Browser.CHROMEHEADLESSSHELL && browser !== Browser.CHROMEDRIVER) {
|
|
136
|
-
return tag;
|
|
137
|
-
}
|
|
138
|
-
if (/^\d+\.\d+\.\d+\.\d+$/.test(tag)) return tag;
|
|
139
|
-
|
|
140
|
-
const channel = tag === BrowserTag.LATEST ? "Canary" : chromeChannelName(tag);
|
|
141
|
-
if (channel) {
|
|
142
|
-
const metadata = await fetchMetadata<LastKnownGoodVersions>("last-known-good-versions.json");
|
|
143
|
-
const version = metadata.channels[channel]?.version;
|
|
144
|
-
if (!version) throw new Error(`Chrome channel ${tag} was not found in Chrome-for-Testing metadata`);
|
|
145
|
-
return version;
|
|
146
|
-
}
|
|
147
|
-
if (/^\d+$/.test(tag)) {
|
|
148
|
-
const metadata = await fetchMetadata<MilestoneVersions>("latest-versions-per-milestone.json");
|
|
149
|
-
return metadata.milestones[tag]?.version ?? tag;
|
|
150
|
-
}
|
|
151
|
-
if (/^\d+\.\d+\.\d+$/.test(tag)) {
|
|
152
|
-
const metadata = await fetchMetadata<PatchVersions>("latest-patch-versions-per-build.json");
|
|
153
|
-
return metadata.builds[tag]?.version ?? tag;
|
|
154
|
-
}
|
|
155
|
-
return tag;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
79
|
/** Return the Chrome-for-Testing archive URL for a browser build. */
|
|
159
|
-
export function getDownloadUrl(
|
|
160
|
-
browser: Browser,
|
|
161
|
-
platform: BrowserPlatform,
|
|
162
|
-
buildId: string,
|
|
163
|
-
baseUrl = CHROME_FOR_TESTING_BASE_URL,
|
|
164
|
-
): URL {
|
|
165
|
-
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 {
|
|
166
81
|
const archivePlatform = chromeArchivePlatform(platform);
|
|
167
82
|
const root = baseUrl.replace(/\/$/, "");
|
|
168
83
|
return new URL(`${root}/${buildId}/${archivePlatform}/chrome-${archivePlatform}.zip`);
|
|
@@ -172,9 +87,8 @@ export function getDownloadUrl(
|
|
|
172
87
|
export function computeExecutablePath(options: ComputeExecutablePathOptions): string {
|
|
173
88
|
const detectedPlatform = options.platform ?? detectBrowserPlatform();
|
|
174
89
|
if (!detectedPlatform) throw new Error("Cannot determine a browser platform for this host");
|
|
175
|
-
if (options.browser !== Browser.CHROME) throw new Error(`Unsupported browser executable: ${options.browser}`);
|
|
176
90
|
const platform = requireChromeForTestingPlatform(detectedPlatform);
|
|
177
|
-
const installDir = installationDir(options.cacheDir,
|
|
91
|
+
const installDir = installationDir(options.cacheDir, platform, options.buildId);
|
|
178
92
|
switch (platform) {
|
|
179
93
|
case BrowserPlatform.LINUX:
|
|
180
94
|
return path.join(installDir, "chrome-linux64", "chrome");
|
|
@@ -203,99 +117,49 @@ export function computeExecutablePath(options: ComputeExecutablePathOptions): st
|
|
|
203
117
|
}
|
|
204
118
|
}
|
|
205
119
|
|
|
206
|
-
/** Scan a Puppeteer cache for browser installation directories. */
|
|
207
|
-
export async function getInstalledBrowsers(options: { cacheDir: string }): Promise<InstalledBrowser[]> {
|
|
208
|
-
const installed: InstalledBrowser[] = [];
|
|
209
|
-
for (const browser of BROWSERS) {
|
|
210
|
-
const browserDir = path.join(options.cacheDir, browser);
|
|
211
|
-
let entries: fs.Dirent[];
|
|
212
|
-
try {
|
|
213
|
-
entries = await fsp.readdir(browserDir, { withFileTypes: true });
|
|
214
|
-
} catch (error) {
|
|
215
|
-
if (isMissingPath(error)) continue;
|
|
216
|
-
throw error;
|
|
217
|
-
}
|
|
218
|
-
for (const entry of entries) {
|
|
219
|
-
if (!entry.isDirectory()) continue;
|
|
220
|
-
const parsed = parseInstallationName(entry.name);
|
|
221
|
-
if (!parsed) continue;
|
|
222
|
-
const installPath = path.join(browserDir, entry.name);
|
|
223
|
-
try {
|
|
224
|
-
installed.push({
|
|
225
|
-
browser,
|
|
226
|
-
buildId: parsed.buildId,
|
|
227
|
-
platform: parsed.platform,
|
|
228
|
-
path: installPath,
|
|
229
|
-
executablePath: computeExecutablePath({
|
|
230
|
-
browser,
|
|
231
|
-
buildId: parsed.buildId,
|
|
232
|
-
cacheDir: options.cacheDir,
|
|
233
|
-
platform: parsed.platform,
|
|
234
|
-
}),
|
|
235
|
-
});
|
|
236
|
-
} catch {
|
|
237
|
-
// Other browser products are not part of the surface used by OMP.
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
return installed;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
120
|
/** Download and unpack Chrome into Puppeteer's existing cache layout. */
|
|
245
121
|
export async function install(options: InstallOptions): Promise<InstalledBrowser> {
|
|
246
122
|
const platform = options.platform ?? detectBrowserPlatform();
|
|
247
123
|
if (!platform) throw new Error("Cannot determine a browser platform for this host");
|
|
248
124
|
const executablePath = computeExecutablePath({ ...options, platform });
|
|
249
|
-
const installPath = installationDir(options.cacheDir,
|
|
125
|
+
const installPath = installationDir(options.cacheDir, platform, options.buildId);
|
|
250
126
|
if (await pathExists(executablePath)) {
|
|
251
|
-
return {
|
|
127
|
+
return { buildId: options.buildId, platform, path: installPath, executablePath };
|
|
252
128
|
}
|
|
253
129
|
|
|
254
|
-
await fsp.mkdir(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
case BrowserTag.CANARY:
|
|
288
|
-
return "Canary";
|
|
289
|
-
default:
|
|
290
|
-
return undefined;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
async function fetchMetadata<T>(filename: string): Promise<T> {
|
|
295
|
-
const response = await fetch(`${CHROME_METADATA_BASE_URL}/${filename}`);
|
|
296
|
-
if (!response.ok)
|
|
297
|
-
throw new Error(`Failed to fetch Chrome-for-Testing metadata (${response.status} ${response.statusText})`);
|
|
298
|
-
return (await response.json()) as T;
|
|
130
|
+
await fsp.mkdir(path.dirname(installPath), { recursive: true });
|
|
131
|
+
return withFileLock(
|
|
132
|
+
`${installPath}.install`,
|
|
133
|
+
async () => {
|
|
134
|
+
// The in-process launch promise cannot serialize separate OMP sessions.
|
|
135
|
+
// Recheck under the OS lock: never replace a winner's running Chrome.
|
|
136
|
+
if (!(await pathExists(executablePath))) {
|
|
137
|
+
const nonce = `${process.pid}-${crypto.randomUUID()}`;
|
|
138
|
+
const archivePath = path.join(options.cacheDir, `.browser-${nonce}.zip`);
|
|
139
|
+
const stagingPath = path.join(options.cacheDir, `.browser-${nonce}`);
|
|
140
|
+
try {
|
|
141
|
+
await downloadArchive(
|
|
142
|
+
getDownloadUrl(platform, options.buildId, options.baseUrl),
|
|
143
|
+
archivePath,
|
|
144
|
+
options.downloadProgressCallback,
|
|
145
|
+
);
|
|
146
|
+
await extractArchive(archivePath, stagingPath, { limits: BROWSER_ARCHIVE_LIMITS });
|
|
147
|
+
if (!(await pathExists(path.join(stagingPath, path.relative(installPath, executablePath))))) {
|
|
148
|
+
throw new Error(`Browser archive did not contain its expected executable: ${executablePath}`);
|
|
149
|
+
}
|
|
150
|
+
await fsp.rm(installPath, { recursive: true, force: true });
|
|
151
|
+
await fsp.rename(stagingPath, installPath);
|
|
152
|
+
} finally {
|
|
153
|
+
await Promise.all([
|
|
154
|
+
fsp.rm(archivePath, { force: true }).catch(() => {}),
|
|
155
|
+
fsp.rm(stagingPath, { recursive: true, force: true }).catch(() => {}),
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { buildId: options.buildId, platform, path: installPath, executablePath };
|
|
160
|
+
},
|
|
161
|
+
{ retries: Math.ceil(DOWNLOAD_TIMEOUT_MS / 100) + 1, retryDelayMs: 100 },
|
|
162
|
+
);
|
|
299
163
|
}
|
|
300
164
|
|
|
301
165
|
function chromeArchivePlatform(platform: BrowserPlatform): string {
|
|
@@ -313,17 +177,8 @@ function chromeArchivePlatform(platform: BrowserPlatform): string {
|
|
|
313
177
|
}
|
|
314
178
|
}
|
|
315
179
|
|
|
316
|
-
function installationDir(cacheDir: string,
|
|
317
|
-
return path.join(cacheDir,
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function parseInstallationName(name: string): { platform: BrowserPlatform; buildId: string } | undefined {
|
|
321
|
-
for (const platform of BROWSER_PLATFORMS) {
|
|
322
|
-
const prefix = `${platform}-`;
|
|
323
|
-
if (name.startsWith(prefix) && name.length > prefix.length)
|
|
324
|
-
return { platform, buildId: name.slice(prefix.length) };
|
|
325
|
-
}
|
|
326
|
-
return undefined;
|
|
180
|
+
function installationDir(cacheDir: string, platform: BrowserPlatform, buildId: string): string {
|
|
181
|
+
return path.join(cacheDir, "chrome", `${platform}-${buildId}`);
|
|
327
182
|
}
|
|
328
183
|
|
|
329
184
|
function isMissingPath(error: unknown): boolean {
|
|
@@ -345,7 +200,7 @@ async function downloadArchive(
|
|
|
345
200
|
destination: string,
|
|
346
201
|
onProgress: ((progress: BrowserDownloadProgress) => void) | undefined,
|
|
347
202
|
): Promise<void> {
|
|
348
|
-
const response = await fetch(url);
|
|
203
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
349
204
|
if (!response.ok || !response.body) {
|
|
350
205
|
throw new Error(`Browser download failed (${response.status} ${response.statusText}) from ${url}`);
|
|
351
206
|
}
|
package/src/color.ts
CHANGED
|
@@ -239,7 +239,7 @@ const ANSI_16: readonly (readonly [number, number, number])[] = [
|
|
|
239
239
|
const CUBE_STEPS = [0, 95, 135, 175, 215, 255] as const;
|
|
240
240
|
|
|
241
241
|
/** Parse a 256-color palette index (0–255) to RGB (0..255). */
|
|
242
|
-
function paletteToRgb(index: number): RGB | undefined {
|
|
242
|
+
export function paletteToRgb(index: number): RGB | undefined {
|
|
243
243
|
if (!Number.isInteger(index) || index < 0 || index > 255) return undefined;
|
|
244
244
|
if (index < 16) {
|
|
245
245
|
const rgb = ANSI_16[index];
|
package/src/dirs.ts
CHANGED
|
@@ -104,7 +104,8 @@ function readProfileFromEnvSafe(): string | undefined {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
/** Profile-independent config root (~/.zeta), shared by every zeta profile. */
|
|
108
|
+
export function getBaseConfigRoot(): string {
|
|
108
109
|
return path.join(os.homedir(), getConfigDirName());
|
|
109
110
|
}
|
|
110
111
|
|
|
@@ -153,19 +154,31 @@ export function normalizePathForComparison(inputPath: string): string {
|
|
|
153
154
|
return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Compare paths already normalized by {@link normalizePathForComparison}.
|
|
159
|
+
*
|
|
160
|
+
* Returns the relative path (an empty string when the paths are equal), or
|
|
161
|
+
* `null` when the candidate is outside the root. Callers classifying one
|
|
162
|
+
* candidate against several static roots can normalize each side once and
|
|
163
|
+
* reuse the public helpers' containment semantics without repeating realpath
|
|
164
|
+
* work.
|
|
165
|
+
*/
|
|
166
|
+
export function relativePathWithinNormalizedRoot(normalizedRoot: string, normalizedCandidate: string): string | null {
|
|
167
|
+
const relative = path.relative(normalizedRoot, normalizedCandidate);
|
|
168
|
+
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) return null;
|
|
169
|
+
return relative;
|
|
170
|
+
}
|
|
171
|
+
|
|
156
172
|
export function pathIsWithin(root: string, candidate: string): boolean {
|
|
157
173
|
const normalizedRoot = normalizePathForComparison(root);
|
|
158
174
|
const normalizedCandidate = normalizePathForComparison(candidate);
|
|
159
|
-
|
|
160
|
-
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
175
|
+
return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) !== null;
|
|
161
176
|
}
|
|
162
177
|
|
|
163
178
|
export function relativePathWithinRoot(root: string, candidate: string): string | null {
|
|
164
|
-
if (!pathIsWithin(root, candidate)) return null;
|
|
165
179
|
const normalizedRoot = normalizePathForComparison(root);
|
|
166
180
|
const normalizedCandidate = normalizePathForComparison(candidate);
|
|
167
|
-
|
|
168
|
-
return relative || null;
|
|
181
|
+
return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) || null;
|
|
169
182
|
}
|
|
170
183
|
|
|
171
184
|
let projectDir: string | undefined;
|
|
@@ -687,6 +700,11 @@ export function getBrowserRelayDir(): string {
|
|
|
687
700
|
return dirs.rootSubdir("browser-relay", "data");
|
|
688
701
|
}
|
|
689
702
|
|
|
703
|
+
/** Get the profile root for Chromium browsers the browser tool spawns via `app.path` (~/.zeta/browser-profiles). */
|
|
704
|
+
export function getBrowserProfilesDir(): string {
|
|
705
|
+
return dirs.rootSubdir("browser-profiles", "state");
|
|
706
|
+
}
|
|
707
|
+
|
|
690
708
|
/** Get DOCS_RS cache directory () */
|
|
691
709
|
export function getDocsRsCacheDir(): string {
|
|
692
710
|
return dirs.rootSubdir("webcache", "cache");
|
|
@@ -893,6 +911,16 @@ export function getTerminalSessionsDir(agentDir?: string): string {
|
|
|
893
911
|
return dirs.agentSubdir(agentDir, "terminal-sessions", "state");
|
|
894
912
|
}
|
|
895
913
|
|
|
914
|
+
/**
|
|
915
|
+
* Get the persistent registry of custom session files
|
|
916
|
+
* (~/.zeta/agent/custom-session-files). Each `--session-dir`/`--session`
|
|
917
|
+
* transcript is recorded here as one marker file so storage GC can scan its
|
|
918
|
+
* exact path after its terminal breadcrumb is overwritten by a later session.
|
|
919
|
+
*/
|
|
920
|
+
export function getCustomSessionFilesDir(agentDir?: string): string {
|
|
921
|
+
return dirs.agentSubdir(agentDir, "custom-session-files", "state");
|
|
922
|
+
}
|
|
923
|
+
|
|
896
924
|
/** Get the crash log path (~/.zeta/agent/zeta-crash.log). */
|
|
897
925
|
export function getCrashLogPath(agentDir?: string): string {
|
|
898
926
|
return dirs.agentSubdir(agentDir, "zeta-crash.log", "state");
|