@oh-my-pi/pi-utils 15.12.4 → 15.13.1
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 +20 -2
- package/README.md +1 -1
- package/dist/types/dirs.d.ts +49 -0
- package/dist/types/env.d.ts +1 -4
- package/dist/types/fetch-retry.d.ts +8 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/loop-phase.d.ts +10 -0
- package/dist/types/runtime-install.d.ts +3 -0
- package/dist/types/worker-host.d.ts +4 -0
- package/package.json +2 -2
- package/src/dirs.ts +267 -15
- package/src/env.ts +10 -21
- package/src/fetch-retry.ts +8 -0
- package/src/index.ts +1 -0
- package/src/loop-phase.ts +49 -0
- package/src/runtime-install.ts +24 -1
- package/src/temp.ts +56 -3
- package/src/worker-host.ts +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.13.1] - 2026-06-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added profile-aware directory helpers and isolated profile state roots, while keeping the install ID shared across profiles.
|
|
10
|
+
- Added a named-profile API to the `dirs` module — `setProfile()`, `getActiveProfile()`, `getProfileRootDir()`, and `normalizeProfileName()` — plus `resolveProfileEnv()`, which selects the active profile from `OMP_PROFILE` (canonical; takes precedence) then `PI_PROFILE` (legacy fallback, consulted only when `OMP_PROFILE` is unset).
|
|
11
|
+
- Added support for a runtime `overrides` map in `RuntimeInstallSpec`, which is now written into generated runtime `package.json` manifests to force dependency pins (including transitive ones) across the runtime tree
|
|
12
|
+
- Added a lightweight loop-phase breadcrumb stack (`pushLoopPhase`/`popLoopPhase`/`currentLoopPhase`, plus `takeRecentLoopPhase` which returns the live phase or the most recently popped one and clears it) so the TUI event-loop watchdog can attribute a main-thread block to the phase that caused it — including a synchronous phase already popped before the watchdog's delayed tick runs ([#2485](https://github.com/can1357/oh-my-pi/issues/2485))
|
|
13
|
+
- Added `FetchWithRetryOptions.timeout` (forwarded to the underlying `fetch` call). `false` disables Bun's native ~300s pre-response timeout; a positive number overrides the ceiling. Bare browser/Node fetch ignores it ([#2422](https://github.com/can1357/oh-my-pi/issues/2422))
|
|
14
|
+
- Added the side-effect-free `@oh-my-pi/pi-utils/worker-host` module (`declareWorkerHostEntry()` / `workerHostEntry()`), extracted from `env` (still re-exported there) so worker spawn sites can resolve the self-dispatching CLI host entry without importing `env`'s side-effecting module graph.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Fixed profile directory isolation when a profile's agent `.env` customizes directory roots: directory-affecting keys (`XDG_DATA_HOME`/`XDG_STATE_HOME`/`XDG_CACHE_HOME`, and a default-mode `PI_CODING_AGENT_DIR`) are now honored. The `env` loader rebuilds the `dirs` resolver after applying `.env` files (`refreshDirsFromEnv()`), so a profile `.env` that points XDG roots elsewhere no longer leaks state into the home-based config dir.
|
|
19
|
+
- Made `TempDir` cleanup retry transient Windows `EBUSY`/`EPERM`/`ENOTEMPTY` removal failures so tests are less likely to fail when deleting just-used temp directories.
|
|
20
|
+
- Fixed `installRuntimeModuleResolver()` to keep bare requests from runtime-cache modules inside that registered runtime before falling back to host/workspace packages.
|
|
21
|
+
|
|
5
22
|
## [15.12.4] - 2026-06-13
|
|
6
23
|
|
|
7
24
|
### Fixed
|
|
@@ -44,6 +61,7 @@
|
|
|
44
61
|
- Fixed the `{{join}}` prompt helper joining with a literal two-character `\n` when templates pass `"\n"` as the separator — Handlebars string literals carry no escape processing. The separator now unescapes `\n`/`\t`, matching the `{{#list}}` helper's documented convention (visible as literal `\n` between paths in compaction `<read-files>` lists).
|
|
45
62
|
|
|
46
63
|
## [15.10.11] - 2026-06-10
|
|
64
|
+
|
|
47
65
|
### Added
|
|
48
66
|
|
|
49
67
|
- Restored `PI_DEBUG_STARTUP` streaming startup markers: `logger.time` now writes a synchronous `[startup] <op>:start` / `:done` / `:fail` stderr line per phase (independent of `PI_TIMING`), so a startup that hangs hard still names the phase it is stuck in — the `PI_TIMING` tree only prints after startup completes and is structurally unable to diagnose a hang. The CLI runner emits `cli:load:<name>` markers around each lazily-imported command module for the same reason.
|
|
@@ -64,6 +82,7 @@
|
|
|
64
82
|
- `omp <cmd> --help` now loads only the requested command module instead of the entire command table, so an unrelated command whose import graph hangs or crashes can no longer take down every per-command help invocation.
|
|
65
83
|
|
|
66
84
|
## [15.10.8] - 2026-06-09
|
|
85
|
+
|
|
67
86
|
### Removed
|
|
68
87
|
|
|
69
88
|
- Removed the exported `hookFetch` API, which previously intercepted `globalThis.fetch` via middleware handlers
|
|
@@ -94,7 +113,6 @@
|
|
|
94
113
|
|
|
95
114
|
- Added color helpers `colorLuma` (perceptual luma), `relativeLuminance` (WCAG, linearized sRGB), and `hslToHex` to the color utilities. The luminance helpers parse `#rgb`/`#rrggbb` hex and 256-color palette indices, returning `undefined` for unparseable values.
|
|
96
115
|
- Added `peekFileEnds`, a single-open head-and-tail file peek helper that reuses the head bytes for the tail when the file fits the head window.
|
|
97
|
-
|
|
98
116
|
- Added `peekFileTail`, the tail mirror of `peekFile`: reads up to the last `maxBytes` of a file ending at EOF, reusing the same pooled-buffer strategy (no per-call allocation for small reads).
|
|
99
117
|
|
|
100
118
|
## [15.7.3] - 2026-05-31
|
|
@@ -111,4 +129,4 @@
|
|
|
111
129
|
|
|
112
130
|
### Added
|
|
113
131
|
|
|
114
|
-
- Added an XDG-aware tiny-title model cache directory helper for coding-agent local title models.
|
|
132
|
+
- Added an XDG-aware tiny-title model cache directory helper for coding-agent local title models.
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Shared utilities for [oh-my-pi](https://github.com/can1357/oh-my-pi) packages. Z
|
|
|
15
15
|
| `which` | `$which()` binary lookup with caching |
|
|
16
16
|
| `fetch-retry` | `fetch` with retry/backoff policies |
|
|
17
17
|
| `fs-error` | Errno guards (`isEnoent` and friends) |
|
|
18
|
-
| `env` | Environment plumbing
|
|
18
|
+
| `env` / `worker-host` | Environment plumbing and side-effect-free worker-host entry contract (`workerHostEntry`) |
|
|
19
19
|
| `abortable` / `async` | AbortSignal-aware stream/promise helpers |
|
|
20
20
|
| `peek-file` | Read the first N bytes of a file with pooled buffers |
|
|
21
21
|
| `frontmatter`, `glob`, `mime`, `temp`, `format`, `color`, `snowflake`, `tab-spacing`, `path-tree`, `sanitize-text` | Smaller single-purpose helpers |
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -18,6 +18,24 @@ export declare const CONFIG_DIR_NAME: string;
|
|
|
18
18
|
export declare const VERSION: string;
|
|
19
19
|
/** Minimum Bun version */
|
|
20
20
|
export declare const MIN_BUN_VERSION: string;
|
|
21
|
+
/**
|
|
22
|
+
* Normalize and validate a profile name. Returns `undefined` for the implicit
|
|
23
|
+
* default (empty string, whitespace, or the explicit "default" sentinel) and
|
|
24
|
+
* throws for syntactically invalid or platform-reserved names.
|
|
25
|
+
*
|
|
26
|
+
* Exported so consumers of `@oh-my-pi/pi-utils/dirs` (CLI bootstrap, tests,
|
|
27
|
+
* downstream tools) can validate user input without re-deriving the rules.
|
|
28
|
+
*/
|
|
29
|
+
export declare function normalizeProfileName(profile: string | undefined): string | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the active profile from the two profile env vars. `OMP_PROFILE` is the
|
|
32
|
+
* canonical variable and takes precedence; `PI_PROFILE` is the legacy
|
|
33
|
+
* compatibility fallback, consulted only when `OMP_PROFILE` is undefined. An
|
|
34
|
+
* explicitly-empty `OMP_PROFILE` therefore selects the default profile rather
|
|
35
|
+
* than silently inheriting `PI_PROFILE`. Delegates validation/normalization to
|
|
36
|
+
* {@link normalizeProfileName} (which throws on a syntactically invalid value).
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveProfileEnv(omp: string | undefined, pi: string | undefined): string | undefined;
|
|
21
39
|
export declare function resolveEquivalentPath(inputPath: string): string;
|
|
22
40
|
export declare function normalizePathForComparison(inputPath: string): string;
|
|
23
41
|
export declare function pathIsWithin(root: string, candidate: string): boolean;
|
|
@@ -30,10 +48,36 @@ export declare function setProjectDir(dir: string): void;
|
|
|
30
48
|
export declare function getConfigDirName(): string;
|
|
31
49
|
/** Get the config agent directory name relative to home (e.g. ".omp/agent" or PI_CONFIG_DIR + "/agent"). */
|
|
32
50
|
export declare function getConfigAgentDirName(): string;
|
|
51
|
+
/**
|
|
52
|
+
* Rebuild the dirs resolver from the current environment, reusing the profile
|
|
53
|
+
* resolved at module load. Directory-affecting keys (XDG_*_HOME and, in default
|
|
54
|
+
* mode, `PI_CODING_AGENT_DIR`) loaded from a profile/agent `.env` only reach
|
|
55
|
+
* `process.env` *after* this module froze the resolver at import time, so
|
|
56
|
+
* `env.ts` calls this once after applying its `.env` files. The agent `.env`
|
|
57
|
+
* location derives from the profile name + home before this runs, so the
|
|
58
|
+
* rebuild re-reads only the directory vars, never the profile selection. The
|
|
59
|
+
* `preProfileAgentDirEnv` snapshot is intentionally left untouched.
|
|
60
|
+
*/
|
|
61
|
+
export declare function refreshDirsFromEnv(): void;
|
|
33
62
|
/** Get the config root directory (~/.omp). */
|
|
34
63
|
export declare function getConfigRootDir(): string;
|
|
35
64
|
/** Set the coding agent directory. Creates a fresh resolver, invalidating all cached paths. */
|
|
36
65
|
export declare function setAgentDir(dir: string): void;
|
|
66
|
+
/**
|
|
67
|
+
* Test-only: reset the pre-profile `PI_CODING_AGENT_DIR` snapshot to whatever
|
|
68
|
+
* the current environment looks like. Cross-suite test pollution can otherwise
|
|
69
|
+
* leak a stale snapshot through `setAgentDir` and corrupt `setProfile(undefined)`
|
|
70
|
+
* restore semantics. Production code MUST NOT call this — the snapshot's
|
|
71
|
+
* lifecycle is owned by `setAgentDir` / `setProfile` and a runtime caller has
|
|
72
|
+
* no business clearing it.
|
|
73
|
+
*/
|
|
74
|
+
export declare function __resetProfileSnapshotForTests(): void;
|
|
75
|
+
/** Activate a named profile. Passing undefined or "default" returns to the default profile. */
|
|
76
|
+
export declare function setProfile(profile: string | undefined): void;
|
|
77
|
+
/** Get the active named profile. Undefined means the default profile. */
|
|
78
|
+
export declare function getActiveProfile(): string | undefined;
|
|
79
|
+
/** Resolve the config root that backs a profile without activating it. */
|
|
80
|
+
export declare function getProfileRootDir(profile: string | undefined): string;
|
|
37
81
|
/** Get the agent config directory (~/.omp/agent). */
|
|
38
82
|
export declare function getAgentDir(): string;
|
|
39
83
|
/** Get the project-local config directory (.omp). */
|
|
@@ -167,6 +211,11 @@ export declare function getSSHConfigPath(scope: "user" | "project", cwd?: string
|
|
|
167
211
|
* winner's id). Survives independently of agent state: deleting
|
|
168
212
|
* `~/.omp/agent/` does not regenerate it. Server-side dedup for grievance
|
|
169
213
|
* pushes (and similar telemetry) keys on this id.
|
|
214
|
+
*
|
|
215
|
+
* Anchored to the base config root (`~/.omp/install-id`) regardless of the
|
|
216
|
+
* active profile: install identity is per-install, not per-profile, so every
|
|
217
|
+
* profile shares one id and the global cache stays correct no matter the
|
|
218
|
+
* profile / `getInstallId` call order.
|
|
170
219
|
*/
|
|
171
220
|
export declare function getInstallId(): string;
|
|
172
221
|
/** Test-only: clear cached install id. Never call from production code. */
|
package/dist/types/env.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from "./worker-host";
|
|
1
2
|
/**
|
|
2
3
|
* Strict shell-identifier shape. Used for dotenv keys we accept into
|
|
3
4
|
* `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
|
|
@@ -53,8 +54,4 @@ export declare function isBunTestRuntime(): boolean;
|
|
|
53
54
|
* first for cheap fast-path detection.
|
|
54
55
|
*/
|
|
55
56
|
export declare function isCompiledBinary(): boolean;
|
|
56
|
-
/** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
|
|
57
|
-
export declare function declareWorkerHostEntry(): void;
|
|
58
|
-
/** Main-module path of the self-dispatching CLI host, or null outside it. */
|
|
59
|
-
export declare function workerHostEntry(): string | null;
|
|
60
57
|
export declare function $flag(name: string, def?: boolean): boolean;
|
|
@@ -43,6 +43,14 @@ export interface FetchWithRetryOptions extends RequestInit {
|
|
|
43
43
|
* mock during tests.
|
|
44
44
|
*/
|
|
45
45
|
fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
46
|
+
/**
|
|
47
|
+
* Bun extension forwarded verbatim to the underlying `fetch` call. `false`
|
|
48
|
+
* disables Bun's native ~300s pre-response timeout (callers that own a
|
|
49
|
+
* configurable first-event/idle watchdog or an external `AbortSignal`
|
|
50
|
+
* supply this so the runtime ceiling cannot pre-empt them); a positive
|
|
51
|
+
* number sets a custom ceiling in ms. Bare browser/Node fetch ignores it.
|
|
52
|
+
*/
|
|
53
|
+
timeout?: number | false;
|
|
46
54
|
}
|
|
47
55
|
/**
|
|
48
56
|
* Fetch with bounded retries and sensible defaults. Retries on any
|
package/dist/types/index.d.ts
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function pushLoopPhase(label: string): void;
|
|
2
|
+
export declare function popLoopPhase(): void;
|
|
3
|
+
export declare function currentLoopPhase(): string | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* Phase to blame for a just-detected loop block: the live top phase if one is
|
|
6
|
+
* still held, else the most recent phase pushed since the last call. Clears the
|
|
7
|
+
* recent slot so a block in a later, phase-less interval is not misattributed
|
|
8
|
+
* to a phase that already finished.
|
|
9
|
+
*/
|
|
10
|
+
export declare function takeRecentLoopPhase(): string | undefined;
|
|
@@ -43,6 +43,8 @@ export declare function installRuntimeModuleResolver({ runtimeNodeModules, stubs
|
|
|
43
43
|
/** Pinned dependency set materialized into a runtime cache directory. */
|
|
44
44
|
export interface RuntimeInstallSpec {
|
|
45
45
|
dependencies: Record<string, string>;
|
|
46
|
+
/** Version pins forced across the whole runtime tree (bun `overrides`), e.g. dislodging a transitive dep. */
|
|
47
|
+
overrides?: Record<string, string>;
|
|
46
48
|
/** Packages whose lifecycle scripts bun may run during the install. */
|
|
47
49
|
trustedDependencies?: string[];
|
|
48
50
|
}
|
|
@@ -58,6 +60,7 @@ export interface EnsureRuntimeInstalledOptions {
|
|
|
58
60
|
lockAttempts?: number;
|
|
59
61
|
lockSleepMs?: number;
|
|
60
62
|
}
|
|
63
|
+
export declare function writeRuntimeManifest(runtimeDir: string, install: RuntimeInstallSpec): Promise<void>;
|
|
61
64
|
/**
|
|
62
65
|
* Materialize a pinned dependency set into `runtimeDir` (idempotent,
|
|
63
66
|
* cross-process safe via a lock directory). Returns `runtimeDir`.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
|
|
2
|
+
export declare function declareWorkerHostEntry(): void;
|
|
3
|
+
/** Main-module path of the self-dispatching CLI host, or null outside it. */
|
|
4
|
+
export declare function workerHostEntry(): string | null;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-utils",
|
|
4
|
-
"version": "15.
|
|
4
|
+
"version": "15.13.1",
|
|
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": "15.
|
|
34
|
+
"@oh-my-pi/pi-natives": "15.13.1",
|
|
35
35
|
"beautiful-mermaid": "^1.1.3",
|
|
36
36
|
"handlebars": "^4.7.9",
|
|
37
37
|
"winston": "^3.19.0",
|
package/src/dirs.ts
CHANGED
|
@@ -28,6 +28,102 @@ export const VERSION: string = version;
|
|
|
28
28
|
/** Minimum Bun version */
|
|
29
29
|
export const MIN_BUN_VERSION: string = engines.bun.replace(/[^0-9.]/g, "");
|
|
30
30
|
|
|
31
|
+
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
32
|
+
const PROFILE_ENV_KEYS = ["OMP_PROFILE", "PI_PROFILE"] as const;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Names Windows treats as reserved device aliases. Matches the basename
|
|
36
|
+
* itself as well as any `BASENAME.<anything>` form, because Windows reserves
|
|
37
|
+
* `CON.foo`/`PRN.txt`/etc. too — using them as a profile name would let
|
|
38
|
+
* `setProfile` accept the input only for directory creation to fail later
|
|
39
|
+
* with a confusing `ENOENT`/`EINVAL`. Case-insensitive: NTFS treats `CON`
|
|
40
|
+
* and `con` identically.
|
|
41
|
+
*/
|
|
42
|
+
const WINDOWS_RESERVED_BASENAME_RE = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Normalize and validate a profile name. Returns `undefined` for the implicit
|
|
46
|
+
* default (empty string, whitespace, or the explicit "default" sentinel) and
|
|
47
|
+
* throws for syntactically invalid or platform-reserved names.
|
|
48
|
+
*
|
|
49
|
+
* Exported so consumers of `@oh-my-pi/pi-utils/dirs` (CLI bootstrap, tests,
|
|
50
|
+
* downstream tools) can validate user input without re-deriving the rules.
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeProfileName(profile: string | undefined): string | undefined {
|
|
53
|
+
const normalized = profile?.trim();
|
|
54
|
+
if (!normalized || normalized === "default") return undefined;
|
|
55
|
+
if (
|
|
56
|
+
normalized === "." ||
|
|
57
|
+
normalized === ".." ||
|
|
58
|
+
normalized.endsWith(".") ||
|
|
59
|
+
!PROFILE_NAME_RE.test(normalized) ||
|
|
60
|
+
WINDOWS_RESERVED_BASENAME_RE.test(normalized)
|
|
61
|
+
) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Invalid OMP profile "${profile}". Profile names must match ${PROFILE_NAME_RE.source}, ` +
|
|
64
|
+
`cannot be "." or "..", cannot end with ".", and cannot be a Windows reserved device name ` +
|
|
65
|
+
`(CON, PRN, AUX, NUL, COM0-9, LPT0-9, or any of those with an extension).`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return normalized;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the active profile from the two profile env vars. `OMP_PROFILE` is the
|
|
73
|
+
* canonical variable and takes precedence; `PI_PROFILE` is the legacy
|
|
74
|
+
* compatibility fallback, consulted only when `OMP_PROFILE` is undefined. An
|
|
75
|
+
* explicitly-empty `OMP_PROFILE` therefore selects the default profile rather
|
|
76
|
+
* than silently inheriting `PI_PROFILE`. Delegates validation/normalization to
|
|
77
|
+
* {@link normalizeProfileName} (which throws on a syntactically invalid value).
|
|
78
|
+
*/
|
|
79
|
+
export function resolveProfileEnv(omp: string | undefined, pi: string | undefined): string | undefined {
|
|
80
|
+
return normalizeProfileName(omp !== undefined ? omp : pi);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getProfileFromEnv(): string | undefined {
|
|
84
|
+
return resolveProfileEnv(process.env.OMP_PROFILE, process.env.PI_PROFILE);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Module-load profile resolution. Unlike {@link getProfileFromEnv}, an invalid
|
|
89
|
+
* OMP_PROFILE/PI_PROFILE value does NOT throw here — a bad env var must not
|
|
90
|
+
* crash a bare `import` of this module with an uncaught stack trace before the
|
|
91
|
+
* CLI's error handling is in scope. The default profile is used instead; the
|
|
92
|
+
* CLI re-validates the env (see `runCli` in coding-agent/src/cli.ts) so the
|
|
93
|
+
* user still gets a clean "Invalid OMP profile" message.
|
|
94
|
+
*/
|
|
95
|
+
function readProfileFromEnvSafe(): string | undefined {
|
|
96
|
+
try {
|
|
97
|
+
return getProfileFromEnv();
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getBaseConfigRoot(): string {
|
|
104
|
+
return path.join(os.homedir(), getConfigDirName());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getProfileConfigRoot(profile: string | undefined): string {
|
|
108
|
+
const root = getBaseConfigRoot();
|
|
109
|
+
return profile ? path.join(root, "profiles", profile) : root;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readPiProfileFromEnvSafe(): string | undefined {
|
|
113
|
+
try {
|
|
114
|
+
return normalizeProfileName(process.env.PI_PROFILE);
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getProfileAgentDir(profile: string): string {
|
|
121
|
+
return path.join(getProfileConfigRoot(profile), "agent");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isProfileDerivedAgentDir(profile: string | undefined, agentDirEnv: string | undefined): boolean {
|
|
125
|
+
return profile !== undefined && agentDirEnv === getProfileAgentDir(profile);
|
|
126
|
+
}
|
|
31
127
|
// =============================================================================
|
|
32
128
|
// Project directory
|
|
33
129
|
// =============================================================================
|
|
@@ -96,7 +192,8 @@ export function getConfigDirName(): string {
|
|
|
96
192
|
|
|
97
193
|
/** Get the config agent directory name relative to home (e.g. ".omp/agent" or PI_CONFIG_DIR + "/agent"). */
|
|
98
194
|
export function getConfigAgentDirName(): string {
|
|
99
|
-
|
|
195
|
+
const profile = getActiveProfile();
|
|
196
|
+
return profile ? path.join(getConfigDirName(), "profiles", profile, "agent") : `${getConfigDirName()}/agent`;
|
|
100
197
|
}
|
|
101
198
|
|
|
102
199
|
// =============================================================================
|
|
@@ -123,29 +220,47 @@ class DirResolver {
|
|
|
123
220
|
readonly #rootCache = new Map<string, string>();
|
|
124
221
|
readonly #agentCache = new Map<string, string>();
|
|
125
222
|
|
|
126
|
-
constructor(agentDirOverride?: string) {
|
|
127
|
-
|
|
223
|
+
constructor(options: { agentDirOverride?: string; profile?: string } = {}) {
|
|
224
|
+
const profile = normalizeProfileName(options.profile);
|
|
225
|
+
this.configRoot = getProfileConfigRoot(profile);
|
|
128
226
|
|
|
129
227
|
const defaultAgent = path.join(this.configRoot, "agent");
|
|
228
|
+
const agentDirOverride = profile ? undefined : options.agentDirOverride;
|
|
130
229
|
this.agentDir = agentDirOverride ? path.resolve(agentDirOverride) : defaultAgent;
|
|
131
230
|
const isDefault = this.agentDir === defaultAgent;
|
|
132
231
|
|
|
133
|
-
// XDG is a Linux convention. On
|
|
134
|
-
//
|
|
232
|
+
// XDG is a Linux convention. On supported platforms, default profile state
|
|
233
|
+
// resolves under $XDG_*_HOME/omp once `omp config init-xdg` has migrated
|
|
234
|
+
// the user's data. Named profiles follow a stricter rule: the XDG choice
|
|
235
|
+
// is keyed on the profile-specific XDG path, never the base app root.
|
|
236
|
+
//
|
|
237
|
+
// Why: if we consulted the base app root for named profiles too, the same
|
|
238
|
+
// profile could resolve to `~/.omp/profiles/<name>` on first activation
|
|
239
|
+
// (when no $XDG_*_HOME/omp exists yet) and then silently move to
|
|
240
|
+
// `$XDG_*_HOME/omp/profiles/<name>` the moment the base appeared, orphaning
|
|
241
|
+
// the earlier state. Pinning on the profile path means a profile's location
|
|
242
|
+
// is decided at first activation and stays put until the user explicitly
|
|
243
|
+
// migrates it (e.g. by mkdir'ing the XDG profile dir).
|
|
135
244
|
let xdgData: string | undefined;
|
|
136
245
|
let xdgState: string | undefined;
|
|
137
246
|
let xdgCache: string | undefined;
|
|
138
247
|
if ((process.platform === "linux" || process.platform === "darwin") && isDefault) {
|
|
139
248
|
const resolveIf = (envVar: string) => {
|
|
140
249
|
const value = process.env[envVar];
|
|
141
|
-
if (value)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
250
|
+
if (!value) return undefined;
|
|
251
|
+
try {
|
|
252
|
+
const appRoot = path.join(value, APP_NAME);
|
|
253
|
+
if (profile) {
|
|
254
|
+
const profilePath = path.join(appRoot, "profiles", profile);
|
|
255
|
+
if (fs.existsSync(profilePath)) {
|
|
256
|
+
return profilePath;
|
|
146
257
|
}
|
|
147
|
-
|
|
148
|
-
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
if (fs.existsSync(appRoot)) {
|
|
261
|
+
return appRoot;
|
|
262
|
+
}
|
|
263
|
+
} catch {}
|
|
149
264
|
return undefined;
|
|
150
265
|
};
|
|
151
266
|
xdgData = resolveIf("XDG_DATA_HOME");
|
|
@@ -190,14 +305,82 @@ class DirResolver {
|
|
|
190
305
|
}
|
|
191
306
|
}
|
|
192
307
|
|
|
193
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Decide which `PI_CODING_AGENT_DIR` value to capture as the pre-profile
|
|
310
|
+
* baseline. A value equal to a profile's derived agent dir is profile-derived
|
|
311
|
+
* (propagated by a parent's `setProfile`), so it must NOT be snapshotted as the
|
|
312
|
+
* default-mode baseline — otherwise default mode would resolve to the profile's
|
|
313
|
+
* agent dir. The profile source can be the active profile or a lower-priority
|
|
314
|
+
* `PI_PROFILE` that was bypassed because `OMP_PROFILE` explicitly selected the
|
|
315
|
+
* default profile. Returns `undefined` in those cases so reset falls back to the
|
|
316
|
+
* standard `~/.omp/agent`.
|
|
317
|
+
*/
|
|
318
|
+
function resolvePreProfileAgentDir(
|
|
319
|
+
profile: string | undefined,
|
|
320
|
+
agentDirEnv: string | undefined,
|
|
321
|
+
profileAgentDirSource: string | undefined = profile,
|
|
322
|
+
): string | undefined {
|
|
323
|
+
return isProfileDerivedAgentDir(profile ?? profileAgentDirSource, agentDirEnv) ? undefined : agentDirEnv;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let activeProfile = readProfileFromEnvSafe();
|
|
194
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Resolve the agent-dir override for the current `activeProfile` from the live
|
|
330
|
+
* environment. A named profile derives its own agent dir (no override); default
|
|
331
|
+
* mode honors a non-profile `PI_CODING_AGENT_DIR` (see
|
|
332
|
+
* {@link resolvePreProfileAgentDir}). Shared by the module-load resolver and
|
|
333
|
+
* {@link refreshDirsFromEnv} so both apply identical logic.
|
|
334
|
+
*/
|
|
335
|
+
function resolveActiveAgentDirOverride(): string | undefined {
|
|
336
|
+
return activeProfile
|
|
337
|
+
? undefined
|
|
338
|
+
: resolvePreProfileAgentDir(undefined, process.env.PI_CODING_AGENT_DIR, readPiProfileFromEnvSafe());
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let dirs = new DirResolver({
|
|
342
|
+
agentDirOverride: resolveActiveAgentDirOverride(),
|
|
343
|
+
profile: activeProfile,
|
|
344
|
+
});
|
|
345
|
+
/**
|
|
346
|
+
* Snapshot of `PI_CODING_AGENT_DIR` from before the first named-profile
|
|
347
|
+
* activation. Reset paths restore this value (or its absence) instead of
|
|
348
|
+
* unconditionally deleting the env var. Without the snapshot, a process started
|
|
349
|
+
* with `PI_CODING_AGENT_DIR=/custom` then `setProfile("work")` then
|
|
350
|
+
* `setProfile(undefined)` would silently lose `/custom` and fall back to
|
|
351
|
+
* `~/.omp/agent`. Captured at module load — ignoring a profile-derived value
|
|
352
|
+
* inherited from a parent's `setProfile` (see {@link resolvePreProfileAgentDir})
|
|
353
|
+
* — and refreshed on `setAgentDir`, since that call is the user explicitly
|
|
354
|
+
* redefining the baseline.
|
|
355
|
+
*/
|
|
356
|
+
let preProfileAgentDirEnv: string | undefined = resolvePreProfileAgentDir(
|
|
357
|
+
activeProfile,
|
|
358
|
+
process.env.PI_CODING_AGENT_DIR,
|
|
359
|
+
activeProfile ?? readPiProfileFromEnvSafe(),
|
|
360
|
+
);
|
|
195
361
|
// Anchor home for the resolver. Captured at module load to stay stable across
|
|
196
362
|
// test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so
|
|
197
363
|
// production callers (`home === RESOLVER_HOME`) hit the XDG-aware resolver while
|
|
198
364
|
// tests passing a temp HOME short-circuit to a deterministic path.
|
|
199
365
|
const RESOLVER_HOME = os.homedir();
|
|
200
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Rebuild the dirs resolver from the current environment, reusing the profile
|
|
369
|
+
* resolved at module load. Directory-affecting keys (XDG_*_HOME and, in default
|
|
370
|
+
* mode, `PI_CODING_AGENT_DIR`) loaded from a profile/agent `.env` only reach
|
|
371
|
+
* `process.env` *after* this module froze the resolver at import time, so
|
|
372
|
+
* `env.ts` calls this once after applying its `.env` files. The agent `.env`
|
|
373
|
+
* location derives from the profile name + home before this runs, so the
|
|
374
|
+
* rebuild re-reads only the directory vars, never the profile selection. The
|
|
375
|
+
* `preProfileAgentDirEnv` snapshot is intentionally left untouched.
|
|
376
|
+
*/
|
|
377
|
+
export function refreshDirsFromEnv(): void {
|
|
378
|
+
dirs = new DirResolver({
|
|
379
|
+
agentDirOverride: resolveActiveAgentDirOverride(),
|
|
380
|
+
profile: activeProfile,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
201
384
|
// =============================================================================
|
|
202
385
|
// Root directories
|
|
203
386
|
// =============================================================================
|
|
@@ -209,10 +392,74 @@ export function getConfigRootDir(): string {
|
|
|
209
392
|
|
|
210
393
|
/** Set the coding agent directory. Creates a fresh resolver, invalidating all cached paths. */
|
|
211
394
|
export function setAgentDir(dir: string): void {
|
|
212
|
-
|
|
395
|
+
activeProfile = undefined;
|
|
396
|
+
dirs = new DirResolver({ agentDirOverride: dir });
|
|
213
397
|
process.env.PI_CODING_AGENT_DIR = dir;
|
|
398
|
+
preProfileAgentDirEnv = dir;
|
|
399
|
+
for (const key of PROFILE_ENV_KEYS) {
|
|
400
|
+
delete process.env[key];
|
|
401
|
+
}
|
|
214
402
|
}
|
|
215
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Test-only: reset the pre-profile `PI_CODING_AGENT_DIR` snapshot to whatever
|
|
406
|
+
* the current environment looks like. Cross-suite test pollution can otherwise
|
|
407
|
+
* leak a stale snapshot through `setAgentDir` and corrupt `setProfile(undefined)`
|
|
408
|
+
* restore semantics. Production code MUST NOT call this — the snapshot's
|
|
409
|
+
* lifecycle is owned by `setAgentDir` / `setProfile` and a runtime caller has
|
|
410
|
+
* no business clearing it.
|
|
411
|
+
*/
|
|
412
|
+
export function __resetProfileSnapshotForTests(): void {
|
|
413
|
+
preProfileAgentDirEnv = resolvePreProfileAgentDir(
|
|
414
|
+
activeProfile,
|
|
415
|
+
process.env.PI_CODING_AGENT_DIR,
|
|
416
|
+
activeProfile ?? readPiProfileFromEnvSafe(),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Activate a named profile. Passing undefined or "default" returns to the default profile. */
|
|
421
|
+
export function setProfile(profile: string | undefined): void {
|
|
422
|
+
const next = normalizeProfileName(profile);
|
|
423
|
+
if (next && !activeProfile) {
|
|
424
|
+
// First activation of a named profile in this process: snapshot the
|
|
425
|
+
// current PI_CODING_AGENT_DIR so a later reset can restore the user's
|
|
426
|
+
// explicit override. Subsequent profile switches keep the original
|
|
427
|
+
// snapshot — the "pre-profile" baseline is the state before profiles
|
|
428
|
+
// entered the picture, not the state between two activations.
|
|
429
|
+
preProfileAgentDirEnv = resolvePreProfileAgentDir(
|
|
430
|
+
undefined,
|
|
431
|
+
process.env.PI_CODING_AGENT_DIR,
|
|
432
|
+
readPiProfileFromEnvSafe(),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
activeProfile = next;
|
|
436
|
+
if (activeProfile) {
|
|
437
|
+
dirs = new DirResolver({ profile: activeProfile });
|
|
438
|
+
process.env.OMP_PROFILE = activeProfile;
|
|
439
|
+
process.env.PI_PROFILE = activeProfile;
|
|
440
|
+
process.env.PI_CODING_AGENT_DIR = dirs.agentDir;
|
|
441
|
+
} else {
|
|
442
|
+
for (const key of PROFILE_ENV_KEYS) {
|
|
443
|
+
delete process.env[key];
|
|
444
|
+
}
|
|
445
|
+
if (preProfileAgentDirEnv === undefined) {
|
|
446
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
447
|
+
} else {
|
|
448
|
+
process.env.PI_CODING_AGENT_DIR = preProfileAgentDirEnv;
|
|
449
|
+
}
|
|
450
|
+
dirs = new DirResolver({ agentDirOverride: preProfileAgentDirEnv });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Get the active named profile. Undefined means the default profile. */
|
|
455
|
+
export function getActiveProfile(): string | undefined {
|
|
456
|
+
return activeProfile;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Resolve the config root that backs a profile without activating it. */
|
|
460
|
+
export function getProfileRootDir(profile: string | undefined): string {
|
|
461
|
+
return getProfileConfigRoot(normalizeProfileName(profile));
|
|
462
|
+
}
|
|
216
463
|
/** Get the agent config directory (~/.omp/agent). */
|
|
217
464
|
export function getAgentDir(): string {
|
|
218
465
|
return dirs.agentDir;
|
|
@@ -534,10 +781,15 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
534
781
|
* winner's id). Survives independently of agent state: deleting
|
|
535
782
|
* `~/.omp/agent/` does not regenerate it. Server-side dedup for grievance
|
|
536
783
|
* pushes (and similar telemetry) keys on this id.
|
|
784
|
+
*
|
|
785
|
+
* Anchored to the base config root (`~/.omp/install-id`) regardless of the
|
|
786
|
+
* active profile: install identity is per-install, not per-profile, so every
|
|
787
|
+
* profile shares one id and the global cache stays correct no matter the
|
|
788
|
+
* profile / `getInstallId` call order.
|
|
537
789
|
*/
|
|
538
790
|
export function getInstallId(): string {
|
|
539
791
|
if (cachedInstallId) return cachedInstallId;
|
|
540
|
-
const filePath = path.join(
|
|
792
|
+
const filePath = path.join(getBaseConfigRoot(), INSTALL_ID_FILE);
|
|
541
793
|
|
|
542
794
|
let observedInvalid = false;
|
|
543
795
|
try {
|
package/src/env.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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 { getAgentDir, getConfigRootDir } from "./dirs";
|
|
4
|
+
import { getAgentDir, getConfigRootDir, refreshDirsFromEnv } from "./dirs";
|
|
5
|
+
|
|
6
|
+
export * from "./worker-host";
|
|
5
7
|
|
|
6
8
|
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7
9
|
|
|
@@ -117,6 +119,13 @@ for (const file of [projectEnv, agentEnv, piEnv, homeEnv]) {
|
|
|
117
119
|
}
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
// Directory-affecting keys (XDG_*_HOME, and in default mode PI_CODING_AGENT_DIR)
|
|
123
|
+
// may have just arrived from the profile/agent `.env` applied above. The dirs
|
|
124
|
+
// resolver cached its paths at module load — before this file ran — so rebuild
|
|
125
|
+
// it now from the updated env. `getAgentDir()` already located the `.env` from
|
|
126
|
+
// the profile name + home, so this re-reads only the directory vars.
|
|
127
|
+
refreshDirsFromEnv();
|
|
128
|
+
|
|
120
129
|
/**
|
|
121
130
|
* Intentional re-export of Bun.env.
|
|
122
131
|
*
|
|
@@ -172,26 +181,6 @@ export function isCompiledBinary(): boolean {
|
|
|
172
181
|
return url.includes("$bunfs") || url.includes("~BUN") || url.includes("%7EBUN");
|
|
173
182
|
}
|
|
174
183
|
|
|
175
|
-
/**
|
|
176
|
-
* Main-module path declared by self-dispatching CLI entrypoints — entries
|
|
177
|
-
* whose top-level argv handling routes hidden `__omp_*` worker selectors.
|
|
178
|
-
* Worker spawn sites re-enter this module via `new Worker(entry, { argv })`,
|
|
179
|
-
* so every distribution (source, npm bundle, compiled binary) needs exactly
|
|
180
|
-
* one JavaScript entrypoint. Never set under `bun test`, SDK embedding, or
|
|
181
|
-
* standalone package bins — those hosts load worker modules directly.
|
|
182
|
-
*/
|
|
183
|
-
let workerHostMain: string | null = null;
|
|
184
|
-
|
|
185
|
-
/** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
|
|
186
|
-
export function declareWorkerHostEntry(): void {
|
|
187
|
-
workerHostMain = Bun.main;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** Main-module path of the self-dispatching CLI host, or null outside it. */
|
|
191
|
-
export function workerHostEntry(): string | null {
|
|
192
|
-
return workerHostMain;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
184
|
const TRUTHY: Dict<boolean> = {
|
|
196
185
|
"1": true,
|
|
197
186
|
Y: true,
|
package/src/fetch-retry.ts
CHANGED
|
@@ -129,6 +129,14 @@ export interface FetchWithRetryOptions extends RequestInit {
|
|
|
129
129
|
* mock during tests.
|
|
130
130
|
*/
|
|
131
131
|
fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
132
|
+
/**
|
|
133
|
+
* Bun extension forwarded verbatim to the underlying `fetch` call. `false`
|
|
134
|
+
* disables Bun's native ~300s pre-response timeout (callers that own a
|
|
135
|
+
* configurable first-event/idle watchdog or an external `AbortSignal`
|
|
136
|
+
* supply this so the runtime ceiling cannot pre-empt them); a positive
|
|
137
|
+
* number sets a custom ceiling in ms. Bare browser/Node fetch ignores it.
|
|
138
|
+
*/
|
|
139
|
+
timeout?: number | false;
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
const DEFAULT_MAX_DELAY_MS = 60_000;
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live event-loop phase breadcrumb. Hot synchronous paths push a short label
|
|
3
|
+
* before running and pop it after (via `try`/`finally`); the loop watchdog
|
|
4
|
+
* reads {@link takeRecentLoopPhase} when it detects a block, so a stall is
|
|
5
|
+
* logged with the work that caused it instead of an opaque "unknown".
|
|
6
|
+
*
|
|
7
|
+
* This is deliberately a process-global stack and not part of the logger span
|
|
8
|
+
* machinery: `main.ts` ends timing spans before the interactive TUI starts, so
|
|
9
|
+
* `logger.openSpanPath()` is empty in a live session.
|
|
10
|
+
*
|
|
11
|
+
* Correctness constraint: each `pushLoopPhase` must be balanced by a
|
|
12
|
+
* `popLoopPhase` within the SAME synchronous execution (always via `try`/
|
|
13
|
+
* `finally`). The stack is global and shared, so a label held across an
|
|
14
|
+
* `await`/async boundary — or interleaved between concurrent tasks — would
|
|
15
|
+
* misattribute or leak phases. Instrument only synchronous spans; for async
|
|
16
|
+
* work, push/pop around each synchronous chunk, not across the await.
|
|
17
|
+
*/
|
|
18
|
+
const stack: string[] = [];
|
|
19
|
+
// The most recent label pushed, retained after it is popped. A hot path pushes
|
|
20
|
+
// and pops a phase entirely within one synchronous macrotask, so by the time
|
|
21
|
+
// the watchdog's delayed tick runs the stack is already empty; this slot keeps
|
|
22
|
+
// the culprit available for that one tick. Consumed (cleared) on read so it
|
|
23
|
+
// only attributes the just-elapsed interval.
|
|
24
|
+
let recentPhase: string | undefined;
|
|
25
|
+
|
|
26
|
+
export function pushLoopPhase(label: string): void {
|
|
27
|
+
stack.push(label);
|
|
28
|
+
recentPhase = label;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function popLoopPhase(): void {
|
|
32
|
+
stack.pop();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function currentLoopPhase(): string | undefined {
|
|
36
|
+
return stack[stack.length - 1];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Phase to blame for a just-detected loop block: the live top phase if one is
|
|
41
|
+
* still held, else the most recent phase pushed since the last call. Clears the
|
|
42
|
+
* recent slot so a block in a later, phase-less interval is not misattributed
|
|
43
|
+
* to a phase that already finished.
|
|
44
|
+
*/
|
|
45
|
+
export function takeRecentLoopPhase(): string | undefined {
|
|
46
|
+
const phase = stack[stack.length - 1] ?? recentPhase;
|
|
47
|
+
recentPhase = undefined;
|
|
48
|
+
return phase;
|
|
49
|
+
}
|
package/src/runtime-install.ts
CHANGED
|
@@ -170,6 +170,16 @@ function resolverRegistry(): ResolverRegistration[] {
|
|
|
170
170
|
holder[REGISTRY] ??= [];
|
|
171
171
|
return holder[REGISTRY];
|
|
172
172
|
}
|
|
173
|
+
function pathContains(root: string, candidate: string): boolean {
|
|
174
|
+
const relative = path.relative(root, candidate);
|
|
175
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parentFilename(parent: unknown): string | null {
|
|
179
|
+
if (!isRecord(parent)) return null;
|
|
180
|
+
const filename = parent.filename;
|
|
181
|
+
return typeof filename === "string" ? filename : null;
|
|
182
|
+
}
|
|
173
183
|
|
|
174
184
|
export interface RuntimeResolverOptions {
|
|
175
185
|
/** Absolute path to the runtime cache's `node_modules`. */
|
|
@@ -212,7 +222,17 @@ export function installRuntimeModuleResolver({ runtimeNodeModules, stubs = {} }:
|
|
|
212
222
|
}
|
|
213
223
|
const bare = !request.startsWith(".") && !request.startsWith("node:") && !path.isAbsolute(request);
|
|
214
224
|
if (bare) {
|
|
225
|
+
const parentFile = parentFilename(parent);
|
|
215
226
|
for (const registration of resolverRegistry()) {
|
|
227
|
+
const parentInRuntime = parentFile !== null && pathContains(registration.runtimeNodeModules, parentFile);
|
|
228
|
+
if (parentInRuntime) {
|
|
229
|
+
const stub = registration.stubs[request];
|
|
230
|
+
if (stub) return stub;
|
|
231
|
+
if (!stockResolved || !pathContains(registration.runtimeNodeModules, stockResolved)) {
|
|
232
|
+
const fallback = resolveRuntimeModule(registration.runtimeNodeModules, request);
|
|
233
|
+
if (fallback) return fallback;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
216
236
|
if (stockResolved) {
|
|
217
237
|
// Correct a stock hit only inside the top-level package the
|
|
218
238
|
// request names. A hit in a nested node_modules (e.g. tar's
|
|
@@ -242,6 +262,8 @@ export function installRuntimeModuleResolver({ runtimeNodeModules, stubs = {} }:
|
|
|
242
262
|
/** Pinned dependency set materialized into a runtime cache directory. */
|
|
243
263
|
export interface RuntimeInstallSpec {
|
|
244
264
|
dependencies: Record<string, string>;
|
|
265
|
+
/** Version pins forced across the whole runtime tree (bun `overrides`), e.g. dislodging a transitive dep. */
|
|
266
|
+
overrides?: Record<string, string>;
|
|
245
267
|
/** Packages whose lifecycle scripts bun may run during the install. */
|
|
246
268
|
trustedDependencies?: string[];
|
|
247
269
|
}
|
|
@@ -281,13 +303,14 @@ async function acquireInstallLock(runtimeDir: string, attempts: number, sleepMs:
|
|
|
281
303
|
throw new Error(`Timed out waiting for runtime install lock: ${lockDir}`);
|
|
282
304
|
}
|
|
283
305
|
|
|
284
|
-
async function writeRuntimeManifest(runtimeDir: string, install: RuntimeInstallSpec): Promise<void> {
|
|
306
|
+
export async function writeRuntimeManifest(runtimeDir: string, install: RuntimeInstallSpec): Promise<void> {
|
|
285
307
|
await fsp.mkdir(runtimeDir, { recursive: true });
|
|
286
308
|
const manifest: Record<string, unknown> = {
|
|
287
309
|
private: true,
|
|
288
310
|
type: "module",
|
|
289
311
|
dependencies: install.dependencies,
|
|
290
312
|
};
|
|
313
|
+
if (install.overrides && Object.keys(install.overrides).length) manifest.overrides = install.overrides;
|
|
291
314
|
if (install.trustedDependencies?.length) manifest.trustedDependencies = install.trustedDependencies;
|
|
292
315
|
await Bun.write(path.join(runtimeDir, "package.json"), `${JSON.stringify(manifest, null, "\t")}\n`);
|
|
293
316
|
}
|
package/src/temp.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as fsPromises from "node:fs/promises";
|
|
2
3
|
import * as os from "node:os";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
|
|
@@ -13,7 +14,7 @@ export class TempDir {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
static async create(prefix?: string): Promise<TempDir> {
|
|
16
|
-
return new TempDir(await
|
|
17
|
+
return new TempDir(await fsPromises.mkdtemp(normalizePrefix(prefix)));
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
#removePromise: Promise<void> | null = null;
|
|
@@ -30,13 +31,13 @@ export class TempDir {
|
|
|
30
31
|
if (this.#removePromise) {
|
|
31
32
|
return this.#removePromise;
|
|
32
33
|
}
|
|
33
|
-
const removePromise =
|
|
34
|
+
const removePromise = removeWithRetries(this.#path);
|
|
34
35
|
this.#removePromise = removePromise;
|
|
35
36
|
return removePromise;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
removeSync(): void {
|
|
39
|
-
|
|
40
|
+
removeSyncWithRetries(this.#path);
|
|
40
41
|
this.#removePromise = Promise.resolve();
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -75,3 +76,55 @@ function normalizePrefix(prefix?: string): string {
|
|
|
75
76
|
}
|
|
76
77
|
return prefix;
|
|
77
78
|
}
|
|
79
|
+
|
|
80
|
+
const kRemoveOptions = { recursive: true, force: true } as const;
|
|
81
|
+
const kRemoveRetries = 4;
|
|
82
|
+
const kRemoveRetryDelayMs = 10;
|
|
83
|
+
const kRetryableRemoveErrorCodes = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]);
|
|
84
|
+
const kSleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
85
|
+
|
|
86
|
+
async function removeWithRetries(target: string): Promise<void> {
|
|
87
|
+
for (let attempt = 0; ; attempt++) {
|
|
88
|
+
try {
|
|
89
|
+
await fsPromises.rm(target, kRemoveOptions);
|
|
90
|
+
return;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (!shouldRetryRemove(err, attempt)) throw err;
|
|
93
|
+
await Bun.sleep(kRemoveRetryDelayMs);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function removeSyncWithRetries(target: string): void {
|
|
99
|
+
for (let attempt = 0; ; attempt++) {
|
|
100
|
+
try {
|
|
101
|
+
fs.rmSync(target, kRemoveOptions);
|
|
102
|
+
return;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (!shouldRetryRemove(err, attempt)) throw err;
|
|
105
|
+
sleepSync(kRemoveRetryDelayMs);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function shouldRetryRemove(err: unknown, attempt: number): boolean {
|
|
111
|
+
return attempt < kRemoveRetries && process.platform === "win32" && isRetryableRemoveError(err);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isRetryableRemoveError(err: unknown): boolean {
|
|
115
|
+
return (
|
|
116
|
+
typeof err === "object" &&
|
|
117
|
+
err !== null &&
|
|
118
|
+
"code" in err &&
|
|
119
|
+
typeof err.code === "string" &&
|
|
120
|
+
kRetryableRemoveErrorCodes.has(err.code)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sleepSync(ms: number): void {
|
|
125
|
+
if ("sleepSync" in Bun && typeof Bun.sleepSync === "function") {
|
|
126
|
+
Bun.sleepSync(ms);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
Atomics.wait(kSleepBuffer, 0, 0, ms);
|
|
130
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main-module path declared by self-dispatching CLI entrypoints — entries
|
|
3
|
+
* whose top-level argv handling routes hidden `__omp_*` worker selectors.
|
|
4
|
+
* Worker spawn sites re-enter this module via `new Worker(entry, { argv })`,
|
|
5
|
+
* so every distribution (source, npm bundle, compiled binary) needs exactly
|
|
6
|
+
* one JavaScript entrypoint. Never set under `bun test`, SDK embedding, or
|
|
7
|
+
* standalone package bins — those hosts load worker modules directly.
|
|
8
|
+
*/
|
|
9
|
+
let workerHostMain: string | null = null;
|
|
10
|
+
|
|
11
|
+
/** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
|
|
12
|
+
export function declareWorkerHostEntry(): void {
|
|
13
|
+
workerHostMain = Bun.main;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Main-module path of the self-dispatching CLI host, or null outside it. */
|
|
17
|
+
export function workerHostEntry(): string | null {
|
|
18
|
+
return workerHostMain;
|
|
19
|
+
}
|