@pushpalsdev/cli 1.0.95 → 1.0.97

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.95",
3
+ "version": "1.0.97",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -369,21 +369,41 @@ def _normalize_choice(
369
369
  return default
370
370
 
371
371
 
372
- def _is_git_repo(repo: str) -> bool:
373
- try:
374
- proc = subprocess.run(
375
- ["git", "rev-parse", "--is-inside-work-tree"],
376
- cwd=repo,
377
- capture_output=True,
378
- text=True,
379
- timeout=10,
380
- check=False,
381
- )
382
- if proc.returncode != 0:
372
+ def _is_git_repo(repo: str, timeout_seconds: float = 5.0, poll_seconds: float = 0.1) -> bool:
373
+ deadline = time.monotonic() + max(0.0, timeout_seconds)
374
+ last_detail = ""
375
+ attempts = 0
376
+
377
+ while True:
378
+ attempts += 1
379
+ try:
380
+ proc = subprocess.run(
381
+ ["git", "rev-parse", "--is-inside-work-tree"],
382
+ cwd=repo,
383
+ capture_output=True,
384
+ text=True,
385
+ timeout=10,
386
+ check=False,
387
+ )
388
+ if proc.returncode == 0 and (proc.stdout or "").strip().lower() == "true":
389
+ return True
390
+ last_detail = "\n".join(
391
+ part.strip()
392
+ for part in [proc.stderr or "", proc.stdout or ""]
393
+ if part and part.strip()
394
+ )
395
+ except Exception as exc:
396
+ last_detail = str(exc)
397
+
398
+ if time.monotonic() >= deadline:
399
+ if last_detail:
400
+ log.warning(
401
+ "Git repository preflight failed "
402
+ f"after {attempts} attempt(s) for {repo}: {to_single_line(last_detail, 240)}"
403
+ )
383
404
  return False
384
- return (proc.stdout or "").strip().lower() == "true"
385
- except Exception:
386
- return False
405
+
406
+ time.sleep(max(0.01, poll_seconds))
387
407
 
388
408
 
389
409
  def _codex_project_config_roots(repo: str, env: Dict[str, str]) -> List[Path]:
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "crypto";
2
- import { existsSync, mkdirSync } from "fs";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
3
  import { homedir, tmpdir } from "os";
4
4
  import { basename, resolve } from "path";
5
5
 
@@ -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;
@@ -33,6 +41,18 @@ function ensureDirs(paths: string[]): void {
33
41
  }
34
42
  }
35
43
 
44
+ function ensureSandboxGitConfig(homeDir: string): void {
45
+ const gitConfigPath = resolve(homeDir, ".gitconfig");
46
+ try {
47
+ const existing = existsSync(gitConfigPath) ? readFileSync(gitConfigPath, "utf8") : "";
48
+ if (/(^|\n)\s*directory\s*=\s*\*/.test(existing)) return;
49
+ const prefix = existing.trim() ? `${existing.replace(/\s+$/, "")}\n\n` : "";
50
+ writeFileSync(gitConfigPath, `${prefix}[safe]\n\tdirectory = *\n`, "utf8");
51
+ } catch {
52
+ // Best effort: git will surface any remaining safe.directory blocker.
53
+ }
54
+ }
55
+
36
56
  function resolveOriginalHome(env: Record<string, string>): string {
37
57
  return env.HOME || env.USERPROFILE || homedir();
38
58
  }
@@ -54,8 +74,18 @@ export function buildWorkerSandboxWritableEnv(
54
74
  const homeDir = resolve(baseDir, "home");
55
75
  const cacheDir = resolve(baseDir, "cache");
56
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
+ );
57
86
  const defaultExpoPort = defaultExpoPortForRepo(repo);
58
- ensureDirs([homeDir, cacheDir, expoDir, resolve(cacheDir, "npm")]);
87
+ ensureDirs([homeDir, cacheDir, expoDir, resolve(cacheDir, "npm"), playwrightBrowsersDir]);
88
+ ensureSandboxGitConfig(homeDir);
59
89
 
