@oh-my-pi/pi-utils 18.11.3 → 18.12.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 CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.12.0] - 2026-10-17
6
+
7
+ ### Added
8
+
9
+ - Added `sanitizeTextDeep()`, which applies `sanitizeText()` to every string in a JSON-shaped value without mutating it ([#15963](https://github.com/can1357/oh-my-pi/issues/15963))
10
+
11
+ ### Fixed
12
+
13
+ - Fixed Windows archive extraction overwriting existing files or following existing symlinks when copying a symlink entry without symlink privileges ([#15931](https://github.com/can1357/oh-my-pi/pull/15931) by [@jchanghong023](https://github.com/jchanghong023)).
14
+
5
15
  ## [18.11.3] - 2026-10-16
6
16
 
7
17
  ### Fixed
@@ -12,6 +12,17 @@
12
12
  * string after the control probe.
13
13
  */
14
14
  export declare function sanitizeText(text: string): string;
15
+ /**
16
+ * Apply {@link sanitizeText} to every string value inside a JSON-shaped value.
17
+ * Only plain objects and arrays are walked; class instances, typed arrays, and
18
+ * cyclic back-references pass through untouched. Object keys are kept as-is.
19
+ *
20
+ * Returns the input reference when nothing changed, so reference-keyed caches
21
+ * stay warm; changed containers are copied and the input is never mutated.
22
+ * The iterative walk also handles arbitrarily deep tool-result `details`
23
+ * without exhausting the TUI's call stack.
24
+ */
25
+ export declare function sanitizeTextDeep(value: unknown): unknown;
15
26
  /**
16
27
  * Escape the three XML-significant characters (`&`, `<`, `>`) in text destined
17
28
  * for an XML/markup element body. Allocation-conscious: returns the input
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.11.3",
3
+ "version": "18.12.1",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -57,7 +57,7 @@
57
57
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
58
58
  },
59
59
  "dependencies": {
60
- "@oh-my-pi/pi-natives": "18.11.3"
60
+ "@oh-my-pi/pi-natives": "18.12.1"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
package/src/ar/open.ts CHANGED
@@ -234,7 +234,8 @@ export async function extractArchive(
234
234
  // privilege; skip the entry so the rest still extracts.
235
235
  const extracted = await archive.readFile(link.path).catch(() => undefined);
236
236
  if (!extracted) continue;
237
- await Bun.write(outputPath, extracted.bytes);
237
+ // Keep symlink creation's exclusive destination semantics.
238
+ await fs.writeFile(outputPath, extracted.bytes, { flag: "wx" });
238
239
  const permissions = (extracted.mode ?? 0) & 0o777;
239
240
  if (permissions) await fs.chmod(outputPath, permissions);
240
241
  }
@@ -416,7 +416,11 @@ function wrapNetworkError(error: unknown): Error {
416
416
  return new Error("Request was aborted");
417
417
  }
418
418
  if (error.message === "fetch failed" && error.cause instanceof Error) {
419
- return new Error(`Network error: ${error.cause.message}`);
419
+ // Keep the original rejection as `cause`: undici nests the errno under
420
+ // `fetch failed` (often through an AggregateError), and
421
+ // classifyNetworkCause (packages/ai/src/error/network-cause.ts) walks
422
+ // `cause`/`errors` off this error. Flattening to text alone loses it.
423
+ return new Error(`Network error: ${error.cause.message}`, { cause: error });
420
424
  }
421
425
  return error;
422
426
  }
@@ -12,6 +12,8 @@
12
12
  * string after the control probe.
13
13
  */
14
14
 
15
+ import { isRecord } from "./type-guards";
16
+
15
17
  const ESC_CHAR = "\x1b";
16
18
 
17
19
  // Well-formed strings only need control/ANSI detection: C0 (excl. \t \n),
@@ -37,6 +39,74 @@ function sanitizeWellFormedText(text: string): string {
37
39
  return stripped.replace(CONTROL_RE, "");
38
40
  }
39
41
 
42
+ /**
43
+ * Apply {@link sanitizeText} to every string value inside a JSON-shaped value.
44
+ * Only plain objects and arrays are walked; class instances, typed arrays, and
45
+ * cyclic back-references pass through untouched. Object keys are kept as-is.
46
+ *
47
+ * Returns the input reference when nothing changed, so reference-keyed caches
48
+ * stay warm; changed containers are copied and the input is never mutated.
49
+ * The iterative walk also handles arbitrarily deep tool-result `details`
50
+ * without exhausting the TUI's call stack.
51
+ */
52
+ export function sanitizeTextDeep(value: unknown): unknown {
53
+ if (typeof value === "string") return sanitizeText(value);
54
+ const root = sanitizerFrame(value);
55
+ if (!root) return value;
56
+ const stack: SanitizerFrame[] = [root];
57
+ const ancestors = new Set<object>([root.value]);
58
+ while (true) {
59
+ const frame = stack[stack.length - 1]!;
60
+ const length = frame.kind === "array" ? frame.value.length : frame.keys.length;
61
+ if (frame.index === length) {
62
+ const clean = frame.copy ?? frame.value;
63
+ ancestors.delete(frame.value);
64
+ stack.pop();
65
+ if (stack.length === 0) return clean;
66
+ acceptSanitizedValue(stack[stack.length - 1]!, clean);
67
+ continue;
68
+ }
69
+ const child = frame.kind === "array" ? frame.value[frame.index++] : frame.value[frame.keys[frame.index++]!];
70
+ if (typeof child === "string") {
71
+ acceptSanitizedValue(frame, sanitizeText(child));
72
+ continue;
73
+ }
74
+ if (child === null || typeof child !== "object" || ancestors.has(child)) continue;
75
+ const next = sanitizerFrame(child);
76
+ if (!next) continue;
77
+ ancestors.add(child);
78
+ stack.push(next);
79
+ }
80
+ }
81
+
82
+ type SanitizerFrame =
83
+ | { kind: "array"; value: unknown[]; index: number; copy?: unknown[] }
84
+ | { kind: "record"; value: Record<string, unknown>; keys: string[]; index: number; copy?: Record<string, unknown> };
85
+
86
+ function sanitizerFrame(value: unknown): SanitizerFrame | undefined {
87
+ if (Array.isArray(value)) return { kind: "array", value, index: 0 };
88
+ if (!isRecord(value)) return undefined;
89
+ const proto: unknown = Object.getPrototypeOf(value);
90
+ if (proto !== Object.prototype && proto !== null) return undefined;
91
+ return { kind: "record", value, keys: Object.keys(value), index: 0 };
92
+ }
93
+
94
+ function acceptSanitizedValue(frame: SanitizerFrame, clean: unknown): void {
95
+ if (frame.kind === "array") {
96
+ const index = frame.index - 1;
97
+ if (clean !== frame.value[index]) (frame.copy ??= frame.value.slice())[index] = clean;
98
+ return;
99
+ }
100
+ const key = frame.keys[frame.index - 1]!;
101
+ if (clean === frame.value[key]) return;
102
+ const copy = (frame.copy ??= { ...frame.value });
103
+ if (key === "__proto__") {
104
+ Object.defineProperty(copy, key, { value: clean, writable: true, enumerable: true, configurable: true });
105
+ } else {
106
+ copy[key] = clean;
107
+ }
108
+ }
109
+
40
110
  /**
41
111
  * Escape the three XML-significant characters (`&`, `<`, `>`) in text destined
42
112
  * for an XML/markup element body. Allocation-conscious: returns the input