@oh-my-pi/pi-utils 17.2.4 → 17.2.6
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 +16 -0
- package/dist/types/cli.d.ts +14 -9
- package/dist/types/dirs.d.ts +4 -0
- package/dist/types/env.d.ts +21 -0
- package/dist/types/file-lock.d.ts +21 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/postmortem.d.ts +12 -0
- package/package.json +2 -2
- package/src/cli.ts +41 -27
- package/src/dirs.ts +13 -0
- package/src/env.ts +35 -2
- package/src/file-lock.ts +66 -0
- package/src/index.ts +1 -0
- package/src/postmortem.ts +35 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.2.6] - 2026-08-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a shared `file-lock` utility backed by process-owned native OS locks with automatic crash release and bounded asynchronous retry.
|
|
10
|
+
|
|
11
|
+
## [17.2.5] - 2026-08-03
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Added utility functions `parseFlag()`, `getBrowserRelayDir()`, and `getGlobalDaemonRuntimeDir()` to support browser relay mode and global daemon runtime directory resolution.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Updated the lightweight CLI runner to support static command metadata, allowing root help to render without importing full command implementations.
|
|
20
|
+
|
|
5
21
|
## [17.2.4] - 2026-08-01
|
|
6
22
|
|
|
7
23
|
### Added
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -70,22 +70,24 @@ export interface ParseOutput<F extends Record<string, FlagDescriptor> = Record<s
|
|
|
70
70
|
args: ArgValues<A>;
|
|
71
71
|
argv: string[];
|
|
72
72
|
}
|
|
73
|
-
export interface
|
|
74
|
-
new (argv: string[], config: CliConfig): Command;
|
|
73
|
+
export interface CommandMetadata {
|
|
75
74
|
description?: string;
|
|
76
75
|
hidden?: boolean;
|
|
77
|
-
strict?: boolean;
|
|
78
|
-
aliases?: string[];
|
|
79
|
-
examples?: string[];
|
|
80
76
|
flags?: Record<string, FlagDescriptor>;
|
|
81
77
|
args?: Record<string, ArgDescriptor>;
|
|
78
|
+
examples?: string[];
|
|
79
|
+
}
|
|
80
|
+
export interface CommandCtor extends CommandMetadata {
|
|
81
|
+
new (argv: string[], config: CliConfig): Command;
|
|
82
|
+
strict?: boolean;
|
|
83
|
+
aliases?: string[];
|
|
82
84
|
}
|
|
83
85
|
/** Configuration passed to every command instance and help renderers. */
|
|
84
|
-
export interface CliConfig {
|
|
86
|
+
export interface CliConfig<TCommand extends CommandMetadata = CommandCtor> {
|
|
85
87
|
bin: string;
|
|
86
88
|
version: string;
|
|
87
89
|
/** All registered commands keyed by their canonical name. */
|
|
88
|
-
commands: Map<string,
|
|
90
|
+
commands: Map<string, TCommand>;
|
|
89
91
|
}
|
|
90
92
|
/** Minimal Command base matching the oclif surface we use. */
|
|
91
93
|
export declare abstract class Command {
|
|
@@ -100,7 +102,7 @@ export declare abstract class Command {
|
|
|
100
102
|
parse<C extends CommandCtor>(_Cmd: C): Promise<ParseOutput<NonNullable<C["flags"]> extends Record<string, FlagDescriptor> ? NonNullable<C["flags"]> : Record<string, FlagDescriptor>, NonNullable<C["args"]> extends Record<string, ArgDescriptor> ? NonNullable<C["args"]> : Record<string, ArgDescriptor>>>;
|
|
101
103
|
}
|
|
102
104
|
/** Render full root help: header, default command details, subcommand list. */
|
|
103
|
-
export declare function renderRootHelp(config: CliConfig): void;
|
|
105
|
+
export declare function renderRootHelp(config: CliConfig<CommandMetadata>): void;
|
|
104
106
|
/** Build the single USAGE line for a command (without the leading label). */
|
|
105
107
|
export declare function commandUsageLine(bin: string, id: string, Cmd: CommandCtor): string;
|
|
106
108
|
/** Render help for a single command. */
|
|
@@ -109,6 +111,7 @@ export declare function renderCommandHelp(bin: string, id: string, Cmd: CommandC
|
|
|
109
111
|
export interface CommandEntry {
|
|
110
112
|
name: string;
|
|
111
113
|
load: () => Promise<CommandCtor>;
|
|
114
|
+
help?: CommandMetadata;
|
|
112
115
|
aliases?: string[];
|
|
113
116
|
}
|
|
114
117
|
export interface RunOptions {
|
|
@@ -116,8 +119,10 @@ export interface RunOptions {
|
|
|
116
119
|
version: string;
|
|
117
120
|
argv: string[];
|
|
118
121
|
commands: CommandEntry[];
|
|
119
|
-
/** Custom help renderer
|
|
122
|
+
/** Custom help renderer with the fully loaded command constructors. */
|
|
120
123
|
help?: (config: CliConfig) => Promise<void> | void;
|
|
124
|
+
/** Lightweight help renderer backed by static command metadata. */
|
|
125
|
+
metadataHelp?: (config: CliConfig<CommandMetadata>) => Promise<void> | void;
|
|
121
126
|
}
|
|
122
127
|
/**
|
|
123
128
|
* Main entry point — replaces `run()` from @oclif/core.
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -153,6 +153,8 @@ export declare function getPythonEnvDir(): string;
|
|
|
153
153
|
export declare function getPythonGatewayDir(): string;
|
|
154
154
|
/** Get the puppeteer sandbox directory (~/.omp/puppeteer). */
|
|
155
155
|
export declare function getPuppeteerDir(): string;
|
|
156
|
+
/** Get the browser relay extension install directory (~/.omp/browser-relay). */
|
|
157
|
+
export declare function getBrowserRelayDir(): string;
|
|
156
158
|
/** Get DOCS_RS cache directory () */
|
|
157
159
|
export declare function getDocsRsCacheDir(): string;
|
|
158
160
|
/** Get the auto-QA grievances SQLite database path (~/.omp/autoqa.db; XDG: $XDG_DATA_HOME/omp/autoqa.db). */
|
|
@@ -241,6 +243,8 @@ export declare function getDebugLogPath(agentDir?: string): string;
|
|
|
241
243
|
export declare function getSecretPlaceholderKeyPath(): string;
|
|
242
244
|
/** Get the daemon runtime directory for a project (~/.omp/run/daemons/<hash>; XDG default: $XDG_STATE_HOME/omp/run/daemons/<hash>). */
|
|
243
245
|
export declare function getDaemonRuntimeDir(projectDir: string): string;
|
|
246
|
+
/** Get a profile-independent runtime directory for a machine-global daemon service. */
|
|
247
|
+
export declare function getGlobalDaemonRuntimeDir(service: string): string;
|
|
244
248
|
/** Get the provider in-flight root directory (~/.omp/run/provider-inflight; XDG default: $XDG_STATE_HOME/omp/run/provider-inflight). */
|
|
245
249
|
export declare function getProviderInFlightRoot(): string;
|
|
246
250
|
/** Get the marketplaces registry path (~/.omp/marketplaces.json; XDG default: $XDG_DATA_HOME/omp/marketplaces.json). Adopts a legacy registry on first XDG resolution. */
|
package/dist/types/env.d.ts
CHANGED
|
@@ -39,6 +39,25 @@ export declare const $env: Record<string, string>;
|
|
|
39
39
|
* @returns The first environment variable value, or undefined if no value is found.
|
|
40
40
|
*/
|
|
41
41
|
export declare function $pickenv(...keys: string[]): string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Read an environment variable by its EXACT, case-sensitive name.
|
|
44
|
+
*
|
|
45
|
+
* `process.env` / `Bun.env` lookups are case-insensitive on Windows (Node backs
|
|
46
|
+
* them with `uv_os_getenv`, Bun with a `CaseInsensitiveASCIIStringArrayHashMap`),
|
|
47
|
+
* so a lowercase literal like `public` silently resolves to a differently-cased
|
|
48
|
+
* system variable — Windows ships `PUBLIC=C:\Users\Public`. Enumerated keys are
|
|
49
|
+
* the only signal that preserves the real casing, so this trusts the lookup only
|
|
50
|
+
* when a key with identical casing is actually present. On POSIX (case-sensitive
|
|
51
|
+
* env) it is equivalent to a direct lookup.
|
|
52
|
+
*
|
|
53
|
+
* Use this instead of `process.env[name] ?? literal` wherever `name` may be a
|
|
54
|
+
* user-supplied literal (e.g. a stored API key) rather than a genuine env-var
|
|
55
|
+
* reference — otherwise the literal gets hijacked by a same-named system var.
|
|
56
|
+
*
|
|
57
|
+
* @param name - Environment variable name to look up.
|
|
58
|
+
* @param env - Environment source; defaults to `process.env`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function $envExact(name: string, env?: Record<string, string | undefined>): string | undefined;
|
|
42
61
|
/**
|
|
43
62
|
* Parses a positive decimal integer from `$env[name]`.
|
|
44
63
|
* Empty, invalid, NaN, zero, or negative values return `defaultValue`.
|
|
@@ -100,4 +119,6 @@ export declare function getDbBusyTimeoutMs(): number;
|
|
|
100
119
|
* first for cheap fast-path detection.
|
|
101
120
|
*/
|
|
102
121
|
export declare function isCompiledBinary(): boolean;
|
|
122
|
+
/** Parse a boolean-ish env value ("1", "yes", "on", …); `def` when unset/empty. */
|
|
123
|
+
export declare function parseFlag(value: string | undefined, def?: boolean): boolean;
|
|
103
124
|
export declare function $flag(name: string, def?: boolean): boolean;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { FileLock as NativeFileLock } from "@oh-my-pi/pi-natives";
|
|
2
|
+
/** Controls bounded waiting when an advisory file lock is contended. */
|
|
3
|
+
export interface FileLockOptions {
|
|
4
|
+
/** Maximum acquisition attempts, including the initial attempt. */
|
|
5
|
+
retries?: number;
|
|
6
|
+
/** Delay between acquisition attempts. */
|
|
7
|
+
retryDelayMs?: number;
|
|
8
|
+
}
|
|
9
|
+
declare function getLockPath(filePath: string): string;
|
|
10
|
+
declare function tryAcquireLock(lockPath: string): NativeFileLock | null;
|
|
11
|
+
/** Run `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
12
|
+
export declare function withFileLock<T>(filePath: string, fn: () => Promise<T>, options?: FileLockOptions): Promise<T>;
|
|
13
|
+
/**
|
|
14
|
+
* Test-only acquisition handle for forcing ownership handoffs. This is not
|
|
15
|
+
* part of the supported package API.
|
|
16
|
+
*/
|
|
17
|
+
export declare const __internalsForTesting: {
|
|
18
|
+
tryAcquireLock: typeof tryAcquireLock;
|
|
19
|
+
getLockPath: typeof getLockPath;
|
|
20
|
+
};
|
|
21
|
+
export {};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -15,6 +15,18 @@ export declare enum Reason {
|
|
|
15
15
|
UNHANDLED_REJECTION = "unhandled_rejection",// Unhandled promise rejection
|
|
16
16
|
MANUAL = "manual"
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Symbol stamped by the extension-load guard onto the throwing replacement it
|
|
20
|
+
* installs over `process.exit` / `process.reallyExit`, carrying the native
|
|
21
|
+
* primitive that replacement shadows.
|
|
22
|
+
*
|
|
23
|
+
* Host-owned shutdown ({@link exitProcess}) reads through it so a signal that
|
|
24
|
+
* lands while the guard is active still terminates the process (#6488), while
|
|
25
|
+
* a signal that lands after the guard has restored the native exit also
|
|
26
|
+
* terminates cleanly (#7393). `Symbol.for` so it survives duplicate module
|
|
27
|
+
* instances across bundles/realms.
|
|
28
|
+
*/
|
|
29
|
+
export declare const NATIVE_PROCESS_EXIT: unique symbol;
|
|
18
30
|
/** Origin of an EPIPE raised by a process communication channel. */
|
|
19
31
|
export type BrokenPipeSource = "ipc-send" | "stdio-write";
|
|
20
32
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-utils",
|
|
4
|
-
"version": "17.2.
|
|
4
|
+
"version": "17.2.6",
|
|
5
5
|
"description": "Shared utilities for pi packages",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"fmt": "biome format --write ."
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@oh-my-pi/pi-natives": "17.2.
|
|
34
|
+
"@oh-my-pi/pi-natives": "17.2.6",
|
|
35
35
|
"handlebars": "^4.7.9",
|
|
36
36
|
"winston": "^3.19.0",
|
|
37
37
|
"winston-daily-rotate-file": "5.0.0"
|
package/src/cli.ts
CHANGED
|
@@ -133,23 +133,26 @@ export interface ParseOutput<
|
|
|
133
133
|
// Command base class
|
|
134
134
|
// ---------------------------------------------------------------------------
|
|
135
135
|
|
|
136
|
-
export interface
|
|
137
|
-
new (argv: string[], config: CliConfig): Command;
|
|
136
|
+
export interface CommandMetadata {
|
|
138
137
|
description?: string;
|
|
139
138
|
hidden?: boolean;
|
|
140
|
-
strict?: boolean;
|
|
141
|
-
aliases?: string[];
|
|
142
|
-
examples?: string[];
|
|
143
139
|
flags?: Record<string, FlagDescriptor>;
|
|
144
140
|
args?: Record<string, ArgDescriptor>;
|
|
141
|
+
examples?: string[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface CommandCtor extends CommandMetadata {
|
|
145
|
+
new (argv: string[], config: CliConfig): Command;
|
|
146
|
+
strict?: boolean;
|
|
147
|
+
aliases?: string[];
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
/** Configuration passed to every command instance and help renderers. */
|
|
148
|
-
export interface CliConfig {
|
|
151
|
+
export interface CliConfig<TCommand extends CommandMetadata = CommandCtor> {
|
|
149
152
|
bin: string;
|
|
150
153
|
version: string;
|
|
151
154
|
/** All registered commands keyed by their canonical name. */
|
|
152
|
-
commands: Map<string,
|
|
155
|
+
commands: Map<string, TCommand>;
|
|
153
156
|
}
|
|
154
157
|
|
|
155
158
|
/** Minimal Command base matching the oclif surface we use. */
|
|
@@ -290,7 +293,7 @@ export abstract class Command {
|
|
|
290
293
|
// ---------------------------------------------------------------------------
|
|
291
294
|
|
|
292
295
|
/** Render full root help: header, default command details, subcommand list. */
|
|
293
|
-
export function renderRootHelp(config: CliConfig): void {
|
|
296
|
+
export function renderRootHelp(config: CliConfig<CommandMetadata>): void {
|
|
294
297
|
const { bin, version, commands } = config;
|
|
295
298
|
const lines: string[] = [];
|
|
296
299
|
lines.push(`${bin} v${version}\n`);
|
|
@@ -299,7 +302,7 @@ export function renderRootHelp(config: CliConfig): void {
|
|
|
299
302
|
|
|
300
303
|
// Show the default command's flags/args/examples inline.
|
|
301
304
|
// The default command is the one marked hidden (it's the implicit entry point).
|
|
302
|
-
const defaultCmd = [...commands.values()].find(
|
|
305
|
+
const defaultCmd = [...commands.values()].find(command => command.hidden);
|
|
303
306
|
if (defaultCmd) {
|
|
304
307
|
renderCommandBody(lines, defaultCmd);
|
|
305
308
|
}
|
|
@@ -309,8 +312,8 @@ export function renderRootHelp(config: CliConfig): void {
|
|
|
309
312
|
if (visible.length > 0) {
|
|
310
313
|
lines.push("COMMANDS");
|
|
311
314
|
const maxLen = Math.max(...visible.map(([n]) => n.length));
|
|
312
|
-
for (const [name,
|
|
313
|
-
lines.push(` ${name.padEnd(maxLen + 2)}${
|
|
315
|
+
for (const [name, command] of visible.sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
316
|
+
lines.push(` ${name.padEnd(maxLen + 2)}${command.description ?? ""}`);
|
|
314
317
|
}
|
|
315
318
|
lines.push("");
|
|
316
319
|
}
|
|
@@ -350,9 +353,9 @@ export function renderCommandHelp(bin: string, id: string, Cmd: CommandCtor): vo
|
|
|
350
353
|
process.stdout.write(lines.join("\n"));
|
|
351
354
|
}
|
|
352
355
|
|
|
353
|
-
function renderCommandBody(lines: string[],
|
|
354
|
-
const argDefs =
|
|
355
|
-
const flagDefs =
|
|
356
|
+
function renderCommandBody(lines: string[], command: CommandMetadata): void {
|
|
357
|
+
const argDefs = command.args ?? {};
|
|
358
|
+
const flagDefs = command.flags ?? {};
|
|
356
359
|
|
|
357
360
|
// Arguments
|
|
358
361
|
const argEntries = Object.entries(argDefs);
|
|
@@ -387,9 +390,9 @@ function renderCommandBody(lines: string[], Cmd: CommandCtor): void {
|
|
|
387
390
|
}
|
|
388
391
|
|
|
389
392
|
// Examples
|
|
390
|
-
if (
|
|
393
|
+
if (command.examples && command.examples.length > 0) {
|
|
391
394
|
lines.push("EXAMPLES");
|
|
392
|
-
for (const ex of
|
|
395
|
+
for (const ex of command.examples) {
|
|
393
396
|
for (const line of ex.split("\n")) {
|
|
394
397
|
lines.push(` ${line}`);
|
|
395
398
|
}
|
|
@@ -406,6 +409,7 @@ function renderCommandBody(lines: string[], Cmd: CommandCtor): void {
|
|
|
406
409
|
export interface CommandEntry {
|
|
407
410
|
name: string;
|
|
408
411
|
load: () => Promise<CommandCtor>;
|
|
412
|
+
help?: CommandMetadata;
|
|
409
413
|
aliases?: string[];
|
|
410
414
|
}
|
|
411
415
|
|
|
@@ -414,8 +418,10 @@ export interface RunOptions {
|
|
|
414
418
|
version: string;
|
|
415
419
|
argv: string[];
|
|
416
420
|
commands: CommandEntry[];
|
|
417
|
-
/** Custom help renderer
|
|
421
|
+
/** Custom help renderer with the fully loaded command constructors. */
|
|
418
422
|
help?: (config: CliConfig) => Promise<void> | void;
|
|
423
|
+
/** Lightweight help renderer backed by static command metadata. */
|
|
424
|
+
metadataHelp?: (config: CliConfig<CommandMetadata>) => Promise<void> | void;
|
|
419
425
|
}
|
|
420
426
|
|
|
421
427
|
/** Find a command entry by exact name or alias. */
|
|
@@ -437,11 +443,15 @@ export async function run(opts: RunOptions): Promise<void> {
|
|
|
437
443
|
|
|
438
444
|
// Top-level help
|
|
439
445
|
if (commandId === "--help" || commandId === "-h" || commandId === "help" || commandId === "") {
|
|
440
|
-
const config = await loadAllCommands(opts);
|
|
441
446
|
if (opts.help) {
|
|
442
|
-
await opts.help(
|
|
447
|
+
await opts.help(await loadAllCommands(opts));
|
|
443
448
|
} else {
|
|
444
|
-
|
|
449
|
+
const config = await loadAllCommandMetadata(opts);
|
|
450
|
+
if (opts.metadataHelp) {
|
|
451
|
+
await opts.metadataHelp(config);
|
|
452
|
+
} else {
|
|
453
|
+
renderRootHelp(config);
|
|
454
|
+
}
|
|
445
455
|
}
|
|
446
456
|
return;
|
|
447
457
|
}
|
|
@@ -504,12 +514,16 @@ async function loadEntry(entry: CommandEntry): Promise<CommandCtor> {
|
|
|
504
514
|
return Cmd;
|
|
505
515
|
}
|
|
506
516
|
|
|
507
|
-
/**
|
|
517
|
+
/** Load every command constructor for backward-compatible custom help callbacks. */
|
|
508
518
|
async function loadAllCommands(opts: RunOptions): Promise<CliConfig> {
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
519
|
+
const loaded = await Promise.all(opts.commands.map(async entry => [entry.name, await loadEntry(entry)] as const));
|
|
520
|
+
return { bin: opts.bin, version: opts.version, commands: new Map(loaded) };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Resolve static command metadata for lightweight root help. */
|
|
524
|
+
async function loadAllCommandMetadata(opts: RunOptions): Promise<CliConfig<CommandMetadata>> {
|
|
525
|
+
const loaded = await Promise.all(
|
|
526
|
+
opts.commands.map(async entry => [entry.name, entry.help ?? (await loadEntry(entry))] as const),
|
|
527
|
+
);
|
|
528
|
+
return { bin: opts.bin, version: opts.version, commands: new Map(loaded) };
|
|
515
529
|
}
|
package/src/dirs.ts
CHANGED
|
@@ -627,6 +627,11 @@ export function getPuppeteerDir(): string {
|
|
|
627
627
|
return dirs.rootSubdir("puppeteer", "cache");
|
|
628
628
|
}
|
|
629
629
|
|
|
630
|
+
/** Get the browser relay extension install directory (~/.omp/browser-relay). */
|
|
631
|
+
export function getBrowserRelayDir(): string {
|
|
632
|
+
return dirs.rootSubdir("browser-relay", "data");
|
|
633
|
+
}
|
|
634
|
+
|
|
630
635
|
/** Get DOCS_RS cache directory () */
|
|
631
636
|
export function getDocsRsCacheDir(): string {
|
|
632
637
|
return dirs.rootSubdir("webcache", "cache");
|
|
@@ -852,6 +857,14 @@ export function getDaemonRuntimeDir(projectDir: string): string {
|
|
|
852
857
|
return dirs.rootSubdir(path.join("run", "daemons", key), "state");
|
|
853
858
|
}
|
|
854
859
|
|
|
860
|
+
/** Get a profile-independent runtime directory for a machine-global daemon service. */
|
|
861
|
+
export function getGlobalDaemonRuntimeDir(service: string): string {
|
|
862
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(service)) {
|
|
863
|
+
throw new Error(`Invalid global daemon service name: ${JSON.stringify(service)}`);
|
|
864
|
+
}
|
|
865
|
+
return path.join(getBaseConfigRoot(), "run", "daemons", "global", service);
|
|
866
|
+
}
|
|
867
|
+
|
|
855
868
|
/** Get the provider in-flight root directory (~/.omp/run/provider-inflight; XDG default: $XDG_STATE_HOME/omp/run/provider-inflight). */
|
|
856
869
|
export function getProviderInFlightRoot(): string {
|
|
857
870
|
return dirs.rootSubdir(path.join("run", "provider-inflight"), "state");
|
package/src/env.ts
CHANGED
|
@@ -246,6 +246,35 @@ export function $pickenv(...keys: string[]): string | undefined {
|
|
|
246
246
|
return undefined;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Read an environment variable by its EXACT, case-sensitive name.
|
|
251
|
+
*
|
|
252
|
+
* `process.env` / `Bun.env` lookups are case-insensitive on Windows (Node backs
|
|
253
|
+
* them with `uv_os_getenv`, Bun with a `CaseInsensitiveASCIIStringArrayHashMap`),
|
|
254
|
+
* so a lowercase literal like `public` silently resolves to a differently-cased
|
|
255
|
+
* system variable — Windows ships `PUBLIC=C:\Users\Public`. Enumerated keys are
|
|
256
|
+
* the only signal that preserves the real casing, so this trusts the lookup only
|
|
257
|
+
* when a key with identical casing is actually present. On POSIX (case-sensitive
|
|
258
|
+
* env) it is equivalent to a direct lookup.
|
|
259
|
+
*
|
|
260
|
+
* Use this instead of `process.env[name] ?? literal` wherever `name` may be a
|
|
261
|
+
* user-supplied literal (e.g. a stored API key) rather than a genuine env-var
|
|
262
|
+
* reference — otherwise the literal gets hijacked by a same-named system var.
|
|
263
|
+
*
|
|
264
|
+
* @param name - Environment variable name to look up.
|
|
265
|
+
* @param env - Environment source; defaults to `process.env`.
|
|
266
|
+
*/
|
|
267
|
+
export function $envExact(name: string, env: Record<string, string | undefined> = process.env): string | undefined {
|
|
268
|
+
const value = env[name];
|
|
269
|
+
if (value === undefined) return undefined;
|
|
270
|
+
// Enumeration preserves real key casing on Windows, unlike the getter; the
|
|
271
|
+
// value is trusted only when an exact-case entry actually exists.
|
|
272
|
+
for (const key in env) {
|
|
273
|
+
if (key === name) return value;
|
|
274
|
+
}
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
249
278
|
/**
|
|
250
279
|
* Parses a positive decimal integer from `$env[name]`.
|
|
251
280
|
* Empty, invalid, NaN, zero, or negative values return `defaultValue`.
|
|
@@ -360,8 +389,12 @@ const TRUTHY: Dict<boolean> = {
|
|
|
360
389
|
ON: true,
|
|
361
390
|
on: true,
|
|
362
391
|
};
|
|
363
|
-
|
|
364
|
-
|
|
392
|
+
/** Parse a boolean-ish env value ("1", "yes", "on", …); `def` when unset/empty. */
|
|
393
|
+
export function parseFlag(value: string | undefined, def = false): boolean {
|
|
365
394
|
if (!value) return def;
|
|
366
395
|
return TRUTHY[value] === true;
|
|
367
396
|
}
|
|
397
|
+
|
|
398
|
+
export function $flag(name: string, def: boolean = false): boolean {
|
|
399
|
+
return parseFlag($env[name], def);
|
|
400
|
+
}
|
package/src/file-lock.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process advisory lock for packages that serialize access to an
|
|
3
|
+
* on-disk resource. The native handle is process-owned and automatically
|
|
4
|
+
* released on exit: Linux uses abstract Unix sockets, Windows uses named
|
|
5
|
+
* mutexes, and other Unix platforms use `flock(2)` on `${filePath}.lock`.
|
|
6
|
+
*/
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { FileLock as NativeFileLock } from "@oh-my-pi/pi-natives";
|
|
9
|
+
|
|
10
|
+
/** Controls bounded waiting when an advisory file lock is contended. */
|
|
11
|
+
export interface FileLockOptions {
|
|
12
|
+
/** Maximum acquisition attempts, including the initial attempt. */
|
|
13
|
+
retries?: number;
|
|
14
|
+
/** Delay between acquisition attempts. */
|
|
15
|
+
retryDelayMs?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_OPTIONS: Required<FileLockOptions> = {
|
|
19
|
+
retries: 50,
|
|
20
|
+
retryDelayMs: 100,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function getLockPath(filePath: string): string {
|
|
24
|
+
return `${path.resolve(filePath)}.lock`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function tryAcquireLock(lockPath: string): NativeFileLock | null {
|
|
28
|
+
const lock = NativeFileLock.tryAcquire(lockPath);
|
|
29
|
+
return lock.acquired ? lock : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<NativeFileLock> {
|
|
33
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
34
|
+
const lockPath = getLockPath(filePath);
|
|
35
|
+
|
|
36
|
+
for (let attempt = 0; attempt < opts.retries; attempt++) {
|
|
37
|
+
const lock = tryAcquireLock(lockPath);
|
|
38
|
+
if (lock) return lock;
|
|
39
|
+
if (attempt + 1 < opts.retries) await Bun.sleep(opts.retryDelayMs);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Run `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
46
|
+
export async function withFileLock<T>(
|
|
47
|
+
filePath: string,
|
|
48
|
+
fn: () => Promise<T>,
|
|
49
|
+
options: FileLockOptions = {},
|
|
50
|
+
): Promise<T> {
|
|
51
|
+
const lock = await acquireLock(filePath, options);
|
|
52
|
+
try {
|
|
53
|
+
return await fn();
|
|
54
|
+
} finally {
|
|
55
|
+
lock.release();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Test-only acquisition handle for forcing ownership handoffs. This is not
|
|
61
|
+
* part of the supported package API.
|
|
62
|
+
*/
|
|
63
|
+
export const __internalsForTesting = {
|
|
64
|
+
tryAcquireLock,
|
|
65
|
+
getLockPath,
|
|
66
|
+
};
|
package/src/index.ts
CHANGED
package/src/postmortem.ts
CHANGED
|
@@ -29,8 +29,41 @@ const callbackList: ((reason: Reason) => Promise<void> | void)[] = [];
|
|
|
29
29
|
// Tracks cleanup run state (to prevent recursion/reentry issues)
|
|
30
30
|
let cleanupStage: "idle" | "running" | "complete" = "idle";
|
|
31
31
|
const CLEANUP_DEADLINE_MS = 10_000;
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Symbol stamped by the extension-load guard onto the throwing replacement it
|
|
34
|
+
* installs over `process.exit` / `process.reallyExit`, carrying the native
|
|
35
|
+
* primitive that replacement shadows.
|
|
36
|
+
*
|
|
37
|
+
* Host-owned shutdown ({@link exitProcess}) reads through it so a signal that
|
|
38
|
+
* lands while the guard is active still terminates the process (#6488), while
|
|
39
|
+
* a signal that lands after the guard has restored the native exit also
|
|
40
|
+
* terminates cleanly (#7393). `Symbol.for` so it survives duplicate module
|
|
41
|
+
* instances across bundles/realms.
|
|
42
|
+
*/
|
|
43
|
+
export const NATIVE_PROCESS_EXIT = Symbol.for("omp.postmortem.nativeProcessExit");
|
|
44
|
+
|
|
45
|
+
type HardExitFn = (code?: number) => never;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Hard-exit the process through the native primitive, resolved on every call.
|
|
49
|
+
*
|
|
50
|
+
* The native exit is deliberately re-resolved here rather than bound at module
|
|
51
|
+
* load: the extension/hook loader's `withHostGuard` transiently swaps
|
|
52
|
+
* `process.reallyExit`/`process.exit` for a stub that throws
|
|
53
|
+
* `ExtensionExitError`, and the shipped bundle defers this module's evaluation
|
|
54
|
+
* until first access — which can land inside that guard window, so binding at
|
|
55
|
+
* init could freeze the throwing stub forever and turn every later shutdown
|
|
56
|
+
* (SIGHUP/SIGINT/fatal) into an unhandled-rejection loop (#7393). When the
|
|
57
|
+
* guard is active the stub carries the native exit under
|
|
58
|
+
* {@link NATIVE_PROCESS_EXIT}; unwrapping it lets a mid-guard signal still exit
|
|
59
|
+
* (#6488). Otherwise the current `process.reallyExit`/`process.exit` is native.
|
|
60
|
+
*/
|
|
61
|
+
function exitProcess(code: number): never {
|
|
62
|
+
const current: HardExitFn = typeof process.reallyExit === "function" ? process.reallyExit : process.exit;
|
|
63
|
+
const behind = Reflect.get(current, NATIVE_PROCESS_EXIT);
|
|
64
|
+
const nativeExit = typeof behind === "function" ? (behind as HardExitFn) : current;
|
|
65
|
+
return nativeExit.call(process, code) as never;
|
|
66
|
+
}
|
|
34
67
|
let cleanupPromise: Promise<void> | undefined;
|
|
35
68
|
let stdioDisconnectRegistrations = 0;
|
|
36
69
|
|