@oh-my-pi/pi-utils 18.2.4 → 18.2.5

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,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.2.5] - 2026-09-17
6
+
7
+ ### Added
8
+
9
+ - Added utilities for reading dotenv-sourced environment values, customizing filtered child-shell environment values, converting color palettes to RGB, cleaning trailing spaces from YAML block headers, and counting newlines in text.
10
+
11
+ ### Fixed
12
+
13
+ - Improved rotating file logging performance by reusing an append file descriptor for each active log file.
14
+ - Improved JSON serialization performance by avoiding unnecessary bigint handling when serializing values without bigints.
15
+ - Fixed `$which` cache collisions for lookups using different PATH or working-directory options.
16
+ - SSE token reads now expose raw wire-line data only when explicitly requested; the default token path no longer includes per-line raw slices.
17
+
5
18
  ## [18.2.3] - 2026-09-17
6
19
 
7
20
  ### Fixed
@@ -82,6 +82,8 @@ export declare function adjustHsv(hex: string, adj: HSVAdjustment): string;
82
82
  * Convert HSL (h: 0-360, s: 0-1, l: 0-1) to a CSS hex string.
83
83
  */
84
84
  export declare function hslToHex(h: number, s: number, l: number): string;
85
+ /** Parse a 256-color palette index (0–255) to RGB (0..255). */
86
+ export declare function paletteToRgb(index: number): RGB | undefined;
85
87
  export interface OKLCH {
86
88
  /** Perceptual lightness (0-1) */
87
89
  l: number;
@@ -36,6 +36,8 @@ export declare function filterProcessEnv(env: Record<string, string | undefined>
36
36
  export declare function stripGitRepoLocationEnv(env: Record<string, string>, platform?: NodeJS.Platform): void;
37
37
  /** Filters process env for child shells without launch-cwd dotenv values. */
38
38
  export declare function filterChildShellEnv(env: Record<string, string | undefined>, cwd?: string): Record<string, string>;
39
+ /** Return every value defined by `cwd`'s dotenv files, plus environment values that came from them. */
40
+ export declare function getDotenvEnvValues(cwd?: string, env?: Record<string, string | undefined>): string[];
39
41
  /**
40
42
  * Parses a complete .env file with the runtime's dotenv grammar, then retains
41
43
  * only shell-identifier names and spawn-safe values before mirroring valid
@@ -14,6 +14,13 @@ export declare function formatNumber(n: number): string;
14
14
  * Examples: "512B", "1.5KB", "2.3MB", "1.2GB"
15
15
  */
16
16
  export declare function formatBytes(bytes: number): string;
17
+ /**
18
+ * Count `\n` code units via native `indexOf` — no split array, roughly an
19
+ * order of magnitude cheaper than a per-code-unit loop on multi-MiB text.
20
+ * Line-count semantics are the caller's (empty text is 0 or 1 lines
21
+ * depending on the contract).
22
+ */
23
+ export declare function countNewlines(text: string): number;
17
24
  /**
18
25
  * Truncate a string to maxLen characters, appending an ellipsis if truncated.
19
26
  * For display-width-aware truncation (terminals), use truncateToWidth from @oh-my-pi/pi-tui.
@@ -40,4 +40,5 @@ export * from "./tls-fetch.js";
40
40
  export * from "./type-guards.js";
41
41
  export * from "./version.js";
42
42
  export * from "./which.js";
43
+ export * from "./yaml-config.js";
43
44
  export declare function structuredCloneJSON<T>(value: T): T;
@@ -91,6 +91,15 @@ export declare function readSseJsonOrText<T>(stream: ReadableStream<Uint8Array>,
91
91
  export interface ServerSentEvent {
92
92
  event: string | null;
93
93
  data: string;
94
+ /**
95
+ * Decoded wire lines for this event (`event:`/`data:`/etc.), for the
96
+ * diagnostic pipeline. Populated only when the reader opts in via
97
+ * {@link ReadSseEventsOptions.captureRaw} (or attaches an `onSseEvent`
98
+ * observer to the JSON readers, which opt in automatically); otherwise
99
+ * `[]`. Direct `readSseEvents` callers that need wire text must pass
100
+ * `{ captureRaw: true }` — the field is allocation-free by default so
101
+ * the token path pays no per-frame array/slice cost.
102
+ */
94
103
  raw: string[];
95
104
  id?: string;
96
105
  retry?: number;
@@ -114,7 +123,16 @@ export interface ServerSentEvent {
114
123
  * }
115
124
  * ```
116
125
  */
117
- export declare function readSseEvents(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<ServerSentEvent>;
126
+ export interface ReadSseEventsOptions {
127
+ /**
128
+ * Capture per-line wire text into `event.raw` for the diagnostic
129
+ * pipeline (`onSseEvent` observers, raw-SSE viewer). Off by default:
130
+ * every frame otherwise pays an array allocation plus one string slice
131
+ * per line on the token path.
132
+ */
133
+ captureRaw?: boolean;
134
+ }
135
+ export declare function readSseEvents(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, options?: ReadSseEventsOptions): AsyncGenerator<ServerSentEvent>;
118
136
  /**
119
137
  * Parse a complete JSONL string, skipping malformed lines instead of throwing.
120
138
  *
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cache policy for which lookups.
3
3
  */
4
- export declare const enum WhichCachePolicy {
4
+ export declare enum WhichCachePolicy {
5
5
  /**
6
6
  * Use cached result if available.
7
7
  */
@@ -22,7 +22,7 @@ export declare const enum WhichCachePolicy {
22
22
  export interface WhichOptions extends Bun.WhichOptions {
23
23
  /**
24
24
  * Cache policy for the lookup.
25
- * Defaults to `WhichCachePolicy.Fresh`.
25
+ * Defaults to `WhichCachePolicy.Cached`.
26
26
  */
27
27
  cache?: WhichCachePolicy;
28
28
  /**
@@ -0,0 +1,2 @@
1
+ /** Serialize config YAML without Bun's trailing space on block mapping headers. */
2
+ export declare function stringifyYamlConfig(value: unknown): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.2.4",
3
+ "version": "18.2.5",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -54,7 +54,7 @@
54
54
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
55
55
  },
56
56
  "dependencies": {
57
- "@oh-my-pi/pi-natives": "18.2.4"
57
+ "@oh-my-pi/pi-natives": "18.2.5"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/bun": "^1.3.14"
package/src/color.ts CHANGED
@@ -239,7 +239,7 @@ const ANSI_16: readonly (readonly [number, number, number])[] = [
239
239
  const CUBE_STEPS = [0, 95, 135, 175, 215, 255] as const;
240
240
 
241
241
  /** Parse a 256-color palette index (0–255) to RGB (0..255). */
242
- function paletteToRgb(index: number): RGB | undefined {
242
+ export function paletteToRgb(index: number): RGB | undefined {
243
243
  if (!Number.isInteger(index) || index < 0 || index > 255) return undefined;
244
244
  if (index < 16) {
245
245
  const rgb = ANSI_16[index];
package/src/env.ts CHANGED
@@ -150,10 +150,10 @@ function expandDotenvValues(values: Record<string, string>, env: Record<string,
150
150
  return expanded;
151
151
  }
152
152
 
153
- /** Filters process env for child shells without launch-cwd dotenv values. */
154
- export function filterChildShellEnv(
153
+ function filterChildShellEnvInternal(
155
154
  env: Record<string, string | undefined>,
156
- cwd: string = getProjectDir(),
155
+ cwd: string,
156
+ onDotenvValue?: (value: string) => void,
157
157
  ): Record<string, string> {
158
158
  const runtimeLaunchEnvValues = env === Bun.env || env === process.env ? launchEnvValues : undefined;
159
159
  const result = filterProcessEnv(env);
@@ -190,6 +190,12 @@ export function filterChildShellEnv(
190
190
  }
191
191
  }
192
192
  const allLaunchEnv = fallbackLaunchEnv ? { ...launchEnv, ...fallbackLaunchEnv } : launchEnv;
193
+ if (onDotenvValue) {
194
+ // Every value the project's dotenv files define is dotenv-sourced, whether
195
+ // or not this process loaded it (a `--cwd` launch never did).
196
+ for (const key in allLaunchEnv) onDotenvValue(allLaunchEnv[key]!);
197
+ for (const key in expandedLaunchEnv) onDotenvValue(expandedLaunchEnv[key]!);
198
+ }
193
199
  for (const key in allLaunchEnv) {
194
200
  const launchValue = runtimeLaunchEnvValues?.get(key);
195
201
  if (launchValue !== undefined) {
@@ -211,6 +217,8 @@ export function filterChildShellEnv(
211
217
  // Strong provenance: the launch environment is known and this name is
212
218
  // absent from it, or OMP itself injected the value — either way it came
213
219
  // from a project dotenv file, not the parent shell.
220
+ const value = result[key];
221
+ if (value !== undefined) onDotenvValue?.(value);
214
222
  delete result[key];
215
223
  } else if (
216
224
  result[key] === launchEnv[key] ||
@@ -220,6 +228,8 @@ export function filterChildShellEnv(
220
228
  ) {
221
229
  // No launch-env snapshot (dotenv autoloaded without procfs): best-effort
222
230
  // value match against the Bun-parsed dotenv.
231
+ const value = result[key];
232
+ if (value !== undefined) onDotenvValue?.(value);
223
233
  delete result[key];
224
234
  }
225
235
  }
@@ -229,6 +239,24 @@ export function filterChildShellEnv(
229
239
  return result;
230
240
  }
231
241
 
242
+ /** Filters process env for child shells without launch-cwd dotenv values. */
243
+ export function filterChildShellEnv(
244
+ env: Record<string, string | undefined>,
245
+ cwd: string = getProjectDir(),
246
+ ): Record<string, string> {
247
+ return filterChildShellEnvInternal(env, cwd);
248
+ }
249
+
250
+ /** Return every value defined by `cwd`'s dotenv files, plus environment values that came from them. */
251
+ export function getDotenvEnvValues(
252
+ cwd: string = getProjectDir(),
253
+ env: Record<string, string | undefined> = process.env,
254
+ ): string[] {
255
+ const values = new Set<string>();
256
+ filterChildShellEnvInternal(env, cwd, value => values.add(value));
257
+ return [...values];
258
+ }
259
+
232
260
  /**
233
261
  * Parses a complete .env file with the runtime's dotenv grammar, then retains
234
262
  * only shell-identifier names and spawn-safe values before mirroring valid
package/src/format.ts CHANGED
@@ -58,6 +58,22 @@ export function formatBytes(bytes: number): string {
58
58
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
59
59
  }
60
60
 
61
+ /**
62
+ * Count `\n` code units via native `indexOf` — no split array, roughly an
63
+ * order of magnitude cheaper than a per-code-unit loop on multi-MiB text.
64
+ * Line-count semantics are the caller's (empty text is 0 or 1 lines
65
+ * depending on the contract).
66
+ */
67
+ export function countNewlines(text: string): number {
68
+ let count = 0;
69
+ let pos = text.indexOf("\n");
70
+ while (pos !== -1) {
71
+ count++;
72
+ pos = text.indexOf("\n", pos + 1);
73
+ }
74
+ return count;
75
+ }
76
+
61
77
  /**
62
78
  * Truncate a string to maxLen characters, appending an ellipsis if truncated.
63
79
  * For display-width-aware truncation (terminals), use truncateToWidth from @oh-my-pi/pi-tui.
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export * from "./tls-fetch";
40
40
  export * from "./type-guards";
41
41
  export * from "./version";
42
42
  export * from "./which";
43
+ export * from "./yaml-config";
43
44
 
44
45
  function isPlainObject(val: object): val is Record<string, unknown> {
45
46
  return Object.getPrototypeOf(val) === Object.prototype || Array.isArray(val);
package/src/json.ts CHANGED
@@ -19,7 +19,18 @@ export function tryParseJson<T = unknown>(content: string): T | null {
19
19
  * only lossless JSON representation.
20
20
  */
21
21
  export function stringifyJson(value: unknown, space?: string | number): string | undefined {
22
- return JSON.stringify(value, (_key, item) => (typeof item === "bigint" ? item.toString() : item), space);
22
+ // Fast path: a replacer forces the slow stringify on every call, but
23
+ // bigint payloads are vanishingly rare. Try plain stringify first and
24
+ // retry with the coercing replacer only on TypeError (the only error a
25
+ // bigint raises). A TypeError from elsewhere (circular value, throwing
26
+ // toJSON) recurs on the retry and surfaces from that second walk, so
27
+ // stateful serializers ahead of a bigint run twice on that path.
28
+ try {
29
+ return JSON.stringify(value, undefined, space);
30
+ } catch (error) {
31
+ if (!(error instanceof TypeError)) throw error;
32
+ return JSON.stringify(value, (_key, item) => (typeof item === "bigint" ? item.toString() : item), space);
33
+ }
23
34
  }
24
35
 
25
36
  function stableJsonClone(value: unknown): unknown {
@@ -47,6 +47,12 @@ export class RotatingFileSink {
47
47
  #activePath: string | undefined;
48
48
  #activeBytes = 0;
49
49
  #closed = false;
50
+ // Held append fd: the old code did open+write+close per line
51
+ // (appendFileSync) plus a throwaway open in the constructor. A single fd
52
+ // per active file removes two syscalls per log line; it is reopened on
53
+ // rotation and closed on close()/rotation (required for Windows
54
+ // delete-on-prune semantics).
55
+ #fd: number | undefined;
50
56
 
51
57
  constructor(options: RotatingFileOptions) {
52
58
  this.#directory = options.directory;
@@ -61,26 +67,57 @@ export class RotatingFileSink {
61
67
  const activePath = this.#activePath;
62
68
  if (activePath) {
63
69
  this.#registerFile(activePath, now.getTime());
64
- fs.closeSync(fs.openSync(activePath, "a"));
70
+ this.#openFd(activePath);
71
+ }
72
+ }
73
+
74
+ #openFd(filePath: string): void {
75
+ this.#closeFd();
76
+ this.#fd = fs.openSync(filePath, "a");
77
+ }
78
+
79
+ #closeFd(): void {
80
+ if (this.#fd !== undefined) {
81
+ try {
82
+ fs.closeSync(this.#fd);
83
+ } catch {
84
+ // Best-effort: the fd may already be invalid after rotation.
85
+ }
86
+ this.#fd = undefined;
65
87
  }
66
88
  }
67
89
 
68
90
  /** Append one already-formatted log record. */
69
91
  write(line: string): void {
70
92
  if (this.#closed) return;
93
+ const prevPath = this.#activePath;
71
94
  const now = new Date();
72
95
  this.#selectFile(this.#localDay(now));
73
96
  const activePath = this.#activePath;
74
97
  if (!activePath) return;
98
+ // Rotation moved the active path: close the old descriptor BEFORE
99
+ // registering the new path, because registration prunes beyond
100
+ // maxFiles — on Windows the pruned predecessor cannot be deleted
101
+ // while still open, which would leak it (audit entry removed, file
102
+ // left on disk, retention unbounded).
103
+ if (activePath !== prevPath) this.#closeFd();
75
104
  this.#registerFile(activePath, now.getTime());
105
+ if (this.#fd === undefined) this.#openFd(activePath);
76
106
  const record = `${line}${os.EOL}`;
77
- fs.appendFileSync(activePath, record, "utf8");
78
- this.#activeBytes += Buffer.byteLength(record);
107
+ const buf = Buffer.from(record, "utf8");
108
+ let off = 0;
109
+ while (off < buf.length) {
110
+ const written = fs.writeSync(this.#fd!, buf, off);
111
+ if (written <= 0) break;
112
+ off += written;
113
+ }
114
+ this.#activeBytes += buf.length;
79
115
  }
80
116
 
81
117
  /** Stop accepting records. Synchronous writes require no drain phase. */
82
118
  close(): void {
83
119
  this.#closed = true;
120
+ this.#closeFd();
84
121
  }
85
122
 
86
123
  #localDay(date: Date): string {
package/src/stream.ts CHANGED
@@ -300,7 +300,9 @@ async function* readSseFrames<T>(
300
300
  signal?: AbortSignal,
301
301
  onEvent?: SseEventObserver,
302
302
  ): AsyncGenerator<SseFrame<T>> {
303
- for await (const sse of readSseEvents(stream, signal)) {
303
+ // The diagnostic observer is the only reader of `raw`; capture it exactly
304
+ // when one is attached so the hot path stays allocation-free.
305
+ for await (const sse of readSseEvents(stream, signal, onEvent ? { captureRaw: true } : undefined)) {
304
306
  const isTrailing = trailingEvents.has(sse);
305
307
  notifySseEventObserver(onEvent, sse);
306
308
  const data = sse.data;
@@ -379,6 +381,15 @@ export async function* readSseJsonOrText<T>(
379
381
  export interface ServerSentEvent {
380
382
  event: string | null;
381
383
  data: string;
384
+ /**
385
+ * Decoded wire lines for this event (`event:`/`data:`/etc.), for the
386
+ * diagnostic pipeline. Populated only when the reader opts in via
387
+ * {@link ReadSseEventsOptions.captureRaw} (or attaches an `onSseEvent`
388
+ * observer to the JSON readers, which opt in automatically); otherwise
389
+ * `[]`. Direct `readSseEvents` callers that need wire text must pass
390
+ * `{ captureRaw: true }` — the field is allocation-free by default so
391
+ * the token path pays no per-frame array/slice cost.
392
+ */
382
393
  raw: string[];
383
394
  id?: string;
384
395
  retry?: number;
@@ -391,7 +402,10 @@ interface SseEventState {
391
402
  // of buffering an array and joining at flush. `null` means "no data: field
392
403
  // seen yet" (distinct from a `data:` field with an empty value).
393
404
  data: string | null;
394
- raw: string[];
405
+ // Diagnostic wire lines, captured only when a reader asked for them (see
406
+ // `readSseEventsOptions.captureRaw`): per-frame array+slice allocation on
407
+ // the token path otherwise. `null` means capture is off.
408
+ raw: string[] | null;
395
409
  id?: string;
396
410
  retry?: number;
397
411
  }
@@ -402,19 +416,19 @@ const SSE_DECODER = new TextDecoder("utf-8");
402
416
 
403
417
  function flushSseEvent(state: SseEventState): ServerSentEvent | null {
404
418
  if (state.event === null && state.data === null && state.id === undefined && state.retry === undefined) {
405
- state.raw = [];
419
+ if (state.raw !== null) state.raw = [];
406
420
  return null;
407
421
  }
408
422
  const event: ServerSentEvent = {
409
423
  event: state.event,
410
424
  data: state.data ?? "",
411
- raw: state.raw,
425
+ raw: state.raw ?? [],
412
426
  };
413
427
  if (state.id !== undefined) event.id = state.id;
414
428
  if (state.retry !== undefined) event.retry = state.retry;
415
429
  state.event = null;
416
430
  state.data = null;
417
- state.raw = [];
431
+ if (state.raw !== null) state.raw = [];
418
432
  state.id = undefined;
419
433
  state.retry = undefined;
420
434
  return event;
@@ -425,11 +439,11 @@ function pushSseLine(line: string, state: SseEventState): ServerSentEvent | null
425
439
 
426
440
  // Comment line: keep in `raw` for diagnostic context, skip parsing.
427
441
  if (line.charCodeAt(0) === 0x3a /* ':' */) {
428
- state.raw.push(line);
442
+ state.raw?.push(line);
429
443
  return null;
430
444
  }
431
445
 
432
- state.raw.push(line);
446
+ state.raw?.push(line);
433
447
 
434
448
  const colon = line.indexOf(":");
435
449
  const fieldName = colon === -1 ? line : line.slice(0, colon);
@@ -483,12 +497,24 @@ function pushSseLine(line: string, state: SseEventState): ServerSentEvent | null
483
497
  * }
484
498
  * ```
485
499
  */
500
+ export interface ReadSseEventsOptions {
501
+ /**
502
+ * Capture per-line wire text into `event.raw` for the diagnostic
503
+ * pipeline (`onSseEvent` observers, raw-SSE viewer). Off by default:
504
+ * every frame otherwise pays an array allocation plus one string slice
505
+ * per line on the token path.
506
+ */
507
+ captureRaw?: boolean;
508
+ }
509
+
486
510
  export async function* readSseEvents(
487
511
  stream: ReadableStream<Uint8Array>,
488
512
  signal?: AbortSignal,
513
+ options?: ReadSseEventsOptions,
489
514
  ): AsyncGenerator<ServerSentEvent> {
490
515
  const lineBuffer = new ConcatSink();
491
- const state: SseEventState = { event: null, data: null, raw: [] };
516
+ const captureRaw = options?.captureRaw === true;
517
+ const state: SseEventState = { event: null, data: null, raw: captureRaw ? [] : null };
492
518
  const source = abortableSource(stream, signal);
493
519
  try {
494
520
  for await (const chunk of source) {
package/src/which.ts CHANGED
@@ -13,8 +13,6 @@ import * as os from "node:os";
13
13
  import * as path from "node:path";
14
14
  import { isFullyQualifiedPath } from "./path";
15
15
 
16
- type CacheKey = string | bigint | number;
17
-
18
16
  // Tools shipped by Xcode / Command Line Tools that callers actually look up.
19
17
  // Keeps the set small so darwinWhich can fast-reject non-Xcode commands without
20
18
  // touching the filesystem. Only needs entries for binaries that live *exclusively*
@@ -149,12 +147,12 @@ function getMacosToolPaths(): Map<string, string> {
149
147
  }
150
148
 
151
149
  // Map: cache key -> resolved binary path or null (not found)
152
- const toolCache = new Map<CacheKey, string | null>();
150
+ const toolCache = new Map<string, string | null>();
153
151
 
154
152
  /**
155
153
  * Cache policy for which lookups.
156
154
  */
157
- export const enum WhichCachePolicy {
155
+ export enum WhichCachePolicy {
158
156
  /**
159
157
  * Use cached result if available.
160
158
  */
@@ -177,7 +175,7 @@ export const enum WhichCachePolicy {
177
175
  export interface WhichOptions extends Bun.WhichOptions {
178
176
  /**
179
177
  * Cache policy for the lookup.
180
- * Defaults to `WhichCachePolicy.Fresh`.
178
+ * Defaults to `WhichCachePolicy.Cached`.
181
179
  */
182
180
  cache?: WhichCachePolicy;
183
181
  /**
@@ -216,14 +214,14 @@ export const whichFresh =
216
214
  ? darwinWhich
217
215
  : (command: string, options?: Bun.WhichOptions): string | null => Bun.which(command, options);
218
216
 
219
- // Derive stable cache key from command and lookup options
220
- function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey {
217
+ // Length-prefixed (command, cwd, PATH) tuple: exact, unlike the 64-bit hash
218
+ // chain it replaced, and the length prefixes keep `("ab", "c")` and
219
+ // `("a", "bc")` distinct without a separator that cwd/PATH could contain.
220
+ function cacheKey(command: string, options?: Bun.WhichOptions): string {
221
221
  if (!options) return command;
222
- if (!options.cwd && !options.PATH) return command;
223
- let h = Bun.hash(command);
224
- if (options.cwd) h = Bun.hash(options.cwd, h);
225
- if (options.PATH) h = Bun.hash(options.PATH, h);
226
- return h;
222
+ const cwd = options?.cwd ?? "";
223
+ const binPath = options?.PATH ?? "";
224
+ return `${command.length}:${command}${cwd.length}:${cwd}${binPath.length}:${binPath}`;
227
225
  }
228
226
 
229
227
  /**
@@ -244,7 +242,7 @@ export function $which(command: string, options?: WhichOptions): string | null {
244
242
  lookupOptions = { ...lookupOptions, PATH: safePath };
245
243
  }
246
244
 
247
- let key: CacheKey | undefined;
245
+ let key: string | undefined;
248
246
 
249
247
  if (cachePolicy !== WhichCachePolicy.Bypass) {
250
248
  key = cacheKey(command, lookupOptions);
@@ -0,0 +1,8 @@
1
+ import { YAML } from "bun";
2
+
3
+ const YAML_MAPPING_HEADER_TRAILING_SPACE = /: +$/gm;
4
+
5
+ /** Serialize config YAML without Bun's trailing space on block mapping headers. */
6
+ export function stringifyYamlConfig(value: unknown): string {
7
+ return YAML.stringify(value, null, 2).replace(YAML_MAPPING_HEADER_TRAILING_SPACE, ":");
8
+ }