@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/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
|
+
}
|
package/src/fetch-retry.ts
CHANGED
|
@@ -11,17 +11,41 @@ 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;
|
|
23
|
+
// Both grammars carry a timezone-naive wall clock. The default reading is UTC;
|
|
24
|
+
// a provider-specific offset (Z.AI/Zhipu Beijing time) is applied only through
|
|
25
|
+
// `RetryHintOptions.naiveResetTimezoneOffset`, never inferred from the language.
|
|
18
26
|
const CN_RESET_AT_PATTERN = /将在\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2}:[0-9]{2})\s*重置/;
|
|
27
|
+
const RESET_AT_PATTERNS: readonly RegExp[] = [WILL_RESET_AT_PATTERN, CN_RESET_AT_PATTERN];
|
|
19
28
|
// "retry-after-ms=98497000" / "retry-after-ms: 7200000" / "retry-after-ms = 7200000"
|
|
20
29
|
const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
21
30
|
|
|
31
|
+
// A timezone-naive `reset at` stamp (no `Z`/offset) is the provider's
|
|
32
|
+
// wall clock in an unknown zone: it cannot be converted to a delay without
|
|
33
|
+
// guessing the zone, so it resolves only as a fallback when the body
|
|
34
|
+
// carries no unambiguous relative signal.
|
|
35
|
+
|
|
36
|
+
// A conflict probe needs no new signal: when a naive `reset at` wall stamp
|
|
37
|
+
// disagrees with the merged relative wait, the merged wait (which already
|
|
38
|
+
// ignores the naive stamp) sleeps first, and the retry after it is the
|
|
39
|
+
// probe — success proves skew, a fresh 429 re-anchors with live timing.
|
|
40
|
+
|
|
41
|
+
/** Provider-specific interpretation for timezone-naive retry timestamps. */
|
|
42
|
+
export interface RetryHintOptions {
|
|
43
|
+
/** UTC offset appended to an absolute reset stamp that omits its timezone. */
|
|
44
|
+
naiveResetTimezoneOffset?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
22
47
|
/**
|
|
23
48
|
* Server-suggested retry delay extraction. Merges the patterns historically used
|
|
24
|
-
* by the OpenAI Codex and Google Gemini retry helpers.
|
|
25
49
|
*
|
|
26
50
|
* Header sources (checked in order):
|
|
27
51
|
* - `retry-after-ms` (milliseconds)
|
|
@@ -37,12 +61,18 @@ const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
|
37
61
|
* - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
|
|
38
62
|
* - `retry-after-ms=98497000` / `retry-after-ms: 7200000` / `retry-after-ms = 7200000`
|
|
39
63
|
* - `Your limit will reset at 2026-09-01 09:44:51` / `将在 2026-09-01 09:44:51 重置`
|
|
64
|
+
* (a provider offset makes a naive wall clock authoritative; otherwise it
|
|
65
|
+
* resolves only when no relative signal is present)
|
|
40
66
|
*
|
|
41
67
|
* Returns `undefined` if no signal is found, or `0` when the provider
|
|
42
68
|
* explicitly asks for an immediate retry (`retry-after…=0`, or an absolute
|
|
43
69
|
* reset timestamp that has already elapsed).
|
|
44
70
|
*/
|
|
45
|
-
export function extractRetryHint(
|
|
71
|
+
export function extractRetryHint(
|
|
72
|
+
source: Response | Headers | null | undefined,
|
|
73
|
+
body?: string,
|
|
74
|
+
options?: RetryHintOptions,
|
|
75
|
+
): number | undefined {
|
|
46
76
|
const headers = source instanceof Headers ? source : (source?.headers ?? undefined);
|
|
47
77
|
if (headers) {
|
|
48
78
|
const retryAfterMs = headers.get("retry-after-ms");
|
|
@@ -98,6 +128,15 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
|
|
|
98
128
|
// when the parse returns undefined, which would sleep a session the
|
|
99
129
|
// provider told to retry immediately.
|
|
100
130
|
let retryNow = false;
|
|
131
|
+
|
|
132
|
+
// Timezone-naive `reset at` stamps (no `Z`/offset) are the provider's
|
|
133
|
+
// wall clock in an unknown zone — converting them to a delay requires
|
|
134
|
+
// guessing the zone. They resolve AFTER every unambiguous signal below,
|
|
135
|
+
// and only as a fallback when none was found.
|
|
136
|
+
let longestNaiveMs: number | undefined;
|
|
137
|
+
const considerNaive = (ms: number | undefined): void => {
|
|
138
|
+
if (ms !== undefined && ms > 0 && (longestNaiveMs === undefined || ms > longestNaiveMs)) longestNaiveMs = ms;
|
|
139
|
+
};
|
|
101
140
|
const consider = (ms: number | undefined): void => {
|
|
102
141
|
if (ms !== undefined && ms > 0 && (longestMs === undefined || ms > longestMs)) longestMs = ms;
|
|
103
142
|
};
|
|
@@ -117,16 +156,30 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
|
|
|
117
156
|
consider(totalMs > 0 ? totalMs : undefined);
|
|
118
157
|
}
|
|
119
158
|
}
|
|
120
|
-
for (const pattern of
|
|
159
|
+
for (const pattern of RESET_AT_PATTERNS) {
|
|
121
160
|
const match = pattern.exec(body);
|
|
122
|
-
if (match?.[1])
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
161
|
+
if (!match?.[1]) continue;
|
|
162
|
+
// Offset-bearing stamps are unambiguous and compete by longest-wins.
|
|
163
|
+
// A configured provider offset makes an otherwise naive wall clock
|
|
164
|
+
// unambiguous too. Without one, preserve the relative-signal-first
|
|
165
|
+
// fallback and interpret the wall clock as UTC only when it stands alone.
|
|
166
|
+
const normalized = match[1].replace(" ", "T");
|
|
167
|
+
const hasOffset = /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/i.test(normalized);
|
|
168
|
+
const configuredOffset = options?.naiveResetTimezoneOffset;
|
|
169
|
+
const parsed = Date.parse(hasOffset ? normalized : `${normalized}${configuredOffset ?? "Z"}`);
|
|
170
|
+
if (!Number.isNaN(parsed) && parsed > Date.now()) {
|
|
171
|
+
if (hasOffset || configuredOffset !== undefined) consider(parsed - Date.now());
|
|
172
|
+
else considerNaive(parsed - Date.now());
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// OpenCode Go compound remainder ("Resets in 2hr 15min"): the generic
|
|
176
|
+
// pattern only captures the leading "2hr", so add the trailing minutes.
|
|
177
|
+
const compoundResetMatch = RESET_IN_HR_MIN_PATTERN.exec(body);
|
|
178
|
+
if (compoundResetMatch?.[1] && compoundResetMatch[2]) {
|
|
179
|
+
const hours = Number.parseFloat(compoundResetMatch[1]);
|
|
180
|
+
const minutes = Number.parseFloat(compoundResetMatch[2]);
|
|
181
|
+
if (Number.isFinite(hours) && Number.isFinite(minutes) && hours >= 0 && minutes > 0) {
|
|
182
|
+
consider(hours * 60 * 60_000 + minutes * 60_000);
|
|
130
183
|
}
|
|
131
184
|
}
|
|
132
185
|
const accountResetMatch = WILL_RESET_IN_PATTERN.exec(body);
|
|
@@ -188,7 +241,9 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
|
|
|
188
241
|
considerClamped(resetSeconds > 1_000_000_000 ? resetSeconds * 1000 - Date.now() : resetSeconds * 1000);
|
|
189
242
|
}
|
|
190
243
|
}
|
|
191
|
-
|
|
244
|
+
// Elapsed naive stamps were ignored before this change and stay ignored:
|
|
245
|
+
// only an explicit zero/expired relative signal is authoritative retry-now.
|
|
246
|
+
return longestMs ?? longestNaiveMs ?? (retryNow ? 0 : undefined);
|
|
192
247
|
}
|
|
193
248
|
|
|
194
249
|
function unitToMs(unit: string): number | undefined {
|
|
@@ -210,6 +265,10 @@ function unitToMs(unit: string): number | undefined {
|
|
|
210
265
|
case "hour":
|
|
211
266
|
case "hours":
|
|
212
267
|
return 60 * 60_000;
|
|
268
|
+
case "d":
|
|
269
|
+
case "day":
|
|
270
|
+
case "days":
|
|
271
|
+
return 24 * 60 * 60_000;
|
|
213
272
|
default:
|
|
214
273
|
return undefined;
|
|
215
274
|
}
|
package/src/file-lock.ts
CHANGED
|
@@ -17,6 +17,16 @@ export interface FileLockOptions {
|
|
|
17
17
|
staleMs?: number;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** An exclusive OS-backed lease. Releasing an already released handle is safe. */
|
|
21
|
+
export interface FileLockHandle {
|
|
22
|
+
release(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** An exclusive OS-backed lease. Releasing an already released handle is safe. */
|
|
26
|
+
export interface FileLockHandle {
|
|
27
|
+
release(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
const DEFAULT_OPTIONS: Required<FileLockOptions> = {
|
|
21
31
|
retries: 50,
|
|
22
32
|
retryDelayMs: 100,
|
|
@@ -32,7 +42,8 @@ function tryAcquireLock(lockPath: string): NativeFileLock | null {
|
|
|
32
42
|
return lock.acquired ? lock : null;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
|
-
|
|
45
|
+
/** Acquire an exclusive lease; callers must release it when their operation ends. */
|
|
46
|
+
export async function acquireFileLock(filePath: string, options: FileLockOptions = {}): Promise<FileLockHandle> {
|
|
36
47
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
37
48
|
const lockPath = getLockPath(filePath);
|
|
38
49
|
|
|
@@ -45,13 +56,26 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
|
|
|
45
56
|
throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
|
|
46
57
|
}
|
|
47
58
|
|
|
59
|
+
function acquireLockSync(filePath: string, options: FileLockOptions = {}): NativeFileLock {
|
|
60
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
61
|
+
const lockPath = getLockPath(filePath);
|
|
62
|
+
|
|
63
|
+
for (let attempt = 0; attempt < opts.retries; attempt++) {
|
|
64
|
+
const lock = tryAcquireLock(lockPath);
|
|
65
|
+
if (lock) return lock;
|
|
66
|
+
if (attempt + 1 < opts.retries && opts.retryDelayMs > 0) Bun.sleepSync(opts.retryDelayMs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
|
|
70
|
+
}
|
|
71
|
+
|
|
48
72
|
/** Run `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
49
73
|
export async function withFileLock<T>(
|
|
50
74
|
filePath: string,
|
|
51
75
|
fn: () => Promise<T>,
|
|
52
76
|
options: FileLockOptions = {},
|
|
53
77
|
): Promise<T> {
|
|
54
|
-
const lock = await
|
|
78
|
+
const lock = await acquireFileLock(filePath, options);
|
|
55
79
|
try {
|
|
56
80
|
return await fn();
|
|
57
81
|
} finally {
|
|
@@ -59,6 +83,16 @@ export async function withFileLock<T>(
|
|
|
59
83
|
}
|
|
60
84
|
}
|
|
61
85
|
|
|
86
|
+
/** Run synchronous `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
87
|
+
export function withFileLockSync<T>(filePath: string, fn: () => T, options: FileLockOptions = {}): T {
|
|
88
|
+
const lock = acquireLockSync(filePath, options);
|
|
89
|
+
try {
|
|
90
|
+
return fn();
|
|
91
|
+
} finally {
|
|
92
|
+
lock.release();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
62
96
|
/**
|
|
63
97
|
* Test-only acquisition handle for forcing ownership handoffs. This is not
|
|
64
98
|
* part of the supported package API.
|
package/src/format.ts
CHANGED
|
@@ -58,6 +58,22 @@ export function formatBytes(bytes: number): string {
|
|
|
58
58
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Count `\n` code units via native `indexOf` — no split array, roughly an
|
|
63
|
+
* order of magnitude cheaper than a per-code-unit loop on multi-MiB text.
|
|
64
|
+
* Line-count semantics are the caller's (empty text is 0 or 1 lines
|
|
65
|
+
* depending on the contract).
|
|
66
|
+
*/
|
|
67
|
+
export function countNewlines(text: string): number {
|
|
68
|
+
let count = 0;
|
|
69
|
+
let pos = text.indexOf("\n");
|
|
70
|
+
while (pos !== -1) {
|
|
71
|
+
count++;
|
|
72
|
+
pos = text.indexOf("\n", pos + 1);
|
|
73
|
+
}
|
|
74
|
+
return count;
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
/**
|
|
62
78
|
* Truncate a string to maxLen characters, appending an ellipsis if truncated.
|
|
63
79
|
* For display-width-aware truncation (terminals), use truncateToWidth from @linxiraos/pi-tui.
|
package/src/index.ts
CHANGED
|
@@ -4,12 +4,12 @@ export * from "./binary";
|
|
|
4
4
|
export * from "./color";
|
|
5
5
|
export * from "./dirs";
|
|
6
6
|
export * from "./env";
|
|
7
|
+
export * from "./executable";
|
|
7
8
|
export * from "./fetch-retry";
|
|
8
9
|
export * from "./file-lock";
|
|
9
10
|
export * from "./format";
|
|
10
11
|
export * from "./frontmatter";
|
|
11
12
|
export * from "./fs-error";
|
|
12
|
-
export * from "./glob";
|
|
13
13
|
export * from "./incoming-json";
|
|
14
14
|
export * from "./json";
|
|
15
15
|
export * from "./json-parse";
|
|
@@ -40,6 +40,7 @@ export * from "./tls-fetch";
|
|
|
40
40
|
export * from "./type-guards";
|
|
41
41
|
export * from "./version";
|
|
42
42
|
export * from "./which";
|
|
43
|
+
export * from "./yaml-config";
|
|
43
44
|
|
|
44
45
|
function isPlainObject(val: object): val is Record<string, unknown> {
|
|
45
46
|
return Object.getPrototypeOf(val) === Object.prototype || Array.isArray(val);
|
package/src/json.ts
CHANGED
|
@@ -19,7 +19,18 @@ export function tryParseJson<T = unknown>(content: string): T | null {
|
|
|
19
19
|
* only lossless JSON representation.
|
|
20
20
|
*/
|
|
21
21
|
export function stringifyJson(value: unknown, space?: string | number): string | undefined {
|
|
22
|
-
|
|
22
|
+
// Fast path: a replacer forces the slow stringify on every call, but
|
|
23
|
+
// bigint payloads are vanishingly rare. Try plain stringify first and
|
|
24
|
+
// retry with the coercing replacer only on TypeError (the only error a
|
|
25
|
+
// bigint raises). A TypeError from elsewhere (circular value, throwing
|
|
26
|
+
// toJSON) recurs on the retry and surfaces from that second walk, so
|
|
27
|
+
// stateful serializers ahead of a bigint run twice on that path.
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(value, undefined, space);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (!(error instanceof TypeError)) throw error;
|
|
32
|
+
return JSON.stringify(value, (_key, item) => (typeof item === "bigint" ? item.toString() : item), space);
|
|
33
|
+
}
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
function stableJsonClone(value: unknown): unknown {
|
|
@@ -47,6 +47,12 @@ export class RotatingFileSink {
|
|
|
47
47
|
#activePath: string | undefined;
|
|
48
48
|
#activeBytes = 0;
|
|
49
49
|
#closed = false;
|
|
50
|
+
// Held append fd: the old code did open+write+close per line
|
|
51
|
+
// (appendFileSync) plus a throwaway open in the constructor. A single fd
|
|
52
|
+
// per active file removes two syscalls per log line; it is reopened on
|
|
53
|
+
// rotation and closed on close()/rotation (required for Windows
|
|
54
|
+
// delete-on-prune semantics).
|
|
55
|
+
#fd: number | undefined;
|
|
50
56
|
|
|
51
57
|
constructor(options: RotatingFileOptions) {
|
|
52
58
|
this.#directory = options.directory;
|
|
@@ -61,26 +67,57 @@ export class RotatingFileSink {
|
|
|
61
67
|
const activePath = this.#activePath;
|
|
62
68
|
if (activePath) {
|
|
63
69
|
this.#registerFile(activePath, now.getTime());
|
|
64
|
-
|
|
70
|
+
this.#openFd(activePath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#openFd(filePath: string): void {
|
|
75
|
+
this.#closeFd();
|
|
76
|
+
this.#fd = fs.openSync(filePath, "a");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#closeFd(): void {
|
|
80
|
+
if (this.#fd !== undefined) {
|
|
81
|
+
try {
|
|
82
|
+
fs.closeSync(this.#fd);
|
|
83
|
+
} catch {
|
|
84
|
+
// Best-effort: the fd may already be invalid after rotation.
|
|
85
|
+
}
|
|
86
|
+
this.#fd = undefined;
|
|
65
87
|
}
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
/** Append one already-formatted log record. */
|
|
69
91
|
write(line: string): void {
|
|
70
92
|
if (this.#closed) return;
|
|
93
|
+
const prevPath = this.#activePath;
|
|
71
94
|
const now = new Date();
|
|
72
95
|
this.#selectFile(this.#localDay(now));
|
|
73
96
|
const activePath = this.#activePath;
|
|
74
97
|
if (!activePath) return;
|
|
98
|
+
// Rotation moved the active path: close the old descriptor BEFORE
|
|
99
|
+
// registering the new path, because registration prunes beyond
|
|
100
|
+
// maxFiles — on Windows the pruned predecessor cannot be deleted
|
|
101
|
+
// while still open, which would leak it (audit entry removed, file
|
|
102
|
+
// left on disk, retention unbounded).
|
|
103
|
+
if (activePath !== prevPath) this.#closeFd();
|
|
75
104
|
this.#registerFile(activePath, now.getTime());
|
|
105
|
+
if (this.#fd === undefined) this.#openFd(activePath);
|
|
76
106
|
const record = `${line}${os.EOL}`;
|
|
77
|
-
|
|
78
|
-
|
|
107
|
+
const buf = Buffer.from(record, "utf8");
|
|
108
|
+
let off = 0;
|
|
109
|
+
while (off < buf.length) {
|
|
110
|
+
const written = fs.writeSync(this.#fd!, buf, off);
|
|
111
|
+
if (written <= 0) break;
|
|
112
|
+
off += written;
|
|
113
|
+
}
|
|
114
|
+
this.#activeBytes += buf.length;
|
|
79
115
|
}
|
|
80
116
|
|
|
81
117
|
/** Stop accepting records. Synchronous writes require no drain phase. */
|
|
82
118
|
close(): void {
|
|
83
119
|
this.#closed = true;
|
|
120
|
+
this.#closeFd();
|
|
84
121
|
}
|
|
85
122
|
|
|
86
123
|
#localDay(date: Date): string {
|
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: "zeta",
|
|
@@ -276,12 +289,11 @@ function getLocalTransports(): LocalTransports {
|
|
|
276
289
|
|
|
277
290
|
function emitLocally(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
|
|
278
291
|
const transports = getLocalTransports();
|
|
279
|
-
const info = normalizeLogInfo(level, message, context);
|
|
280
292
|
if (!transports.file && !transports.console) return;
|
|
281
|
-
|
|
293
|
+
const info = normalizeLogInfo(level, message, context);
|
|
282
294
|
const line = formatLogInfo(info);
|
|
283
295
|
if (transports.file) transports.file.write(line);
|
|
284
|
-
if (transports.console) fs.writeSync(1, `${
|
|
296
|
+
if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
|
|
285
297
|
}
|
|
286
298
|
|
|
287
299
|
/**
|
package/src/mime.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { peekFile, peekFileSync } from "./peek-file";
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
|
|
3
|
+
export const IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
|
|
5
4
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
6
5
|
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
|
|
7
6
|
const WEBP_RIFF_MAGIC = Buffer.from([0x52, 0x49, 0x46, 0x46]);
|
|
@@ -144,16 +143,13 @@ export function parseImageMetadata(header: Uint8Array): ImageMetadata | null {
|
|
|
144
143
|
);
|
|
145
144
|
}
|
|
146
145
|
|
|
147
|
-
export function readImageMetadataSync(
|
|
148
|
-
filePath: string,
|
|
149
|
-
maxBytes = DEFAULT_IMAGE_METADATA_HEADER_BYTES,
|
|
150
|
-
): ImageMetadata | null {
|
|
146
|
+
export function readImageMetadataSync(filePath: string, maxBytes = IMAGE_METADATA_HEADER_BYTES): ImageMetadata | null {
|
|
151
147
|
return peekFileSync(filePath, maxBytes, parseImageMetadata);
|
|
152
148
|
}
|
|
153
149
|
|
|
154
150
|
export function readImageMetadata(
|
|
155
151
|
filePath: string,
|
|
156
|
-
maxBytes =
|
|
152
|
+
maxBytes = IMAGE_METADATA_HEADER_BYTES,
|
|
157
153
|
): Promise<ImageMetadata | null> {
|
|
158
154
|
return peekFile(filePath, maxBytes, parseImageMetadata);
|
|
159
155
|
}
|
package/src/path.ts
CHANGED
|
@@ -40,3 +40,17 @@ export function stripWindowsExtendedLengthPathPrefix(
|
|
|
40
40
|
|
|
41
41
|
return filePath;
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Test whether a path is fully qualified and drive-independent.
|
|
46
|
+
* On Windows, requires a drive letter with separator (e.g. `C:\`) or UNC (`\\server\share` or `//server/share`).
|
|
47
|
+
* On POSIX, requires an absolute path.
|
|
48
|
+
*/
|
|
49
|
+
export function isFullyQualifiedPath(filePath: string, platform: NodeJS.Platform = process.platform): boolean {
|
|
50
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
51
|
+
if (!p.isAbsolute(filePath)) return false;
|
|
52
|
+
if (platform === "win32") {
|
|
53
|
+
return /^[a-zA-Z]:[/\\]/.test(filePath) || /^[\\/]{2}[^\\/]/.test(filePath);
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}
|