@pushpalsdev/cli 1.0.81 → 1.0.82

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushpalsdev/cli",
3
- "version": "1.0.81",
3
+ "version": "1.0.82",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1742,6 +1742,27 @@ var KNOWN_TOOL_NAMES = new Set([
1742
1742
  "python",
1743
1743
  "shell"
1744
1744
  ]);
1745
+ // packages/shared/src/toolchain.ts
1746
+ var SHELL_CONTROL_TOKENS = new Set(["|", "||", "&", "&&", ";", ">", ">>", "<", "<<"]);
1747
+ var NODE_BACKED_CLI_NAMES = new Set([
1748
+ "astro",
1749
+ "babel",
1750
+ "cypress",
1751
+ "eslint",
1752
+ "expo",
1753
+ "jest",
1754
+ "metro",
1755
+ "next",
1756
+ "nuxt",
1757
+ "playwright",
1758
+ "react-native",
1759
+ "rollup",
1760
+ "tsc",
1761
+ "tsx",
1762
+ "vite",
1763
+ "vitest",
1764
+ "webpack"
1765
+ ]);
1745
1766
  // packages/shared/src/session_event_visibility.ts
1746
1767
  var ALWAYS_VISIBLE_EVENT_TYPES = new Set(["question_asked"]);
1747
1768
  // packages/shared/src/localbuddy_runtime.ts
@@ -9,6 +9,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
9
9
  git \
10
10
  curl \
11
11
  ca-certificates \
12
+ build-essential \
13
+ cmake \
14
+ pkg-config \
15
+ nodejs \
16
+ npm \
12
17
  openssh-client \
13
18
  grep \
14
19
  jq \
@@ -11,14 +11,18 @@ import {
11
11
  explicitSourceControlCommitIdentityFromEnv,
12
12
  loadPromptTemplate,
13
13
  loadPushPalsConfig,
14
+ buildToolchainPlan,
14
15
  extractVisionKeyItems,
16
+ formatToolRequirement,
15
17
  matchesGlob,
16
18
  normalizeAutonomyComponentArea,
17
19
  normalizeTargetPath,
20
+ requirementsForValidationCommand,
18
21
  sanitizeSourceControlIdentityField,
19
22
  validateScopeInvariants,
20
23
  type AutonomyComponentArea,
21
24
  type SourceControlCommitIdentity,
25
+ type ToolRequirement,
22
26
  } from "shared";
23
27
  import { resolveExecutor, type WorkerpalsRuntimeConfig } from "./common/executor_backend.js";
24
28
  import type { JobPublishBlockedInfo, JobResult } from "./common/types.js";
@@ -586,6 +590,99 @@ async function runValidationCommand(
586
590
  };
587
591
  }
588
592
 
