@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 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
@@ -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 CommandCtor {
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, CommandCtor>;
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. Receives fully-populated config. */
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.
@@ -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. */
@@ -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 {};
@@ -5,6 +5,7 @@ export * from "./color.js";
5
5
  export * from "./dirs.js";
6
6
  export * from "./env.js";
7
7
  export * from "./fetch-retry.js";
8
+ export * from "./file-lock.js";
8
9
  export * from "./format.js";
9
10
  export * from "./frontmatter.js";
10
11
  export * from "./fs-error.js";
@@ -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",
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.4",
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 CommandCtor {
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, CommandCtor>;
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(C => C.hidden);
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, C] of visible.sort((a, b) => a[0].localeCompare(b[0]))) {
313
- lines.push(` ${name.padEnd(maxLen + 2)}${C.description ?? ""}`);
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[], Cmd: CommandCtor): void {
354
- const argDefs = Cmd.args ?? {};
355
- const flagDefs = Cmd.flags ?? {};
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 (Cmd.examples && Cmd.examples.length > 0) {
393
+ if (command.examples && command.examples.length > 0) {
391
394
  lines.push("EXAMPLES");
392
- for (const ex of Cmd.examples) {
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. Receives fully-populated config. */
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(config);
447
+ await opts.help(await loadAllCommands(opts));
443
448
  } else {
444
- renderRootHelp(config);
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
- /** Resolve all command loaders for help/alias display. */
517
+ /** Load every command constructor for backward-compatible custom help callbacks. */
508
518
  async function loadAllCommands(opts: RunOptions): Promise<CliConfig> {
509
- const commands = new Map<string, CommandCtor>();
510
- const loaded = await Promise.all(opts.commands.map(async e => [e.name, await loadEntry(e)] as const));
511
- for (const [name, Cmd] of loaded) {
512
- commands.set(name, Cmd);
513
- }
514
- return { bin: opts.bin, version: opts.version, commands };
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
- export function $flag(name: string, def: boolean = false): boolean {
364
- const value = $env[name];
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
+ }
@@ -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
@@ -5,6 +5,7 @@ export * from "./color";
5
5
  export * from "./dirs";
6
6
  export * from "./env";
7
7
  export * from "./fetch-retry";
8
+ export * from "./file-lock";
8
9
  export * from "./format";
9
10
  export * from "./frontmatter";
10
11
  export * from "./fs-error";
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
- const exitProcess =
33
- typeof process.reallyExit === "function" ? process.reallyExit.bind(process) : process.exit.bind(process);
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