@oh-my-pi/pi-utils 13.18.0 → 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/package.json +12 -5
- package/src/frontmatter.ts +118 -0
- package/src/index.ts +26 -1
- package/src/logger.ts +78 -80
- package/src/mime.ts +159 -0
- package/src/peek-file.ts +114 -0
- package/src/procmgr.ts +3 -14
- package/src/prompt.ts +401 -0
- package/src/which.ts +230 -0
- package/src/indent.ts +0 -264
package/src/procmgr.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import * as timers from "node:timers";
|
|
4
4
|
import type { Subprocess } from "bun";
|
|
5
5
|
import { $env } from "./env";
|
|
6
|
+
import { $which } from "./which";
|
|
6
7
|
|
|
7
8
|
export interface ShellConfig {
|
|
8
9
|
shell: string;
|
|
@@ -60,18 +61,6 @@ function getShellPrefix(): string | undefined {
|
|
|
60
61
|
return $env.PI_SHELL_PREFIX || $env.CLAUDE_CODE_SHELL_PREFIX;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
/**
|
|
64
|
-
* Find bash executable on PATH (Windows)
|
|
65
|
-
*/
|
|
66
|
-
function findBashOnPath(): string | null {
|
|
67
|
-
try {
|
|
68
|
-
return Bun.which("bash.exe");
|
|
69
|
-
} catch {
|
|
70
|
-
// Ignore errors
|
|
71
|
-
}
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
64
|
/**
|
|
76
65
|
* Build full shell config from a shell path.
|
|
77
66
|
*/
|
|
@@ -89,7 +78,7 @@ function buildConfig(shell: string): ShellConfig {
|
|
|
89
78
|
*/
|
|
90
79
|
export function resolveBasicShell(): string | undefined {
|
|
91
80
|
for (const name of ["bash", "bash.exe", "sh", "sh.exe"]) {
|
|
92
|
-
const resolved =
|
|
81
|
+
const resolved = $which(name);
|
|
93
82
|
if (resolved) return resolved;
|
|
94
83
|
}
|
|
95
84
|
|
|
@@ -152,7 +141,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
|
|
|
152
141
|
}
|
|
153
142
|
|
|
154
143
|
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
|
|
155
|
-
const bashOnPath =
|
|
144
|
+
const bashOnPath = $which("bash.exe");
|
|
156
145
|
if (bashOnPath) {
|
|
157
146
|
cachedShellConfig = buildConfig(bashOnPath);
|
|
158
147
|
return cachedShellConfig;
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import type { HelperDelegate, HelperOptions, Template, TemplateDelegate } from "handlebars";
|
|
2
|
+
import Handlebars from "handlebars";
|
|
3
|
+
|
|
4
|
+
export type { HelperDelegate, HelperOptions, Template, TemplateDelegate };
|
|
5
|
+
|
|
6
|
+
export type PromptRenderPhase = "pre-render" | "post-render";
|
|
7
|
+
|
|
8
|
+
export interface PromptFormatOptions {
|
|
9
|
+
renderPhase?: PromptRenderPhase;
|
|
10
|
+
replaceAsciiSymbols?: boolean;
|
|
11
|
+
boldRfc2119Keywords?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Opening XML tag (not self-closing, not closing)
|
|
15
|
+
const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/;
|
|
16
|
+
// Closing XML tag
|
|
17
|
+
const CLOSING_XML = /^<\/([a-z_-]+)>$/;
|
|
18
|
+
// Handlebars block start: {{#if}}, {{#has}}, {{#list}}, etc.
|
|
19
|
+
const OPENING_HBS = /^\{\{#/;
|
|
20
|
+
// Handlebars block end: {{/if}}, {{/has}}, {{/list}}, etc.
|
|
21
|
+
const CLOSING_HBS = /^\{\{\//;
|
|
22
|
+
// List item (- or * or 1.)
|
|
23
|
+
const LIST_ITEM = /^(?:[-*]\s|\d+\.\s)/;
|
|
24
|
+
// Table row
|
|
25
|
+
const TABLE_ROW = /^\|.*\|$/;
|
|
26
|
+
// Table separator (|---|---|)
|
|
27
|
+
const TABLE_SEP = /^\|[-:\s|]+\|$/;
|
|
28
|
+
|
|
29
|
+
/** RFC 2119 keywords used in prompts. */
|
|
30
|
+
const RFC2119_KEYWORDS = /\b(?:MUST NOT|SHOULD NOT|SHALL NOT|RECOMMENDED|REQUIRED|OPTIONAL|SHOULD|SHALL|MUST|MAY)\b/g;
|
|
31
|
+
|
|
32
|
+
function boldRfc2119Keywords(line: string): string {
|
|
33
|
+
return line.replace(RFC2119_KEYWORDS, (match, offset, source) => {
|
|
34
|
+
const isAlreadyBold =
|
|
35
|
+
source[offset - 2] === "*" &&
|
|
36
|
+
source[offset - 1] === "*" &&
|
|
37
|
+
source[offset + match.length] === "*" &&
|
|
38
|
+
source[offset + match.length + 1] === "*";
|
|
39
|
+
if (isAlreadyBold) {
|
|
40
|
+
return match;
|
|
41
|
+
}
|
|
42
|
+
return `**${match}**`;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Compact a table row by trimming cell padding */
|
|
47
|
+
function compactTableRow(line: string): string {
|
|
48
|
+
const cells = line.split("|");
|
|
49
|
+
return cells.map(c => c.trim()).join("|");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Compact a table separator row */
|
|
53
|
+
function compactTableSep(line: string): string {
|
|
54
|
+
const cells = line.split("|").filter(c => c.trim());
|
|
55
|
+
const normalized = cells.map(c => {
|
|
56
|
+
const trimmed = c.trim();
|
|
57
|
+
const left = trimmed.startsWith(":");
|
|
58
|
+
const right = trimmed.endsWith(":");
|
|
59
|
+
if (left && right) return ":---:";
|
|
60
|
+
if (left) return ":---";
|
|
61
|
+
if (right) return "---:";
|
|
62
|
+
return "---";
|
|
63
|
+
});
|
|
64
|
+
return `|${normalized.join("|")}|`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function replaceCommonAsciiSymbols(line: string): string {
|
|
68
|
+
return line
|
|
69
|
+
.replace(/\.{3}/g, "…")
|
|
70
|
+
.replace(/<->/g, "↔")
|
|
71
|
+
.replace(/->/g, "→")
|
|
72
|
+
.replace(/<-/g, "←")
|
|
73
|
+
.replace(/!=/g, "≠")
|
|
74
|
+
.replace(/<=/g, "≤")
|
|
75
|
+
.replace(/>=/g, "≥");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function format(content: string, options: PromptFormatOptions = {}): string {
|
|
79
|
+
const {
|
|
80
|
+
renderPhase = "post-render",
|
|
81
|
+
replaceAsciiSymbols = false,
|
|
82
|
+
boldRfc2119Keywords: shouldBoldRfc2119 = false,
|
|
83
|
+
} = options;
|
|
84
|
+
const isPreRender = renderPhase === "pre-render";
|
|
85
|
+
const lines = content.split("\n");
|
|
86
|
+
const result: string[] = [];
|
|
87
|
+
let inCodeBlock = false;
|
|
88
|
+
const topLevelTags: string[] = [];
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < lines.length; i++) {
|
|
91
|
+
let line = lines[i].trimEnd();
|
|
92
|
+
let trimmedStart = line.trimStart();
|
|
93
|
+
if (trimmedStart.startsWith("```") || trimmedStart.startsWith("~~~")) {
|
|
94
|
+
inCodeBlock = !inCodeBlock;
|
|
95
|
+
result.push(line);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (inCodeBlock) {
|
|
100
|
+
result.push(line);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (replaceAsciiSymbols) {
|
|
105
|
+
line = replaceCommonAsciiSymbols(line);
|
|
106
|
+
}
|
|
107
|
+
trimmedStart = line.trimStart();
|
|
108
|
+
const trimmed = line.trim();
|
|
109
|
+
|
|
110
|
+
const isOpeningXml = OPENING_XML.test(trimmedStart) && !trimmedStart.endsWith("/>");
|
|
111
|
+
if (isOpeningXml && line.length === trimmedStart.length) {
|
|
112
|
+
const match = OPENING_XML.exec(trimmedStart);
|
|
113
|
+
if (match) topLevelTags.push(match[1]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const closingMatch = CLOSING_XML.exec(trimmedStart);
|
|
117
|
+
if (closingMatch) {
|
|
118
|
+
const tagName = closingMatch[1];
|
|
119
|
+
if (topLevelTags.length > 0 && topLevelTags[topLevelTags.length - 1] === tagName) {
|
|
120
|
+
topLevelTags.pop();
|
|
121
|
+
}
|
|
122
|
+
} else if (isPreRender && trimmedStart.startsWith("{{")) {
|
|
123
|
+
/* keep indentation as-is in pre-render for Handlebars markers */
|
|
124
|
+
} else if (TABLE_SEP.test(trimmedStart)) {
|
|
125
|
+
const leadingWhitespace = line.slice(0, line.length - trimmedStart.length);
|
|
126
|
+
line = `${leadingWhitespace}${compactTableSep(trimmedStart)}`;
|
|
127
|
+
} else if (TABLE_ROW.test(trimmedStart)) {
|
|
128
|
+
const leadingWhitespace = line.slice(0, line.length - trimmedStart.length);
|
|
129
|
+
line = `${leadingWhitespace}${compactTableRow(trimmedStart)}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (shouldBoldRfc2119) {
|
|
133
|
+
line = boldRfc2119Keywords(line);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const isBlank = trimmed === "";
|
|
137
|
+
if (isBlank) {
|
|
138
|
+
const prevLine = result[result.length - 1]?.trim() ?? "";
|
|
139
|
+
const nextLine = lines[i + 1]?.trim() ?? "";
|
|
140
|
+
|
|
141
|
+
if (LIST_ITEM.test(nextLine)) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (OPENING_XML.test(prevLine) || (isPreRender && OPENING_HBS.test(prevLine))) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (CLOSING_XML.test(nextLine) || (isPreRender && CLOSING_HBS.test(nextLine))) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const prevIsBlank = prevLine === "";
|
|
154
|
+
if (prevIsBlank) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (CLOSING_XML.test(trimmed) || (isPreRender && CLOSING_HBS.test(trimmed))) {
|
|
160
|
+
while (result.length > 0 && result[result.length - 1].trim() === "") {
|
|
161
|
+
result.pop();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
result.push(line);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
while (result.length > 0 && result[result.length - 1].trim() === "") {
|
|
169
|
+
result.pop();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return result.join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface TemplateContext extends Record<string, unknown> {
|
|
176
|
+
args?: string[];
|
|
177
|
+
ARGUMENTS?: string;
|
|
178
|
+
arguments?: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const handlebars = Handlebars.create();
|
|
182
|
+
|
|
183
|
+
handlebars.registerHelper("arg", function (this: TemplateContext, index: number | string): string {
|
|
184
|
+
const args = this.args ?? [];
|
|
185
|
+
const parsedIndex = typeof index === "number" ? index : Number.parseInt(index, 10);
|
|
186
|
+
if (!Number.isFinite(parsedIndex)) return "";
|
|
187
|
+
const zeroBased = parsedIndex - 1;
|
|
188
|
+
if (zeroBased < 0) return "";
|
|
189
|
+
return args[zeroBased] ?? "";
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* {{#list items prefix="- " suffix="" join="\n"}}{{this}}{{/list}}
|
|
194
|
+
* Renders an array with customizable prefix, suffix, and join separator.
|
|
195
|
+
* Note: Use \n in join for newlines (will be unescaped automatically).
|
|
196
|
+
*/
|
|
197
|
+
handlebars.registerHelper(
|
|
198
|
+
"list",
|
|
199
|
+
function (this: unknown, context: unknown[], options: Handlebars.HelperOptions): string {
|
|
200
|
+
if (!Array.isArray(context) || context.length === 0) return "";
|
|
201
|
+
const prefix = (options.hash.prefix as string) ?? "";
|
|
202
|
+
const suffix = (options.hash.suffix as string) ?? "";
|
|
203
|
+
const rawSeparator = (options.hash.join as string) ?? "\n";
|
|
204
|
+
const separator = rawSeparator.replace(/\\n/g, "\n").replace(/\\t/g, "\t");
|
|
205
|
+
return context.map(item => `${prefix}${options.fn(item)}${suffix}`).join(separator);
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* {{join array ", "}}
|
|
211
|
+
* Joins an array with a separator (default: ", ").
|
|
212
|
+
*/
|
|
213
|
+
handlebars.registerHelper("join", (context: unknown[], separator?: unknown): string => {
|
|
214
|
+
if (!Array.isArray(context)) return "";
|
|
215
|
+
const sep = typeof separator === "string" ? separator : ", ";
|
|
216
|
+
return context.join(sep);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* {{default value "fallback"}}
|
|
221
|
+
* Returns the value if truthy, otherwise returns the fallback.
|
|
222
|
+
*/
|
|
223
|
+
handlebars.registerHelper("default", (value: unknown, defaultValue: unknown): unknown => value || defaultValue);
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* {{pluralize count "item" "items"}}
|
|
227
|
+
* Returns "1 item" or "5 items" based on count.
|
|
228
|
+
*/
|
|
229
|
+
handlebars.registerHelper(
|
|
230
|
+
"pluralize",
|
|
231
|
+
(count: number, singular: string, plural: string): string => `${count} ${count === 1 ? singular : plural}`,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* {{#when value "==" compare}}...{{else}}...{{/when}}
|
|
236
|
+
* Conditional block with comparison operators: ==, ===, !=, !==, >, <, >=, <=
|
|
237
|
+
*/
|
|
238
|
+
handlebars.registerHelper(
|
|
239
|
+
"when",
|
|
240
|
+
function (this: unknown, lhs: unknown, operator: string, rhs: unknown, options: Handlebars.HelperOptions): string {
|
|
241
|
+
const ops: Record<string, (a: unknown, b: unknown) => boolean> = {
|
|
242
|
+
"==": (a, b) => a === b,
|
|
243
|
+
"===": (a, b) => a === b,
|
|
244
|
+
"!=": (a, b) => a !== b,
|
|
245
|
+
"!==": (a, b) => a !== b,
|
|
246
|
+
">": (a, b) => (a as number) > (b as number),
|
|
247
|
+
"<": (a, b) => (a as number) < (b as number),
|
|
248
|
+
">=": (a, b) => (a as number) >= (b as number),
|
|
249
|
+
"<=": (a, b) => (a as number) <= (b as number),
|
|
250
|
+
};
|
|
251
|
+
const fn = ops[operator];
|
|
252
|
+
if (!fn) return options.inverse(this);
|
|
253
|
+
return fn(lhs, rhs) ? options.fn(this) : options.inverse(this);
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* {{#ifAny a b c}}...{{else}}...{{/ifAny}}
|
|
259
|
+
* True if any argument is truthy.
|
|
260
|
+
*/
|
|
261
|
+
handlebars.registerHelper("ifAny", function (this: unknown, ...args: unknown[]): string {
|
|
262
|
+
const options = args.pop() as Handlebars.HelperOptions;
|
|
263
|
+
return args.some(Boolean) ? options.fn(this) : options.inverse(this);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* {{#ifAll a b c}}...{{else}}...{{/ifAll}}
|
|
268
|
+
* True if all arguments are truthy.
|
|
269
|
+
*/
|
|
270
|
+
handlebars.registerHelper("ifAll", function (this: unknown, ...args: unknown[]): string {
|
|
271
|
+
const options = args.pop() as Handlebars.HelperOptions;
|
|
272
|
+
return args.every(Boolean) ? options.fn(this) : options.inverse(this);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* {{#table rows headers="Col1|Col2"}}{{col1}}|{{col2}}{{/table}}
|
|
277
|
+
* Generates a markdown table from an array of objects.
|
|
278
|
+
*/
|
|
279
|
+
handlebars.registerHelper(
|
|
280
|
+
"table",
|
|
281
|
+
function (this: unknown, context: unknown[], options: Handlebars.HelperOptions): string {
|
|
282
|
+
if (!Array.isArray(context) || context.length === 0) return "";
|
|
283
|
+
const headersStr = options.hash.headers as string | undefined;
|
|
284
|
+
const headers = headersStr?.split("|") ?? [];
|
|
285
|
+
const separator = headers.map(() => "---").join(" | ");
|
|
286
|
+
const headerRow = headers.length > 0 ? `| ${headers.join(" | ")} |\n| ${separator} |\n` : "";
|
|
287
|
+
const rows = context.map(item => `| ${options.fn(item).trim()} |`).join("\n");
|
|
288
|
+
return headerRow + rows;
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* {{#codeblock lang="diff"}}...{{/codeblock}}
|
|
294
|
+
* Wraps content in a fenced code block.
|
|
295
|
+
*/
|
|
296
|
+
handlebars.registerHelper("codeblock", function (this: unknown, options: Handlebars.HelperOptions): string {
|
|
297
|
+
const lang = (options.hash.lang as string) ?? "";
|
|
298
|
+
const content = options.fn(this).trim();
|
|
299
|
+
return `\`\`\`${lang}\n${content}\n\`\`\``;
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* {{#xml "tag"}}content{{/xml}}
|
|
304
|
+
* Wraps content in XML-style tags. Returns empty string if content is empty.
|
|
305
|
+
*/
|
|
306
|
+
handlebars.registerHelper("xml", function (this: unknown, tag: string, options: Handlebars.HelperOptions): string {
|
|
307
|
+
const content = options.fn(this).trim();
|
|
308
|
+
if (!content) return "";
|
|
309
|
+
return `<${tag}>\n${content}\n</${tag}>`;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* {{escapeXml value}}
|
|
314
|
+
* Escapes XML special characters: & < > "
|
|
315
|
+
*/
|
|
316
|
+
handlebars.registerHelper("escapeXml", (value: unknown): string => {
|
|
317
|
+
if (value == null) return "";
|
|
318
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* {{len array}}
|
|
323
|
+
* Returns the length of an array or string.
|
|
324
|
+
*/
|
|
325
|
+
handlebars.registerHelper("len", (value: unknown): number => {
|
|
326
|
+
if (Array.isArray(value)) return value.length;
|
|
327
|
+
if (typeof value === "string") return value.length;
|
|
328
|
+
return 0;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* {{add a b}}
|
|
333
|
+
* Adds two numbers.
|
|
334
|
+
*/
|
|
335
|
+
handlebars.registerHelper("add", (a: number, b: number): number => (a ?? 0) + (b ?? 0));
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* {{sub a b}}
|
|
339
|
+
* Subtracts b from a.
|
|
340
|
+
*/
|
|
341
|
+
handlebars.registerHelper("sub", (a: number, b: number): number => (a ?? 0) - (b ?? 0));
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* {{#has collection item}}...{{else}}...{{/has}}
|
|
345
|
+
* Checks if an array includes an item or if a Set/Map has a key.
|
|
346
|
+
*/
|
|
347
|
+
handlebars.registerHelper(
|
|
348
|
+
"has",
|
|
349
|
+
function (this: unknown, collection: unknown, item: unknown, options: Handlebars.HelperOptions): string {
|
|
350
|
+
let found = false;
|
|
351
|
+
if (Array.isArray(collection)) {
|
|
352
|
+
found = collection.includes(item);
|
|
353
|
+
} else if (collection instanceof Set) {
|
|
354
|
+
found = collection.has(item);
|
|
355
|
+
} else if (collection instanceof Map) {
|
|
356
|
+
found = collection.has(item);
|
|
357
|
+
} else if (collection && typeof collection === "object") {
|
|
358
|
+
if (typeof item === "string" || typeof item === "number" || typeof item === "symbol") {
|
|
359
|
+
found = item in collection;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return found ? options.fn(this) : options.inverse(this);
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* {{includes array item}}
|
|
368
|
+
* Returns true if array includes item. For use in other helpers.
|
|
369
|
+
*/
|
|
370
|
+
handlebars.registerHelper("includes", (collection: unknown, item: unknown): boolean => {
|
|
371
|
+
if (Array.isArray(collection)) return collection.includes(item);
|
|
372
|
+
if (collection instanceof Set) return collection.has(item);
|
|
373
|
+
if (collection instanceof Map) return collection.has(item);
|
|
374
|
+
return false;
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* {{not value}}
|
|
379
|
+
* Returns logical NOT of value. For use in subexpressions.
|
|
380
|
+
*/
|
|
381
|
+
handlebars.registerHelper("not", (value: unknown): boolean => !value);
|
|
382
|
+
|
|
383
|
+
handlebars.registerHelper("jsonStringify", (value: unknown): string => JSON.stringify(value));
|
|
384
|
+
|
|
385
|
+
export function registerHelper(name: string, fn: HelperDelegate): void {
|
|
386
|
+
handlebars.registerHelper(name, fn);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function registerPartial(name: string, fn: Template): void {
|
|
390
|
+
handlebars.registerPartial(name, fn);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function compile(template: string): (context: TemplateContext) => string {
|
|
394
|
+
return handlebars.compile(template, { noEscape: true, strict: false });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function render(template: string, context: TemplateContext = {}): string {
|
|
398
|
+
const compiled = compile(template);
|
|
399
|
+
const rendered = compiled(context ?? {});
|
|
400
|
+
return format(rendered, { renderPhase: "post-render" });
|
|
401
|
+
}
|
package/src/which.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// OS-agnostic "which" helper with robust macOS toolchain lookup and flexible cache control.
|
|
2
|
+
//
|
|
3
|
+
// - Falls back to macOS Xcode/CLT toolchain directories if standard `Bun.which()` fails on Darwin.
|
|
4
|
+
// Resolves the active developer directory via $DEVELOPER_DIR / /var/db/xcode_select_link symlink
|
|
5
|
+
// to avoid spawning xcrun subprocesses.
|
|
6
|
+
// - Supports four cache modes (`none`, `fresh`, `ro`, `cached`) for control over discovery cost and determinism.
|
|
7
|
+
// - Computes a stable cache key from command + options to avoid redundant lookups within a process.
|
|
8
|
+
// - Returns path to resolved binary or null if not found.
|
|
9
|
+
//
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
|
|
15
|
+
// Tools shipped by Xcode / Command Line Tools that callers actually look up.
|
|
16
|
+
// Keeps the set small so darwinWhich can fast-reject non-Xcode commands without
|
|
17
|
+
// touching the filesystem. Only needs entries for binaries that live *exclusively*
|
|
18
|
+
// in toolchain dirs (not on a typical $PATH).
|
|
19
|
+
const XCODE_BINS = new Set([
|
|
20
|
+
// Compilers & driver aliases
|
|
21
|
+
"clang",
|
|
22
|
+
"clang++",
|
|
23
|
+
"gcc",
|
|
24
|
+
"g++",
|
|
25
|
+
"cc",
|
|
26
|
+
"c++",
|
|
27
|
+
"cpp",
|
|
28
|
+
"c89",
|
|
29
|
+
"c99",
|
|
30
|
+
"swift",
|
|
31
|
+
"swiftc",
|
|
32
|
+
"swift-frontend",
|
|
33
|
+
// Language servers (LSP)
|
|
34
|
+
"clangd",
|
|
35
|
+
"sourcekit-lsp",
|
|
36
|
+
// Linker & archive tools
|
|
37
|
+
"ld",
|
|
38
|
+
"ld-classic",
|
|
39
|
+
"ar",
|
|
40
|
+
"ranlib",
|
|
41
|
+
"libtool",
|
|
42
|
+
"as",
|
|
43
|
+
"lipo",
|
|
44
|
+
"install_name_tool",
|
|
45
|
+
"codesign_allocate",
|
|
46
|
+
// Build utilities
|
|
47
|
+
"make",
|
|
48
|
+
"gnumake",
|
|
49
|
+
"m4",
|
|
50
|
+
"flex",
|
|
51
|
+
"bison",
|
|
52
|
+
"yacc",
|
|
53
|
+
"lex",
|
|
54
|
+
// VCS (CLT ships git)
|
|
55
|
+
"git",
|
|
56
|
+
"git-receive-pack",
|
|
57
|
+
"git-upload-pack",
|
|
58
|
+
"git-upload-archive",
|
|
59
|
+
"git-shell",
|
|
60
|
+
"scalar",
|
|
61
|
+
// Debugger
|
|
62
|
+
"lldb",
|
|
63
|
+
"lldb-dap",
|
|
64
|
+
// Binary inspection
|
|
65
|
+
"nm",
|
|
66
|
+
"otool",
|
|
67
|
+
"objdump",
|
|
68
|
+
"strings",
|
|
69
|
+
"strip",
|
|
70
|
+
"size",
|
|
71
|
+
"dsymutil",
|
|
72
|
+
"dwarfdump",
|
|
73
|
+
"lipo",
|
|
74
|
+
"vtool",
|
|
75
|
+
// Clang tooling
|
|
76
|
+
"clang-format",
|
|
77
|
+
"swift-format",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
// Prefixes for versioned binaries (e.g. python3.9, pip3.12, pydoc3.9, 2to3-3.9)
|
|
81
|
+
const XCODE_BIN_PREFIXES = ["python", "pip", "pydoc", "2to3"];
|
|
82
|
+
|
|
83
|
+
function isXcodeBin(command: string): boolean {
|
|
84
|
+
if (XCODE_BINS.has(command)) return true;
|
|
85
|
+
for (const prefix of XCODE_BIN_PREFIXES) {
|
|
86
|
+
if (command.startsWith(prefix)) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Resolve the active Xcode developer directory once, without spawning any process.
|
|
92
|
+
// Priority: $DEVELOPER_DIR env → /var/db/xcode_select_link symlink → common fallback paths.
|
|
93
|
+
function getDeveloperDirs(): string | null {
|
|
94
|
+
// 1. Explicit env override
|
|
95
|
+
const envDir = process.env.DEVELOPER_DIR;
|
|
96
|
+
if (envDir && fs.existsSync(envDir)) {
|
|
97
|
+
return envDir;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 2. xcode-select stores the active path as a symlink
|
|
101
|
+
try {
|
|
102
|
+
return fs.readlinkSync("/var/db/xcode_select_link");
|
|
103
|
+
} catch {
|
|
104
|
+
// symlink may not exist on minimal installs
|
|
105
|
+
}
|
|
106
|
+
// 3. Common locations
|
|
107
|
+
for (const candidate of ["/Applications/Xcode.app/Contents/Developer", "/Library/Developer/CommandLineTools"]) {
|
|
108
|
+
if (fs.existsSync(candidate)) {
|
|
109
|
+
return candidate;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Build the list of extra toolchain bin directories to check on macOS.
|
|
116
|
+
// Computed lazily once from the resolved developer directory.
|
|
117
|
+
let macosToolPaths: Map<string, string> | undefined;
|
|
118
|
+
function getMacosToolPaths(): Map<string, string> {
|
|
119
|
+
if (macosToolPaths) return macosToolPaths;
|
|
120
|
+
const paths: string[] = [
|
|
121
|
+
// Always check Command Line Tools (may be independent of Xcode)
|
|
122
|
+
"/Library/Developer/CommandLineTools/usr/bin",
|
|
123
|
+
];
|
|
124
|
+
const devDir = getDeveloperDirs();
|
|
125
|
+
if (devDir) {
|
|
126
|
+
paths.push(path.join(devDir, "usr/bin"), path.join(devDir, "Toolchains/XcodeDefault.xctoolchain/usr/bin"));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Deduplicate (e.g. devDir may already be CommandLineTools)
|
|
130
|
+
macosToolPaths = new Map<string, string>();
|
|
131
|
+
for (const dir of Array.from(new Set(paths))) {
|
|
132
|
+
try {
|
|
133
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
134
|
+
if (entry.isFile() || entry.isSymbolicLink()) {
|
|
135
|
+
if (macosToolPaths.has(entry.name)) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
macosToolPaths.set(entry.name, path.join(dir, entry.name));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// dir doesn't exist or isn't readable
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return macosToolPaths;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Map: cache key -> resolved binary path or null (not found)
|
|
149
|
+
const toolCache = new Map<string | bigint, string | null>();
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Cache policy for which lookups.
|
|
153
|
+
*/
|
|
154
|
+
export const enum WhichCachePolicy {
|
|
155
|
+
/**
|
|
156
|
+
* Use cached result if available.
|
|
157
|
+
*/
|
|
158
|
+
Cached = 0,
|
|
159
|
+
/**
|
|
160
|
+
* Bypass cache and perform a new lookup.
|
|
161
|
+
*/
|
|
162
|
+
Bypass,
|
|
163
|
+
/**
|
|
164
|
+
* Always update cache.
|
|
165
|
+
*/
|
|
166
|
+
Fresh,
|
|
167
|
+
/**
|
|
168
|
+
* Read-only, serves from cache if present, but doesn't write.
|
|
169
|
+
*/
|
|
170
|
+
ReadOnly,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Extension: additional cache policy for tool path lookup
|
|
174
|
+
export interface WhichOptions extends Bun.WhichOptions {
|
|
175
|
+
/**
|
|
176
|
+
* Cache policy for the lookup.
|
|
177
|
+
* Defaults to `WhichCachePolicy.Fresh`.
|
|
178
|
+
*/
|
|
179
|
+
cache?: WhichCachePolicy;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH.
|
|
183
|
+
// Uses cached directory listings instead of per-command existsSync or xcrun subprocesses.
|
|
184
|
+
function darwinWhich(command: string, _options?: Bun.WhichOptions): string | null {
|
|
185
|
+
const regular = Bun.which(command);
|
|
186
|
+
if (regular) return regular;
|
|
187
|
+
if (isXcodeBin(command)) {
|
|
188
|
+
return getMacosToolPaths().get(command) ?? null;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Which function that incorporates Darwin Xcode logic if platform reports as 'darwin'
|
|
194
|
+
export const whichFresh = os.platform() === "darwin" ? darwinWhich : Bun.which;
|
|
195
|
+
|
|
196
|
+
// Derive stable cache key from command and lookup options
|
|
197
|
+
function cacheKey(command: string, options?: Bun.WhichOptions): string | bigint {
|
|
198
|
+
if (!options) return command;
|
|
199
|
+
if (!options.cwd && !options.PATH) return command;
|
|
200
|
+
let h = Bun.hash.xxHash64(command);
|
|
201
|
+
if (options.cwd) h = Bun.hash.xxHash64(options.cwd, h);
|
|
202
|
+
if (options.PATH) h = Bun.hash.xxHash64(options.PATH, h);
|
|
203
|
+
return h;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Locate binary on PATH (with flexible caching).
|
|
208
|
+
*
|
|
209
|
+
* @param command - Binary name to resolve
|
|
210
|
+
* @param options - Bun.WhichOptions plus `cache` control
|
|
211
|
+
* @returns Filesystem path if found, else null
|
|
212
|
+
*/
|
|
213
|
+
export function $which(command: string, options?: WhichOptions): string | null {
|
|
214
|
+
const cachePolicy = options?.cache ?? WhichCachePolicy.Cached;
|
|
215
|
+
let key: string | bigint | undefined;
|
|
216
|
+
|
|
217
|
+
if (cachePolicy !== WhichCachePolicy.Bypass) {
|
|
218
|
+
key = cacheKey(command, options);
|
|
219
|
+
if (cachePolicy !== WhichCachePolicy.Fresh) {
|
|
220
|
+
const cached = toolCache.get(key);
|
|
221
|
+
if (cached !== undefined) return cached;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const result = whichFresh(command, options);
|
|
226
|
+
if (key != null && cachePolicy !== WhichCachePolicy.ReadOnly) {
|
|
227
|
+
toolCache.set(key, result);
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
}
|