pi-supernova 0.0.7 → 0.0.8

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/snap.js CHANGED
@@ -63,37 +63,41 @@ export function scorePathTopology(filePath, tokens, { wantsTest, wantsDoc, wants
63
63
  return score;
64
64
  }
65
65
 
66
- function scoreContentDefinitions(content, tokens) {
67
- const lines = content.split("\n");
66
+ function isSkippableLine(line) {
67
+ return !line || line.startsWith("//") || line.startsWith("#") || line.startsWith("*");
68
+ }
69
+
70
+ function lineScoreFor(line, tokens, isDef) {
71
+ const lower = line.toLowerCase();
72
+ let lineScore = 0;
73
+ for (const token of tokens) {
74
+ if (lower.includes(token)) lineScore += isDef ? 40 : 5;
75
+ }
76
+ return lineScore;
77
+ }
78
+
79
+ function accumulateContentScore(lines, tokens, defPattern) {
68
80
  let score = 0;
69
81
  let bestLine = 1;
70
82
  let bestLineScore = 0;
71
-
72
- const defRegex = /^(?:pub\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(function|class|def|fn|const|let|interface|type|struct|enum)\s+([a-zA-Z0-9_$]+)/;
73
-
74
83
  for (let i = 0; i < lines.length; i++) {
75
84
  const line = lines[i].trim();
76
- if (!line || line.startsWith("//") || line.startsWith("#") || line.startsWith("*")) continue;
77
-
78
- let lineScore = 0;
79
- const isDef = defRegex.test(line);
80
-
81
- for (const token of tokens) {
82
- if (line.toLowerCase().includes(token)) {
83
- lineScore += isDef ? 40 : 5;
84
- }
85
- }
86
-
85
+ if (isSkippableLine(line)) continue;
86
+ const lineScore = lineScoreFor(line, tokens, defPattern.test(line));
87
87
  if (lineScore > bestLineScore) {
88
88
  bestLineScore = lineScore;
89
89
  bestLine = i + 1;
90
90
  }
91
91
  score += lineScore;
92
92
  }
93
-
94
93
  return { totalScore: score, bestLine, bestLineScore };
95
94
  }
96
95
 
96
+ function scoreContentDefinitions(content, tokens) {
97
+ const defRegex = /^(?:pub\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(function|class|def|fn|const|let|interface|type|struct|enum)\s+([a-zA-Z0-9_$]+)/;
98
+ return accumulateContentScore(content.split("\n"), tokens, defRegex);
99
+ }
100
+
97
101
  function relativeHasSegment(relativePath, segmentName) {
98
102
  return relativePath.split(path.sep).includes(segmentName);
99
103
  }
@@ -102,78 +106,83 @@ function relativeHasHiddenSegment(relativePath) {
102
106
  return relativePath.split(path.sep).some((segment) => segment.startsWith(".") && segment.length > 1);
103
107
  }
104
108
 
105
- export async function executeSnap({ query, searchDir, includeHidden = false, vfs, runCommand, pendingPaths = [] }) {
106
- const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
107
- if (tokens.length === 0) {
108
- throw new Error("snap requires at least one searchable concept keyword");
109
- }
110
-
111
- const dir = searchDir || process.cwd();
112
- if (path.resolve(dir).split(path.sep).includes(".git")) {
113
- throw new Error("snap cannot search Git metadata");
114
- }
115
- let fileList = [];
109
+ async function listCandidateFiles(dir, includeHidden, runCommand) {
110
+ const rgArgs = ["rg", "--files"];
111
+ if (includeHidden) rgArgs.push("--hidden");
112
+ rgArgs.push("-g", "!.git/**", "-g", "!**/.git/**", dir);
116
113
  try {
117
- const rgArgs = ["rg", "--files"];
118
- if (includeHidden) rgArgs.push("--hidden");
119
- rgArgs.push("-g", "!.git/**", "-g", "!**/.git/**", dir);
120
114
  const res = await runCommand(rgArgs, { timeoutMs: 15_000 });
121
- fileList = res.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
115
+ // rg --files is multithreaded and emits in nondeterministic order; ties must rank stably.
116
+ return res.stdout.split("\n").map((f) => f.trim()).filter(Boolean).sort();
122
117
  } catch {
123
- fileList = [];
118
+ return [];
124
119
  }
120
+ }
125
121
 
122
+ function mergePendingPaths(fileList, pendingPaths, dir, includeHidden = false) {
123
+ const resolvedDir = path.resolve(dir);
126
124
  const seenPaths = new Set(fileList.map((filePath) => path.resolve(filePath)));
127
125
  for (const pendingPath of pendingPaths) {
128
126
  const absolutePath = path.resolve(pendingPath);
129
- const relativePath = path.relative(path.resolve(dir), absolutePath);
127
+ const relativePath = path.relative(resolvedDir, absolutePath);
130
128
  const escapesDir = relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
131
129
  const hiddenRelativePath = relativeHasHiddenSegment(relativePath);
132
130
  if (escapesDir || relativeHasSegment(relativePath, ".git") || (!includeHidden && hiddenRelativePath) || seenPaths.has(absolutePath)) continue;
133
131
  seenPaths.add(absolutePath);
134
132
  fileList.push(absolutePath);
135
133
  }
134
+ return fileList;
135
+ }
136
136
 
137
- if (fileList.length === 0) {
138
- throw new Error(`no files found to search in ${dir}`);
137
+ function mergeGrepHits(candidates, grepHits) {
138
+ const seen = new Set(candidates);
139
+ for (const h of grepHits) {
140
+ if (seen.has(h)) continue;
141
+ seen.add(h);
142
+ candidates.push(h);
143
+ if (candidates.length >= 15) break;
139
144
  }
145
+ return candidates;
146
+ }
140
147
 
141
- const scoredPaths = [];
142
- for (const f of fileList) {
143
- const score = scorePathTopology(f, tokens, { wantsTest, wantsDoc, wantsType });
144
- if (score > 0) scoredPaths.push({ path: f, score });
148
+ async function expandCandidatesWithGrep(candidates, fileList, tokens, flags, dir, includeHidden, runCommand) {
149
+ if (candidates.length >= 5) return candidates;
150
+ try {
151
+ // Tokens are lowercased; identifiers are not.
152
+ const grepArgs = ["-l", "-i", "--max-count=1", "-g", "!.git/**", "-g", "!**/.git/**"];
153
+ if (includeHidden) grepArgs.push("--hidden");
154
+ if (!flags.wantsTest) {
155
+ grepArgs.push("-g", "!test/**", "-g", "!tests/**", "-g", "!*.test.*", "-g", "!*.spec.*");
156
+ }
157
+ const salient = tokens.filter((t) => t.length > 2).slice(0, 4);
158
+ for (const t of salient) grepArgs.push("-e", t);
159
+ const res = await runCommand(["rg", ...grepArgs, dir], { timeoutMs: 15_000 });
160
+ mergeGrepHits(candidates, res.stdout.split("\n").map((f) => f.trim()).filter(Boolean).sort());
161
+ } catch {
162
+ if (candidates.length === 0) candidates = fileList.slice(0, 5);
145
163
  }
164
+ return candidates;
165
+ }
146
166
 
147
- scoredPaths.sort((a, b) => b.score - a.score);
148
-
149
- let candidates = scoredPaths.filter((p) => p.score >= 25).slice(0, 10).map((p) => p.path);
150
-
151
- if (candidates.length < 5) {
152
- try {
153
- const grepArgs = ["-l", "--max-count=1", "-g", "!.git/**", "-g", "!**/.git/**"];
154
- if (includeHidden) grepArgs.push("--hidden");
155
- if (!wantsTest) {
156
- grepArgs.push("-g", "!test/**", "-g", "!tests/**", "-g", "!*.test.*", "-g", "!*.spec.*");
157
- }
158
- const salient = tokens.filter((t) => t.length > 2).slice(0, 4);
159
- for (const t of salient) grepArgs.push("-e", t);
160
- const res = await runCommand(["rg", ...grepArgs, dir], { timeoutMs: 15_000 });
161
- const grepHits = res.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
162
- const seen = new Set(candidates);
163
- for (const h of grepHits) {
164
- if (!seen.has(h)) {
165
- seen.add(h);
166
- candidates.push(h);
167
- if (candidates.length >= 15) break;
168
- }
169
- }
170
- } catch {
171
- if (candidates.length === 0) candidates = fileList.slice(0, 5);
167
+ function scoreSurfaceItems(items, tokens, fallbackLine) {
168
+ let bonus = 0;
169
+ let signature = "";
170
+ let anchorLine = fallbackLine;
171
+ for (const item of items) {
172
+ const nameLower = item.name.toLowerCase();
173
+ for (const token of tokens) {
174
+ if (!nameLower.includes(token)) continue;
175
+ bonus += item.isExport ? 80 : 50;
176
+ if (signature) continue;
177
+ signature = item.signature;
178
+ anchorLine = item.line;
172
179
  }
173
180
  }
181
+ return { bonus, signature, anchorLine };
182
+ }
174
183
 
184
+ async function scoreCandidateContents(candidates, tokens, flags, vfs) {
175
185
  const candidateScores = [];
176
-
177
186
  for (const filePath of candidates) {
178
187
  let content = "";
179
188
  try {
@@ -181,71 +190,81 @@ export async function executeSnap({ query, searchDir, includeHidden = false, vfs
181
190
  } catch {
182
191
  continue;
183
192
  }
184
-
185
193
  const { totalScore, bestLine, bestLineScore } = scoreContentDefinitions(content, tokens);
186
- const ext = path.extname(filePath);
187
- const surface = extractStructuralSurface(content, ext);
188
-
189
- let surfaceBonus = 0;
190
- let signature = "";
191
- let anchorLine = bestLine;
192
-
193
- for (const item of surface.items) {
194
- const nameLower = item.name.toLowerCase();
195
- for (const token of tokens) {
196
- if (nameLower.includes(token)) {
197
- surfaceBonus += item.isExport ? 80 : 50;
198
- if (!signature) {
199
- signature = item.signature;
200
- anchorLine = item.line;
201
- }
202
- }
203
- }
204
- }
205
-
206
- const isTestFile = filePath.toLowerCase().includes("test") || filePath.toLowerCase().includes("spec");
207
- const testAdjustment = isTestFile && !wantsTest ? -200 : (isTestFile && wantsTest ? 100 : 0);
208
- const finalScore = totalScore + surfaceBonus + (bestLineScore * 2) + testAdjustment;
194
+ const surface = extractStructuralSurface(content, path.extname(filePath));
195
+ const { bonus: surfaceBonus, signature, anchorLine } = scoreSurfaceItems(surface.items, tokens, bestLine);
196
+ const lowerPath = filePath.toLowerCase();
197
+ const isTestFile = lowerPath.includes("test") || lowerPath.includes("spec");
198
+ const testAdjustment = !isTestFile ? 0 : (flags.wantsTest ? 100 : -200);
209
199
  candidateScores.push({
210
200
  path: filePath,
211
- score: finalScore,
201
+ score: totalScore + surfaceBonus + (bestLineScore * 2) + testAdjustment,
212
202
  anchorLine,
213
203
  signature,
214
204
  content,
215
205
  });
216
206
  }
217
-
218
207
  candidateScores.sort((a, b) => b.score - a.score);
208
+ return candidateScores;
209
+ }
210
+
211
+ async function rankCandidates(fileList, tokens, flags, dir, includeHidden, runCommand, vfs) {
212
+ const scoredPaths = [];
213
+ for (const f of fileList) {
214
+ const score = scorePathTopology(f, tokens, flags);
215
+ if (score > 0) scoredPaths.push({ path: f, score });
216
+ }
217
+ scoredPaths.sort((a, b) => b.score - a.score);
218
+ const selected = scoredPaths.filter((p) => p.score >= 25).slice(0, 10).map((p) => p.path);
219
+ const candidates = await expandCandidatesWithGrep(selected, fileList, tokens, flags, dir, includeHidden, runCommand);
220
+ const candidateScores = await scoreCandidateContents(candidates, tokens, flags, vfs);
221
+ return { candidates, candidateScores };
222
+ }
219
223
 
224
+ function buildSnapResult(candidates, candidateScores, fileList) {
220
225
  if (candidateScores.length === 0 || candidateScores[0].score <= 0) {
221
- const fallbackPath = candidates[0] || fileList[0];
222
226
  return {
223
- path: fallbackPath,
227
+ path: candidates[0] || fileList[0],
224
228
  line: 1,
225
229
  signature: "",
226
230
  confidence: 0.3,
227
231
  context: [],
228
232
  };
229
233
  }
230
-
231
234
  const best = candidateScores[0];
232
235
  const lines = best.content.split("\n");
233
236
  const startLine = Math.max(1, best.anchorLine - 3);
234
237
  const endLine = Math.min(lines.length, best.anchorLine + 8);
235
-
236
- const contextLines = [];
238
+ const context = [];
237
239
  for (let l = startLine; l <= endLine; l++) {
238
240
  const marker = l === best.anchorLine ? "►" : " ";
239
- contextLines.push(`${marker} ${String(l).padStart(4)} │ ${lines[l - 1]}`);
241
+ context.push(`${marker} ${String(l).padStart(4)} │ ${lines[l - 1]}`);
240
242
  }
241
-
242
243
  const confidence = Math.min(0.98, Math.max(0.65, best.score / 150));
243
-
244
244
  return {
245
245
  path: best.path,
246
246
  line: best.anchorLine,
247
247
  signature: best.signature,
248
248
  confidence: Number(confidence.toFixed(2)),
249
- context: contextLines,
249
+ context,
250
250
  };
251
251
  }
252
+
253
+ export async function executeSnap({ query, searchDir, includeHidden = false, vfs, runCommand, pendingPaths = [] }) {
254
+ const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
255
+ if (tokens.length === 0) {
256
+ throw new Error("snap requires at least one searchable concept keyword");
257
+ }
258
+ const dir = searchDir || process.cwd();
259
+ if (path.resolve(dir).split(path.sep).includes(".git")) {
260
+ throw new Error("snap cannot search Git metadata");
261
+ }
262
+ const fileList = await listCandidateFiles(dir, includeHidden, runCommand);
263
+ mergePendingPaths(fileList, pendingPaths, dir, includeHidden);
264
+ if (fileList.length === 0) {
265
+ throw new Error(`no files found to search in ${dir}`);
266
+ }
267
+ const flags = { wantsTest, wantsDoc, wantsType };
268
+ const { candidates, candidateScores } = await rankCandidates(fileList, tokens, flags, dir, includeHidden, runCommand, vfs);
269
+ return buildSnapResult(candidates, candidateScores, fileList);
270
+ }
package/surface.js CHANGED
@@ -61,45 +61,28 @@ function scanGo(lines) {
61
61
  return items;
62
62
  }
63
63
 
64
+ const JS_DECL_PATTERNS = [
65
+ [/^export\s+(?:default\s+)?(?:async\s+)?(function\*?|class|const|let|var|type|interface|enum)\s+([a-zA-Z0-9_$]+)/, true],
66
+ [/^(?:async\s+)?(function\*?|class)\s+([a-zA-Z0-9_$]+)/, false],
67
+ [/^(interface|type)\s+([a-zA-Z0-9_$]+)/, false],
68
+ ];
69
+
64
70
  function scanJavaScript(lines) {
65
71
  const items = [];
66
72
  for (let i = 0; i < lines.length; i++) {
67
73
  const line = lines[i].trim();
68
74
  if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
69
-
70
- const expMatch = /^export\s+(?:default\s+)?(?:async\s+)?(function\*?|class|const|let|var|type|interface|enum)\s+([a-zA-Z0-9_$]+)/.exec(line);
71
- if (expMatch) {
72
- items.push({
73
- kind: expMatch[1],
74
- name: expMatch[2],
75
- isExport: true,
76
- signature: line.replace(/\{.*$/, "").trim(),
77
- line: i + 1,
78
- });
79
- continue;
80
- }
81
-
82
- const declMatch = /^(?:async\s+)?(function\*?|class)\s+([a-zA-Z0-9_$]+)/.exec(line);
83
- if (declMatch) {
84
- items.push({
85
- kind: declMatch[1],
86
- name: declMatch[2],
87
- isExport: false,
88
- signature: line.replace(/\{.*$/, "").trim(),
89
- line: i + 1,
90
- });
91
- continue;
92
- }
93
-
94
- const tsMatch = /^(interface|type)\s+([a-zA-Z0-9_$]+)/.exec(line);
95
- if (tsMatch) {
75
+ for (const [pattern, isExport] of JS_DECL_PATTERNS) {
76
+ const match = pattern.exec(line);
77
+ if (!match) continue;
96
78
  items.push({
97
- kind: tsMatch[1],
98
- name: tsMatch[2],
99
- isExport: false,
79
+ kind: match[1],
80
+ name: match[2],
81
+ isExport,
100
82
  signature: line.replace(/\{.*$/, "").trim(),
101
83
  line: i + 1,
102
84
  });
85
+ break;
103
86
  }
104
87
  }
105
88
  return items;
package/vfs.js ADDED
@@ -0,0 +1,138 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ const VFS_CACHE_MAX = 1024;
5
+
6
+ export class CausalVfs {
7
+ constructor() {
8
+ this.cache = new Map();
9
+ this.overlays = [];
10
+ }
11
+
12
+ setCache(target, content) {
13
+ if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) {
14
+ const oldest = this.cache.keys().next().value;
15
+ if (oldest !== undefined) this.cache.delete(oldest);
16
+ }
17
+ this.cache.set(target, content);
18
+ }
19
+
20
+ getOverlay(target) {
21
+ for (let i = this.overlays.length - 1; i >= 0; i--) {
22
+ if (this.overlays[i].has(target)) return this.overlays[i].get(target);
23
+ }
24
+ return undefined;
25
+ }
26
+
27
+ getOverlayPaths() {
28
+ const paths = new Set();
29
+ for (const overlay of this.overlays) {
30
+ for (const target of overlay.keys()) paths.add(target);
31
+ }
32
+ return [...paths];
33
+ }
34
+
35
+ async read(target) {
36
+ const overlay = this.getOverlay(target);
37
+ if (overlay !== undefined) return overlay;
38
+
39
+ const cached = this.cache.get(target);
40
+ if (cached !== undefined) return cached;
41
+
42
+ try {
43
+ const text = await fs.readFile(target, "utf8");
44
+ this.setCache(target, text);
45
+ return text;
46
+ } catch (err) {
47
+ if (err.code === "EISDIR") {
48
+ throw new Error(`read path is a directory, not a file: ${target}`);
49
+ }
50
+ if (err.code === "ENOENT") {
51
+ const missing = new Error(`no such file: ${target} (locate it with nova.call("glob", {pattern}) or snap(query))`);
52
+ missing.code = "ENOENT";
53
+ throw missing;
54
+ }
55
+ throw err;
56
+ }
57
+ }
58
+
59
+ async write(target, content) {
60
+ if (this.overlays.length > 0) {
61
+ this.overlays[this.overlays.length - 1].set(target, content);
62
+ return { speculative: true };
63
+ }
64
+
65
+ try {
66
+ const stat = await fs.stat(target);
67
+ if (stat.isDirectory()) {
68
+ throw new Error(`cannot write to a directory: ${target}`);
69
+ }
70
+ } catch (err) {
71
+ if (err.code !== "ENOENT") throw err;
72
+ }
73
+
74
+ await fs.mkdir(path.dirname(target), { recursive: true });
75
+ await fs.writeFile(target, content, "utf8");
76
+ this.setCache(target, content);
77
+ return { speculative: false };
78
+ }
79
+
80
+ begin() {
81
+ this.overlays.push(new Map());
82
+ return this.overlays.length;
83
+ }
84
+
85
+ async commit() {
86
+ if (this.overlays.length === 0) return { committed: 0, depth: 0 };
87
+ const top = this.overlays.pop();
88
+ if (this.overlays.length > 0) {
89
+ const parent = this.overlays[this.overlays.length - 1];
90
+ for (const [k, v] of top.entries()) parent.set(k, v);
91
+ return { committed: top.size, depth: this.overlays.length };
92
+ }
93
+ for (const [filePath, fileContent] of top.entries()) {
94
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
95
+ await fs.writeFile(filePath, fileContent, "utf8");
96
+ this.setCache(filePath, fileContent);
97
+ }
98
+ return { committed: top.size, depth: 0 };
99
+ }
100
+
101
+ rollback() {
102
+ if (this.overlays.length === 0) return { rolledBack: 0, depth: 0 };
103
+ const top = this.overlays.pop();
104
+ return { rolledBack: top.size, depth: this.overlays.length };
105
+ }
106
+
107
+ async prepareExternalMutation(name) {
108
+ if (this.overlays.length > 1) {
109
+ throw new Error(`${name} cannot run inside nova.speculate because external mutations cannot be rolled back`);
110
+ }
111
+ if (this.overlays.length === 0) return false;
112
+ const pending = this.overlays[0];
113
+ for (const [filePath, fileContent] of pending.entries()) {
114
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
115
+ await fs.writeFile(filePath, fileContent, "utf8");
116
+ this.setCache(filePath, fileContent);
117
+ }
118
+ this.overlays[0] = new Map();
119
+ return pending.size > 0;
120
+ }
121
+
122
+ invalidateCache() {
123
+ this.cache.clear();
124
+ }
125
+
126
+ clear() {
127
+ this.invalidateCache();
128
+ this.overlays.length = 0;
129
+ }
130
+
131
+ getCacheSize() {
132
+ return this.cache.size;
133
+ }
134
+
135
+ getOverlayDepth() {
136
+ return this.overlays.length;
137
+ }
138
+ }
package/workspace.js ADDED
@@ -0,0 +1,112 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { isString } from "./decode.js";
5
+
6
+ let cachedCwd = null;
7
+ let cachedResolvedCwd = null;
8
+
9
+ function getResolvedCwd(cwd) {
10
+ if (cwd === cachedCwd && cachedResolvedCwd) return cachedResolvedCwd;
11
+ cachedCwd = cwd;
12
+ cachedResolvedCwd = path.resolve(cwd);
13
+ return cachedResolvedCwd;
14
+ }
15
+
16
+ function assertInside(rel, message) {
17
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
18
+ throw new Error(message);
19
+ }
20
+ }
21
+
22
+ async function realpathNearest(target) {
23
+ let probe = target;
24
+ while (true) {
25
+ try {
26
+ return await fs.realpath(probe);
27
+ } catch (err) {
28
+ if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
29
+ const parent = path.dirname(probe);
30
+ if (parent === probe) throw err;
31
+ probe = parent;
32
+ }
33
+ }
34
+ }
35
+
36
+ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false) {
37
+ if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
38
+ throw new Error(`${opName} requires path`);
39
+ }
40
+ const resolvedCwd = getResolvedCwd(cwd);
41
+ const target = path.resolve(resolvedCwd, inputPath.trim());
42
+ assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
43
+ if (!allowRoot && target === resolvedCwd) {
44
+ throw new Error(`${opName} path cannot be the workspace root directory`);
45
+ }
46
+ const realRoot = await fs.realpath(resolvedCwd);
47
+ const probe = await realpathNearest(target);
48
+ assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink`);
49
+ return target;
50
+ }
51
+
52
+ export async function runCommand(argv, options = {}) {
53
+ const cwd = options.cwd || process.cwd();
54
+ const timeoutMs = options.timeoutMs ?? 60_000;
55
+ const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
56
+ return await new Promise((resolve, reject) => {
57
+ const child = spawn(argv[0], argv.slice(1), {
58
+ cwd,
59
+ env: process.env,
60
+ stdio: ["ignore", "pipe", "pipe"],
61
+ });
62
+ let stdout = "";
63
+ let stderr = "";
64
+ let settled = false;
65
+ let outputTruncated = false;
66
+ let onAbort;
67
+
68
+ const cleanup = () => {
69
+ clearTimeout(timer);
70
+ if (options.signal && onAbort) options.signal.removeEventListener("abort", onAbort);
71
+ };
72
+ const fail = (err) => {
73
+ if (settled) return;
74
+ settled = true;
75
+ cleanup();
76
+ reject(err);
77
+ };
78
+ const append = (current, chunk) => {
79
+ const remaining = Math.max(0, maxOutputChars - current.length);
80
+ if (chunk.length > remaining) outputTruncated = true;
81
+ return remaining > 0 ? current + chunk.slice(0, remaining) : current;
82
+ };
83
+ const timer = setTimeout(() => {
84
+ child.kill("SIGTERM");
85
+ fail(new Error(`command timed out after ${timeoutMs}ms: ${argv.join(" ")}`));
86
+ }, timeoutMs);
87
+
88
+ child.stdout.setEncoding("utf8");
89
+ child.stderr.setEncoding("utf8");
90
+ child.stdout.on("data", (chunk) => {
91
+ stdout = append(stdout, chunk);
92
+ });
93
+ child.stderr.on("data", (chunk) => {
94
+ stderr = append(stderr, chunk);
95
+ });
96
+ child.on("error", fail);
97
+ child.on("close", (code) => {
98
+ if (settled) return;
99
+ settled = true;
100
+ cleanup();
101
+ resolve({ stdout, stderr, exitCode: code ?? 0, outputTruncated });
102
+ });
103
+ if (options.signal) {
104
+ onAbort = () => {
105
+ child.kill("SIGTERM");
106
+ fail(new Error("aborted"));
107
+ };
108
+ if (options.signal.aborted) onAbort();
109
+ else options.signal.addEventListener("abort", onAbort, { once: true });
110
+ }
111
+ });
112
+ }