593
+ interface ToolAvailabilityResult {
594
+ requirement: ToolRequirement;
595
+ ok: boolean;
596
+ candidate: string | null;
597
+ detail: string;
598
+ }
599
+
600
+ function toolProbeArgv(candidate: string): string[] {
601
+ const normalized = candidate.toLowerCase();
602
+ if (normalized === "sh") {
603
+ return [candidate, "-c", "exit 0"];
604
+ }
605
+ if (normalized === "cmd") {
606
+ return [candidate, "/c", "exit 0"];
607
+ }
608
+ if (normalized === "bash") {
609
+ return [candidate, "-lc", "exit 0"];
610
+ }
611
+ if (normalized === "powershell" || normalized === "pwsh") {
612
+ return [candidate, "-NoProfile", "-Command", "exit 0"];
613
+ }
614
+ return [candidate, "--version"];
615
+ }
616
+
617
+ async function checkToolCandidate(candidate: string, timeoutMs = 5_000): Promise<boolean> {
618
+ try {
619
+ const proc = Bun.spawn(toolProbeArgv(candidate), {
620
+ stdout: "pipe",
621
+ stderr: "pipe",
622
+ });
623
+ let timedOut = false;
624
+ const timer = setTimeout(() => {
625
+ timedOut = true;
626
+ try {
627
+ proc.kill();
628
+ } catch {
629
+ // ignore
630
+ }
631
+ }, Math.max(1_000, timeoutMs));
632
+ try {
633
+ const [exitCode] = await Promise.all([
634
+ proc.exited,
635
+ new Response(proc.stdout).text().catch(() => ""),
636
+ new Response(proc.stderr).text().catch(() => ""),
637
+ ]);
638
+ return !timedOut && exitCode === 0;
639
+ } finally {
640
+ clearTimeout(timer);
641
+ }
642
+ } catch {
643
+ return false;
644
+ }
645
+ }
646
+
647
+ async function checkToolAvailability(
648
+ requirements: ToolRequirement[],
649
+ ): Promise<ToolAvailabilityResult[]> {
650
+ const cache = new Map<string, Promise<boolean>>();
651
+ const check = (candidate: string) => {
652
+ const key = candidate.toLowerCase();
653
+ let cached = cache.get(key);
654
+ if (!cached) {
655
+ cached = checkToolCandidate(candidate);
656
+ cache.set(key, cached);
657
+ }
658
+ return cached;
659
+ };
660
+
661
+ const out: ToolAvailabilityResult[] = [];
662
+ for (const requirement of requirements) {
663
+ let availableCandidate: string | null = null;
664
+ for (const candidate of requirement.candidates) {
665
+ if (await check(candidate)) {
666
+ availableCandidate = candidate;
667
+ break;
668
+ }
669
+ }
670
+ out.push({
671
+ requirement,
672
+ ok: Boolean(availableCandidate),
673
+ candidate: availableCandidate,
674
+ detail: availableCandidate
675
+ ? `${availableCandidate} is available`
676
+ : `missing ${formatToolRequirement(requirement)}`,
677
+ });
678
+ }
679
+ return out;
680
+ }
681
+
682
+ function formatMissingToolRequirements(requirements: ToolRequirement[]): string {
683
+ return requirements.map(formatToolRequirement).join(", ");
684
+ }
685
+
589
686
  function extractPreparedMergeConflictPaths(params: Record<string, unknown>): string[] {
590
687
  const reviewAgent =
591
688
  params.reviewAgent && typeof params.reviewAgent === "object" && !Array.isArray(params.reviewAgent)
@@ -607,6 +704,20 @@ function detectValidationBlocker(runs: ValidationExecutionResult[]): ValidationB
607
704
  .toLowerCase();
608
705
  if (!combined) return null;
609
706
 
707
+ if (
708
+ combined.includes("validation skipped before execution because required tool") ||
709
+ combined.includes("missing required tool") ||
710
+ combined.includes("command not found") ||
711
+ combined.includes("executable not found") ||
712
+ combined.includes("not recognized as an internal or external command")
713
+ ) {
714
+ return {
715
+ category: "environment",
716
+ detail:
717
+ "Validation is blocked by missing required toolchain executables in the worker environment. Install/provision the missing tools or declare a supported repo toolchain before retrying this job.",
718
+ };
719
+ }
720
+
610
721
  if (
611
722
  combined.includes("cannot find module") ||
612
723
  combined.includes("module not found") ||
@@ -1086,7 +1197,54 @@ async function runDeterministicQualityGate(
1086
1197
  `[QualityGate] No runnable planning.validationSteps found; using fallback validation command(s): ${commandsToRun.join(" | ")}`,
1087
1198
  );
1088
1199
  }
1200
+ const toolchainPlan = buildToolchainPlan({
1201
+ repoRoot: repo,
1202
+ validationCommands: commandsToRun,
1203
+ });
1204
+ if (toolchainPlan.requirements.length > 0) {
1205
+ onLog?.(
1206
+ "stdout",
1207
+ `[QualityGate] Toolchain preflight: source=${toolchainPlan.environmentSource}, required=${toolchainPlan.requirements
1208
+ .map((requirement) => requirement.tool)
1209
+ .join(", ")}`,
1210
+ );
1211
+ }
1212
+ const toolAvailability = await checkToolAvailability(toolchainPlan.requirements);
1213
+ const missingToolRequirements = toolAvailability
1214
+ .filter((entry) => !entry.ok)
1215
+ .map((entry) => entry.requirement);
1216
+ if (missingToolRequirements.length > 0) {
1217
+ onLog?.(
1218
+ "stderr",
1219
+ `[QualityGate] Toolchain preflight blocked dependent validation command(s): ${formatMissingToolRequirements(
1220
+ missingToolRequirements,
1221
+ )}`,
1222
+ );
1223
+ }
1089
1224
  for (const command of commandsToRun) {
1225
+ const commandMissingTools = requirementsForValidationCommand(toolchainPlan, command).filter(
1226
+ (requirement) =>
1227
+ missingToolRequirements.some((missing) => missing.tool === requirement.tool),
1228
+ );
1229
+ if (commandMissingTools.length > 0) {
1230
+ const stderr = `Validation skipped before execution because required tool(s) are missing: ${formatMissingToolRequirements(
1231
+ commandMissingTools,
1232
+ )}.`;
1233
+ validationRuns.push({
1234
+ step: command,
1235
+ command,
1236
+ ok: false,
1237
+ exitCode: 127,
1238
+ stdout: "",
1239
+ stderr,
1240
+ elapsedMs: 1,
1241
+ });
1242
+ onLog?.(
1243
+ "stderr",
1244
+ `[QualityGate] Quality gate validation skipped (missing toolchain): ${command}`,
1245
+ );
1246
+ continue;
1247
+ }
1090
1248
  onLog?.("stdout", `[QualityGate] Quality gate validation: running "${command}"`);
1091
1249
  const run = await runValidationCommand(
1092
1250
  repo,
@@ -73,6 +73,17 @@ export {
73
73
  type ToolRegistry,
74
74
  type ToolRunRecord,
75
75
  } from "./tooling.js";
76
+ export {
77
+ buildToolchainPlan,
78
+ formatToolRequirement,
79
+ inferToolRequirementsForValidationCommand,
80
+ requirementsForValidationCommand,
81
+ tokenizeToolchainCommand,
82
+ type BuildToolchainPlanOptions,
83
+ type ToolRequirement,
84
+ type ToolchainEnvironmentSource,
85
+ type ToolchainPlan,
86
+ } from "./toolchain.js";
76
87
  export {
77
88
  DEFAULT_WORKERPALS_EXECUTOR,
78
89
  invalidatePushPalsConfigCache,
@@ -0,0 +1,509 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
2
+ import { join, normalize } from "path";
3
+
4
+ export type ToolchainEnvironmentSource =
5
+ | "devcontainer"
6
+ | "dockerfile"
7
+ | "mise"
8
+ | "asdf"
9
+ | "nix"
10
+ | "pushpals-default-sandbox";
11
+
12
+ export interface ToolRequirement {
13
+ tool: string;
14
+ candidates: string[];
15
+ reason: string;
16
+ detectedFrom: string;
17
+ requiredFor: string[];
18
+ optional?: boolean;
19
+ }
20
+
21
+ export interface ToolchainPlan {
22
+ requirements: ToolRequirement[];
23
+ environmentSource: ToolchainEnvironmentSource;
24
+ }
25
+
26
+ export interface BuildToolchainPlanOptions {
27
+ repoRoot: string;
28
+ validationCommands: string[];
29
+ maxNativeScanEntries?: number;
30
+ }
31
+
32
+ const SHELL_CONTROL_TOKENS = new Set(["|", "||", "&", "&&", ";", ">", ">>", "<", "<<"]);
33
+
34
+ const NODE_BACKED_CLI_NAMES = new Set([
35
+ "astro",
36
+ "babel",
37
+ "cypress",
38
+ "eslint",
39
+ "expo",
40
+ "jest",
41
+ "metro",
42
+ "next",
43
+ "nuxt",
44
+ "playwright",
45
+ "react-native",
46
+ "rollup",
47
+ "tsc",
48
+ "tsx",
49
+ "vite",
50
+ "vitest",
51
+ "webpack",
52
+ ]);
53
+
54
+ const DIRECT_TOOL_CANDIDATES: Record<string, string[]> = {
55
+ bash: ["bash"],
56
+ bun: ["bun"],
57
+ bunx: ["bun"],
58
+ cargo: ["cargo"],
59
+ cc: ["cc"],
60
+ clang: ["clang"],
61
+ "clang++": ["clang++"],
62
+ cmake: ["cmake"],
63
+ cypress: ["cypress"],
64
+ docker: ["docker"],
65
+ eslint: ["eslint"],
66
+ expo: ["expo"],
67
+ gcc: ["gcc"],
68
+ "g++": ["g++"],
69
+ gh: ["gh"],
70
+ go: ["go"],
71
+ java: ["java"],
72
+ javac: ["javac"],
73
+ make: ["make"],
74
+ mvn: ["mvn"],
75
+ next: ["next"],
76
+ ninja: ["ninja"],
77
+ node: ["node"],
78
+ npm: ["npm"],
79
+ npx: ["npx"],
80
+ playwright: ["playwright"],
81
+ pnpm: ["pnpm"],
82
+ powershell: ["powershell"],
83
+ pwsh: ["pwsh"],
84
+ python: ["python3", "python", "py"],
85
+ python3: ["python3", "python"],
86
+ pytest: ["python3", "python", "py"],
87
+ rustc: ["rustc"],
88
+ sh: ["sh"],
89
+ tsc: ["tsc"],
90
+ vite: ["vite"],
91
+ vitest: ["vitest"],
92
+ yarn: ["yarn"],
93
+ };
94
+
95
+ interface NativeSignals {
96
+ hasC: boolean;
97
+ hasCxx: boolean;
98
+ hasMakefile: boolean;
99
+ hasCMake: boolean;
100
+ }
101
+
102
+ export function tokenizeToolchainCommand(command: string): string[] | null {
103
+ const input = command.trim();
104
+ if (!input) return null;
105
+ const out: string[] = [];
106
+ let current = "";
107
+ let quote: "'" | '"' | null = null;
108
+
109
+ const pushCurrent = () => {
110
+ const trimmed = current.trim();
111
+ if (trimmed) out.push(trimmed);
112
+ current = "";
113
+ };
114
+
115
+ for (let index = 0; index < input.length; index += 1) {
116
+ const ch = input[index] ?? "";
117
+ if (quote) {
118
+ if (ch === quote) {
119
+ quote = null;
120
+ continue;
121
+ }
122
+ current += ch;
123
+ continue;
124
+ }
125
+ if (ch === "'" || ch === '"') {
126
+ quote = ch;
127
+ continue;
128
+ }
129
+ if (/\s/.test(ch)) {
130
+ pushCurrent();
131
+ continue;
132
+ }
133
+ current += ch;
134
+ }
135
+ if (quote) return null;
136
+ pushCurrent();
137
+ if (out.length === 0) return null;
138
+ if (out.some((token) => SHELL_CONTROL_TOKENS.has(token))) return null;
139
+ return out;
140
+ }
141
+
142
+ export function buildToolchainPlan(options: BuildToolchainPlanOptions): ToolchainPlan {
143
+ const repoRoot = options.repoRoot;
144
+ const nativeSignals = detectNativeSignals(repoRoot, options.maxNativeScanEntries ?? 1_000);
145
+ const requirements: ToolRequirement[] = [];
146
+ for (const command of options.validationCommands) {
147
+ requirements.push(...inferToolRequirementsForValidationCommand(repoRoot, command, nativeSignals));
148
+ }
149
+ return {
150
+ requirements: dedupeToolRequirements(requirements),
151
+ environmentSource: detectToolchainEnvironmentSource(repoRoot),
152
+ };
153
+ }
154
+
155
+ export function inferToolRequirementsForValidationCommand(
156
+ repoRoot: string,
157
+ command: string,
158
+ nativeSignals: NativeSignals = detectNativeSignals(repoRoot),
159
+ ): ToolRequirement[] {
160
+ const tokens = tokenizeToolchainCommand(command);
161
+ if (!tokens) return [];
162
+ const requirements: ToolRequirement[] = [];
163
+ const first = normalizeToolToken(tokens[0] ?? "");
164
+
165
+ addDirectExecutableRequirement(requirements, first, command);
166
+ addNodeBackedCliRequirement(requirements, first, `validation command "${command}"`, command);
167
+
168
+ const bunSubcommand = resolveBunSubcommand(tokens);
169
+ if (bunSubcommand?.kind === "x") {
170
+ addNodeBackedCliRequirement(
171
+ requirements,
172
+ normalizeToolToken(bunSubcommand.value),
173
+ `bun x package "${bunSubcommand.value}"`,
174
+ command,
175
+ );
176
+ }
177
+
178
+ const script = resolvePackageScript(repoRoot, tokens);
179
+ if (script) {
180
+ addScriptRequirements(requirements, script.script, script.detectedFrom, command);
181
+ }
182
+
183
+ if (usesNativeBuildCommand(tokens)) {
184
+ if (nativeSignals.hasC) {
185
+ requirements.push({
186
+ tool: "c-compiler",
187
+ candidates: ["cc", "gcc", "clang"],
188
+ reason: "native C sources may be compiled by this validation command",
189
+ detectedFrom: nativeSignals.hasCMake
190
+ ? "CMakeLists.txt/native source scan"
191
+ : "Makefile/native source scan",
192
+ requiredFor: [command],
193
+ });
194
+ }
195
+ if (nativeSignals.hasCxx) {
196
+ requirements.push({
197
+ tool: "cxx-compiler",
198
+ candidates: ["c++", "g++", "clang++"],
199
+ reason: "native C++ sources may be compiled by this validation command",
200
+ detectedFrom: nativeSignals.hasCMake
201
+ ? "CMakeLists.txt/native source scan"
202
+ : "Makefile/native source scan",
203
+ requiredFor: [command],
204
+ });
205
+ }
206
+ }
207
+
208
+ return dedupeToolRequirements(requirements);
209
+ }
210
+
211
+ export function requirementsForValidationCommand(
212
+ plan: ToolchainPlan,
213
+ command: string,
214
+ ): ToolRequirement[] {
215
+ return plan.requirements.filter((requirement) => requirement.requiredFor.includes(command));
216
+ }
217
+
218
+ export function formatToolRequirement(requirement: ToolRequirement): string {
219
+ const candidates =
220
+ requirement.candidates.length === 1
221
+ ? requirement.candidates[0]
222
+ : `${requirement.tool} (${requirement.candidates.join(" or ")})`;
223
+ return `${candidates} from ${requirement.detectedFrom}`;
224
+ }
225
+
226
+ function addDirectExecutableRequirement(
227
+ requirements: ToolRequirement[],
228
+ tool: string,
229
+ command: string,
230
+ ): void {
231
+ const candidates = DIRECT_TOOL_CANDIDATES[tool];
232
+ if (!candidates) return;
233
+ requirements.push({
234
+ tool: canonicalToolName(tool),
235
+ candidates,
236
+ reason: `validation command invokes ${tool}`,
237
+ detectedFrom: `validation command "${command}"`,
238
+ requiredFor: [command],
239
+ });
240
+ }
241
+
242
+ function addNodeBackedCliRequirement(
243
+ requirements: ToolRequirement[],
244
+ cliName: string,
245
+ detectedFrom: string,
246
+ command: string,
247
+ ): void {
248
+ if (!NODE_BACKED_CLI_NAMES.has(cliName)) return;
249
+ requirements.push({
250
+ tool: "node",
251
+ candidates: ["node"],
252
+ reason: `${cliName} is normally distributed as a Node.js CLI`,
253
+ detectedFrom,
254
+ requiredFor: [command],
255
+ });
256
+ }
257
+
258
+ function addScriptRequirements(
259
+ requirements: ToolRequirement[],
260
+ script: string,
261
+ detectedFrom: string,
262
+ command: string,
263
+ ): void {
264
+ const tokens = tokenizeToolchainCommand(script) ?? script.split(/\s+/).filter(Boolean);
265
+ const first = normalizeToolToken(tokens[0] ?? "");
266
+ addDirectExecutableRequirement(requirements, first, command);
267
+ addNodeBackedCliRequirement(requirements, first, detectedFrom, command);
268
+ for (const token of tokens) {
269
+ addNodeBackedCliRequirement(requirements, normalizeToolToken(token), detectedFrom, command);
270
+ }
271
+ if (/\bnode\b/.test(script)) {
272
+ requirements.push({
273
+ tool: "node",
274
+ candidates: ["node"],
275
+ reason: "package script invokes node directly",
276
+ detectedFrom,
277
+ requiredFor: [command],
278
+ });
279
+ }
280
+ if (/\bbun\b/.test(script)) {
281
+ requirements.push({
282
+ tool: "bun",
283
+ candidates: ["bun"],
284
+ reason: "package script invokes bun",
285
+ detectedFrom,
286
+ requiredFor: [command],
287
+ });
288
+ }
289
+ }
290
+
291
+ function resolveBunSubcommand(tokens: string[]): { kind: "run" | "x"; value: string } | null {
292
+ if (normalizeToolToken(tokens[0] ?? "") !== "bun") return null;
293
+ let index = 1;
294
+ while (index < tokens.length) {
295
+ const token = tokens[index] ?? "";
296
+ if (token === "--cwd" || token === "-C") {
297
+ index += 2;
298
+ continue;
299
+ }
300
+ if (token.startsWith("--")) {
301
+ index += 1;
302
+ continue;
303
+ }
304
+ break;
305
+ }
306
+ const subcommand = normalizeToolToken(tokens[index] ?? "");
307
+ if ((subcommand === "run" || subcommand === "x") && tokens[index + 1]) {
308
+ return { kind: subcommand, value: tokens[index + 1] ?? "" };
309
+ }
310
+ return null;
311
+ }
312
+
313
+ function resolvePackageScript(
314
+ repoRoot: string,
315
+ tokens: string[],
316
+ ): { script: string; detectedFrom: string } | null {
317
+ const first = normalizeToolToken(tokens[0] ?? "");
318
+ let cwd = repoRoot;
319
+ let scriptName = "";
320
+ if (first === "bun") {
321
+ let index = 1;
322
+ while (index < tokens.length) {
323
+ const token = tokens[index] ?? "";
324
+ if ((token === "--cwd" || token === "-C") && tokens[index + 1]) {
325
+ cwd = join(repoRoot, tokens[index + 1] ?? "");
326
+ index += 2;
327
+ continue;
328
+ }
329
+ if (token.startsWith("--")) {
330
+ index += 1;
331
+ continue;
332
+ }
333
+ break;
334
+ }
335
+ if (normalizeToolToken(tokens[index] ?? "") === "run") {
336
+ scriptName = tokens[index + 1] ?? "";
337
+ } else {
338
+ const candidate = tokens[index] ?? "";
339
+ if (candidate && !["install", "test", "x"].includes(normalizeToolToken(candidate))) {
340
+ scriptName = candidate;
341
+ }
342
+ }
343
+ } else if (first === "npm" || first === "pnpm" || first === "yarn") {
344
+ let index = 1;
345
+ while (index < tokens.length) {
346
+ const token = tokens[index] ?? "";
347
+ const normalized = normalizeToolToken(token);
348
+ if (
349
+ (token === "--prefix" ||
350
+ token === "--dir" ||
351
+ token === "--cwd" ||
352
+ token === "-C") &&
353
+ tokens[index + 1]
354
+ ) {
355
+ cwd = join(repoRoot, tokens[index + 1] ?? "");
356
+ index += 2;
357
+ continue;
358
+ }
359
+ if (normalized === "run") {
360
+ scriptName = tokens[index + 1] ?? "";
361
+ break;
362
+ }
363
+ if (!token.startsWith("-")) {
364
+ scriptName = normalized;
365
+ break;
366
+ }
367
+ index += 1;
368
+ }
369
+ }
370
+ if (!scriptName) return null;
371
+
372
+ const packagePath = join(cwd, "package.json");
373
+ if (!existsSync(packagePath)) return null;
374
+ try {
375
+ const parsed = JSON.parse(readFileSync(packagePath, "utf8")) as {
376
+ scripts?: Record<string, unknown>;
377
+ };
378
+ const script = parsed.scripts?.[scriptName];
379
+ if (typeof script !== "string" || !script.trim()) return null;
380
+ return {
381
+ script,
382
+ detectedFrom: `${repoRelativePath(repoRoot, packagePath)} script "${scriptName}"`,
383
+ };
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+
389
+ function detectToolchainEnvironmentSource(repoRoot: string): ToolchainEnvironmentSource {
390
+ if (existsSync(join(repoRoot, ".devcontainer", "devcontainer.json"))) return "devcontainer";
391
+ if (existsSync(join(repoRoot, "devcontainer.json"))) return "devcontainer";
392
+ if (existsSync(join(repoRoot, "Dockerfile"))) return "dockerfile";
393
+ if (existsSync(join(repoRoot, "mise.toml")) || existsSync(join(repoRoot, ".mise.toml"))) {
394
+ return "mise";
395
+ }
396
+ if (existsSync(join(repoRoot, ".tool-versions"))) return "asdf";
397
+ if (existsSync(join(repoRoot, "flake.nix")) || existsSync(join(repoRoot, "shell.nix"))) {
398
+ return "nix";
399
+ }
400
+ return "pushpals-default-sandbox";
401
+ }
402
+
403
+ function detectNativeSignals(repoRoot: string, maxEntries = 1_000): NativeSignals {
404
+ const signals: NativeSignals = {
405
+ hasC: false,
406
+ hasCxx: false,
407
+ hasMakefile:
408
+ existsSync(join(repoRoot, "Makefile")) ||
409
+ existsSync(join(repoRoot, "makefile")) ||
410
+ existsSync(join(repoRoot, "GNUmakefile")),
411
+ hasCMake: existsSync(join(repoRoot, "CMakeLists.txt")),
412
+ };
413
+ const ignored = new Set([
414
+ ".git",
415
+ ".worktrees",
416
+ "node_modules",
417
+ "outputs",
418
+ "dist",
419
+ "build",
420
+ ".next",
421
+ ".expo",
422
+ ]);
423
+ let visited = 0;
424
+ const scan = (dir: string, depth: number) => {
425
+ if (visited >= maxEntries || depth > 4 || (signals.hasC && signals.hasCxx)) return;
426
+ let entries: string[] = [];
427
+ try {
428
+ entries = readdirSync(dir);
429
+ } catch {
430
+ return;
431
+ }
432
+ for (const entry of entries) {
433
+ if (visited >= maxEntries) return;
434
+ if (ignored.has(entry)) continue;
435
+ const fullPath = join(dir, entry);
436
+ visited += 1;
437
+ let stats;
438
+ try {
439
+ stats = statSync(fullPath);
440
+ } catch {
441
+ continue;
442
+ }
443
+ if (stats.isDirectory()) {
444
+ scan(fullPath, depth + 1);
445
+ continue;
446
+ }
447
+ const lower = entry.toLowerCase();
448
+ if (/\.(c|h)$/.test(lower)) signals.hasC = true;
449
+ if (/\.(cc|cpp|cxx|hpp|hh|hxx)$/.test(lower)) signals.hasCxx = true;
450
+ if (lower === "cmakelists.txt") signals.hasCMake = true;
451
+ }
452
+ };
453
+ scan(repoRoot, 0);
454
+ return signals;
455
+ }
456
+
457
+ function usesNativeBuildCommand(tokens: string[]): boolean {
458
+ return tokens.some((token) => {
459
+ const normalized = normalizeToolToken(token);
460
+ return normalized === "make" || normalized === "cmake" || normalized === "ninja";
461
+ });
462
+ }
463
+
464
+ function dedupeToolRequirements(requirements: ToolRequirement[]): ToolRequirement[] {
465
+ const merged = new Map<string, ToolRequirement>();
466
+ for (const requirement of requirements) {
467
+ const key = requirement.tool;
468
+ const existing = merged.get(key);
469
+ if (!existing) {
470
+ merged.set(key, {
471
+ ...requirement,
472
+ candidates: Array.from(new Set(requirement.candidates)),
473
+ requiredFor: Array.from(new Set(requirement.requiredFor)),
474
+ });
475
+ continue;
476
+ }
477
+ for (const candidate of requirement.candidates) {
478
+ if (!existing.candidates.includes(candidate)) existing.candidates.push(candidate);
479
+ }
480
+ for (const command of requirement.requiredFor) {
481
+ if (!existing.requiredFor.includes(command)) existing.requiredFor.push(command);
482
+ }
483
+ if (!existing.detectedFrom.includes(requirement.detectedFrom)) {
484
+ existing.detectedFrom = `${existing.detectedFrom}; ${requirement.detectedFrom}`;
485
+ }
486
+ if (!existing.reason.includes(requirement.reason)) {
487
+ existing.reason = `${existing.reason}; ${requirement.reason}`;
488
+ }
489
+ }
490
+ return Array.from(merged.values()).sort((a, b) => a.tool.localeCompare(b.tool));
491
+ }
492
+
493
+ function canonicalToolName(tool: string): string {
494
+ if (tool === "bunx") return "bun";
495
+ if (tool === "python3" || tool === "pytest") return "python";
496
+ return tool;
497
+ }
498
+
499
+ function normalizeToolToken(token: string): string {
500
+ const normalizedToken = token.trim().replace(/\\/g, "/").split("/").pop() ?? token;
501
+ return normalizedToken.toLowerCase().replace(/\.(cmd|exe|ps1)$/i, "");
502
+ }
503
+
504
+ function repoRelativePath(repoRoot: string, pathValue: string): string {
505
+ const root = normalize(repoRoot).replace(/\\/g, "/").replace(/\/+$/, "");
506
+ const path = normalize(pathValue).replace(/\\/g, "/");
507
+ if (path.startsWith(`${root}/`)) return path.slice(root.length + 1);
508
+ return path;
509
+ }