@pushpalsdev/cli 1.0.96 → 1.0.98

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.96",
3
+ "version": "1.0.98",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,6 +17,14 @@ function safeRepoSlug(repo: string): string {
17
17
  return `${leaf}-${hash}`;
18
18
  }
19
19
 
20
+ function browserCacheRepoKey(repo: string): string {
21
+ const normalized = resolve(repo).replace(/\\/g, "/");
22
+ const marker = "/.worktrees/";
23
+ const markerIndex = normalized.lastIndexOf(marker);
24
+ if (markerIndex < 0) return resolve(repo);
25
+ return normalized.slice(0, markerIndex);
26
+ }
27
+
20
28
  function defaultExpoPortForRepo(repo: string): string {
21
29
  const hashPrefix = createHash("sha256").update(resolve(repo)).digest("hex").slice(0, 8);
22
30
  const offset = Number.parseInt(hashPrefix, 16) % 1_000;
@@ -66,8 +74,17 @@ export function buildWorkerSandboxWritableEnv(
66
74
  const homeDir = resolve(baseDir, "home");
67
75
  const cacheDir = resolve(baseDir, "cache");
68
76
  const expoDir = resolve(baseDir, "expo");
77
+ const playwrightBrowsersDir =
78
+ env.PLAYWRIGHT_BROWSERS_PATH && env.PLAYWRIGHT_BROWSERS_PATH !== "0"
79
+ ? env.PLAYWRIGHT_BROWSERS_PATH
80
+ : resolve(
81
+ tmpdir(),
82
+ "pushpals-worker-env",
83
+ safeRepoSlug(browserCacheRepoKey(repo)),
84
+ "playwright-browsers",
85
+ );
69
86
  const defaultExpoPort = defaultExpoPortForRepo(repo);
70
- ensureDirs([homeDir, cacheDir, expoDir, resolve(cacheDir, "npm")]);
87
+ ensureDirs([homeDir, cacheDir, expoDir, resolve(cacheDir, "npm"), playwrightBrowsersDir]);
71
88
  ensureSandboxGitConfig(homeDir);
72
89
 
73
90
  return {
@@ -77,6 +94,7 @@ export function buildWorkerSandboxWritableEnv(
77
94
  USERPROFILE: homeDir,
78
95
  XDG_CACHE_HOME: cacheDir,
79
96
  npm_config_cache: resolve(cacheDir, "npm"),
97
+ PLAYWRIGHT_BROWSERS_PATH: env.PLAYWRIGHT_BROWSERS_PATH ?? playwrightBrowsersDir,
80
98
  EXPO_HOME: expoDir,
81
99
  EXPO_NO_TELEMETRY: env.EXPO_NO_TELEMETRY ?? "1",
82
100
  EXPO_NO_INTERACTIVE: env.EXPO_NO_INTERACTIVE ?? "1",
@@ -601,42 +601,75 @@ export function tokenizeValidationCommandArgv(command: string): string[] | null
601
601
  return out;
602
602
  }
603
603
 
604
- async function runValidationCommand(
604
+ async function terminateValidationProcessTree(
605
+ proc: ReturnType<typeof Bun.spawn>,
606
+ ): Promise<void> {
607
+ const pid = Number(proc.pid);
608
+ if (process.platform === "win32" && Number.isFinite(pid) && pid > 0) {
609
+ try {
610
+ Bun.spawnSync(["taskkill", "/PID", String(pid), "/T", "/F"], {
611
+ stdout: "pipe",
612
+ stderr: "pipe",
613
+ });
614
+ return;
615
+ } catch {
616
+ // Fall through to Bun's process handle.
617
+ }
618
+ }
619
+
620
+ if (process.platform !== "win32" && Number.isFinite(pid) && pid > 0) {
621
+ try {
622
+ process.kill(-pid, "SIGTERM");
623
+ } catch {
624
+ try {
625
+ proc.kill("SIGTERM");
626
+ } catch {
627
+ // ignore
628
+ }
629
+ }
630
+ await Bun.sleep(2_000);
631
+ try {
632
+ process.kill(-pid, "SIGKILL");
633
+ } catch {
634
+ try {
635
+ proc.kill("SIGKILL");
636
+ } catch {
637
+ // ignore
638
+ }
639
+ }
640
+ return;
641
+ }
642
+
643
+ try {
644
+ proc.kill();
645
+ } catch {
646
+ // ignore
647
+ }
648
+ }
649
+
650
+ async function runValidationArgv(
605
651
  repo: string,
606
652
  command: string,
653
+ argv: string[],
654
+ env: Record<string, string>,
607
655
  timeoutMs: number,
608
656
  outputPolicy: Partial<OutputCompactionPolicy>,
657
+ timeoutMessage: string,
609
658
  ): Promise<ValidationExecutionResult> {
610
- const env = buildWorkerSandboxWritableEnv(repo);
611
- const argv = prepareValidationCommandArgv(command, env);
612
- if (!argv) {
613
- return {
614
- step: command,
615
- command,
616
- ok: false,
617
- exitCode: 2,
618
- stdout: "",
619
- stderr:
620
- "Validation command could not be parsed safely. Use a plain command without shell chaining/pipes.",
621
- elapsedMs: 1,
622
- };
623
- }
624
659
  const startedAt = Date.now();
625
660
  const proc = Bun.spawn(argv, {
626
661
  cwd: repo,
627
662
  env,
663
+ stdin: "ignore",
628
664
  stdout: "pipe",
629
665
  stderr: "pipe",
666
+ detached: process.platform !== "win32",
630
667
  });
631
668
  let timedOut = false;
632
669
  const timer = setTimeout(
633
670
  () => {
634
671
  timedOut = true;
635
- try {
636
- proc.kill();
637
- } catch {
638
- // ignore
639
- }
672
+ void terminateValidationProcessTree(proc);
640
673
  },
641
674
  Math.max(1_000, timeoutMs),
642
675
  );
@@ -657,9 +690,7 @@ async function runValidationCommand(
657
690
  stderr: compactJobOutput(
658
691
  [
659
692
  stderr.trim(),
660
- timedOut
661
- ? `Validation command timed out after ${Math.max(1_000, timeoutMs)}ms. Captured output is the process output emitted before PushPals terminated the command.`
662
- : "",
693
+ timedOut ? timeoutMessage : "",
663
694
  ]
664
695
  .filter(Boolean)
665
696
  .join("\n"),
@@ -669,6 +700,38 @@ async function runValidationCommand(
669
700
  };
670
701
  }
671
702
 
703
+ async function runValidationCommand(
704
+ repo: string,
705
+ command: string,
706
+ timeoutMs: number,
707
+ outputPolicy: Partial<OutputCompactionPolicy>,
708
+ ): Promise<ValidationExecutionResult> {
709
+ const env = buildWorkerSandboxWritableEnv(repo);
710
+ const argv = prepareValidationCommandArgv(command, env);
711
+ if (!argv) {
712
+ return {
713
+ step: command,
714
+ command,
715
+ ok: false,
716
+ exitCode: 2,
717
+ stdout: "",
718
+ stderr:
719
+ "Validation command could not be parsed safely. Use a plain command without shell chaining/pipes.",
720
+ elapsedMs: 1,
721
+ };
722
+ }
723
+
724
+ return runValidationArgv(
725
+ repo,
726
+ command,
727
+ argv,
728
+ env,
729
+ timeoutMs,
730
+ outputPolicy,
731
+ `Validation command timed out after ${Math.max(1_000, timeoutMs)}ms. Captured output is the process output emitted before PushPals terminated the command and its process tree.`,
732
+ );
733
+ }
734
+
672
735
  export function isLongRunningBrowserValidationCommand(command: string): boolean {
673
736
  const normalized = validationCommandKey(command);
674
737
  if (!normalized) return false;
@@ -681,6 +744,224 @@ export function isLongRunningBrowserValidationCommand(command: string): boolean
681
744
  );
682
745
  }
683
746
 
747
+ function readPackageJson(repo: string): {
748
+ scripts?: Record<string, unknown>;
749
+ dependencies?: Record<string, unknown>;
750
+ devDependencies?: Record<string, unknown>;
751
+ optionalDependencies?: Record<string, unknown>;
752
+ peerDependencies?: Record<string, unknown>;
753
+ } | null {
754
+ const packagePath = resolve(repo, "package.json");
755
+ if (!existsSync(packagePath)) return null;
756
+ try {
757
+ return JSON.parse(readFileSync(packagePath, "utf8"));
758
+ } catch {
759
+ return null;
760
+ }
761
+ }
762
+
763
+ function packageJsonDeclaresPlaywright(repo: string): boolean {
764
+ const parsed = readPackageJson(repo);
765
+ if (!parsed) return false;
766
+ const dependencyGroups = [
767
+ parsed.dependencies,
768
+ parsed.devDependencies,
769
+ parsed.optionalDependencies,
770
+ parsed.peerDependencies,
771
+ ];
772
+ return dependencyGroups.some((group) =>
773
+ Boolean(group && (group.playwright || group["@playwright/test"])),
774
+ );
775
+ }
776
+
777
+ function resolvePackageScriptForValidationCommand(
778
+ repo: string,
779
+ command: string,
780
+ ): { script: string; cwd: string } | null {
781
+ const argv = tokenizeValidationCommandArgv(command);
782
+ if (!argv || argv.length === 0) return null;
783
+ const first = argv[0]?.toLowerCase();
784
+ let cwd = repo;
785
+ let scriptName = "";
786
+
787
+ const consumeCwdOption = (index: number): number | null => {
788
+ const token = argv[index] ?? "";
789
+ if ((token === "--cwd" || token === "-C" || token === "--prefix") && argv[index + 1]) {
790
+ cwd = resolve(repo, argv[index + 1] ?? "");
791
+ return index + 2;
792
+ }
793
+ for (const prefix of ["--cwd=", "-C=", "--prefix="]) {
794
+ if (token.startsWith(prefix)) {
795
+ cwd = resolve(repo, token.slice(prefix.length));
796
+ return index + 1;
797
+ }
798
+ }
799
+ return null;
800
+ };
801
+
802
+ if (first === "bun") {
803
+ let index = 1;
804
+ while (index < argv.length) {
805
+ const consumed = consumeCwdOption(index);
806
+ if (consumed !== null) {
807
+ index = consumed;
808
+ continue;
809
+ }
810
+ if ((argv[index] ?? "").startsWith("--")) {
811
+ index += 1;
812
+ continue;
813
+ }
814
+ break;
815
+ }
816
+ if ((argv[index] ?? "").toLowerCase() === "run") {
817
+ scriptName = argv[index + 1] ?? "";
818
+ } else {
819
+ const candidate = argv[index] ?? "";
820
+ if (candidate && !["install", "test", "x"].includes(candidate.toLowerCase())) {
821
+ scriptName = candidate;
822
+ }
823
+ }
824
+ } else if (first === "npm" || first === "pnpm" || first === "yarn") {
825
+ let index = 1;
826
+ while (index < argv.length) {
827
+ const consumed = consumeCwdOption(index);
828
+ if (consumed !== null) {
829
+ index = consumed;
830
+ continue;
831
+ }
832
+ if ((argv[index] ?? "").toLowerCase() === "run") {
833
+ scriptName = argv[index + 1] ?? "";
834
+ break;
835
+ }
836
+ if (!(argv[index] ?? "").startsWith("-")) {
837
+ scriptName = argv[index] ?? "";
838
+ break;
839
+ }
840
+ index += 1;
841
+ }
842
+ }
843
+
844
+ if (!scriptName) return null;
845
+ const script = readPackageJson(cwd)?.scripts?.[scriptName];
846
+ if (typeof script !== "string" || !script.trim()) return null;
847
+ return { script, cwd };
848
+ }
849
+
850
+ function readReferencedValidationScriptText(cwd: string, script: string): string {
851
+ const texts: string[] = [];
852
+ const tokens = tokenizeValidationCommandArgv(script) ?? script.split(/\s+/).filter(Boolean);
853
+ for (const rawToken of tokens) {
854
+ const token = rawToken
855
+ .trim()
856
+ .replace(/^['"`]+|['"`]+$/g, "")
857
+ .replace(/\\/g, "/");
858
+ if (!/\.(cjs|cts|js|jsx|mjs|mts|ts|tsx)$/i.test(token)) continue;
859
+ if (token.includes("://") || token.includes("node_modules/")) continue;
860
+ const scriptPath = resolve(cwd, token);
861
+ if (!existsSync(scriptPath)) continue;
862
+ try {
863
+ texts.push(readFileSync(scriptPath, "utf8").slice(0, 64_000));
864
+ } catch {
865
+ // Best effort: the validation command will surface unreadable files.
866
+ }
867
+ }
868
+ return texts.join("\n");
869
+ }
870
+
871
+ export function shouldEnsurePlaywrightBrowserRuntime(repo: string, command: string): boolean {
872
+ if (!isLongRunningBrowserValidationCommand(command)) return false;
873
+ if (/\bplaywright\b/i.test(command)) return true;
874
+
875
+ const script = resolvePackageScriptForValidationCommand(repo, command);
876
+ const scriptCwd = script?.cwd ?? repo;
877
+ if (packageJsonDeclaresPlaywright(repo) || packageJsonDeclaresPlaywright(scriptCwd)) {
878
+ return true;
879
+ }
880
+ if (!script) return false;
881
+ return /(?:^|[^A-Za-z0-9_-])(?:@playwright\/test|playwright)(?:$|[^A-Za-z0-9_-])/i.test(
882
+ `${script.script}\n${readReferencedValidationScriptText(script.cwd, script.script)}`,
883
+ );
884
+ }
885
+
886
+ const PLAYWRIGHT_BROWSER_INSTALL_TARGETS = new Set([
887
+ "chromium",
888
+ "chrome",
889
+ "chrome-beta",
890
+ "chrome-dev",
891
+ "chrome-canary",
892
+ "msedge",
893
+ "msedge-beta",
894
+ "msedge-dev",
895
+ "msedge-canary",
896
+ "firefox",
897
+ "webkit",
898
+ ]);
899
+
900
+ function addPlaywrightInstallTarget(targets: Set<string>, rawValue: string): void {
901
+ const value = rawValue.trim().toLowerCase();
902
+ if (!value) return;
903
+ const normalized = value === "edge" ? "msedge" : value;
904
+ if (PLAYWRIGHT_BROWSER_INSTALL_TARGETS.has(normalized)) {
905
+ targets.add(normalized);
906
+ }
907
+ }
908
+
909
+ export function inferPlaywrightBrowserInstallTargets(repo: string, command: string): string[] {
910
+ const targets = new Set<string>(["chromium"]);
911
+ const script = resolvePackageScriptForValidationCommand(repo, command);
912
+ const scriptText = script
913
+ ? `${script.script}\n${readReferencedValidationScriptText(script.cwd, script.script)}`
914
+ : "";
915
+ const text = `${command}\n${scriptText}`;
916
+
917
+ for (const match of text.matchAll(/\bchannel\s*:\s*["'`]([^"'`]+)["'`]/gi)) {
918
+ addPlaywrightInstallTarget(targets, match[1] ?? "");
919
+ }
920
+ for (const match of text.matchAll(/\bbrowserName\s*:\s*["'`]([^"'`]+)["'`]/gi)) {
921
+ addPlaywrightInstallTarget(targets, match[1] ?? "");
922
+ }
923
+ for (const match of text.matchAll(/(?:^|\s)(?:--browser|--browser-name|--channel)[=\s]+["'`]?([A-Za-z0-9_-]+)/gi)) {
924
+ addPlaywrightInstallTarget(targets, match[1] ?? "");
925
+ }
926
+ for (const target of PLAYWRIGHT_BROWSER_INSTALL_TARGETS) {
927
+ const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
928
+ if (new RegExp(`\\b${escaped}\\s*\\.\\s*launch\\b`, "i").test(text)) {
929
+ addPlaywrightInstallTarget(targets, target);
930
+ }
931
+ }
932
+
933
+ return Array.from(targets).sort((a, b) => {
934
+ if (a === "chromium") return -1;
935
+ if (b === "chromium") return 1;
936
+ return a.localeCompare(b);
937
+ });
938
+ }
939
+
940
+ export function playwrightBrowserInstallArgv(targets: string[] = ["chromium"]): string[] {
941
+ const installTargets = Array.from(new Set(targets.map((target) => target.trim()).filter(Boolean)));
942
+ return ["bunx", "playwright", "install", ...(installTargets.length > 0 ? installTargets : ["chromium"])];
943
+ }
944
+
945
+ async function runPlaywrightBrowserRuntimePreflight(
946
+ repo: string,
947
+ command: string,
948
+ targets: string[],
949
+ timeoutMs: number,
950
+ outputPolicy: Partial<OutputCompactionPolicy>,
951
+ ): Promise<ValidationExecutionResult> {
952
+ const env = buildWorkerSandboxWritableEnv(repo);
953
+ const timeout = Math.max(120_000, Math.min(600_000, timeoutMs));
954
+ return runValidationArgv(
955
+ repo,
956
+ command,
957
+ playwrightBrowserInstallArgv(targets),
958
+ env,
959
+ timeout,
960
+ outputPolicy,
961
+ `Browser runtime preflight timed out after ${timeout}ms while ensuring Playwright browser target(s): ${targets.join(", ")}. Captured output is the process output emitted before PushPals terminated the installer process tree.`,
962
+ );
963
+ }
964
+
684
965
  export function resolveValidationCommandTimeoutMs(command: string, baseTimeoutMs: number): number {
685
966
  const normalizedBase = Number.isFinite(Number(baseTimeoutMs))
686
967
  ? Math.max(1_000, Math.min(7_200_000, Math.floor(Number(baseTimeoutMs))))
@@ -927,12 +1208,16 @@ function detectValidationBlocker(runs: ValidationExecutionResult[]): ValidationB
927
1208
  combined.includes("missing required tool") ||
928
1209
  combined.includes("command not found") ||
929
1210
  combined.includes("executable not found") ||
1211
+ combined.includes("browser runtime preflight failed") ||
1212
+ combined.includes("playwright install") ||
1213
+ combined.includes("executable doesn't exist") ||
1214
+ combined.includes("please run the following command to download new browsers") ||
930
1215
  combined.includes("not recognized as an internal or external command")
931
1216
  ) {
932
1217
  return {
933
1218
  category: "environment",
934
1219
  detail:
935
- "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.",
1220
+ "Validation is blocked by missing required toolchain executables or browser runtime support in the worker environment. Install/provision the missing tools or browser runtime before retrying this job.",
936
1221
  };
937
1222
  }
938
1223
 
@@ -1121,6 +1406,10 @@ export function extractValidationFailureDigest(run: {
1121
1406
  /\bFailed to resolve import\s+['"`][^'"`\r\n]+['"`][^\r\n]*/i,
1122
1407
  /\bCould not resolve\s+['"`]?[^'"`\r\n]+['"`]?[^\r\n]*/i,
1123
1408
  /\bModule not found[^\r\n]*/i,
1409
+ /\bbrowserType\.launch:[^\r\n]*/i,
1410
+ /\bExecutable doesn't exist[^\r\n]*/i,
1411
+ /\bPlease run the following command to download new browsers:[^\r\n]*(?:\r?\n\s+[^\r\n]+)?/i,
1412
+ /\bRun ["`]?npx playwright install[^'"`\r\n]*["`]?[^\r\n]*/i,
1124
1413
  /\bERR_SOCKET_BAD_PORT[^\r\n]*/i,
1125
1414
  /\berror TS\d+:[^\r\n]*/i,
1126
1415
  /\bError:\s+[^\r\n]*/i,
@@ -1539,6 +1828,7 @@ async function runDeterministicQualityGate(
1539
1828
  )}`,
1540
1829
  );
1541
1830
  }
1831
+ const playwrightBrowserRuntimeReadyTargets = new Set<string>();
1542
1832
  for (const command of commandsToRun) {
1543
1833
  const commandMissingTools = requirementsForValidationCommand(toolchainPlan, command).filter(
1544
1834
  (requirement) =>
@@ -1563,6 +1853,58 @@ async function runDeterministicQualityGate(
1563
1853
  );
1564
1854
  continue;
1565
1855
  }
1856
+ const commandNeedsPlaywrightBrowserRuntime = shouldEnsurePlaywrightBrowserRuntime(
1857
+ repo,
1858
+ command,
1859
+ );
1860
+ const playwrightBrowserTargets = commandNeedsPlaywrightBrowserRuntime
1861
+ ? inferPlaywrightBrowserInstallTargets(repo, command)
1862
+ : [];
1863
+ const missingPlaywrightBrowserTargets = playwrightBrowserTargets.filter(
1864
+ (target) => !playwrightBrowserRuntimeReadyTargets.has(target),
1865
+ );
1866
+ let commandBrowserRuntimeEnsured =
1867
+ commandNeedsPlaywrightBrowserRuntime &&
1868
+ missingPlaywrightBrowserTargets.length === 0;
1869
+ if (missingPlaywrightBrowserTargets.length > 0) {
1870
+ const browserEnv = buildWorkerSandboxWritableEnv(repo);
1871
+ onLog?.(
1872
+ "stdout",
1873
+ `[ValidationGate] Browser runtime preflight: ensuring Playwright browser target(s) ${missingPlaywrightBrowserTargets.join(", ")} for "${command}" at ${browserEnv.PLAYWRIGHT_BROWSERS_PATH ?? "(default browser cache)"}`,
1874
+ );
1875
+ const browserPreflight = await runPlaywrightBrowserRuntimePreflight(
1876
+ repo,
1877
+ command,
1878
+ missingPlaywrightBrowserTargets,
1879
+ resolveValidationCommandTimeoutMs(command, qualityValidationStepTimeoutMs),
1880
+ outputPolicy,
1881
+ );
1882
+ if (!browserPreflight.ok) {
1883
+ const digest = extractValidationFailureDigest(browserPreflight);
1884
+ validationRuns.push({
1885
+ ...browserPreflight,
1886
+ stderr: [
1887
+ `Browser runtime preflight failed before validation command "${command}". WorkerPals could not ensure Playwright browser target(s) ${missingPlaywrightBrowserTargets.join(", ")} in PLAYWRIGHT_BROWSERS_PATH=${browserEnv.PLAYWRIGHT_BROWSERS_PATH ?? "(default)"}.`,
1888
+ browserPreflight.stderr,
1889
+ ]
1890
+ .filter(Boolean)
1891
+ .join("\n"),
1892
+ });
1893
+ onLog?.(
1894
+ "stderr",
1895
+ `[ValidationGate] Browser runtime preflight failed for "${command}"${digest ? ` - ${digest}` : ""}`,
1896
+ );
1897
+ continue;
1898
+ }
1899
+ for (const target of missingPlaywrightBrowserTargets) {
1900
+ playwrightBrowserRuntimeReadyTargets.add(target);
1901
+ }
1902
+ onLog?.(
1903
+ "stdout",
1904
+ `[ValidationGate] Browser runtime preflight passed for "${command}" (${missingPlaywrightBrowserTargets.join(", ")})`,
1905
+ );
1906
+ commandBrowserRuntimeEnsured = true;
1907
+ }
1566
1908
  const previousDigest = validationRetryState?.previousFailureDigests?.get(
1567
1909
  validationCommandKey(command),
1568
1910
  );
@@ -1570,7 +1912,8 @@ async function runDeterministicQualityGate(
1570
1912
  previousDigest &&
1571
1913
  Number(validationRetryState?.revisionAttempt ?? 0) > 0 &&
1572
1914
  isLongRunningBrowserValidationCommand(command) &&
1573
- isBrowserValidationInfrastructureDigest(previousDigest)
1915
+ isBrowserValidationInfrastructureDigest(previousDigest) &&
1916
+ !commandBrowserRuntimeEnsured
1574
1917
  ) {
1575
1918
  const stderr =
1576
1919
  `Skipped repeated browser validation after the same command failed in an earlier revision: ${previousDigest}. ` +