60
90
  return {
61
91
  ...env,
@@ -64,6 +94,7 @@ export function buildWorkerSandboxWritableEnv(
64
94
  USERPROFILE: homeDir,
65
95
  XDG_CACHE_HOME: cacheDir,
66
96
  npm_config_cache: resolve(cacheDir, "npm"),
97
+ PLAYWRIGHT_BROWSERS_PATH: env.PLAYWRIGHT_BROWSERS_PATH ?? playwrightBrowsersDir,
67
98
  EXPO_HOME: expoDir,
68
99
  EXPO_NO_TELEMETRY: env.EXPO_NO_TELEMETRY ?? "1",
69
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,168 @@ 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
+ export function playwrightBrowserInstallArgv(): string[] {
887
+ return ["bunx", "playwright", "install", "chromium"];
888
+ }
889
+
890
+ async function runPlaywrightBrowserRuntimePreflight(
891
+ repo: string,
892
+ command: string,
893
+ timeoutMs: number,
894
+ outputPolicy: Partial<OutputCompactionPolicy>,
895
+ ): Promise<ValidationExecutionResult> {
896
+ const env = buildWorkerSandboxWritableEnv(repo);
897
+ const timeout = Math.max(120_000, Math.min(600_000, timeoutMs));
898
+ return runValidationArgv(
899
+ repo,
900
+ command,
901
+ playwrightBrowserInstallArgv(),
902
+ env,
903
+ timeout,
904
+ outputPolicy,
905
+ `Browser runtime preflight timed out after ${timeout}ms while ensuring Playwright Chromium. Captured output is the process output emitted before PushPals terminated the installer process tree.`,
906
+ );
907
+ }
908
+
684
909
  export function resolveValidationCommandTimeoutMs(command: string, baseTimeoutMs: number): number {
685
910
  const normalizedBase = Number.isFinite(Number(baseTimeoutMs))
686
911
  ? Math.max(1_000, Math.min(7_200_000, Math.floor(Number(baseTimeoutMs))))
@@ -927,12 +1152,16 @@ function detectValidationBlocker(runs: ValidationExecutionResult[]): ValidationB
927
1152
  combined.includes("missing required tool") ||
928
1153
  combined.includes("command not found") ||
929
1154
  combined.includes("executable not found") ||
1155
+ combined.includes("browser runtime preflight failed") ||
1156
+ combined.includes("playwright install") ||
1157
+ combined.includes("executable doesn't exist") ||
1158
+ combined.includes("please run the following command to download new browsers") ||
930
1159
  combined.includes("not recognized as an internal or external command")
931
1160
  ) {
932
1161
  return {
933
1162
  category: "environment",
934
1163
  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.",
1164
+ "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
1165
  };
937
1166
  }
938
1167
 
@@ -1121,6 +1350,10 @@ export function extractValidationFailureDigest(run: {
1121
1350
  /\bFailed to resolve import\s+['"`][^'"`\r\n]+['"`][^\r\n]*/i,
1122
1351
  /\bCould not resolve\s+['"`]?[^'"`\r\n]+['"`]?[^\r\n]*/i,
1123
1352
  /\bModule not found[^\r\n]*/i,
1353
+ /\bbrowserType\.launch:[^\r\n]*/i,
1354
+ /\bExecutable doesn't exist[^\r\n]*/i,
1355
+ /\bPlease run the following command to download new browsers:[^\r\n]*(?:\r?\n\s+[^\r\n]+)?/i,
1356
+ /\bRun ["`]?npx playwright install[^'"`\r\n]*["`]?[^\r\n]*/i,
1124
1357
  /\bERR_SOCKET_BAD_PORT[^\r\n]*/i,
1125
1358
  /\berror TS\d+:[^\r\n]*/i,
1126
1359
  /\bError:\s+[^\r\n]*/i,
@@ -1539,6 +1772,7 @@ async function runDeterministicQualityGate(
1539
1772
  )}`,
1540
1773
  );
1541
1774
  }
1775
+ let playwrightBrowserRuntimeReady = false;
1542
1776
  for (const command of commandsToRun) {
1543
1777
  const commandMissingTools = requirementsForValidationCommand(toolchainPlan, command).filter(
1544
1778
  (requirement) =>
@@ -1563,6 +1797,48 @@ async function runDeterministicQualityGate(
1563
1797
  );
1564
1798
  continue;
1565
1799
  }
1800
+ const commandNeedsPlaywrightBrowserRuntime = shouldEnsurePlaywrightBrowserRuntime(
1801
+ repo,
1802
+ command,
1803
+ );
1804
+ let commandBrowserRuntimeEnsured =
1805
+ playwrightBrowserRuntimeReady && commandNeedsPlaywrightBrowserRuntime;
1806
+ if (!playwrightBrowserRuntimeReady && commandNeedsPlaywrightBrowserRuntime) {
1807
+ const browserEnv = buildWorkerSandboxWritableEnv(repo);
1808
+ onLog?.(
1809
+ "stdout",
1810
+ `[ValidationGate] Browser runtime preflight: ensuring Playwright Chromium for "${command}" at ${browserEnv.PLAYWRIGHT_BROWSERS_PATH ?? "(default browser cache)"}`,
1811
+ );
1812
+ const browserPreflight = await runPlaywrightBrowserRuntimePreflight(
1813
+ repo,
1814
+ command,
1815
+ resolveValidationCommandTimeoutMs(command, qualityValidationStepTimeoutMs),
1816
+ outputPolicy,
1817
+ );
1818
+ if (!browserPreflight.ok) {
1819
+ const digest = extractValidationFailureDigest(browserPreflight);
1820
+ validationRuns.push({
1821
+ ...browserPreflight,
1822
+ stderr: [
1823
+ `Browser runtime preflight failed before validation command "${command}". WorkerPals could not ensure Playwright Chromium in PLAYWRIGHT_BROWSERS_PATH=${browserEnv.PLAYWRIGHT_BROWSERS_PATH ?? "(default)"}.`,
1824
+ browserPreflight.stderr,
1825
+ ]
1826
+ .filter(Boolean)
1827
+ .join("\n"),
1828
+ });
1829
+ onLog?.(
1830
+ "stderr",
1831
+ `[ValidationGate] Browser runtime preflight failed for "${command}"${digest ? ` - ${digest}` : ""}`,
1832
+ );
1833
+ continue;
1834
+ }
1835
+ playwrightBrowserRuntimeReady = true;
1836
+ onLog?.(
1837
+ "stdout",
1838
+ `[ValidationGate] Browser runtime preflight passed for "${command}"`,
1839
+ );
1840
+ commandBrowserRuntimeEnsured = true;
1841
+ }
1566
1842
  const previousDigest = validationRetryState?.previousFailureDigests?.get(
1567
1843
  validationCommandKey(command),
1568
1844
  );
@@ -1570,7 +1846,8 @@ async function runDeterministicQualityGate(
1570
1846
  previousDigest &&
1571
1847
  Number(validationRetryState?.revisionAttempt ?? 0) > 0 &&
1572
1848
  isLongRunningBrowserValidationCommand(command) &&
1573
- isBrowserValidationInfrastructureDigest(previousDigest)
1849
+ isBrowserValidationInfrastructureDigest(previousDigest) &&
1850
+ !commandBrowserRuntimeEnsured
1574
1851
  ) {
1575
1852
  const stderr =
1576
1853
  `Skipped repeated browser validation after the same command failed in an earlier revision: ${previousDigest}. ` +