@pushpalsdev/cli 1.0.81 → 1.0.83
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 +1 -1
- package/runtime/sandbox/.pushpals-remotebuddy-fallback.js +21 -0
- package/runtime/sandbox/apps/workerpals/Dockerfile.sandbox +5 -0
- package/runtime/sandbox/apps/workerpals/src/execute_job.ts +193 -1
- package/runtime/sandbox/packages/shared/src/index.ts +11 -0
- package/runtime/sandbox/packages/shared/src/toolchain.ts +622 -0
package/package.json
CHANGED
|
@@ -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
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Used by both the host Worker (direct mode) and the Docker job runner.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync, readFileSync, rmSync, unlinkSync } from "fs";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync } from "fs";
|
|
7
|
+
import { tmpdir } from "os";
|
|
7
8
|
import { resolve } from "path";
|
|
8
9
|
import {
|
|
9
10
|
deriveAutonomyComponentArea,
|
|
@@ -11,14 +12,18 @@ import {
|
|
|
11
12
|
explicitSourceControlCommitIdentityFromEnv,
|
|
12
13
|
loadPromptTemplate,
|
|
13
14
|
loadPushPalsConfig,
|
|
15
|
+
buildToolchainPlan,
|
|
14
16
|
extractVisionKeyItems,
|
|
17
|
+
formatToolRequirement,
|
|
15
18
|
matchesGlob,
|
|
16
19
|
normalizeAutonomyComponentArea,
|
|
17
20
|
normalizeTargetPath,
|
|
21
|
+
requirementsForValidationCommand,
|
|
18
22
|
sanitizeSourceControlIdentityField,
|
|
19
23
|
validateScopeInvariants,
|
|
20
24
|
type AutonomyComponentArea,
|
|
21
25
|
type SourceControlCommitIdentity,
|
|
26
|
+
type ToolRequirement,
|
|
22
27
|
} from "shared";
|
|
23
28
|
import { resolveExecutor, type WorkerpalsRuntimeConfig } from "./common/executor_backend.js";
|
|
24
29
|
import type { JobPublishBlockedInfo, JobResult } from "./common/types.js";
|
|
@@ -552,6 +557,7 @@ async function runValidationCommand(
|
|
|
552
557
|
const startedAt = Date.now();
|
|
553
558
|
const proc = Bun.spawn(argv, {
|
|
554
559
|
cwd: repo,
|
|
560
|
+
env: buildValidationCommandEnv(repo),
|
|
555
561
|
stdout: "pipe",
|
|
556
562
|
stderr: "pipe",
|
|
557
563
|
});
|
|
@@ -586,6 +592,131 @@ async function runValidationCommand(
|
|
|
586
592
|
};
|
|
587
593
|
}
|
|
588
594
|
|
|
595
|
+
function buildValidationCommandEnv(repo: string): Record<string, string> {
|
|
596
|
+
const homeDir = resolve(tmpdir(), "pushpals-validation-home");
|
|
597
|
+
const cacheDir = resolve(tmpdir(), "pushpals-validation-cache");
|
|
598
|
+
const expoDir = resolve(tmpdir(), "pushpals-validation-expo");
|
|
599
|
+
for (const dir of [homeDir, cacheDir, expoDir]) {
|
|
600
|
+
try {
|
|
601
|
+
mkdirSync(dir, { recursive: true });
|
|
602
|
+
} catch {
|
|
603
|
+
// Keep validation best-effort; the command output will expose any real env blocker.
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const env: Record<string, string> = {};
|
|
607
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
608
|
+
if (typeof value === "string") env[key] = value;
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
...env,
|
|
612
|
+
HOME: homeDir,
|
|
613
|
+
USERPROFILE: homeDir,
|
|
614
|
+
XDG_CACHE_HOME: cacheDir,
|
|
615
|
+
npm_config_cache: resolve(cacheDir, "npm"),
|
|
616
|
+
EXPO_HOME: expoDir,
|
|
617
|
+
EXPO_NO_TELEMETRY: process.env.EXPO_NO_TELEMETRY ?? "1",
|
|
618
|
+
EXPO_NO_INTERACTIVE: process.env.EXPO_NO_INTERACTIVE ?? "1",
|
|
619
|
+
CI: process.env.CI ?? "1",
|
|
620
|
+
BROWSER: process.env.BROWSER ?? "none",
|
|
621
|
+
EXPO_DEV_SERVER_PORT: process.env.EXPO_DEV_SERVER_PORT ?? "19006",
|
|
622
|
+
RCT_METRO_PORT: process.env.RCT_METRO_PORT ?? "19006",
|
|
623
|
+
PUSHPALS_VALIDATION_REPO: repo,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
interface ToolAvailabilityResult {
|
|
628
|
+
requirement: ToolRequirement;
|
|
629
|
+
ok: boolean;
|
|
630
|
+
candidate: string | null;
|
|
631
|
+
detail: string;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function toolProbeArgv(candidate: string): string[] {
|
|
635
|
+
const normalized = candidate.toLowerCase();
|
|
636
|
+
if (normalized === "sh") {
|
|
637
|
+
return [candidate, "-c", "exit 0"];
|
|
638
|
+
}
|
|
639
|
+
if (normalized === "cmd") {
|
|
640
|
+
return [candidate, "/c", "exit 0"];
|
|
641
|
+
}
|
|
642
|
+
if (normalized === "bash") {
|
|
643
|
+
return [candidate, "-lc", "exit 0"];
|
|
644
|
+
}
|
|
645
|
+
if (normalized === "powershell" || normalized === "pwsh") {
|
|
646
|
+
return [candidate, "-NoProfile", "-Command", "exit 0"];
|
|
647
|
+
}
|
|
648
|
+
return [candidate, "--version"];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function checkToolCandidate(candidate: string, timeoutMs = 5_000): Promise<boolean> {
|
|
652
|
+
try {
|
|
653
|
+
const proc = Bun.spawn(toolProbeArgv(candidate), {
|
|
654
|
+
stdout: "pipe",
|
|
655
|
+
stderr: "pipe",
|
|
656
|
+
});
|
|
657
|
+
let timedOut = false;
|
|
658
|
+
const timer = setTimeout(() => {
|
|
659
|
+
timedOut = true;
|
|
660
|
+
try {
|
|
661
|
+
proc.kill();
|
|
662
|
+
} catch {
|
|
663
|
+
// ignore
|
|
664
|
+
}
|
|
665
|
+
}, Math.max(1_000, timeoutMs));
|
|
666
|
+
try {
|
|
667
|
+
const [exitCode] = await Promise.all([
|
|
668
|
+
proc.exited,
|
|
669
|
+
new Response(proc.stdout).text().catch(() => ""),
|
|
670
|
+
new Response(proc.stderr).text().catch(() => ""),
|
|
671
|
+
]);
|
|
672
|
+
return !timedOut && exitCode === 0;
|
|
673
|
+
} finally {
|
|
674
|
+
clearTimeout(timer);
|
|
675
|
+
}
|
|
676
|
+
} catch {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function checkToolAvailability(
|
|
682
|
+
requirements: ToolRequirement[],
|
|
683
|
+
): Promise<ToolAvailabilityResult[]> {
|
|
684
|
+
const cache = new Map<string, Promise<boolean>>();
|
|
685
|
+
const check = (candidate: string) => {
|
|
686
|
+
const key = candidate.toLowerCase();
|
|
687
|
+
let cached = cache.get(key);
|
|
688
|
+
if (!cached) {
|
|
689
|
+
cached = checkToolCandidate(candidate);
|
|
690
|
+
cache.set(key, cached);
|
|
691
|
+
}
|
|
692
|
+
return cached;
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const out: ToolAvailabilityResult[] = [];
|
|
696
|
+
for (const requirement of requirements) {
|
|
697
|
+
let availableCandidate: string | null = null;
|
|
698
|
+
for (const candidate of requirement.candidates) {
|
|
699
|
+
if (await check(candidate)) {
|
|
700
|
+
availableCandidate = candidate;
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
out.push({
|
|
705
|
+
requirement,
|
|
706
|
+
ok: Boolean(availableCandidate),
|
|
707
|
+
candidate: availableCandidate,
|
|
708
|
+
detail: availableCandidate
|
|
709
|
+
? `${availableCandidate} is available`
|
|
710
|
+
: `missing ${formatToolRequirement(requirement)}`,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
return out;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function formatMissingToolRequirements(requirements: ToolRequirement[]): string {
|
|
717
|
+
return requirements.map(formatToolRequirement).join(", ");
|
|
718
|
+
}
|
|
719
|
+
|
|
589
720
|
function extractPreparedMergeConflictPaths(params: Record<string, unknown>): string[] {
|
|
590
721
|
const reviewAgent =
|
|
591
722
|
params.reviewAgent && typeof params.reviewAgent === "object" && !Array.isArray(params.reviewAgent)
|
|
@@ -607,6 +738,20 @@ function detectValidationBlocker(runs: ValidationExecutionResult[]): ValidationB
|
|
|
607
738
|
.toLowerCase();
|
|
608
739
|
if (!combined) return null;
|
|
609
740
|
|
|
741
|
+
if (
|
|
742
|
+
combined.includes("validation skipped before execution because required tool") ||
|
|
743
|
+
combined.includes("missing required tool") ||
|
|
744
|
+
combined.includes("command not found") ||
|
|
745
|
+
combined.includes("executable not found") ||
|
|
746
|
+
combined.includes("not recognized as an internal or external command")
|
|
747
|
+
) {
|
|
748
|
+
return {
|
|
749
|
+
category: "environment",
|
|
750
|
+
detail:
|
|
751
|
+
"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.",
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
610
755
|
if (
|
|
611
756
|
combined.includes("cannot find module") ||
|
|
612
757
|
combined.includes("module not found") ||
|
|
@@ -1086,7 +1231,54 @@ async function runDeterministicQualityGate(
|
|
|
1086
1231
|
`[QualityGate] No runnable planning.validationSteps found; using fallback validation command(s): ${commandsToRun.join(" | ")}`,
|
|
1087
1232
|
);
|
|
1088
1233
|
}
|
|
1234
|
+
const toolchainPlan = buildToolchainPlan({
|
|
1235
|
+
repoRoot: repo,
|
|
1236
|
+
validationCommands: commandsToRun,
|
|
1237
|
+
});
|
|
1238
|
+
if (toolchainPlan.requirements.length > 0) {
|
|
1239
|
+
onLog?.(
|
|
1240
|
+
"stdout",
|
|
1241
|
+
`[QualityGate] Toolchain preflight: source=${toolchainPlan.environmentSource}, required=${toolchainPlan.requirements
|
|
1242
|
+
.map((requirement) => requirement.tool)
|
|
1243
|
+
.join(", ")}`,
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
const toolAvailability = await checkToolAvailability(toolchainPlan.requirements);
|
|
1247
|
+
const missingToolRequirements = toolAvailability
|
|
1248
|
+
.filter((entry) => !entry.ok)
|
|
1249
|
+
.map((entry) => entry.requirement);
|
|
1250
|
+
if (missingToolRequirements.length > 0) {
|
|
1251
|
+
onLog?.(
|
|
1252
|
+
"stderr",
|
|
1253
|
+
`[QualityGate] Toolchain preflight blocked dependent validation command(s): ${formatMissingToolRequirements(
|
|
1254
|
+
missingToolRequirements,
|
|
1255
|
+
)}`,
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1089
1258
|
for (const command of commandsToRun) {
|
|
1259
|
+
const commandMissingTools = requirementsForValidationCommand(toolchainPlan, command).filter(
|
|
1260
|
+
(requirement) =>
|
|
1261
|
+
missingToolRequirements.some((missing) => missing.tool === requirement.tool),
|
|
1262
|
+
);
|
|
1263
|
+
if (commandMissingTools.length > 0) {
|
|
1264
|
+
const stderr = `Validation skipped before execution because required tool(s) are missing: ${formatMissingToolRequirements(
|
|
1265
|
+
commandMissingTools,
|
|
1266
|
+
)}.`;
|
|
1267
|
+
validationRuns.push({
|
|
1268
|
+
step: command,
|
|
1269
|
+
command,
|
|
1270
|
+
ok: false,
|
|
1271
|
+
exitCode: 127,
|
|
1272
|
+
stdout: "",
|
|
1273
|
+
stderr,
|
|
1274
|
+
elapsedMs: 1,
|
|
1275
|
+
});
|
|
1276
|
+
onLog?.(
|
|
1277
|
+
"stderr",
|
|
1278
|
+
`[QualityGate] Quality gate validation skipped (missing toolchain): ${command}`,
|
|
1279
|
+
);
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1090
1282
|
onLog?.("stdout", `[QualityGate] Quality gate validation: running "${command}"`);
|
|
1091
1283
|
const run = await runValidationCommand(
|
|
1092
1284
|
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,622 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
2
|
+
import { isAbsolute, 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
|
+
maxScriptScanChars?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SHELL_CONTROL_TOKENS = new Set(["|", "||", "&", "&&", ";", ">", ">>", "<", "<<"]);
|
|
34
|
+
|
|
35
|
+
const NODE_BACKED_CLI_NAMES = new Set([
|
|
36
|
+
"astro",
|
|
37
|
+
"babel",
|
|
38
|
+
"cypress",
|
|
39
|
+
"eslint",
|
|
40
|
+
"expo",
|
|
41
|
+
"jest",
|
|
42
|
+
"metro",
|
|
43
|
+
"next",
|
|
44
|
+
"nuxt",
|
|
45
|
+
"playwright",
|
|
46
|
+
"react-native",
|
|
47
|
+
"rollup",
|
|
48
|
+
"tsc",
|
|
49
|
+
"tsx",
|
|
50
|
+
"vite",
|
|
51
|
+
"vitest",
|
|
52
|
+
"webpack",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const DIRECT_TOOL_CANDIDATES: Record<string, string[]> = {
|
|
56
|
+
bash: ["bash"],
|
|
57
|
+
bun: ["bun"],
|
|
58
|
+
bunx: ["bun"],
|
|
59
|
+
cargo: ["cargo"],
|
|
60
|
+
cc: ["cc"],
|
|
61
|
+
clang: ["clang"],
|
|
62
|
+
"clang++": ["clang++"],
|
|
63
|
+
cmake: ["cmake"],
|
|
64
|
+
cypress: ["cypress"],
|
|
65
|
+
docker: ["docker"],
|
|
66
|
+
eslint: ["eslint"],
|
|
67
|
+
expo: ["expo"],
|
|
68
|
+
gcc: ["gcc"],
|
|
69
|
+
"g++": ["g++"],
|
|
70
|
+
gh: ["gh"],
|
|
71
|
+
go: ["go"],
|
|
72
|
+
java: ["java"],
|
|
73
|
+
javac: ["javac"],
|
|
74
|
+
make: ["make"],
|
|
75
|
+
mvn: ["mvn"],
|
|
76
|
+
next: ["next"],
|
|
77
|
+
ninja: ["ninja"],
|
|
78
|
+
node: ["node"],
|
|
79
|
+
npm: ["npm"],
|
|
80
|
+
npx: ["npx"],
|
|
81
|
+
playwright: ["playwright"],
|
|
82
|
+
pnpm: ["pnpm"],
|
|
83
|
+
powershell: ["powershell"],
|
|
84
|
+
pwsh: ["pwsh"],
|
|
85
|
+
python: ["python3", "python", "py"],
|
|
86
|
+
python3: ["python3", "python"],
|
|
87
|
+
pytest: ["python3", "python", "py"],
|
|
88
|
+
rustc: ["rustc"],
|
|
89
|
+
sh: ["sh"],
|
|
90
|
+
tsc: ["tsc"],
|
|
91
|
+
vite: ["vite"],
|
|
92
|
+
vitest: ["vitest"],
|
|
93
|
+
yarn: ["yarn"],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
interface NativeSignals {
|
|
97
|
+
hasC: boolean;
|
|
98
|
+
hasCxx: boolean;
|
|
99
|
+
hasMakefile: boolean;
|
|
100
|
+
hasCMake: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function tokenizeToolchainCommand(command: string): string[] | null {
|
|
104
|
+
const input = command.trim();
|
|
105
|
+
if (!input) return null;
|
|
106
|
+
const out: string[] = [];
|
|
107
|
+
let current = "";
|
|
108
|
+
let quote: "'" | '"' | null = null;
|
|
109
|
+
|
|
110
|
+
const pushCurrent = () => {
|
|
111
|
+
const trimmed = current.trim();
|
|
112
|
+
if (trimmed) out.push(trimmed);
|
|
113
|
+
current = "";
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
117
|
+
const ch = input[index] ?? "";
|
|
118
|
+
if (quote) {
|
|
119
|
+
if (ch === quote) {
|
|
120
|
+
quote = null;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
current += ch;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (ch === "'" || ch === '"') {
|
|
127
|
+
quote = ch;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (/\s/.test(ch)) {
|
|
131
|
+
pushCurrent();
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
current += ch;
|
|
135
|
+
}
|
|
136
|
+
if (quote) return null;
|
|
137
|
+
pushCurrent();
|
|
138
|
+
if (out.length === 0) return null;
|
|
139
|
+
if (out.some((token) => SHELL_CONTROL_TOKENS.has(token))) return null;
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function buildToolchainPlan(options: BuildToolchainPlanOptions): ToolchainPlan {
|
|
144
|
+
const repoRoot = options.repoRoot;
|
|
145
|
+
const nativeSignals = detectNativeSignals(repoRoot, options.maxNativeScanEntries ?? 1_000);
|
|
146
|
+
const requirements: ToolRequirement[] = [];
|
|
147
|
+
for (const command of options.validationCommands) {
|
|
148
|
+
requirements.push(
|
|
149
|
+
...inferToolRequirementsForValidationCommand(
|
|
150
|
+
repoRoot,
|
|
151
|
+
command,
|
|
152
|
+
nativeSignals,
|
|
153
|
+
options.maxScriptScanChars ?? 64_000,
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
requirements: dedupeToolRequirements(requirements),
|
|
159
|
+
environmentSource: detectToolchainEnvironmentSource(repoRoot),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function inferToolRequirementsForValidationCommand(
|
|
164
|
+
repoRoot: string,
|
|
165
|
+
command: string,
|
|
166
|
+
nativeSignals: NativeSignals = detectNativeSignals(repoRoot),
|
|
167
|
+
maxScriptScanChars = 64_000,
|
|
168
|
+
): ToolRequirement[] {
|
|
169
|
+
const tokens = tokenizeToolchainCommand(command);
|
|
170
|
+
if (!tokens) return [];
|
|
171
|
+
const requirements: ToolRequirement[] = [];
|
|
172
|
+
const first = normalizeToolToken(tokens[0] ?? "");
|
|
173
|
+
|
|
174
|
+
addDirectExecutableRequirement(requirements, first, command);
|
|
175
|
+
addNodeBackedCliRequirement(requirements, first, `validation command "${command}"`, command);
|
|
176
|
+
|
|
177
|
+
const bunSubcommand = resolveBunSubcommand(tokens);
|
|
178
|
+
if (bunSubcommand?.kind === "x") {
|
|
179
|
+
addNodeBackedCliRequirement(
|
|
180
|
+
requirements,
|
|
181
|
+
normalizeToolToken(bunSubcommand.value),
|
|
182
|
+
`bun x package "${bunSubcommand.value}"`,
|
|
183
|
+
command,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const script = resolvePackageScript(repoRoot, tokens);
|
|
188
|
+
if (script) {
|
|
189
|
+
addScriptRequirements(
|
|
190
|
+
requirements,
|
|
191
|
+
repoRoot,
|
|
192
|
+
script.scriptCwd,
|
|
193
|
+
script.script,
|
|
194
|
+
script.detectedFrom,
|
|
195
|
+
command,
|
|
196
|
+
{
|
|
197
|
+
maxScriptScanChars,
|
|
198
|
+
depth: 0,
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (usesNativeBuildCommand(tokens)) {
|
|
204
|
+
if (nativeSignals.hasC) {
|
|
205
|
+
requirements.push({
|
|
206
|
+
tool: "c-compiler",
|
|
207
|
+
candidates: ["cc", "gcc", "clang"],
|
|
208
|
+
reason: "native C sources may be compiled by this validation command",
|
|
209
|
+
detectedFrom: nativeSignals.hasCMake
|
|
210
|
+
? "CMakeLists.txt/native source scan"
|
|
211
|
+
: "Makefile/native source scan",
|
|
212
|
+
requiredFor: [command],
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (nativeSignals.hasCxx) {
|
|
216
|
+
requirements.push({
|
|
217
|
+
tool: "cxx-compiler",
|
|
218
|
+
candidates: ["c++", "g++", "clang++"],
|
|
219
|
+
reason: "native C++ sources may be compiled by this validation command",
|
|
220
|
+
detectedFrom: nativeSignals.hasCMake
|
|
221
|
+
? "CMakeLists.txt/native source scan"
|
|
222
|
+
: "Makefile/native source scan",
|
|
223
|
+
requiredFor: [command],
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return dedupeToolRequirements(requirements);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function requirementsForValidationCommand(
|
|
232
|
+
plan: ToolchainPlan,
|
|
233
|
+
command: string,
|
|
234
|
+
): ToolRequirement[] {
|
|
235
|
+
return plan.requirements.filter((requirement) => requirement.requiredFor.includes(command));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function formatToolRequirement(requirement: ToolRequirement): string {
|
|
239
|
+
const candidates =
|
|
240
|
+
requirement.candidates.length === 1
|
|
241
|
+
? requirement.candidates[0]
|
|
242
|
+
: `${requirement.tool} (${requirement.candidates.join(" or ")})`;
|
|
243
|
+
return `${candidates} from ${requirement.detectedFrom}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function addDirectExecutableRequirement(
|
|
247
|
+
requirements: ToolRequirement[],
|
|
248
|
+
tool: string,
|
|
249
|
+
command: string,
|
|
250
|
+
): void {
|
|
251
|
+
const candidates = DIRECT_TOOL_CANDIDATES[tool];
|
|
252
|
+
if (!candidates) return;
|
|
253
|
+
requirements.push({
|
|
254
|
+
tool: canonicalToolName(tool),
|
|
255
|
+
candidates,
|
|
256
|
+
reason: `validation command invokes ${tool}`,
|
|
257
|
+
detectedFrom: `validation command "${command}"`,
|
|
258
|
+
requiredFor: [command],
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function addNodeBackedCliRequirement(
|
|
263
|
+
requirements: ToolRequirement[],
|
|
264
|
+
cliName: string,
|
|
265
|
+
detectedFrom: string,
|
|
266
|
+
command: string,
|
|
267
|
+
): void {
|
|
268
|
+
if (!NODE_BACKED_CLI_NAMES.has(cliName)) return;
|
|
269
|
+
requirements.push({
|
|
270
|
+
tool: "node",
|
|
271
|
+
candidates: ["node"],
|
|
272
|
+
reason: `${cliName} is normally distributed as a Node.js CLI`,
|
|
273
|
+
detectedFrom,
|
|
274
|
+
requiredFor: [command],
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function addScriptRequirements(
|
|
279
|
+
requirements: ToolRequirement[],
|
|
280
|
+
repoRoot: string,
|
|
281
|
+
scriptCwd: string,
|
|
282
|
+
script: string,
|
|
283
|
+
detectedFrom: string,
|
|
284
|
+
command: string,
|
|
285
|
+
options: { maxScriptScanChars: number; depth: number },
|
|
286
|
+
): void {
|
|
287
|
+
const tokens = tokenizeToolchainCommand(script) ?? script.split(/\s+/).filter(Boolean);
|
|
288
|
+
const first = normalizeToolToken(tokens[0] ?? "");
|
|
289
|
+
// Package-manager scripts resolve Node CLIs from local node_modules/.bin. Requiring a
|
|
290
|
+
// global expo/vite/tsc binary creates false environment blockers for normal JS repos.
|
|
291
|
+
if (!NODE_BACKED_CLI_NAMES.has(first)) {
|
|
292
|
+
addDirectExecutableRequirement(requirements, first, command);
|
|
293
|
+
}
|
|
294
|
+
addNodeBackedCliRequirement(requirements, first, detectedFrom, command);
|
|
295
|
+
for (const token of tokens) {
|
|
296
|
+
addNodeBackedCliRequirement(requirements, normalizeToolToken(token), detectedFrom, command);
|
|
297
|
+
}
|
|
298
|
+
for (const scriptPath of inferReferencedScriptPaths(repoRoot, scriptCwd, tokens)) {
|
|
299
|
+
const scanned = scanScriptFileForToolRequirements(
|
|
300
|
+
requirements,
|
|
301
|
+
repoRoot,
|
|
302
|
+
scriptPath,
|
|
303
|
+
command,
|
|
304
|
+
options,
|
|
305
|
+
);
|
|
306
|
+
if (scanned) continue;
|
|
307
|
+
}
|
|
308
|
+
if (/\bnode\b/.test(script)) {
|
|
309
|
+
requirements.push({
|
|
310
|
+
tool: "node",
|
|
311
|
+
candidates: ["node"],
|
|
312
|
+
reason: "package script invokes node directly",
|
|
313
|
+
detectedFrom,
|
|
314
|
+
requiredFor: [command],
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
if (/\bbun\b/.test(script)) {
|
|
318
|
+
requirements.push({
|
|
319
|
+
tool: "bun",
|
|
320
|
+
candidates: ["bun"],
|
|
321
|
+
reason: "package script invokes bun",
|
|
322
|
+
detectedFrom,
|
|
323
|
+
requiredFor: [command],
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function scanScriptFileForToolRequirements(
|
|
329
|
+
requirements: ToolRequirement[],
|
|
330
|
+
repoRoot: string,
|
|
331
|
+
scriptPath: string,
|
|
332
|
+
command: string,
|
|
333
|
+
options: { maxScriptScanChars: number; depth: number },
|
|
334
|
+
): boolean {
|
|
335
|
+
if (options.depth > 2 || !existsSync(scriptPath)) return false;
|
|
336
|
+
let text = "";
|
|
337
|
+
try {
|
|
338
|
+
const stats = statSync(scriptPath);
|
|
339
|
+
if (!stats.isFile() || stats.size > options.maxScriptScanChars) return false;
|
|
340
|
+
text = readFileSync(scriptPath, "utf8");
|
|
341
|
+
} catch {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
const detectedFrom = `${repoRelativePath(repoRoot, scriptPath)} referenced by validation command "${command}"`;
|
|
345
|
+
for (const cliName of NODE_BACKED_CLI_NAMES) {
|
|
346
|
+
const pattern = new RegExp(`(?:^|[^A-Za-z0-9_-])${escapeRegExp(cliName)}(?:$|[^A-Za-z0-9_-])`);
|
|
347
|
+
if (pattern.test(text)) {
|
|
348
|
+
addNodeBackedCliRequirement(requirements, cliName, detectedFrom, command);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (/\bnode\b/.test(text)) {
|
|
352
|
+
requirements.push({
|
|
353
|
+
tool: "node",
|
|
354
|
+
candidates: ["node"],
|
|
355
|
+
reason: "referenced validation script invokes node directly",
|
|
356
|
+
detectedFrom,
|
|
357
|
+
requiredFor: [command],
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
if (/\bbun\b/.test(text)) {
|
|
361
|
+
requirements.push({
|
|
362
|
+
tool: "bun",
|
|
363
|
+
candidates: ["bun"],
|
|
364
|
+
reason: "referenced validation script invokes bun",
|
|
365
|
+
detectedFrom,
|
|
366
|
+
requiredFor: [command],
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function resolveBunSubcommand(tokens: string[]): { kind: "run" | "x"; value: string } | null {
|
|
373
|
+
if (normalizeToolToken(tokens[0] ?? "") !== "bun") return null;
|
|
374
|
+
let index = 1;
|
|
375
|
+
while (index < tokens.length) {
|
|
376
|
+
const token = tokens[index] ?? "";
|
|
377
|
+
if (token === "--cwd" || token === "-C") {
|
|
378
|
+
index += 2;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (token.startsWith("--")) {
|
|
382
|
+
index += 1;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
const subcommand = normalizeToolToken(tokens[index] ?? "");
|
|
388
|
+
if ((subcommand === "run" || subcommand === "x") && tokens[index + 1]) {
|
|
389
|
+
return { kind: subcommand, value: tokens[index + 1] ?? "" };
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function resolvePackageScript(
|
|
395
|
+
repoRoot: string,
|
|
396
|
+
tokens: string[],
|
|
397
|
+
): { script: string; scriptCwd: string; detectedFrom: string } | null {
|
|
398
|
+
const first = normalizeToolToken(tokens[0] ?? "");
|
|
399
|
+
let cwd = repoRoot;
|
|
400
|
+
let scriptName = "";
|
|
401
|
+
if (first === "bun") {
|
|
402
|
+
let index = 1;
|
|
403
|
+
while (index < tokens.length) {
|
|
404
|
+
const token = tokens[index] ?? "";
|
|
405
|
+
if ((token === "--cwd" || token === "-C") && tokens[index + 1]) {
|
|
406
|
+
cwd = join(repoRoot, tokens[index + 1] ?? "");
|
|
407
|
+
index += 2;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (token.startsWith("--")) {
|
|
411
|
+
index += 1;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
if (normalizeToolToken(tokens[index] ?? "") === "run") {
|
|
417
|
+
scriptName = tokens[index + 1] ?? "";
|
|
418
|
+
} else {
|
|
419
|
+
const candidate = tokens[index] ?? "";
|
|
420
|
+
if (candidate && !["install", "test", "x"].includes(normalizeToolToken(candidate))) {
|
|
421
|
+
scriptName = candidate;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
} else if (first === "npm" || first === "pnpm" || first === "yarn") {
|
|
425
|
+
let index = 1;
|
|
426
|
+
while (index < tokens.length) {
|
|
427
|
+
const token = tokens[index] ?? "";
|
|
428
|
+
const normalized = normalizeToolToken(token);
|
|
429
|
+
if (
|
|
430
|
+
(token === "--prefix" ||
|
|
431
|
+
token === "--dir" ||
|
|
432
|
+
token === "--cwd" ||
|
|
433
|
+
token === "-C") &&
|
|
434
|
+
tokens[index + 1]
|
|
435
|
+
) {
|
|
436
|
+
cwd = join(repoRoot, tokens[index + 1] ?? "");
|
|
437
|
+
index += 2;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (normalized === "run") {
|
|
441
|
+
scriptName = tokens[index + 1] ?? "";
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
if (!token.startsWith("-")) {
|
|
445
|
+
scriptName = normalized;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
index += 1;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (!scriptName) return null;
|
|
452
|
+
|
|
453
|
+
const packagePath = join(cwd, "package.json");
|
|
454
|
+
if (!existsSync(packagePath)) return null;
|
|
455
|
+
try {
|
|
456
|
+
const parsed = JSON.parse(readFileSync(packagePath, "utf8")) as {
|
|
457
|
+
scripts?: Record<string, unknown>;
|
|
458
|
+
};
|
|
459
|
+
const script = parsed.scripts?.[scriptName];
|
|
460
|
+
if (typeof script !== "string" || !script.trim()) return null;
|
|
461
|
+
return {
|
|
462
|
+
script,
|
|
463
|
+
scriptCwd: cwd,
|
|
464
|
+
detectedFrom: `${repoRelativePath(repoRoot, packagePath)} script "${scriptName}"`,
|
|
465
|
+
};
|
|
466
|
+
} catch {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function inferReferencedScriptPaths(repoRoot: string, scriptCwd: string, tokens: string[]): string[] {
|
|
472
|
+
const out: string[] = [];
|
|
473
|
+
const seen = new Set<string>();
|
|
474
|
+
for (const token of tokens) {
|
|
475
|
+
const normalized = normalizeReferencedScriptToken(token);
|
|
476
|
+
if (!normalized) continue;
|
|
477
|
+
const resolved = isAbsolute(normalized) ? normalized : join(scriptCwd, normalized);
|
|
478
|
+
const key = normalize(resolved);
|
|
479
|
+
if (seen.has(key)) continue;
|
|
480
|
+
seen.add(key);
|
|
481
|
+
out.push(resolved);
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function normalizeReferencedScriptToken(token: string): string | null {
|
|
487
|
+
let normalized = token.replace(/\\/g, "/");
|
|
488
|
+
if (normalized.startsWith("-")) {
|
|
489
|
+
const equalsIndex = normalized.indexOf("=");
|
|
490
|
+
if (equalsIndex === -1) return null;
|
|
491
|
+
normalized = normalized.slice(equalsIndex + 1);
|
|
492
|
+
}
|
|
493
|
+
if (!/\.(cjs|cts|js|jsx|mjs|mts|ts|tsx)$/i.test(normalized)) return null;
|
|
494
|
+
if (normalized.includes("://")) return null;
|
|
495
|
+
return normalized;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function detectToolchainEnvironmentSource(repoRoot: string): ToolchainEnvironmentSource {
|
|
499
|
+
if (existsSync(join(repoRoot, ".devcontainer", "devcontainer.json"))) return "devcontainer";
|
|
500
|
+
if (existsSync(join(repoRoot, "devcontainer.json"))) return "devcontainer";
|
|
501
|
+
if (existsSync(join(repoRoot, "Dockerfile"))) return "dockerfile";
|
|
502
|
+
if (existsSync(join(repoRoot, "mise.toml")) || existsSync(join(repoRoot, ".mise.toml"))) {
|
|
503
|
+
return "mise";
|
|
504
|
+
}
|
|
505
|
+
if (existsSync(join(repoRoot, ".tool-versions"))) return "asdf";
|
|
506
|
+
if (existsSync(join(repoRoot, "flake.nix")) || existsSync(join(repoRoot, "shell.nix"))) {
|
|
507
|
+
return "nix";
|
|
508
|
+
}
|
|
509
|
+
return "pushpals-default-sandbox";
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function detectNativeSignals(repoRoot: string, maxEntries = 1_000): NativeSignals {
|
|
513
|
+
const signals: NativeSignals = {
|
|
514
|
+
hasC: false,
|
|
515
|
+
hasCxx: false,
|
|
516
|
+
hasMakefile:
|
|
517
|
+
existsSync(join(repoRoot, "Makefile")) ||
|
|
518
|
+
existsSync(join(repoRoot, "makefile")) ||
|
|
519
|
+
existsSync(join(repoRoot, "GNUmakefile")),
|
|
520
|
+
hasCMake: existsSync(join(repoRoot, "CMakeLists.txt")),
|
|
521
|
+
};
|
|
522
|
+
const ignored = new Set([
|
|
523
|
+
".git",
|
|
524
|
+
".worktrees",
|
|
525
|
+
"node_modules",
|
|
526
|
+
"outputs",
|
|
527
|
+
"dist",
|
|
528
|
+
"build",
|
|
529
|
+
".next",
|
|
530
|
+
".expo",
|
|
531
|
+
]);
|
|
532
|
+
let visited = 0;
|
|
533
|
+
const scan = (dir: string, depth: number) => {
|
|
534
|
+
if (visited >= maxEntries || depth > 4 || (signals.hasC && signals.hasCxx)) return;
|
|
535
|
+
let entries: string[] = [];
|
|
536
|
+
try {
|
|
537
|
+
entries = readdirSync(dir);
|
|
538
|
+
} catch {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
for (const entry of entries) {
|
|
542
|
+
if (visited >= maxEntries) return;
|
|
543
|
+
if (ignored.has(entry)) continue;
|
|
544
|
+
const fullPath = join(dir, entry);
|
|
545
|
+
visited += 1;
|
|
546
|
+
let stats;
|
|
547
|
+
try {
|
|
548
|
+
stats = statSync(fullPath);
|
|
549
|
+
} catch {
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (stats.isDirectory()) {
|
|
553
|
+
scan(fullPath, depth + 1);
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
const lower = entry.toLowerCase();
|
|
557
|
+
if (/\.(c|h)$/.test(lower)) signals.hasC = true;
|
|
558
|
+
if (/\.(cc|cpp|cxx|hpp|hh|hxx)$/.test(lower)) signals.hasCxx = true;
|
|
559
|
+
if (lower === "cmakelists.txt") signals.hasCMake = true;
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
scan(repoRoot, 0);
|
|
563
|
+
return signals;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function usesNativeBuildCommand(tokens: string[]): boolean {
|
|
567
|
+
return tokens.some((token) => {
|
|
568
|
+
const normalized = normalizeToolToken(token);
|
|
569
|
+
return normalized === "make" || normalized === "cmake" || normalized === "ninja";
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function dedupeToolRequirements(requirements: ToolRequirement[]): ToolRequirement[] {
|
|
574
|
+
const merged = new Map<string, ToolRequirement>();
|
|
575
|
+
for (const requirement of requirements) {
|
|
576
|
+
const key = requirement.tool;
|
|
577
|
+
const existing = merged.get(key);
|
|
578
|
+
if (!existing) {
|
|
579
|
+
merged.set(key, {
|
|
580
|
+
...requirement,
|
|
581
|
+
candidates: Array.from(new Set(requirement.candidates)),
|
|
582
|
+
requiredFor: Array.from(new Set(requirement.requiredFor)),
|
|
583
|
+
});
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
for (const candidate of requirement.candidates) {
|
|
587
|
+
if (!existing.candidates.includes(candidate)) existing.candidates.push(candidate);
|
|
588
|
+
}
|
|
589
|
+
for (const command of requirement.requiredFor) {
|
|
590
|
+
if (!existing.requiredFor.includes(command)) existing.requiredFor.push(command);
|
|
591
|
+
}
|
|
592
|
+
if (!existing.detectedFrom.includes(requirement.detectedFrom)) {
|
|
593
|
+
existing.detectedFrom = `${existing.detectedFrom}; ${requirement.detectedFrom}`;
|
|
594
|
+
}
|
|
595
|
+
if (!existing.reason.includes(requirement.reason)) {
|
|
596
|
+
existing.reason = `${existing.reason}; ${requirement.reason}`;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return Array.from(merged.values()).sort((a, b) => a.tool.localeCompare(b.tool));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function canonicalToolName(tool: string): string {
|
|
603
|
+
if (tool === "bunx") return "bun";
|
|
604
|
+
if (tool === "python3" || tool === "pytest") return "python";
|
|
605
|
+
return tool;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function normalizeToolToken(token: string): string {
|
|
609
|
+
const normalizedToken = token.trim().replace(/\\/g, "/").split("/").pop() ?? token;
|
|
610
|
+
return normalizedToken.toLowerCase().replace(/\.(cmd|exe|ps1)$/i, "");
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function escapeRegExp(value: string): string {
|
|
614
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function repoRelativePath(repoRoot: string, pathValue: string): string {
|
|
618
|
+
const root = normalize(repoRoot).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
619
|
+
const path = normalize(pathValue).replace(/\\/g, "/");
|
|
620
|
+
if (path.startsWith(`${root}/`)) return path.slice(root.length + 1);
|
|
621
|
+
return path;
|
|
622
|
+
}
|