@f5xc-salesdemos/pi-utils 14.0.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/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 @f5xc-salesdemos/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
+ }
@@ -0,0 +1,118 @@
1
+ import { YAML } from "bun";
2
+ import { truncate } from "./format";
3
+ import * as logger from "./logger";
4
+
5
+ function stripHtmlComments(content: string): string {
6
+ return content.replace(/<!--[\s\S]*?-->/g, "");
7
+ }
8
+
9
+ /** Convert kebab-case to camelCase (e.g. "thinking-level" -> "thinkingLevel") */
10
+ function kebabToCamel(key: string): string {
11
+ return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
12
+ }
13
+
14
+ /** Recursively normalize object keys from kebab-case to camelCase */
15
+ function normalizeKeys<T>(obj: T): T {
16
+ if (obj === null || typeof obj !== "object") {
17
+ return obj;
18
+ }
19
+ if (Array.isArray(obj)) {
20
+ return obj.map(normalizeKeys) as T;
21
+ }
22
+ const result: Record<string, unknown> = {};
23
+ for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
24
+ const normalizedKey = kebabToCamel(key);
25
+ result[normalizedKey] = normalizeKeys(value);
26
+ }
27
+ return result as T;
28
+ }
29
+
30
+ export class FrontmatterError extends Error {
31
+ constructor(
32
+ error: Error,
33
+ readonly source?: unknown,
34
+ ) {
35
+ super(`Failed to parse YAML frontmatter (${source}): ${error.message}`, { cause: error });
36
+ this.name = "FrontmatterError";
37
+ }
38
+
39
+ toString(): string {
40
+ // Format the error with stack and detail, including the error message, stack, and source if present
41
+ const details: string[] = [this.message];
42
+ if (this.source !== undefined) {
43
+ details.push(`Source: ${JSON.stringify(this.source)}`);
44
+ }
45
+ if (this.cause && typeof this.cause === "object" && "stack" in this.cause && this.cause.stack) {
46
+ details.push(`Stack:\n${this.cause.stack}`);
47
+ } else if (this.stack) {
48
+ details.push(`Stack:\n${this.stack}`);
49
+ }
50
+ return details.join("\n\n");
51
+ }
52
+ }
53
+
54
+ export interface FrontmatterOptions {
55
+ /** Source of the content (alias: source) */
56
+ location?: unknown;
57
+ /** Source of the content (alias for location) */
58
+ source?: unknown;
59
+ /** Fallback frontmatter values */
60
+ fallback?: Record<string, unknown>;
61
+ /** Normalize the content */
62
+ normalize?: boolean;
63
+ /** Level of error handling */
64
+ level?: "off" | "warn" | "fatal";
65
+ }
66
+
67
+ /**
68
+ * Parse YAML frontmatter from markdown content
69
+ * Returns { frontmatter, body } where body has frontmatter stripped
70
+ */
71
+ export function parseFrontmatter(
72
+ content: string,
73
+ options?: FrontmatterOptions,
74
+ ): { frontmatter: Record<string, unknown>; body: string } {
75
+ const { location, source, fallback, normalize = true, level = "warn" } = options ?? {};
76
+ const loc = location ?? source;
77
+ const frontmatter: Record<string, unknown> = { ...fallback };
78
+
79
+ const normalized = normalize ? stripHtmlComments(content.replace(/\r\n/g, "\n").replace(/\r/g, "\n")) : content;
80
+ if (!normalized.startsWith("---")) {
81
+ return { frontmatter, body: normalized };
82
+ }
83
+
84
+ const endIndex = normalized.indexOf("\n---", 3);
85
+ if (endIndex === -1) {
86
+ return { frontmatter, body: normalized };
87
+ }
88
+
89
+ const metadata = normalized.slice(4, endIndex);
90
+ const body = normalized.slice(endIndex + 4).trim();
91
+
92
+ try {
93
+ // Replace tabs with spaces for YAML compatibility, use failsafe mode for robustness
94
+ const loaded = YAML.parse(metadata.replaceAll("\t", " ")) as Record<string, unknown> | null;
95
+ return { frontmatter: normalizeKeys({ ...frontmatter, ...loaded }), body };
96
+ } catch (error) {
97
+ const err = new FrontmatterError(
98
+ error instanceof Error ? error : new Error(`YAML: ${error}`),
99
+ loc ?? `Inline '${truncate(content, 64)}'`,
100
+ );
101
+ if (level === "warn" || level === "fatal") {
102
+ logger.warn("Failed to parse YAML frontmatter", { err: err.toString() });
103
+ }
104
+ if (level === "fatal") {
105
+ throw err;
106
+ }
107
+
108
+ // Simple YAML parsing - just key: value pairs
109
+ for (const line of metadata.split("\n")) {
110
+ const match = line.match(/^([\w-]+):\s*(.*)$/);
111
+ if (match) {
112
+ frontmatter[match[1]] = match[2].trim();
113
+ }
114
+ }
115
+
116
+ return { frontmatter: normalizeKeys(frontmatter) as Record<string, unknown>, body };
117
+ }
118
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Type-safe filesystem error handling utilities.
3
+ *
4
+ * Use these to check error codes without string matching on messages:
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { isEnoent, isFsError } from "@f5xc-salesdemos/pi-utils";
9
+ *
10
+ * try {
11
+ * return await Bun.file(path).text();
12
+ * } catch (err) {
13
+ * if (isEnoent(err)) return null;
14
+ * throw err;
15
+ * }
16
+ * ```
17
+ */
18
+
19
+ export interface FsError extends Error {
20
+ code: string;
21
+ errno?: number;
22
+ syscall?: string;
23
+ path?: string;
24
+ }
25
+
26
+ export function isFsError(err: unknown): err is FsError {
27
+ return err instanceof Error && "code" in err && typeof (err as FsError).code === "string";
28
+ }
29
+
30
+ export function isEnoent(err: unknown): err is FsError {
31
+ return isFsError(err) && err.code === "ENOENT";
32
+ }
33
+
34
+ export function isEacces(err: unknown): err is FsError {
35
+ return isFsError(err) && err.code === "EACCES";
36
+ }
37
+
38
+ export function isEisdir(err: unknown): err is FsError {
39
+ return isFsError(err) && err.code === "EISDIR";
40
+ }
41
+
42
+ export function isEnotdir(err: unknown): err is FsError {
43
+ return isFsError(err) && err.code === "ENOTDIR";
44
+ }
45
+
46
+ export function isEexist(err: unknown): err is FsError {
47
+ return isFsError(err) && err.code === "EEXIST";
48
+ }
49
+
50
+ export function isEnotempty(err: unknown): err is FsError {
51
+ return isFsError(err) && err.code === "ENOTEMPTY";
52
+ }
53
+
54
+ export function hasFsCode(err: unknown, code: string): err is FsError {
55
+ return isFsError(err) && err.code === code;
56
+ }
package/src/glob.ts ADDED
@@ -0,0 +1,189 @@
1
+ import * as path from "node:path";
2
+ import { Glob } from "bun";
3
+ import { getProjectDir } from "./dirs";
4
+
5
+ export interface GlobPathsOptions {
6
+ /** Base directory for glob patterns. Defaults to getProjectDir(). */
7
+ cwd?: string;
8
+ /** Glob exclusion patterns. */
9
+ exclude?: string[];
10
+ /** Abort signal to cancel the glob. */
11
+ signal?: AbortSignal;
12
+ /** Timeout in milliseconds for the glob operation. */
13
+ timeoutMs?: number;
14
+ /** Include dotfiles when true. */
15
+ dot?: boolean;
16
+ /** Only return files (skip directories). Default: true. */
17
+ onlyFiles?: boolean;
18
+ /** Respect .gitignore files when true. Walks up directory tree to find all applicable .gitignore files. */
19
+ gitignore?: boolean;
20
+ }
21
+
22
+ /** Patterns always excluded (.git is never useful in glob results). */
23
+ const ALWAYS_IGNORED = ["**/.git", "**/.git/**"];
24
+
25
+ /** node_modules exclusion patterns (skipped if pattern explicitly references node_modules). */
26
+ const NODE_MODULES_IGNORED = ["**/node_modules", "**/node_modules/**"];
27
+
28
+ /**
29
+ * Parse a single .gitignore file and return glob-compatible exclude patterns.
30
+ * @param content - Raw content of the .gitignore file
31
+ * @param gitignoreDir - Absolute path to the directory containing the .gitignore
32
+ * @param baseDir - Absolute path to the glob's cwd (for relativizing rooted patterns)
33
+ */
34
+ function parseGitignorePatterns(content: string, gitignoreDir: string, baseDir: string): string[] {
35
+ const patterns: string[] = [];
36
+
37
+ for (const rawLine of content.split("\n")) {
38
+ const line = rawLine.trim();
39
+ // Skip empty lines and comments
40
+ if (!line || line.startsWith("#")) {
41
+ continue;
42
+ }
43
+ // Skip negation patterns (unsupported for simple exclude)
44
+ if (line.startsWith("!")) {
45
+ continue;
46
+ }
47
+
48
+ let pattern = line;
49
+
50
+ // Handle trailing slash (directory-only match)
51
+ // For glob exclude, we treat it as matching the dir and its contents
52
+ const isDirectoryOnly = pattern.endsWith("/");
53
+ if (isDirectoryOnly) {
54
+ pattern = pattern.slice(0, -1);
55
+ }
56
+
57
+ // Handle rooted patterns (start with /)
58
+ if (pattern.startsWith("/")) {
59
+ // Rooted pattern: relative to the .gitignore location
60
+ const absolutePattern = path.join(gitignoreDir, pattern.slice(1));
61
+ const relativeToBase = path.relative(baseDir, absolutePattern);
62
+ if (relativeToBase.startsWith("..")) {
63
+ // Pattern is outside the search directory, skip
64
+ continue;
65
+ }
66
+ pattern = relativeToBase.replace(/\\/g, "/");
67
+ if (isDirectoryOnly) {
68
+ patterns.push(pattern);
69
+ patterns.push(`${pattern}/**`);
70
+ } else {
71
+ patterns.push(pattern);
72
+ }
73
+ } else {
74
+ // Unrooted pattern: match anywhere in the tree
75
+ if (pattern.includes("/")) {
76
+ // Contains slash: match from any directory level
77
+ patterns.push(`**/${pattern}`);
78
+ if (isDirectoryOnly) {
79
+ patterns.push(`**/${pattern}/**`);
80
+ }
81
+ } else {
82
+ // No slash: match file/dir name anywhere
83
+ patterns.push(`**/${pattern}`);
84
+ if (isDirectoryOnly) {
85
+ patterns.push(`**/${pattern}/**`);
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ return patterns;
92
+ }
93
+
94
+ /**
95
+ * Load .gitignore patterns from a directory and its parents.
96
+ * Walks up the directory tree to find all applicable .gitignore files.
97
+ * Returns glob-compatible exclude patterns.
98
+ */
99
+ export async function loadGitignorePatterns(baseDir: string): Promise<string[]> {
100
+ const patterns: string[] = [];
101
+ const absoluteBase = path.resolve(baseDir);
102
+
103
+ let current = absoluteBase;
104
+ const maxDepth = 50; // Prevent infinite loops
105
+
106
+ for (let i = 0; i < maxDepth; i++) {
107
+ const gitignorePath = path.join(current, ".gitignore");
108
+
109
+ try {
110
+ const content = await Bun.file(gitignorePath).text();
111
+ const filePatterns = parseGitignorePatterns(content, current, absoluteBase);
112
+ patterns.push(...filePatterns);
113
+ } catch {
114
+ // .gitignore doesn't exist or can't be read, continue
115
+ }
116
+
117
+ const parent = path.dirname(current);
118
+ if (parent === current) {
119
+ // Reached filesystem root
120
+ break;
121
+ }
122
+ current = parent;
123
+ }
124
+
125
+ return patterns;
126
+ }
127
+
128
+ /**
129
+ * Resolve filesystem paths matching glob patterns with optional exclude filters.
130
+ * Returns paths relative to the provided cwd (or getProjectDir()).
131
+ * Errors and abort/timeouts are surfaced to the caller.
132
+ */
133
+ export async function globPaths(patterns: string | string[], options: GlobPathsOptions = {}): Promise<string[]> {
134
+ const { cwd, exclude, signal, timeoutMs, dot, onlyFiles = true, gitignore } = options;
135
+
136
+ // Build exclude list: always exclude .git, exclude node_modules unless pattern references it
137
+ const patternArray = Array.isArray(patterns) ? patterns : [patterns];
138
+ const mentionsNodeModules = patternArray.some(p => p.includes("node_modules"));
139
+
140
+ const baseExclude = mentionsNodeModules ? [...ALWAYS_IGNORED] : [...ALWAYS_IGNORED, ...NODE_MODULES_IGNORED];
141
+ let effectiveExclude = exclude ? [...baseExclude, ...exclude] : baseExclude;
142
+
143
+ if (gitignore) {
144
+ const gitignorePatterns = await loadGitignorePatterns(cwd ?? getProjectDir());
145
+ effectiveExclude = [...effectiveExclude, ...gitignorePatterns];
146
+ }
147
+
148
+ const base = cwd ?? getProjectDir();
149
+ const allResults: string[] = [];
150
+
151
+ // Combine timeout and abort signals
152
+ const timeoutSignal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
153
+ const combinedSignal =
154
+ signal && timeoutSignal ? AbortSignal.any([signal, timeoutSignal]) : (signal ?? timeoutSignal);
155
+
156
+ for (const pattern of patternArray) {
157
+ const glob = new Glob(pattern);
158
+ const scanOptions = {
159
+ cwd: base,
160
+ dot,
161
+ onlyFiles,
162
+ throwErrorOnBrokenSymlink: false,
163
+ };
164
+
165
+ for await (const entry of glob.scan(scanOptions)) {
166
+ if (combinedSignal?.aborted) {
167
+ const reason = combinedSignal.reason;
168
+ if (reason instanceof Error) throw reason;
169
+ throw new DOMException("Aborted", "AbortError");
170
+ }
171
+
172
+ // Check exclusion patterns
173
+ const normalized = entry.replace(/\\/g, "/");
174
+ let excluded = false;
175
+ for (const excludePattern of effectiveExclude) {
176
+ const excludeGlob = new Glob(excludePattern);
177
+ if (excludeGlob.match(normalized)) {
178
+ excluded = true;
179
+ break;
180
+ }
181
+ }
182
+ if (!excluded) {
183
+ allResults.push(normalized);
184
+ }
185
+ }
186
+ }
187
+
188
+ return allResults;
189
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Intercept `globalThis.fetch` with a middleware-style handler.
3
+ *
4
+ * Returns a `Disposable` so callers can use `using` for automatic cleanup:
5
+ *
6
+ * ```ts
7
+ * using _hook = hookFetch((input, init, next) => {
8
+ * if (shouldIntercept(input)) {
9
+ * return new Response("mocked");
10
+ * }
11
+ * return next(input, init);
12
+ * });
13
+ * ```
14
+ */
15
+ export type FetchHandler = (
16
+ input: string | URL | Request,
17
+ init: RequestInit | undefined,
18
+ next: typeof fetch,
19
+ ) => Response | Promise<Response>;
20
+
21
+ export function hookFetch(handler: FetchHandler): Disposable {
22
+ const original = globalThis.fetch;
23
+ globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) =>
24
+ handler(input, init, original)) as typeof fetch;
25
+ return {
26
+ [Symbol.dispose]() {
27
+ globalThis.fetch = original;
28
+ },
29
+ };
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ export { abortableSleep, createAbortableStream, once, untilAborted } from "./abortable";
2
+ export * from "./async";
3
+ export * from "./color";
4
+ export * from "./dirs";
5
+ export * from "./env";
6
+ export * from "./format";
7
+ export * from "./frontmatter";
8
+ export * from "./fs-error";
9
+ export * from "./glob";
10
+ export * from "./hook-fetch";
11
+ export * from "./json";
12
+ export * as logger from "./logger";
13
+ export * from "./mermaid-ascii";
14
+ export * from "./mime";
15
+ export * from "./peek-file";
16
+ export * as postmortem from "./postmortem";
17
+ export * as procmgr from "./procmgr";
18
+ export { setNativeKillTree } from "./procmgr";
19
+ export * as prompt from "./prompt";
20
+ export * as ptree from "./ptree";
21
+ export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
22
+ export * from "./snowflake";
23
+ export * from "./stream";
24
+ export * from "./temp";
25
+ export * from "./type-guards";
26
+ export * from "./which";
27
+
28
+ function isPlainObject(val: object): val is Record<string, unknown> {
29
+ return Object.getPrototypeOf(val) === Object.prototype || Array.isArray(val);
30
+ }
31
+
32
+ export function structuredCloneJSON<T>(value: T): T {
33
+ // primitives|null|undefined, copy
34
+ if (!value || typeof value !== "object") {
35
+ return value;
36
+ }
37
+
38
+ // deep clone
39
+ if (isPlainObject(value)) {
40
+ try {
41
+ return structuredClone(value);
42
+ } catch {
43
+ // might still fail due to nested structures
44
+ }
45
+ }
46
+ return JSON.parse(JSON.stringify(value)) as T;
47
+ }
package/src/json.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Try to parse JSON, returning null on failure.
3
+ */
4
+ export function tryParseJson<T = unknown>(content: string): T | null {
5
+ try {
6
+ return JSON.parse(content) as T;
7
+ } catch {
8
+ return null;
9
+ }
10
+ }