@linxiraos/pi-utils 1.1.15 → 1.1.17
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 -2
- 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 +18 -1
- 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 +6 -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 +10 -169
- package/src/color.ts +1 -1
- package/src/dirs.ts +28 -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 +28 -0
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxiraos/pi-utils",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.17",
|
|
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.17"
|
|
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,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/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,7 @@ function readProfileFromEnvSafe(): string | undefined {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/** Profile-independent config root (~/.zeta), shared by every
|
|
107
|
+
/** Profile-independent config root (~/.zeta), shared by every zeta profile. */
|
|
108
108
|
export function getBaseConfigRoot(): string {
|
|
109
109
|
return path.join(os.homedir(), getConfigDirName());
|
|
110
110
|
}
|
|
@@ -154,19 +154,31 @@ export function normalizePathForComparison(inputPath: string): string {
|
|
|
154
154
|
return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
|
|
155
155
|
}
|
|
156
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
|
+
|
|
157
172
|
export function pathIsWithin(root: string, candidate: string): boolean {
|
|
158
173
|
const normalizedRoot = normalizePathForComparison(root);
|
|
159
174
|
const normalizedCandidate = normalizePathForComparison(candidate);
|
|
160
|
-
|
|
161
|
-
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
175
|
+
return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) !== null;
|
|
162
176
|
}
|
|
163
177
|
|
|
164
178
|
export function relativePathWithinRoot(root: string, candidate: string): string | null {
|
|
165
|
-
if (!pathIsWithin(root, candidate)) return null;
|
|
166
179
|
const normalizedRoot = normalizePathForComparison(root);
|
|
167
180
|
const normalizedCandidate = normalizePathForComparison(candidate);
|
|
168
|
-
|
|
169
|
-
return relative || null;
|
|
181
|
+
return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) || null;
|
|
170
182
|
}
|
|
171
183
|
|
|
172
184
|
let projectDir: string | undefined;
|
|
@@ -899,6 +911,16 @@ export function getTerminalSessionsDir(agentDir?: string): string {
|
|
|
899
911
|
return dirs.agentSubdir(agentDir, "terminal-sessions", "state");
|
|
900
912
|
}
|
|
901
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
|
+
|
|
902
924
|
/** Get the crash log path (~/.zeta/agent/zeta-crash.log). */
|
|
903
925
|
export function getCrashLogPath(agentDir?: string): string {
|
|
904
926
|
return dirs.agentSubdir(agentDir, "zeta-crash.log", "state");
|
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";
|
|
@@ -64,6 +65,48 @@ export function filterProcessEnv(env: Record<string, string | undefined>): Recor
|
|
|
64
65
|
}
|
|
65
66
|
return result;
|
|
66
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Git variables that pin a repository location. They describe the checkout the
|
|
70
|
+
* agent process itself was launched from (git hooks, `git --git-dir` wrappers),
|
|
71
|
+
* so forwarding them to a child shell makes `git` ignore the command's `cwd`
|
|
72
|
+
* and mutate the wrong worktree or index. Stripped from child shell envs so git
|
|
73
|
+
* rediscovers the repository from the working directory. Mirrors the
|
|
74
|
+
* `env_remove` list in `crates/pi-vcs/src/git/cli.rs`.
|
|
75
|
+
*/
|
|
76
|
+
const GIT_REPO_LOCATION_ENV_NAMES = [
|
|
77
|
+
"GIT_DIR",
|
|
78
|
+
"GIT_COMMON_DIR",
|
|
79
|
+
"GIT_WORK_TREE",
|
|
80
|
+
"GIT_INDEX_FILE",
|
|
81
|
+
"GIT_OBJECT_DIRECTORY",
|
|
82
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
83
|
+
] as const;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Removes {@link GIT_REPO_LOCATION_ENV_NAMES} from a copied child env in place.
|
|
87
|
+
*
|
|
88
|
+
* Windows environment lookups are case-insensitive, so a block that spells a
|
|
89
|
+
* variable `git_dir` is just as binding there; match case-insensitively on
|
|
90
|
+
* win32 and exactly elsewhere (POSIX env names are case-sensitive).
|
|
91
|
+
*/
|
|
92
|
+
export function stripGitRepoLocationEnv(
|
|
93
|
+
env: Record<string, string>,
|
|
94
|
+
platform: NodeJS.Platform = process.platform,
|
|
95
|
+
): void {
|
|
96
|
+
if (platform !== "win32") {
|
|
97
|
+
for (const name of GIT_REPO_LOCATION_ENV_NAMES) {
|
|
98
|
+
delete env[name];
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const folded = new Set<string>(GIT_REPO_LOCATION_ENV_NAMES.map(name => name.toLowerCase()));
|
|
103
|
+
for (const key of Object.keys(env)) {
|
|
104
|
+
if (folded.has(key.toLowerCase())) {
|
|
105
|
+
delete env[key];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
67
110
|
// Bun autoloads the project's dotenv files into `process.env` before user code
|
|
68
111
|
// runs — including inside `bun build --compile` binaries — so a snapshot of
|
|
69
112
|
// `Bun.env` is only pre-dotenv when autoloading was explicitly disabled. Linux
|
|
@@ -107,10 +150,10 @@ function expandDotenvValues(values: Record<string, string>, env: Record<string,
|
|
|
107
150
|
return expanded;
|
|
108
151
|
}
|
|
109
152
|
|
|
110
|
-
|
|
111
|
-
export function filterChildShellEnv(
|
|
153
|
+
function filterChildShellEnvInternal(
|
|
112
154
|
env: Record<string, string | undefined>,
|
|
113
|
-
cwd: string
|
|
155
|
+
cwd: string,
|
|
156
|
+
onDotenvValue?: (value: string) => void,
|
|
114
157
|
): Record<string, string> {
|
|
115
158
|
const runtimeLaunchEnvValues = env === Bun.env || env === process.env ? launchEnvValues : undefined;
|
|
116
159
|
const result = filterProcessEnv(env);
|
|
@@ -147,6 +190,12 @@ export function filterChildShellEnv(
|
|
|
147
190
|
}
|
|
148
191
|
}
|
|
149
192
|
const allLaunchEnv = fallbackLaunchEnv ? { ...launchEnv, ...fallbackLaunchEnv } : launchEnv;
|
|
193
|
+
if (onDotenvValue) {
|
|
194
|
+
// Every value the project's dotenv files define is dotenv-sourced, whether
|
|
195
|
+
// or not this process loaded it (a `--cwd` launch never did).
|
|
196
|
+
for (const key in allLaunchEnv) onDotenvValue(allLaunchEnv[key]!);
|
|
197
|
+
for (const key in expandedLaunchEnv) onDotenvValue(expandedLaunchEnv[key]!);
|
|
198
|
+
}
|
|
150
199
|
for (const key in allLaunchEnv) {
|
|
151
200
|
const launchValue = runtimeLaunchEnvValues?.get(key);
|
|
152
201
|
if (launchValue !== undefined) {
|
|
@@ -168,6 +217,8 @@ export function filterChildShellEnv(
|
|
|
168
217
|
// Strong provenance: the launch environment is known and this name is
|
|
169
218
|
// absent from it, or OMP itself injected the value — either way it came
|
|
170
219
|
// from a project dotenv file, not the parent shell.
|
|
220
|
+
const value = result[key];
|
|
221
|
+
if (value !== undefined) onDotenvValue?.(value);
|
|
171
222
|
delete result[key];
|
|
172
223
|
} else if (
|
|
173
224
|
result[key] === launchEnv[key] ||
|
|
@@ -177,50 +228,46 @@ export function filterChildShellEnv(
|
|
|
177
228
|
) {
|
|
178
229
|
// No launch-env snapshot (dotenv autoloaded without procfs): best-effort
|
|
179
230
|
// value match against the Bun-parsed dotenv.
|
|
231
|
+
const value = result[key];
|
|
232
|
+
if (value !== undefined) onDotenvValue?.(value);
|
|
180
233
|
delete result[key];
|
|
181
234
|
}
|
|
182
235
|
}
|
|
236
|
+
// Last, after dotenv merging: no source (inherited, launcher, or dotenv) may
|
|
237
|
+
// pin the child shell to the agent's own repository.
|
|
238
|
+
stripGitRepoLocationEnv(result);
|
|
183
239
|
return result;
|
|
184
240
|
}
|
|
185
241
|
|
|
186
|
-
/**
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
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() };
|
|
242
|
+
/** Filters process env for child shells without launch-cwd dotenv values. */
|
|
243
|
+
export function filterChildShellEnv(
|
|
244
|
+
env: Record<string, string | undefined>,
|
|
245
|
+
cwd: string = getProjectDir(),
|
|
246
|
+
): Record<string, string> {
|
|
247
|
+
return filterChildShellEnvInternal(env, cwd);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Return every value defined by `cwd`'s dotenv files, plus environment values that came from them. */
|
|
251
|
+
export function getDotenvEnvValues(
|
|
252
|
+
cwd: string = getProjectDir(),
|
|
253
|
+
env: Record<string, string | undefined> = process.env,
|
|
254
|
+
): string[] {
|
|
255
|
+
const values = new Set<string>();
|
|
256
|
+
filterChildShellEnvInternal(env, cwd, value => values.add(value));
|
|
257
|
+
return [...values];
|
|
211
258
|
}
|
|
212
259
|
|
|
213
260
|
/**
|
|
214
|
-
* Parses a .env file
|
|
215
|
-
*
|
|
261
|
+
* Parses a complete .env file with the runtime's dotenv grammar, then retains
|
|
262
|
+
* only shell-identifier names and spawn-safe values.
|
|
216
263
|
*/
|
|
217
264
|
export function parseEnvFile(filePath: string): Record<string, string> {
|
|
218
265
|
const result: Record<string, string> = {};
|
|
219
266
|
try {
|
|
220
|
-
const
|
|
221
|
-
for (const
|
|
222
|
-
const
|
|
223
|
-
if (
|
|
267
|
+
const parsed = parseEnv(fs.readFileSync(filePath, "utf-8"));
|
|
268
|
+
for (const key in parsed) {
|
|
269
|
+
const value = parsed[key];
|
|
270
|
+
if (value !== undefined && isValidEnvName(key) && isSafeEnvValue(value)) result[key] = value;
|
|
224
271
|
}
|
|
225
272
|
} catch {
|
|
226
273
|
// File doesn't exist or can't be read - return empty result
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check if a file path exists, is a regular file, and has effective execute permission.
|
|
5
|
+
*/
|
|
6
|
+
export function isExecutable(filePath: string): boolean {
|
|
7
|
+
try {
|
|
8
|
+
const stat = fs.statSync(filePath);
|
|
9
|
+
if (!stat.isFile()) return false;
|
|
10
|
+
if (process.platform !== "win32") {
|
|
11
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
12
|
+
}
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|