vibexray 0.1.0__py3-none-any.whl

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.
vibexray/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """X-ray an AI-built prototype for the engineer who has to build it for real."""
2
+
3
+ __version__ = "0.1.0"
vibexray/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from vibexray.cli import main
2
+
3
+ raise SystemExit(main())
vibexray/apprun.py ADDED
@@ -0,0 +1,273 @@
1
+ """Start the prototype in a clean environment, crawl it, and always stop it again."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from vibexray.apprun_boot import (
16
+ USUAL_PORTS,
17
+ Process,
18
+ clean_env,
19
+ detect_package_manager,
20
+ detect_script,
21
+ find_env_names,
22
+ free_port,
23
+ log_tail,
24
+ port_open,
25
+ python_entry,
26
+ read_package,
27
+ reap_strays,
28
+ url_from_log,
29
+ wait_for_port_free,
30
+ )
31
+ from vibexray.apprun_crawl import RUN_EXTRA_HINT, crawl
32
+ from vibexray.model import AppRun
33
+ from vibexray.rules.base import is_env_file
34
+
35
+ BOOT_SECONDS = 90
36
+ COPY_SKIP = (".git", "node_modules", ".next", "dist", "build", "vibexray-report")
37
+ BUILD_MISSING = re.compile(r"(not found|ENOENT|Cannot find).*\b(dist|build|\.next)\b", re.I)
38
+ INSTALL_SECONDS = 300
39
+ INSTALL_ARGS = {
40
+ "npm": ["install", "--ignore-scripts", "--no-audit", "--no-fund"],
41
+ "pnpm": ["install", "--ignore-scripts"],
42
+ "yarn": ["install", "--ignore-scripts"],
43
+ "bun": ["install", "--ignore-scripts"],
44
+ }
45
+
46
+
47
+ def _not_attempted(reason: str) -> AppRun:
48
+ return AppRun(state="not_attempted", reason=reason)
49
+
50
+
51
+ def run_app(root: Path, out_dir: Path, enabled: bool) -> AppRun:
52
+ if not enabled:
53
+ return _not_attempted("Skipped: the scan ran with --no-run.")
54
+ pkg = read_package(root)
55
+ entry = python_entry(root)
56
+ if pkg is None and entry is None:
57
+ return _not_attempted(
58
+ "There is no package.json or Python start file in the top folder, so vibexray "
59
+ "does not know how to start this app."
60
+ )
61
+ out_dir.mkdir(parents=True, exist_ok=True)
62
+ # The copy lives outside the user's folder, so a killed scan never leaves it there.
63
+ try:
64
+ run_dir = Path(tempfile.mkdtemp(prefix="vibexray-run-"))
65
+ except OSError as exc:
66
+ return AppRun(
67
+ state="could_not_boot",
68
+ reason=f"vibexray could not make a temporary folder for the app ({exc.strerror}).",
69
+ )
70
+ home = run_dir / "home"
71
+ home.mkdir()
72
+ previous = _trap_stop_signals()
73
+ try:
74
+ # Install and start in a copy, so the user's folder never changes.
75
+ try:
76
+ work = _make_copy(root, run_dir / "app", out_dir)
77
+ except (shutil.Error, OSError) as exc:
78
+ return AppRun(
79
+ state="could_not_boot",
80
+ reason="vibexray could not copy the app to start it. " + _copy_problem(exc),
81
+ )
82
+ if pkg is not None:
83
+ script = detect_script(pkg)
84
+ if script is None:
85
+ return _not_attempted(
86
+ "package.json has no dev, start, or preview script, so vibexray cannot start it."
87
+ )
88
+ manager = detect_package_manager(root)
89
+ command = f"{manager} run {script}"
90
+ return _boot_and_crawl(work, out_dir, home, command, [manager, "run", script], manager)
91
+ cmd = ["python3", entry]
92
+ if entry == "manage.py":
93
+ cmd = ["python3", entry, "runserver"]
94
+ return _boot_and_crawl(work, out_dir, home, " ".join(cmd), cmd, None)
95
+ except PermissionError as exc:
96
+ return AppRun(
97
+ state="could_not_boot",
98
+ reason=(
99
+ "This computer blocked the app from starting "
100
+ f"({exc.strerror or exc}). A sandbox, such as the one in Codex, does this. "
101
+ "Run the scan in a normal terminal to see the app run."
102
+ ),
103
+ )
104
+ finally:
105
+ reap_strays(home)
106
+ shutil.rmtree(run_dir, ignore_errors=True)
107
+ _restore_signals(previous)
108
+
109
+
110
+ def _trap_stop_signals() -> dict | None:
111
+ """Turn SIGTERM and SIGHUP into SystemExit, so the cleanup in run_app always runs."""
112
+ if threading.current_thread() is not threading.main_thread():
113
+ return None
114
+
115
+ def stop(signum, _frame):
116
+ raise SystemExit(128 + signum)
117
+
118
+ return {s: signal.signal(s, stop) for s in (signal.SIGTERM, signal.SIGHUP)}
119
+
120
+
121
+ def _restore_signals(previous: dict | None) -> None:
122
+ for sig, handler in (previous or {}).items():
123
+ signal.signal(sig, handler)
124
+
125
+
126
+ def _copy_problem(exc: Exception) -> str:
127
+ # shutil.Error carries a list of (source, destination, reason) for each failed file.
128
+ if isinstance(exc, shutil.Error) and exc.args and isinstance(exc.args[0], list):
129
+ src, _, why = exc.args[0][0]
130
+ return f"It could not read {Path(src).name}: {str(why).split(': ')[0]}."
131
+ return f"{getattr(exc, 'strerror', None) or exc}."
132
+
133
+
134
+ def _make_copy(root: Path, dest: Path, out_dir: Path) -> Path:
135
+ skip = set(COPY_SKIP)
136
+ out_resolved = out_dir.resolve()
137
+
138
+ def ignore(folder: str, names: list[str]) -> set[str]:
139
+ left_out = set()
140
+ for n in names:
141
+ path = Path(folder) / n
142
+ # .env files hold the user's keys. The app starts without them.
143
+ if (
144
+ n in skip
145
+ or is_env_file(n)
146
+ or path.resolve() == out_resolved
147
+ or not (path.is_symlink() or path.is_dir() or path.is_file())
148
+ ):
149
+ left_out.add(n)
150
+ return left_out
151
+
152
+ shutil.copytree(root, dest, ignore=ignore, symlinks=True)
153
+ if (root / "node_modules").is_dir():
154
+ _clone(root / "node_modules", dest / "node_modules")
155
+ return dest
156
+
157
+
158
+ def _clone(src: Path, dest: Path) -> None:
159
+ """Copy node_modules copy-on-write, so caches the dev server writes stay in the copy.
160
+
161
+ APFS and Btrfs share the file blocks, so the clone costs almost no disk. If the clone
162
+ fails, the copy has no node_modules and the app run installs the packages instead.
163
+ """
164
+ if sys.platform == "darwin":
165
+ cmd = ["cp", "-cR", str(src), str(dest)]
166
+ else:
167
+ cmd = ["cp", "-R", "--reflink=auto", str(src), str(dest)]
168
+ if subprocess.run(cmd, capture_output=True).returncode != 0:
169
+ shutil.rmtree(dest, ignore_errors=True)
170
+
171
+
172
+ def _boot_and_crawl(root, out_dir, home, command, cmd, manager) -> AppRun:
173
+ result = AppRun(command=command)
174
+ port = free_port()
175
+ env = clean_env(home, port)
176
+ tool = cmd[0]
177
+ if shutil.which(tool, path=env["PATH"]) is None:
178
+ return _failed(
179
+ result,
180
+ root,
181
+ f"{tool} is not installed on this computer, so the app did not start.",
182
+ None,
183
+ )
184
+ if manager and not (root / "node_modules").is_dir():
185
+ problem = _install(root, env, home, manager)
186
+ if problem:
187
+ return _failed(result, root, problem[0], problem[1])
188
+ # Ports that were already busy before the start belong to someone else.
189
+ fallback = [p for p in USUAL_PORTS if not port_open(p)]
190
+ server = Process(cmd, root, env, home / "server.log")
191
+ try:
192
+ url, why = _wait_for_url(server, port, fallback)
193
+ if url is None:
194
+ return _failed(result, root, why, server.log)
195
+ result.url = url
196
+ try:
197
+ result.pages = crawl(url, root, out_dir)
198
+ except ImportError:
199
+ return _failed(result, root, RUN_EXTRA_HINT, None, keep_url=True)
200
+ except Exception as exc: # noqa: BLE001 - browser launch problems must not leak a traceback
201
+ return _failed(
202
+ result,
203
+ root,
204
+ "The browser could not open the app: "
205
+ + str(exc).splitlines()[0][:160]
206
+ + " Run: playwright install chromium",
207
+ None,
208
+ keep_url=True,
209
+ )
210
+ result.state = "ran"
211
+ n = len(result.pages)
212
+ result.reason = f"vibexray started the app and visited {n} page{'' if n == 1 else 's'}."
213
+ return result
214
+ finally:
215
+ server.stop()
216
+ wait_for_port_free(port, 5.0)
217
+ if result.url:
218
+ wait_for_port_free(int(result.url.rsplit(":", 1)[1]), 5.0)
219
+
220
+
221
+ def _install(root: Path, env: dict, home: Path, manager: str) -> tuple[str, Path] | None:
222
+ log = home / "install.log"
223
+ proc = Process([manager, *INSTALL_ARGS[manager]], root, env, log)
224
+ try:
225
+ code = proc.wait(INSTALL_SECONDS)
226
+ finally:
227
+ proc.stop()
228
+ if code is None:
229
+ return (
230
+ "Installing the app's packages took longer than 10 minutes, so vibexray stopped.",
231
+ log,
232
+ )
233
+ if code != 0:
234
+ return ("Installing the app's packages failed, so the app did not start.", log)
235
+ return None
236
+
237
+
238
+ def _wait_for_url(server: Process, port: int, fallback: list[int]) -> tuple[str | None, str]:
239
+ deadline = time.monotonic() + BOOT_SECONDS
240
+ while time.monotonic() < deadline:
241
+ if not server.alive():
242
+ return None, "The app started and then stopped on its own, so it cannot run here."
243
+ url = url_from_log(server.log)
244
+ if url:
245
+ port_in_url = int(url.rsplit(":", 1)[1])
246
+ if port_open(port_in_url):
247
+ return url, ""
248
+ elif port_open(port):
249
+ return f"http://localhost:{port}", ""
250
+ else:
251
+ for p in fallback:
252
+ if port_open(p):
253
+ return f"http://localhost:{p}", ""
254
+ time.sleep(0.5)
255
+ return None, f"The app did not answer within {BOOT_SECONDS} seconds."
256
+
257
+
258
+ def _failed(
259
+ result: AppRun, root: Path, reason: str, log: Path | None, keep_url: bool = False
260
+ ) -> AppRun:
261
+ result.state = "could_not_boot"
262
+ missing = find_env_names(root)
263
+ result.missing_env = missing
264
+ tail = log_tail(log) if log is not None else []
265
+ if any(BUILD_MISSING.search(line) for line in tail):
266
+ reason += " A build step looks missing: the start file does not exist yet."
267
+ elif missing and log is not None:
268
+ reason += " It may need settings or keys that vibexray does not pass on."
269
+ result.reason = reason
270
+ result.log_tail = tail
271
+ if not keep_url:
272
+ result.url = None
273
+ return result
@@ -0,0 +1,273 @@
1
+ """Pure helpers and process control for the app run: detection, URL parsing, safe start and stop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import re
9
+ import signal
10
+ import socket
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from vibexray.rules.base import redact
17
+
18
+ ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
19
+ URL = re.compile(r"https?://(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d{2,5})")
20
+ ENV_CODE = re.compile(r"(?:process\.env|import\.meta\.env)\.([A-Za-z_][A-Za-z0-9_]*)")
21
+ ENV_KEY = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=", re.M)
22
+ SOURCE_SUFFIXES = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte"}
23
+ SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".nuxt", ".svelte-kit", "tmp"}
24
+ # Set by the runtime or by the clean environment itself, so never "missing".
25
+ BUILTIN_ENV = {"NODE_ENV", "PORT", "PATH", "HOME", "MODE", "DEV", "PROD", "SSR", "BASE_URL", "CI"}
26
+ USUAL_PORTS = (3000, 5173, 4173, 8080, 8000, 4321, 3001)
27
+ PYTHON_ENTRIES = ("app.py", "main.py", "server.py", "manage.py")
28
+ SCRIPT_ORDER = ("dev", "start", "preview")
29
+
30
+
31
+ def parse_url(line: str) -> str | None:
32
+ """Return the local URL a dev server prints, or None. Network lines are skipped."""
33
+ text = ANSI.sub("", line)
34
+ if "Network" in text:
35
+ return None
36
+ m = URL.search(text)
37
+ if not m:
38
+ return None
39
+ host = "127.0.0.1" if m.group(1) == "127.0.0.1" else "localhost"
40
+ return f"http://{host}:{m.group(2)}"
41
+
42
+
43
+ def detect_package_manager(root: Path) -> str:
44
+ # Bun first: a repo with both bun.lock and package-lock.json usually runs its scripts with bun.
45
+ if (root / "bun.lock").exists() or (root / "bun.lockb").exists():
46
+ return "bun"
47
+ if (root / "pnpm-lock.yaml").exists():
48
+ return "pnpm"
49
+ if (root / "yarn.lock").exists():
50
+ return "yarn"
51
+ return "npm"
52
+
53
+
54
+ def detect_script(pkg: dict) -> str | None:
55
+ scripts = pkg.get("scripts")
56
+ if not isinstance(scripts, dict):
57
+ return None
58
+ return next((s for s in SCRIPT_ORDER if s in scripts), None)
59
+
60
+
61
+ def read_package(root: Path) -> dict | None:
62
+ try:
63
+ data = json.loads((root / "package.json").read_text(encoding="utf-8"))
64
+ except (OSError, ValueError):
65
+ return None
66
+ return data if isinstance(data, dict) else None
67
+
68
+
69
+ def python_entry(root: Path) -> str | None:
70
+ return next((e for e in PYTHON_ENTRIES if (root / e).is_file()), None)
71
+
72
+
73
+ def iter_source(root: Path):
74
+ for dirpath, dirnames, filenames in os.walk(root):
75
+ dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
76
+ for name in sorted(filenames):
77
+ path = Path(dirpath) / name
78
+ if path.suffix in SOURCE_SUFFIXES:
79
+ yield path
80
+
81
+
82
+ def find_env_names(root: Path) -> list[str]:
83
+ names: set[str] = set()
84
+ for path in iter_source(root):
85
+ try:
86
+ names.update(ENV_CODE.findall(path.read_text(encoding="utf-8", errors="ignore")))
87
+ except OSError:
88
+ continue
89
+ for example in (".env.example", ".env.sample", ".env.template"):
90
+ try:
91
+ names.update(ENV_KEY.findall((root / example).read_text(encoding="utf-8")))
92
+ except OSError:
93
+ continue
94
+ return sorted(names - BUILTIN_ENV)
95
+
96
+
97
+ def clean_env(home: Path, port: int) -> dict[str, str]:
98
+ """The only variables a scanned app sees. The user's own keys never pass through."""
99
+ return {"PATH": os.environ.get("PATH", ""), "HOME": str(home), "PORT": str(port)}
100
+
101
+
102
+ def port_open(port: int, host: str = "127.0.0.1") -> bool:
103
+ with socket.socket() as s:
104
+ s.settimeout(0.5)
105
+ return s.connect_ex((host, port)) == 0
106
+
107
+
108
+ def free_port() -> int:
109
+ with socket.socket() as s:
110
+ s.bind(("127.0.0.1", 0))
111
+ return s.getsockname()[1]
112
+
113
+
114
+ def log_tail(path: Path, n: int = 20) -> list[str]:
115
+ try:
116
+ lines = ANSI.sub("", path.read_text(encoding="utf-8", errors="replace")).splitlines()
117
+ except OSError:
118
+ return []
119
+ return [redact(line) for line in lines if line.strip()][-n:]
120
+
121
+
122
+ def url_from_log(path: Path) -> str | None:
123
+ try:
124
+ text = path.read_text(encoding="utf-8", errors="replace")
125
+ except OSError:
126
+ return None
127
+ for line in text.splitlines():
128
+ url = parse_url(line)
129
+ if url:
130
+ return url
131
+ return None
132
+
133
+
134
+ class Process:
135
+ """A child in its own process group, so one call stops it and everything it spawned."""
136
+
137
+ def __init__(self, cmd: list[str], cwd: Path, env: dict[str, str], log: Path):
138
+ log.parent.mkdir(parents=True, exist_ok=True)
139
+ self.log = log
140
+ self._fh = open(log, "wb") # noqa: SIM115 - closed in stop()
141
+ try:
142
+ self.proc = subprocess.Popen(
143
+ cmd,
144
+ cwd=cwd,
145
+ env=env,
146
+ stdin=subprocess.DEVNULL,
147
+ stdout=self._fh,
148
+ stderr=subprocess.STDOUT,
149
+ start_new_session=True,
150
+ )
151
+ except OSError:
152
+ self._fh.close()
153
+ raise
154
+
155
+ def alive(self) -> bool:
156
+ return self.proc.poll() is None
157
+
158
+ def wait(self, timeout: float) -> int | None:
159
+ try:
160
+ return self.proc.wait(timeout=timeout)
161
+ except subprocess.TimeoutExpired:
162
+ return None
163
+
164
+ def stop(self) -> None:
165
+ pgid = self.proc.pid
166
+ # Some tools start helpers in their own session (Next.js telemetry does). The group
167
+ # kill misses those, so list every descendant first and stop them by pid too.
168
+ kids = _descendants(pgid)
169
+ for sig in (signal.SIGTERM, signal.SIGKILL):
170
+ _signal_all(pgid, kids, sig)
171
+ deadline = time.monotonic() + 5.0
172
+ while time.monotonic() < deadline and (_group_alive(pgid) or _any_alive(kids)):
173
+ time.sleep(0.1)
174
+ if not _group_alive(pgid) and not _any_alive(kids):
175
+ break
176
+ with contextlib.suppress(subprocess.TimeoutExpired):
177
+ self.proc.wait(timeout=1)
178
+ self._fh.close()
179
+
180
+
181
+ def _ps(args: list[str]) -> str:
182
+ # A sandbox, such as the one Codex uses, can block ps. The process group kill still works.
183
+ try:
184
+ return subprocess.run(["ps", *args], capture_output=True, text=True).stdout
185
+ except OSError:
186
+ return ""
187
+
188
+
189
+ def _descendants(pid: int) -> list[int]:
190
+ out = _ps(["-axo", "pid=,ppid="])
191
+ children: dict[int, list[int]] = {}
192
+ for line in out.splitlines():
193
+ parts = line.split()
194
+ if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
195
+ children.setdefault(int(parts[1]), []).append(int(parts[0]))
196
+ found, todo = [], [pid]
197
+ while todo:
198
+ for kid in children.get(todo.pop(), []):
199
+ found.append(kid)
200
+ todo.append(kid)
201
+ return found
202
+
203
+
204
+ def _signal_all(pgid: int, pids: list[int], sig: int) -> None:
205
+ if pgid > 0:
206
+ with contextlib.suppress(ProcessLookupError, PermissionError):
207
+ os.killpg(pgid, sig)
208
+ for pid in pids:
209
+ with contextlib.suppress(ProcessLookupError, PermissionError):
210
+ os.kill(pid, sig)
211
+
212
+
213
+ def _any_alive(pids: list[int]) -> bool:
214
+ for pid in pids:
215
+ try:
216
+ os.kill(pid, 0)
217
+ except (ProcessLookupError, PermissionError):
218
+ continue
219
+ return True
220
+ return False
221
+
222
+
223
+ def _group_alive(pgid: int) -> bool:
224
+ try:
225
+ os.killpg(pgid, 0)
226
+ except (ProcessLookupError, PermissionError):
227
+ return False
228
+ return True
229
+
230
+
231
+ def wait_for_port_free(port: int, seconds: float = 10.0) -> bool:
232
+ deadline = time.monotonic() + seconds
233
+ while time.monotonic() < deadline:
234
+ if not port_open(port):
235
+ return True
236
+ time.sleep(0.2)
237
+ return not port_open(port)
238
+
239
+
240
+ def reap_strays(home: Path, seconds: float = 10.0) -> None:
241
+ """Stop leftovers that escaped the process group, such as the detached telemetry flush
242
+ that Next.js starts at exit. Only processes that carry our unique HOME in their
243
+ environment count, so the user's own processes are never touched."""
244
+ marker = f"HOME={home.resolve()}"
245
+ start = time.monotonic()
246
+ for _ in range(int(seconds / 0.2)):
247
+ pids = _pids_with_env(marker)
248
+ if not pids:
249
+ return
250
+ if time.monotonic() - start > 2.0:
251
+ _signal_all(-1, pids, signal.SIGKILL)
252
+ time.sleep(0.2)
253
+
254
+
255
+ def _pids_with_env(marker: str) -> list[int]:
256
+ me = os.getpid()
257
+ if sys.platform == "darwin":
258
+ # -E appends each process's environment to its command line.
259
+ found = []
260
+ for line in _ps(["-axE", "-o", "pid=,command="]).splitlines():
261
+ pid, _, rest = line.strip().partition(" ")
262
+ if pid.isdigit() and int(pid) != me and f" {marker}" in f" {rest} ":
263
+ found.append(int(pid))
264
+ return found
265
+ found = []
266
+ for entry in Path("/proc").glob("[0-9]*"):
267
+ try:
268
+ env = (entry / "environ").read_bytes().split(b"\0")
269
+ except OSError:
270
+ continue
271
+ if marker.encode() in env and int(entry.name) != me:
272
+ found.append(int(entry.name))
273
+ return found