@oh-my-pi/pi-utils 12.18.3 → 12.19.2

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/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "12.18.3",
4
+ "version": "12.19.2",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
- "author": "Can Bölük",
7
+ "author": "Can Boluk",
8
8
  "license": "MIT",
9
9
  "repository": {
10
10
  "type": "git",
@@ -27,11 +27,11 @@
27
27
  "test": "bun test"
28
28
  },
29
29
  "dependencies": {
30
- "winston": "^3.19.0",
31
- "winston-daily-rotate-file": "^5.0.0"
30
+ "winston": "^3.19",
31
+ "winston-daily-rotate-file": "^5.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@types/bun": "^1.3.9"
34
+ "@types/bun": "^1.3"
35
35
  },
36
36
  "engines": {
37
37
  "bun": ">=1.3.7"
package/src/async.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Wrap a promise with a timeout and optional abort signal.
3
+ * Rejects with the given message if the timeout fires first.
4
+ * Cleans up all listeners on settlement.
5
+ */
6
+ export function withTimeout<T>(promise: Promise<T>, ms: number, message: string, signal?: AbortSignal): Promise<T> {
7
+ if (signal?.aborted) {
8
+ const reason = signal.reason instanceof Error ? signal.reason : new Error("Aborted");
9
+ return Promise.reject(reason);
10
+ }
11
+
12
+ const { promise: wrapped, resolve, reject } = Promise.withResolvers<T>();
13
+ let settled = false;
14
+ const timeoutId = setTimeout(() => {
15
+ if (settled) return;
16
+ settled = true;
17
+ if (signal) signal.removeEventListener("abort", onAbort);
18
+ reject(new Error(message));
19
+ }, ms);
20
+
21
+ const onAbort = () => {
22
+ if (settled) return;
23
+ settled = true;
24
+ clearTimeout(timeoutId);
25
+ reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted"));
26
+ };
27
+
28
+ if (signal) {
29
+ signal.addEventListener("abort", onAbort, { once: true });
30
+ }
31
+
32
+ promise.then(
33
+ value => {
34
+ if (settled) return;
35
+ settled = true;
36
+ clearTimeout(timeoutId);
37
+ if (signal) signal.removeEventListener("abort", onAbort);
38
+ resolve(value);
39
+ },
40
+ err => {
41
+ if (settled) return;
42
+ settled = true;
43
+ clearTimeout(timeoutId);
44
+ if (signal) signal.removeEventListener("abort", onAbort);
45
+ reject(err);
46
+ },
47
+ );
48
+
49
+ return wrapped;
50
+ }
package/src/color.ts CHANGED
@@ -3,10 +3,9 @@
3
3
  *
4
4
  * @example
5
5
  * ```ts
6
- * import { shiftHue, hexToHsv, hsvToHex } from "@oh-my-pi/pi-utils";
6
+ * import { hexToHsv, hsvToHex } from "@oh-my-pi/pi-utils";
7
7
  *
8
- * // Shift green toward blue for colorblind accessibility
9
- * const blue = shiftHue("#4ade80", 90); // ~90° shift
8
+ * // Work with HSV directly
10
9
  *
11
10
  * // Or work with HSV directly
12
11
  * const hsv = hexToHsv("#4ade80");
@@ -160,16 +159,6 @@ export function hsvToHex(hsv: HSV): string {
160
159
 
161
160
  /**
162
161
  * Shift the hue of a hex color by a given number of degrees.
163
- *
164
- * @param hex - Hex color string (#RGB or #RRGGBB)
165
- * @param degrees - Degrees to shift (positive = toward blue, negative = toward red)
166
- * @returns New hex color string
167
- *
168
- * @example
169
- * ```ts
170
- * // Shift green 90° toward blue (for colorblind accessibility)
171
- * shiftHue("#4ade80", 90) // Returns a cyan/blue color
172
- * ```
173
162
  */
174
163
  export function shiftHue(hex: string, degrees: number): string {
175
164
  const hsv = hexToHsv(hex);
@@ -177,7 +166,6 @@ export function shiftHue(hex: string, degrees: number): string {
177
166
  if (hsv.h < 0) hsv.h += 360;
178
167
  return hsvToHex(hsv);
179
168
  }
180
-
181
169
  export interface HSVAdjustment {
182
170
  /** Hue shift in degrees (additive) */
183
171
  h?: number;
package/src/format.ts ADDED
@@ -0,0 +1,106 @@
1
+ const SEC = 1_000;
2
+ const MIN = 60 * SEC;
3
+ const HOUR = 60 * MIN;
4
+ const DAY = 24 * HOUR;
5
+
6
+ /**
7
+ * Format a duration in milliseconds to a short human-readable string.
8
+ * Examples: "123ms", "1.5s", "30m15s", "2h30m", "3d2h"
9
+ */
10
+ export function formatDuration(ms: number): string {
11
+ if (ms < SEC) return `${ms}ms`;
12
+ if (ms < MIN) return `${(ms / SEC).toFixed(1)}s`;
13
+ if (ms < HOUR) {
14
+ const mins = Math.floor(ms / MIN);
15
+ const secs = Math.floor((ms % MIN) / SEC);
16
+ return secs > 0 ? `${mins}m${secs}s` : `${mins}m`;
17
+ }
18
+ if (ms < DAY) {
19
+ const hours = Math.floor(ms / HOUR);
20
+ const mins = Math.floor((ms % HOUR) / MIN);
21
+ return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
22
+ }
23
+ const days = Math.floor(ms / DAY);
24
+ const hours = Math.floor((ms % DAY) / HOUR);
25
+ return hours > 0 ? `${days}d${hours}h` : `${days}d`;
26
+ }
27
+
28
+ /**
29
+ * Format a number with K/M/B suffix for compact display.
30
+ * Uses 1 decimal for small leading digits, rounded otherwise.
31
+ * Examples: "999", "1.5K", "25K", "1.5M", "25M", "1.5B"
32
+ */
33
+ export function formatNumber(n: number): string {
34
+ if (n < 1_000) return n.toString();
35
+ if (n < 10_000) return `${(n / 1_000).toFixed(1)}K`;
36
+ if (n < 1_000_000) return `${Math.round(n / 1_000)}K`;
37
+ if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
38
+ if (n < 1_000_000_000) return `${Math.round(n / 1_000_000)}M`;
39
+ if (n < 10_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
40
+ return `${Math.round(n / 1_000_000_000)}B`;
41
+ }
42
+
43
+ /**
44
+ * Format a byte count to a human-readable string.
45
+ * Examples: "512B", "1.5KB", "2.3MB", "1.2GB"
46
+ */
47
+ export function formatBytes(bytes: number): string {
48
+ if (bytes < 1024) return `${bytes}B`;
49
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
50
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
51
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
52
+ }
53
+
54
+ /**
55
+ * Truncate a string to maxLen characters, appending an ellipsis if truncated.
56
+ * For display-width-aware truncation (terminals), use truncateToWidth from @oh-my-pi/pi-tui.
57
+ */
58
+ export function truncate(str: string, maxLen: number, ellipsis = "…"): string {
59
+ if (str.length <= maxLen) return str;
60
+ const sliceLen = Math.max(0, maxLen - ellipsis.length);
61
+ return `${str.slice(0, sliceLen)}${ellipsis}`;
62
+ }
63
+
64
+ /**
65
+ * Format count with pluralized label (e.g., "3 files", "1 error").
66
+ */
67
+ export function formatCount(label: string, count: number): string {
68
+ const safeCount = Number.isFinite(count) ? count : 0;
69
+ return `${safeCount} ${pluralize(label, safeCount)}`;
70
+ }
71
+
72
+ /**
73
+ * Format age from seconds to human-readable string.
74
+ */
75
+ export function formatAge(ageSeconds: number | null | undefined): string {
76
+ if (!ageSeconds) return "";
77
+ const mins = Math.floor(ageSeconds / 60);
78
+ const hours = Math.floor(mins / 60);
79
+ const days = Math.floor(hours / 24);
80
+ const weeks = Math.floor(days / 7);
81
+ const months = Math.floor(days / 30);
82
+
83
+ if (months > 0) return `${months}mo ago`;
84
+ if (weeks > 0) return `${weeks}w ago`;
85
+ if (days > 0) return `${days}d ago`;
86
+ if (hours > 0) return `${hours}h ago`;
87
+ if (mins > 0) return `${mins}m ago`;
88
+ return "just now";
89
+ }
90
+
91
+ /**
92
+ * Pluralize a label based on the count.
93
+ */
94
+ export function pluralize(label: string, count: number): string {
95
+ if (count === 1) return label;
96
+ if (/(?:ch|sh|s|x|z)$/i.test(label)) return `${label}es`;
97
+ if (/[^aeiou]y$/i.test(label)) return `${label.slice(0, -1)}ies`;
98
+ return `${label}s`;
99
+ }
100
+
101
+ /**
102
+ * Format a ratio as a percentage.
103
+ */
104
+ export function formatPercent(ratio: number): string {
105
+ return `${(ratio * 100).toFixed(1)}%`;
106
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from "./abortable";
2
+ export * from "./async";
2
3
  export * from "./color";
3
4
  export * from "./env";
5
+ export * from "./format";
4
6
  export * from "./fs-error";
5
7
  export * from "./glob";
6
8
  export * as logger from "./logger";
@@ -12,3 +14,4 @@ export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
12
14
  export { Snowflake } from "./snowflake";
13
15
  export * from "./stream";
14
16
  export * from "./temp";
17
+ export * from "./type-guards";
package/src/procmgr.ts CHANGED
@@ -50,7 +50,7 @@ function buildSpawnEnv(shell: string): Record<string, string> {
50
50
  */
51
51
  function getShellArgs(): string[] {
52
52
  const noLogin = $env.PI_BASH_NO_LOGIN || $env.CLAUDE_BASH_NO_LOGIN;
53
- return noLogin ? ["-l", "-c"] : ["-l", "-c"];
53
+ return noLogin ? ["-c"] : ["-l", "-c"];
54
54
  }
55
55
 
56
56
  /**
package/src/stream.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  import { createAbortableStream } from "./abortable";
2
2
 
3
3
  const LF = 0x0a;
4
- const CR = 0x0d;
5
- const decoder = new TextDecoder();
6
-
7
4
  type JsonlChunkResult = {
8
5
  values: unknown[];
9
6
  error: unknown;
@@ -11,107 +8,17 @@ type JsonlChunkResult = {
11
8
  done: boolean;
12
9
  };
13
10
 
14
- function hasBunJsonlParseChunk(): boolean {
15
- return typeof Bun !== "undefined" && typeof Bun.JSONL !== "undefined" && typeof Bun.JSONL.parseChunk === "function";
16
- }
17
-
18
- function parseJsonLine(lineBytes: Uint8Array): unknown {
19
- let end = lineBytes.length;
20
- if (end > 0 && lineBytes[end - 1] === CR) {
21
- end--;
22
- }
23
- const text = decoder.decode(lineBytes.subarray(0, end)).trim();
24
- if (text.length === 0) return undefined;
25
- return JSON.parse(text);
26
- }
27
-
28
- function parseJsonlChunkFallbackBytes(bytes: Uint8Array, beg = 0, end = bytes.length): JsonlChunkResult {
29
- const values: unknown[] = [];
30
- let lineStart = beg;
31
-
32
- for (let i = beg; i < end; i++) {
33
- if (bytes[i] !== LF) continue;
34
- const line = bytes.subarray(lineStart, i);
35
- try {
36
- const parsed = parseJsonLine(line);
37
- if (parsed !== undefined) values.push(parsed);
38
- } catch (error) {
39
- return { values, error, read: lineStart, done: false };
40
- }
41
- lineStart = i + 1;
42
- }
43
-
44
- if (lineStart >= end) {
45
- return { values, error: null, read: end, done: true };
46
- }
47
-
48
- const tail = bytes.subarray(lineStart, end);
49
- const tailText = decoder.decode(tail).trim();
50
- if (tailText.length === 0) {
51
- return { values, error: null, read: end, done: true };
52
- }
53
- try {
54
- values.push(JSON.parse(tailText));
55
- return { values, error: null, read: end, done: true };
56
- } catch {
57
- // In streaming mode this is usually a partial line/object.
58
- return { values, error: null, read: lineStart, done: false };
59
- }
60
- }
61
-
62
- function parseJsonlChunkFallbackString(buffer: string): JsonlChunkResult {
63
- const values: unknown[] = [];
64
- let lineStart = 0;
65
-
66
- for (let i = 0; i < buffer.length; i++) {
67
- if (buffer.charCodeAt(i) !== LF) continue;
68
- const rawLine = buffer.slice(lineStart, i);
69
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
70
- const trimmed = line.trim();
71
- if (trimmed.length > 0) {
72
- try {
73
- values.push(JSON.parse(trimmed));
74
- } catch (error) {
75
- return { values, error, read: lineStart, done: false };
76
- }
77
- }
78
- lineStart = i + 1;
79
- }
80
-
81
- if (lineStart >= buffer.length) {
82
- return { values, error: null, read: buffer.length, done: true };
83
- }
84
-
85
- const tail = buffer.slice(lineStart).trim();
86
- if (tail.length === 0) {
87
- return { values, error: null, read: buffer.length, done: true };
88
- }
89
- try {
90
- values.push(JSON.parse(tail));
91
- return { values, error: null, read: buffer.length, done: true };
92
- } catch {
93
- return { values, error: null, read: lineStart, done: false };
94
- }
95
- }
96
-
97
11
  function parseJsonlChunkCompat(input: Uint8Array, beg?: number, end?: number): JsonlChunkResult;
98
12
  function parseJsonlChunkCompat(input: string): JsonlChunkResult;
99
13
  function parseJsonlChunkCompat(input: Uint8Array | string, beg?: number, end?: number): JsonlChunkResult {
100
- if (hasBunJsonlParseChunk()) {
101
- if (typeof input === "string") {
102
- const { values, error, read, done } = Bun.JSONL.parseChunk(input);
103
- return { values, error, read, done };
104
- }
105
- const start = beg ?? 0;
106
- const stop = end ?? input.length;
107
- const { values, error, read, done } = Bun.JSONL.parseChunk(input, start, stop);
108
- return { values, error, read, done };
109
- }
110
-
111
14
  if (typeof input === "string") {
112
- return parseJsonlChunkFallbackString(input);
15
+ const { values, error, read, done } = Bun.JSONL.parseChunk(input);
16
+ return { values, error, read, done };
113
17
  }
114
- return parseJsonlChunkFallbackBytes(input, beg, end);
18
+ const start = beg ?? 0;
19
+ const stop = end ?? input.length;
20
+ const { values, error, read, done } = Bun.JSONL.parseChunk(input, start, stop);
21
+ return { values, error, read, done };
115
22
  }
116
23
 
117
24
  export async function* readLines(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<Uint8Array> {
@@ -0,0 +1,11 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return !!value && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+
5
+ export function asRecord(value: unknown): Record<string, unknown> | null {
6
+ return isRecord(value) ? value : null;
7
+ }
8
+
9
+ export function toError(value: unknown): Error {
10
+ return value instanceof Error ? value : new Error(String(value));
11
+ }