diff --git a/README.md b/README.md index 1bc72d6..6245d5c 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ for selection keys, automation, shared dependencies, and recovery. ## Documentation - [Installation, prerequisites, supported systems, and migration](docs/installation.md) +- [Developer workflow and project command reference](docs/developer-workflow.md) - [Selective uninstall, cache choices, and recovery](docs/uninstall.md) - [Troubleshooting and development findings](docs/troubleshooting.md) - [Development, tests, and maintaining pinned versions](docs/development.md) @@ -166,6 +167,8 @@ reliably without knowing the terminal emulator. | `kip PORT [PORT ...]` | Confirm and send SIGTERM to the listeners on explicit ports | | `serve [PORT] [DIR]` | Serve a directory on localhost (default port 8000) | | `db [ENV_FILE]` / `dbm` | Interactive PostgreSQL database/schema manager | +| `dev-check [PATH]` | Read-only project dependency, Git, runtime, and Docker checks | +| `dev-browser doctor` | Check a project's Playwright/browser setup | | `pathlist` | Print one PATH entry per line | | `linux-doctor` / `dev-doctor` | Check installed tools and shell integration | | `linuxsetup` / `devsetup` | Enter this repository | @@ -185,6 +188,21 @@ tmux keeps **Ctrl-B** as the prefix. After the prefix, press `|` or `-` to split detach. `work` names sessions by project directory plus a short path hash, so two projects with the same directory name do not collide. Existing sessions are kept. +## Project checks and browser tests + +```sh +dev-check # Inspect this checkout; --network opts into registry probes +dev-browser doctor # Inspect the project's Playwright/browser setup +dev-browser install # Download its pinned Chromium version +dev-browser run -- pnpm test:e2e +``` + +On Ubuntu/Debian, `dev-browser install --user-deps` can also extract required +APT libraries into a user cache without sudo. `run` applies those libraries only +to its child command. Project runtime versions, task names, dependencies, and +credentials remain project-owned. See the [developer workflow guide](docs/developer-workflow.md) +for the full recipe, optional aliases, environment checks, and database tools. + ## Ports and local servers ```sh @@ -222,9 +240,9 @@ server. It uses the database role and permissions in the selected connection. The server's `postgres` database is used for database-level operations by default. Your role needs CONNECT there and the appropriate CREATE/ownership privileges. -Recognized variables include `DB_URL`, `DATABASE_URL`, `TEST_DB_URL`, `DB_URLS`, -and `DATABASE_URLS`. Use `--var NAME` for any other name. Multiple connections can -be a JSON array or a named JSON object on a single line: +Recognized variables include `DB_URL`, `DATABASE_URL`, `TEST_DB_URL`, +`TEST_DATABASE_URL`, `DB_URLS`, and `DATABASE_URLS`. Use `--var NAME` for any other +name. Multiple connections can be a JSON array or a named JSON object on a single line: ```dotenv DATABASE_URL=postgresql://app:password@localhost:5432/my_project diff --git a/bin/dev-browser b/bin/dev-browser new file mode 100755 index 0000000..e4b046e --- /dev/null +++ b/bin/dev-browser @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +sys.dont_write_bytecode = True +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from browser import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/dev-check b/bin/dev-check new file mode 100755 index 0000000..1959eae --- /dev/null +++ b/bin/dev-check @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +sys.dont_write_bytecode = True +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from project_check import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/developer-workflow.md b/docs/developer-workflow.md new file mode 100644 index 0000000..49ae9fb --- /dev/null +++ b/docs/developer-workflow.md @@ -0,0 +1,146 @@ +# Developer workflow + +Run project commands from the relevant checkout. This setup supplies shared +tools; each project keeps its own runtime version, package manager, dependencies, +tasks, and credentials. + +## Activate new helpers in an existing installation + +After the changes are merged, update your persistent installation checkout +and refresh its configuration links: + +```sh +cd ~/linux-setup # Use your actual installation checkout +git pull --ff-only +./install.sh --config-only --skip-mcp +exec zsh +``` + +Use the persistent checkout for installation, rather than a temporary review +worktree. Configuration-only installation keeps existing tools and their versions. + +## Check a checkout + +```sh +dev-check # Read-only checks for the current project +dev-check ../another-project # Check another checkout +dev-check --json # Machine-readable results +dev-check --network # Also probe registry reachability +linux-doctor # Check the terminal setup itself +``` + +`dev-check` inspects manifests, lockfiles, dependency markers, Git checkout status, and +tool availability. A Compose filename enables a local Docker check. It does not +install packages, start services, source dotenv files, or run project tasks. +The optional npm registry probe requires `--network`. + +JSON `ready: true` and exit status 0 mean no failure was detected. Review warnings +and skipped checks: PnP dependencies and remote Docker contexts can remain +unverified. Follow the reported next steps in the project's own setup guide. + +## Find and review work + +| Command | Use | +| --- | --- | +| `rg --files` | List project files using ignore rules | +| `fd config` | Find paths matching a name | +| `rg -n 'pattern' src tests` | Search selected source directories | +| `jq '.scripts' package.json` | Inspect JavaScript project tasks | +| `git status --short --branch` | Review branch and local changes | +| `git diff`, `git diff --staged` | Review pending and staged changes | +| `git worktree list` | Locate other checkouts of the repository | +| `lg` | Open the installed Git interface | + +## Run project tasks + +Use the repository's pinned runtime and package manager. `vp` supports the +JavaScript workflow; projects can also call their selected manager directly. +Examples below assume those task names exist: + +```sh +vp install # Install the project's dependencies +vp run dev +vp run test +vp run typecheck +pnpm env:check # When the project defines this Varlock task +uv sync # Install a Python project's locked environment +uv run pytest # When pytest belongs to that project +``` + +Keep Varlock schemas and application-specific scripts in their projects. A +shared shell helper should not choose new dependency versions or copy a +project's secrets into global configuration. In this repository, the Python +check is `python3 -m unittest discover -s tests -v`; see +[development checks](development.md) for the complete maintenance workflow. + +## Prepare browser tests + +Install the project's dependencies first so its pinned Playwright package is +available. From that project, use: + +```sh +dev-browser doctor +dev-browser install --user-deps +dev-browser run -- pnpm test:e2e +``` + +`doctor` reports installed paths and launches and closes headless Chromium to +check that it works. `install` downloads the Chromium version selected by the project's Playwright package; +it does not install or upgrade Playwright globally. Omit `--user-deps` when the +host already provides the required libraries. The initial supported browser +workflow is headless Chromium. + +On Ubuntu/Debian, `--user-deps` explicitly downloads and extracts APT packages +into `${XDG_CACHE_HOME:-~/.cache}/linux-setup/browser-deps`. It does not use sudo +or update APT indexes, so package indexes must already be available. Chromium +itself uses Playwright's normal browser cache. Extracted libraries and fonts +apply only to commands launched by the helper; your shell's global +library configuration stays unchanged. `run` returns that command's exit code. + +Rootless installation accepts only the recognized Playwright dependency-report +format. An unfamiliar or older dry-run format stops before APT downloads; +follow the displayed administrator-assisted `playwright install-deps chromium` +guidance instead. The helper does not upgrade Playwright to change its output. + +Replace `pnpm test:e2e` with the project's actual browser-test command. Use +`dev-browser --project /path/to/project doctor` to target another checkout. +The browser helper does not read dotenv files itself; it executes the project's +installed Playwright and your chosen test command. On hosts without the supported +APT workflow, use the administrator-managed system dependencies documented by +[Playwright](https://playwright.dev/docs/browsers#install-system-dependencies). +The optional browser dependency cache can be selected for removal +in the uninstaller; ordinary uninstall keeps it. + +## Inspect local services + +```sh +lip 3001 5173 # List listeners for an API and web server +kip 5173 # Confirm before stopping the matching process +docker compose ps # Inspect this project's Compose services +db --connections # List redacted .env targets without connecting +db .env.local --connections # Select a different dotenv file explicitly +db --var TEST_DATABASE_URL # Open the interactive manager for that target +``` + +The database helper discovers `TEST_DATABASE_URL` and `TEST_DB_URL` alongside +the usual `DATABASE_URL`/`DB_URL` names. It parses the selected file as data and +does not source shell commands. `db --connections` is an inspection command; +the interactive manager also offers database changes with confirmation. Keep +test URLs pointed at disposable databases. Installing `psql` does not start a +database server. + +## Optional personal shortcuts + +Add only the shortcuts you want to your private +`~/.config/linux-setup-local/zshrc`, then open a new shell: + +```sh +alias dcheck='dev-check' +alias dbr='dev-browser' +alias vr='vp run' +``` + +Keep credentials in the appropriate project's ignored files or configured +secret provider. Paid UI registry keys belong only in their component-install +workflow, with no `VITE_` prefix and no browser import. Use the project's +redacted configuration checks when sharing diagnostics. diff --git a/docs/uninstall.md b/docs/uninstall.md index 8d15c56..7abe045 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -37,6 +37,7 @@ Node/Python runtimes, project dependencies, shell history, or databases. ./uninstall.sh --keep-tools ./uninstall.sh --remove bat --remove fd ./uninstall.sh --keep-tools --cache setup-downloads --dry-run +./uninstall.sh --keep-tools --cache browser-deps --dry-run ./uninstall.sh --all-tools --dry-run ./uninstall.sh --all-tools ``` @@ -58,6 +59,7 @@ owned by the setup: | --- | --- | | `setup-downloads` | Verified archives downloaded by the installer | | `setup-test-artifacts` | Extracted disposable PostgreSQL development-test binaries, if present | +| `browser-deps` | Libraries and fonts extracted by `dev-browser install --user-deps`, if present | | `zsh` | Known completion dump files; shell history is preserved | | `uv` | The known uv dependency cache; installed Python versions and tools are preserved | | `oh-my-posh` | Known prompt cache directory | @@ -70,6 +72,13 @@ cache is not offered because its temporary/configured location cannot be inferre safely from the setup's ownership records; see its [cache configuration](https://yazi-rs.github.io/docs/configuration/yazi/#cache_dir). +`browser-deps` refers only to +`${XDG_CACHE_HOME:-~/.cache}/linux-setup/browser-deps`. It stays unchecked by +default and can be removed independently of installed programs. Playwright's +`ms-playwright` browser cache is preserved. Removing these extracted dependencies +may require running `dev-browser install --user-deps` again for projects that use +them; no legacy browser-cache paths are inferred or migrated for removal. + For a selected uv cache, the uninstaller runs `uv cache clean` against the listed directory before removing uv itself. This coordinates access with other uv processes; its directory/lock may remain after the cached entries are cleared. diff --git a/scripts/browser.py b/scripts/browser.py new file mode 100644 index 0000000..93b4ff4 --- /dev/null +++ b/scripts/browser.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Use project-pinned Playwright with optional user-local Chromium libraries.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import signal +import subprocess +import sys +import tempfile +from xml.sax.saxutils import escape + + +class BrowserError(Exception): + pass + + +PACKAGE = re.compile(r"[a-z0-9][a-z0-9+.-]*(?::[a-z0-9-]+)?\Z") +VERSION = re.compile(r"[A-Za-z0-9.+:~_-]+\Z") +RESOLVE = r""" +const fs = require('node:fs'); +const path = require('node:path'); +const {createRequire} = require('node:module'); +const local = createRequire(path.join(process.cwd(), 'package.json')); +const roots = []; +for (let p = process.cwd();; p = path.dirname(p)) { + if (fs.existsSync(path.join(p, 'package.json'))) + roots.push(path.join(p, 'node_modules') + path.sep); + if (p === path.dirname(p)) break; +} +for (const name of ['@playwright/test', 'playwright']) { + try { + const cli = local.resolve(name + '/cli'); + const api = local.resolve(name); + if (![cli, api].every(p => roots.some(root => p.startsWith(root)))) continue; + const version = local(name + '/package.json').version; + const executable = local(name).chromium.executablePath(); + console.log(JSON.stringify({cli, api, version, executable})); + process.exit(0); + } catch {} +} +process.exit(1); +""" +PROBE = r""" +(async () => { + let browser; + try { + browser = await require(process.argv[1]).chromium.launch({headless: true, timeout: 15000}); + await browser.close(); + } catch (e) { + // Do not expose inherited environment or full browser command lines. + console.log(/Executable doesn't exist/.test(String(e)) ? 'missing-browser' : 'launch-failed'); + process.exitCode = 1; + } +})(); +""" + + +def capture(args, cwd=None, env=None, timeout=60): + try: + process = subprocess.Popen(args, cwd=cwd, env=env, text=True, encoding="utf-8", errors="replace", + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + stdin=subprocess.DEVNULL, start_new_session=True) + except OSError: + raise BrowserError("A required command could not start; check Node, APT, and file permissions.") from None + try: + stdout, stderr = process.communicate(timeout=timeout) + return subprocess.CompletedProcess(args, process.returncode, stdout, stderr) + except (subprocess.TimeoutExpired, KeyboardInterrupt) as error: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + try: + process.communicate(timeout=1) + except subprocess.TimeoutExpired: + process.stdout.close() + process.stderr.close() + if isinstance(error, KeyboardInterrupt): + raise + raise BrowserError("A browser setup check timed out; retry or inspect the host tools.") from None + + +def resolve_project(project): + project = project.expanduser().resolve() + if not project.is_dir(): + raise BrowserError("Project directory does not exist.") + node = shutil.which("node") + if not node: + raise BrowserError("Node is unavailable. Activate the project's pinned Node runtime first.") + result = capture([node, "-e", RESOLVE], cwd=project, timeout=20) + try: + info = json.loads(result.stdout) + if result.returncode or not all(isinstance(info[k], str) and info[k] for k in ("cli", "api", "version", "executable")): + raise ValueError + if not VERSION.fullmatch(info["version"]): + raise ValueError + except (ValueError, KeyError, TypeError): + raise BrowserError("No usable project-local Playwright found. Install this project's locked dependencies first.") from None + return dict(info, node=node, project=project) + + +def dependency_packages(output, returncode): + """Fail closed when Playwright changes its human-readable dry-run format.""" + if returncode == 0 and output.strip() == "All system dependencies are installed.": + return [] + lines = output.strip().splitlines() + match = re.fullmatch(r"Missing system dependencies \(([1-9][0-9]*)\):", lines[0]) if lines else None + packages = [line[2:] for line in lines[1:] if line.startswith(" ")] + if (returncode != 1 or not match or len(packages) != int(match[1]) or + len(lines) != len(packages) + 1 or len(set(packages)) != len(packages) or + not all(PACKAGE.fullmatch(p) for p in packages)): + raise BrowserError("Cannot interpret this Playwright dependency report. Use the project's Playwright install-deps chromium with administrator support; no packages were installed.") + return packages + + +def apt_plan(output): + specs = [] + for line in output.splitlines(): + if line.startswith("Remv "): + raise BrowserError("APT would remove packages. Repair the host package state before continuing.") + if not line.startswith("Inst "): + continue + match = re.match(r"^Inst (\S+)(?: \[[^\]]+\])? \((\S+) ", line) + if not match or not PACKAGE.fullmatch(match[1]) or not VERSION.fullmatch(match[2]): + raise BrowserError("Cannot interpret the APT package plan.") + if match[1].split(":")[0] in ("libc6", "libc-bin", "libc6-dev", "libc-dev-bin"): + raise BrowserError("These dependencies require a host glibc update. Ask the host administrator to install the system dependencies.") + specs.append(match[1] + "=" + match[2]) + return sorted(set(specs)) + + +def cache_directory(info): + try: + distro = Path("/etc/os-release").read_bytes() + except OSError: + distro = b"unknown" + identity = hashlib.sha256(distro + os.uname().machine.encode() + info["version"].encode()).hexdigest()[:20] + root = Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))).expanduser().absolute() + return root / "linux-setup" / "browser-deps" / identity + + +def checked_path(path): + # Refuse redirected cache ancestors, including dangling symlinks. + if any(p.is_symlink() for p in [path, *path.parents]): + raise BrowserError("Browser dependency cache contains a symlink; choose a regular XDG_CACHE_HOME directory.") + + +def active_runtime(directory): + checked_path(directory) + active = directory / "active.json" + if not active.exists(): + if active.is_symlink(): + raise BrowserError("Invalid browser dependency cache selector.") + return None + checked_path(active) + try: + entry = json.loads(active.read_text()) + digest = entry["runtime"] + if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise ValueError + runtime = directory / digest + checked_path(runtime) + marker = runtime / "ready.json" + checked_path(marker) + manifest = json.loads(marker.read_text()) + payload = runtime / "root" + checked_path(payload) + if not isinstance(manifest, dict) or manifest.get("format") != 1 or not payload.is_dir(): + raise ValueError + return runtime + except (OSError, ValueError, TypeError, KeyError): + raise BrowserError("Browser dependency cache is incomplete. Inspect it and rerun install --user-deps.") from None + + +def runtime_environment(runtime, original=None): + environment = dict(os.environ if original is None else original) + if runtime: + checked_path(runtime) + payload = runtime / "root" + directories = [payload / "lib", payload / "usr/lib"] + for base in list(directories): + directories.extend(sorted(base.glob("*-linux-gnu*"))) + libraries = [str(p) for p in directories if p.is_dir()] + for p in libraries: + checked_path(Path(p)) + if libraries: + if environment.get("LD_LIBRARY_PATH"): + libraries.append(environment["LD_LIBRARY_PATH"]) + environment["LD_LIBRARY_PATH"] = ":".join(libraries) + fonts = runtime / "fonts.conf" + checked_path(fonts) + if fonts.is_file(): + environment["FONTCONFIG_FILE"] = str(fonts) + return environment + + +def install_user_dependencies(info, directory): + if not all(shutil.which(name) for name in ("apt-get", "dpkg-deb")): + raise BrowserError("User dependencies require Ubuntu/Debian APT and dpkg-deb. Use Playwright's system dependency installation on this host.") + environment = dict(os.environ, LC_ALL="C") + report = capture([info["node"], info["cli"], "install-deps", "--dry-run", "chromium"], cwd=info["project"], env=environment) + packages = dependency_packages(report.stdout, report.returncode) + if not packages: + if active_runtime(directory): + (directory / "active.json").unlink() + print("System dependencies already installed.") + return + plan = capture(["apt-get", "-s", "--no-install-recommends", "install", *packages], env=environment) + if plan.returncode: + raise BrowserError("APT could not resolve dependencies. Ask the host administrator to check package indexes and repositories.") + specs = apt_plan(plan.stdout) + if not specs: + raise BrowserError("APT returned no dependency packages; no cache was changed.") + digest = hashlib.sha256("\n".join(specs).encode()).hexdigest() + checked_path(directory) + directory.mkdir(parents=True, exist_ok=True) + target = directory / digest + checked_path(target) + if target.exists(): + marker = target / "ready.json" + checked_path(marker) + try: + manifest = json.loads(marker.read_text()) + except (OSError, ValueError): + manifest = {} + checked_path(target / "root") + if manifest != {"format": 1, "packages": specs} or not (target / "root").is_dir(): + raise BrowserError("An unrecognized dependency cache already occupies this path; retained without changes.") + else: + print(f"Preparing {len(specs)} APT packages in the user cache…", flush=True) + with tempfile.TemporaryDirectory(prefix=".prepare-", dir=directory) as temporary: + staging = Path(temporary) + downloads = staging / "downloads" + downloads.mkdir() + result = capture(["apt-get", "download", *specs], cwd=downloads, env=environment, timeout=600) + archives = sorted(downloads.glob("*.deb")) + if result.returncode or len(archives) != len(specs): + raise BrowserError("APT download failed; the previous runtime was kept. Check repository access and package indexes.") + prepared = staging / "runtime" + payload = prepared / "root" + payload.mkdir(parents=True) + for archive in archives: + if capture(["dpkg-deb", "-x", str(archive), str(payload)], timeout=60).returncode: + raise BrowserError("Package extraction failed; the previous runtime was kept.") + # Debian /usr merge packages may contain absolute symlinks. Only use + # real library directories in runtime_environment, never those aliases. + for base in (payload / "lib", payload / "lib64"): + if base.is_symlink(): + base.unlink() + fonts = target / "root/usr/share/fonts" + (prepared / "fonts.conf").write_text( + '\n' + '/etc/fonts/fonts.conf' + f'{escape(str(fonts))}{escape(str(target / "font-cache"))}\n') + (prepared / "ready.json").write_text(json.dumps({"format": 1, "packages": specs}) + "\n") + prepared.rename(target) + # Publish a complete runtime only. Unique temporary names also avoid clashes + # between independent installs selecting their completed package set. + with tempfile.NamedTemporaryFile(mode="w", dir=directory, prefix=".active-", delete=False) as stream: + json.dump({"runtime": digest}, stream) + temporary = Path(stream.name) + temporary.replace(directory / "active.json") + + +def doctor(info, environment): + print(f"Project: {info['project']}\nPlaywright: {info['version']}\nCLI: {info['cli']}\nChromium: {info['executable']}") + probe = capture([info["node"], "-e", PROBE, info["api"]], cwd=info["project"], env=environment, timeout=25) + if probe.returncode: + if probe.stdout.strip() == "missing-browser": + raise BrowserError("Chromium is missing. Run dev-browser install.") + raise BrowserError("Chromium could not launch. Run dev-browser install --user-deps on Ubuntu/Debian, or inspect the host's browser dependencies and sandbox support.") + print("PASS: headless Chromium launched and closed successfully.") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project", type=Path, default=Path.cwd(), help="project directory (default: current directory)") + commands = parser.add_subparsers(dest="action", required=True) + commands.add_parser("doctor", help="check the pinned browser with a local headless launch") + install = commands.add_parser("install", help="install project-pinned Chromium") + install.add_argument("--user-deps", action="store_true", help="extract Ubuntu/Debian dependencies into the user cache") + run = commands.add_parser("run", help="run a command with cached browser libraries") + run.add_argument("command", nargs=argparse.REMAINDER) + args = parser.parse_args(argv) + try: + info = resolve_project(args.project) + directory = cache_directory(info) + if args.action == "install": + if args.user_deps: + install_user_dependencies(info, directory) + environment = runtime_environment(active_runtime(directory)) + result = subprocess.run([info["node"], info["cli"], "install", "chromium"], cwd=info["project"], env=environment, check=False) + if result.returncode: + return result.returncode if result.returncode > 0 else 128 - result.returncode + doctor(info, environment) + elif args.action == "doctor": + doctor(info, runtime_environment(active_runtime(directory))) + else: + command = args.command[1:] if args.command[:1] == ["--"] else args.command + if not command: + parser.error("run requires a command, for example: run -- pnpm test:e2e") + environment = runtime_environment(active_runtime(directory)) + os.chdir(info["project"]) + os.execvpe(command[0], command, environment) + return 0 + except (BrowserError, OSError) as error: + message = str(error) if isinstance(error, BrowserError) else "A command or cache file could not be accessed. Check installed tools and permissions." + print("dev-browser: " + message, file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/db_manager.py b/scripts/db_manager.py index 6a7dd9e..6d1f467 100644 --- a/scripts/db_manager.py +++ b/scripts/db_manager.py @@ -160,7 +160,7 @@ def load_connections(path, variables=None): urls = re.split(r"\s+|,(?=postgres(?:ql)?://)", value) connections += [parse_connection(key if len(urls) == 1 else f"{key}.{i + 1}", url) for i, url in enumerate(urls)] if not connections: - raise DBError("No PostgreSQL URLs found. Set DB_URL, DATABASE_URL, DB_URLS, or use --var NAME") + raise DBError("No PostgreSQL URLs found. Set DB_URL, DATABASE_URL, TEST_DB_URL, TEST_DATABASE_URL, DB_URLS, or use --var NAME") return connections @@ -422,7 +422,7 @@ def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("file", nargs="?", default=".env", help="Dotenv file (default: .env)") parser.add_argument("-f", "--env-file", help="Explicit dotenv file path") - parser.add_argument("--var", action="append", help="Read only this variable; repeat for multiple variables") + parser.add_argument("--var", action="append", help="Read only this variable; repeat for multiple variables. By default, discover URL names including DB_URL, DATABASE_URL, TEST_DB_URL, and TEST_DATABASE_URL") parser.add_argument("--maintenance-db", default="postgres", help="Database for server-level operations") parser.add_argument("--connections", action="store_true", help="List redacted connection labels without connecting") args = parser.parse_args(argv) diff --git a/scripts/project_check.py b/scripts/project_check.py new file mode 100644 index 0000000..15e7ef8 --- /dev/null +++ b/scripts/project_check.py @@ -0,0 +1,338 @@ +"""Read-only checks for a project's local development prerequisites.""" +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import signal +import ssl +import subprocess +import sys +import urllib.error +import urllib.request + + +COMPOSE_FILES = ("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml") +LOCKFILES = { + "pnpm": ("pnpm-lock.yaml",), + "npm": ("package-lock.json", "npm-shrinkwrap.json"), + "yarn": ("yarn.lock",), + "bun": ("bun.lock", "bun.lockb"), +} +PACKAGE_NAME = re.compile(r"^(?:@[a-zA-Z0-9_.-]+/)?[a-zA-Z0-9_][a-zA-Z0-9_.-]*$") + + +def clean(value): + return "".join(character if character.isprintable() else "?" for character in str(value)) + + +def result(check, status, message, hint=""): + return {"check": check, "status": status, "message": message, "hint": hint} + + +def command_environment(): + """Preserve proxy/CA settings without loading dotenv or redirecting Git.""" + environment = os.environ.copy() + for name in list(environment): + if name.startswith("GIT_TRACE") or name in ( + "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", + "COMPOSE_ENV_FILES", "COMPOSE_FILE", "COMPOSE_PROJECT_NAME", + ): + environment.pop(name, None) + environment["GIT_OPTIONAL_LOCKS"] = "0" + environment["COMPOSE_DISABLE_ENV_FILE"] = "true" + return environment + + +def run_command(arguments, *, timeout=5, environment=None): + """Return (code, bounded output, problem); never return raw stderr.""" + try: + process = subprocess.Popen( + arguments, cwd="/", env=environment or command_environment(), + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True, encoding="utf-8", errors="replace", start_new_session=True, + ) + except OSError: + return None, "", "unavailable" + try: + output, _ = process.communicate(timeout=timeout) + return process.returncode, output[:4096], None + except (subprocess.TimeoutExpired, KeyboardInterrupt) as error: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + # Kill the group, including a CLI plugin still holding the output pipe. + try: + process.communicate(timeout=1) + except subprocess.TimeoutExpired: + if process.stdout: + process.stdout.close() + if isinstance(error, KeyboardInterrupt): + raise + return None, "", "timeout" + + +def git_root(directory, checks): + git = shutil.which("git") + if not git: + checks.append(result("git", "warn", "Git is not on PATH.", "Install Git or restore your shell PATH.")) + return None + code, output, problem = run_command([git, "-c", "core.fsmonitor=false", "-C", str(directory), "rev-parse", "--show-toplevel"]) + if problem: + checks.append(result("git", "fail", "Git inspection timed out or could not run.", "Check the Git executable and checkout permissions.")) + return None + if code == 0: + try: + root = Path(output.strip()).resolve() + if root == directory or root in directory.parents: + checks.append(result("git", "pass", "Inside a Git checkout.")) + return root + except (OSError, RuntimeError, ValueError): + pass + checks.append(result("git", "warn", "No Git checkout was identified.", "Run inside the intended checkout, or initialize version control when appropriate.")) + return None + + +def read_manifest(path): + # A package.json symlink must not turn this check into an environment-file read. + if path.resolve().name.startswith(".env"): + raise ValueError("Environment-file manifest link") + if path.stat().st_size > 2 * 1024 * 1024: + raise ValueError("Manifest too large") + with path.open(encoding="utf-8") as stream: + manifest = json.load(stream) + if not isinstance(manifest, dict): + raise ValueError("Manifest must be an object") + return manifest + + +def find_project(directory, boundary, checks): + ancestors = [] + for candidate in (directory, *directory.parents): + ancestors.append(candidate) + if candidate == boundary: + break + package = next((parent / "package.json" for parent in ancestors if (parent / "package.json").is_file()), None) + if package is None: + checks.append(result("package", "warn", "No package.json found; JavaScript dependency checks are skipped.", "Pass a package or workspace directory if this is a JavaScript project.")) + return None, boundary or directory, {} + try: + manifest = read_manifest(package) + except (OSError, ValueError, RuntimeError): + checks.append(result("package", "fail", "The nearest package.json is unreadable, invalid or unsafe to inspect.", "Fix the JSON object or file permissions; environment-file links are not followed.")) + return package, package.parent, {} + manifests = {package.parent: manifest} + workspace = package.parent + for parent in ancestors[ancestors.index(package.parent):]: + candidate = parent / "package.json" + data = manifests.get(parent) + if data is None and candidate.is_file(): + try: + data = read_manifest(candidate) + except (OSError, ValueError, RuntimeError): + checks.append(result("workspace", "fail", "An enclosing package.json could not be inspected.", "Fix its JSON or permissions before relying on workspace discovery.")) + break + if (parent / "pnpm-workspace.yaml").is_file() or (isinstance(data, dict) and data.get("workspaces")): + workspace = parent + if data is not None: + manifests[parent] = data + break + checks.append(result("package", "pass", "Selected the nearest package and enclosing workspace.")) + return package, workspace, manifests + + +def tool_check(name, required, checks, directories=()): + available = shutil.which(name) or any( + (directory / "node_modules" / ".bin" / name).is_file() + and os.access(directory / "node_modules" / ".bin" / name, os.X_OK) + for directory in directories + ) + if available: + checks.append(result(name, "pass", "Executable is available; no toolchain download or version command was attempted.")) + elif required: + checks.append(result(name, "fail", "Required executable is unavailable.", "Install or activate " + name + " using the project's documented toolchain.")) + else: + checks.append(result(name, "skip", "Not available and not required by the selected manifests.")) + + +def dependency_checks(package, workspace, manifests, checks): + if package is None or not manifests: + for name in ("node", "pnpm", "vp"): + tool_check(name, False, checks) + return + owner = manifests.get(workspace, manifests[package.parent]) + declaration = owner.get("packageManager", "") + manager = declaration.split("@", 1)[0] if isinstance(declaration, str) else "" + found = {name: [workspace / filename for filename in filenames if (workspace / filename).is_file()] + for name, filenames in LOCKFILES.items()} + if manager not in LOCKFILES: + manager = "pnpm" if (workspace / "pnpm-workspace.yaml").is_file() else next((name for name, paths in found.items() if paths), "npm") + locks = found[manager] + if not locks or not any(path.stat().st_size for path in locks): + checks.append(result("lockfile", "fail", "A nonempty " + manager + " lockfile is missing.", "Run the reviewed dependency install in the workspace and commit its generated lockfile.")) + else: + checks.append(result("lockfile", "pass", "The selected package manager has a nonempty lockfile.")) + if sum(bool(paths) for paths in found.values()) > 1: + checks.append(result("lockfile-selection", "warn", "Multiple package-manager lockfiles exist.", "Confirm the intended manager before installing or changing lockfiles.")) + + dependencies = [] + for parent, manifest in manifests.items(): + for field in ("dependencies", "devDependencies"): + values = manifest.get(field, {}) + if not isinstance(values, dict) or any(not isinstance(name, str) or not PACKAGE_NAME.fullmatch(name) for name in values): + checks.append(result("dependencies", "fail", "Dependency declarations are not valid package-name mappings.", "Correct dependencies/devDependencies in package.json.")) + return + dependencies.extend((parent, name) for name in values) + tool_check("node", True, checks) + tool_check("pnpm", manager == "pnpm", checks) + if manager not in ("pnpm",): + tool_check(manager, True, checks) + tool_check("vp", any(name == "vite-plus" for _, name in dependencies), checks, (package.parent, workspace)) + if (workspace / ".pnp.cjs").is_file() or (workspace / ".pnp.js").is_file(): + checks.append(result("dependencies", "skip", "Yarn PnP was detected; its loader was not executed.", "Use the project's own dependency verification separately.")) + return + if not dependencies: + checks.append(result("dependencies", "skip", "No direct dependencies are declared.")) + return + missing = 0 + for parent, name in dependencies: + candidates = [] + for directory in (parent, *parent.parents): + candidates.append(directory / "node_modules" / name / "package.json") + if directory == workspace: + break + if not any(candidate.is_file() for candidate in candidates): + missing += 1 + if missing: + checks.append(result("dependencies", "fail", str(missing) + " declared direct package(s) are absent from node_modules.", "Run the reviewed install in the workspace; an empty node_modules directory is insufficient.")) + else: + checks.append(result("dependencies", "pass", "Declared direct packages are present; builds and transitive compatibility were not checked.")) + + +def docker_checks(directory, workspace, checks, boundary=None): + candidates = [] + for parent in (directory, *directory.parents): + candidates.append(parent) + if parent == (boundary or workspace): + break + compose = next((parent / name for parent in candidates + for name in COMPOSE_FILES if (parent / name).is_file()), None) + if compose is None: + checks.append(result("docker", "skip", "No standard Compose file detected at the project/workspace root.")) + return + checks.append(result("compose-file", "pass", "Compose configuration detected; contents and environment files were not loaded.")) + docker = shutil.which("docker") + if not docker: + checks.append(result("docker", "fail", "Docker is unavailable for this Compose project.", "Install Docker/Compose, or use the project's documented external-service alternative.")) + return + environment = command_environment() + code, _, problem = run_command([docker, "compose", "version", "--short"], environment=environment) + if code != 0 or problem: + checks.append(result("compose", "fail", "Docker Compose could not be queried.", "Check the Compose plugin; no Compose configuration was evaluated.")) + else: + checks.append(result("compose", "pass", "Docker Compose is available.")) + endpoint = environment.get("DOCKER_HOST") + if environment.get("DOCKER_CONTEXT") or not endpoint: + code, output, problem = run_command([docker, "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"], environment=environment) + if code != 0 or problem: + checks.append(result("docker-daemon", "fail", "Docker context inspection failed or timed out.", "Inspect your selected Docker context and local socket access.")) + return + endpoint = output.strip() + if not endpoint.startswith("unix:///") or any(character.isspace() for character in endpoint): + checks.append(result("docker-daemon", "skip", "The selected Docker endpoint is not a local Unix socket; it was not contacted.", "Select a local Docker context to check it without remote access.")) + return + for variable in ("DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_TLS", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH"): + environment.pop(variable, None) + code, _, problem = run_command([docker, "--host=" + endpoint, "info", "--format", "{{.ServerVersion}}"], timeout=8, environment=environment) + if code == 0 and not problem: + checks.append(result("docker-daemon", "pass", "The selected local Docker daemon responds.")) + else: + checks.append(result("docker-daemon", "fail", "The local Docker daemon is unavailable, inaccessible or timed out.", "Check socket permissions and daemon health; on WSL enable Docker Desktop integration for this distro.")) + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, message, headers, newurl): + return None + + +def registry_probe(): + """Runs in a separately bounded child so even DNS cannot hang the CLI.""" + try: + context = ssl.create_default_context() + for name in ("NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"): + if os.environ.get(name): + context.load_verify_locations(cafile=os.path.expanduser(os.environ[name])) + opener = urllib.request.build_opener( + urllib.request.ProxyHandler(), urllib.request.HTTPSHandler(context=context), NoRedirect(), + ) + request = urllib.request.Request("https://registry.npmjs.org/-/ping", headers={"User-Agent": "linux-setup/dev-check"}) + with opener.open(request, timeout=6) as response: + return 0 if response.status == 200 else 1 + except (OSError, ValueError, urllib.error.URLError): + return 1 + + +def check_project(path=".", *, network=False): + checks = [] + try: + directory = Path(path).expanduser().resolve(strict=True) + if directory.is_file(): + directory = directory.parent + if not directory.is_dir(): + raise ValueError("Not a directory") + except (OSError, RuntimeError, ValueError): + return {"project": None, "package": None, "workspace": None, "ready": False, + "checks": [result("path", "fail", "The requested project path is unavailable.", "Pass an existing readable project directory.")]} + boundary = git_root(directory, checks) + package, workspace, manifests = find_project(directory, boundary, checks) + dependency_checks(package, workspace, manifests, checks) + docker_checks(directory, workspace, checks, boundary) + if shutil.which("psql"): + checks.append(result("psql", "pass", "PostgreSQL client is available; no database connection was attempted.")) + else: + checks.append(result("psql", "warn", "Optional PostgreSQL client is not on PATH.", "Install postgresql-client if you need host-side database inspection.")) + if network: + code, _, problem = run_command([sys.executable, "-I", "-B", str(Path(__file__).resolve()), "--_registry-probe"], timeout=10) + checks.append(result("registry", "pass" if code == 0 and not problem else "fail", + "npm registry HTTPS probe succeeded." if code == 0 and not problem else "npm registry HTTPS probe failed or timed out.", + "" if code == 0 and not problem else "Check DNS, proxy access and configured CA certificates; keep TLS verification enabled.")) + else: + checks.append(result("registry", "skip", "External network probe disabled.", "Use --network to check npm registry HTTPS explicitly.")) + return {"project": str(directory), "package": str(package) if package else None, + "workspace": str(workspace), "ready": not any(item["status"] == "fail" for item in checks), "checks": checks} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("path", nargs="?", default=".", metavar="PATH") + parser.add_argument("--network", action="store_true", help="Explicitly probe npm registry HTTPS using configured proxy/CA trust") + parser.add_argument("--json", action="store_true", help="Print structured check results") + arguments = parser.parse_args(argv) + try: + report = check_project(arguments.path, network=arguments.network) + except (OSError, RuntimeError, ValueError): + report = {"project": None, "package": None, "workspace": None, "ready": False, + "checks": [result("inspection", "fail", "Project inspection could not finish.", "Check file permissions and executable availability; no raw command output was printed.")]} + except KeyboardInterrupt: + print("dev-check: interrupted.", file=sys.stderr) + return 130 + if arguments.json: + print(json.dumps(report)) + else: + if report["project"]: + print("Project: " + clean(report["project"])) + if report["workspace"] != report["project"]: + print("Workspace: " + clean(report["workspace"])) + for item in report["checks"]: + print(f"{item['status'].upper():<4} {item['check']}: {item['message']}") + if item["hint"]: + print(" " + item["hint"]) + return 0 if report["ready"] else 1 + + +if __name__ == "__main__": + if sys.argv[1:] == ["--_registry-probe"]: + sys.exit(registry_probe()) + sys.exit(main()) diff --git a/scripts/smoke-install.py b/scripts/smoke-install.py index 244f9ec..29711c3 100644 --- a/scripts/smoke-install.py +++ b/scripts/smoke-install.py @@ -34,12 +34,18 @@ def run(*command): subprocess.run(list(map(str, command)), cwd=ROOT, env=environment, check=True) run(ROOT / "install.sh", "--skip-mcp") run(ROOT / "install.sh", "--check") + run("dev-check", "--help") + run("dev-browser", "--help") run(ROOT / "uninstall.sh", "--keep-tools", "--yes") + for helper in ("dev-check", "dev-browser"): + assert not (root / ".local/bin" / helper).is_symlink(), "Configuration uninstall left " + helper # Retained rootless Zsh must load modules without the setup's .zshenv. run("zsh", "-c", "zmodload zsh/parameter && autoload -Uz compinit && compinit -i -D") run("uv", "--version") run("vp", "--version") run(ROOT / "install.sh", "--config-only", "--skip-mcp") + run("dev-check", "--help") + run("dev-browser", "--help") cache = root / ".cache/bat" cache.mkdir(parents=True, exist_ok=True) (cache / "smoke-cache").write_text("disposable test cache") diff --git a/scripts/uninstall.py b/scripts/uninstall.py index 382a6d7..a0a5950 100755 --- a/scripts/uninstall.py +++ b/scripts/uninstall.py @@ -107,6 +107,7 @@ def cache_options(programs): candidates = { "setup-downloads": {"paths": [setup_cache / "downloads"], "label": "Verified setup download archives", "shared": False}, "setup-test-artifacts": {"paths": [setup_cache / "postgres-test"], "label": "Disposable PostgreSQL development-test binaries", "shared": False}, + "browser-deps": {"paths": [setup.CACHE / "browser-deps"], "label": "Extracted browser libraries and fonts (shared by projects)", "shared": True}, } owned = {name for name, item in programs.items() if item.get("owned")} for name, directory in [("uv", "uv"), ("oh-my-posh", "oh-my-posh"), ("bat", "bat")]: @@ -159,6 +160,7 @@ def safe_cache_path(path, cache_id=None): return False exact = { "setup-downloads": setup.CACHE / "downloads", "setup-test-artifacts": setup.CACHE / "postgres-test", + "browser-deps": setup.CACHE / "browser-deps", "uv": root / "uv", "oh-my-posh": root / "oh-my-posh", "bat": root / "bat", } if cache_id == "zsh": diff --git a/tests/test_browser.py b/tests/test_browser.py new file mode 100644 index 0000000..6b71ef5 --- /dev/null +++ b/tests/test_browser.py @@ -0,0 +1,320 @@ +import contextlib +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import tempfile +import unittest +from unittest.mock import Mock, call, patch + + +SPEC = importlib.util.spec_from_file_location("browser", Path(__file__).resolve().parents[1] / "scripts/browser.py") +browser = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(browser) + + +def result(code=0, stdout="", stderr=""): + return subprocess.CompletedProcess([], code, stdout, stderr) + + +class DependencyReportTests(unittest.TestCase): + def test_exact_playwright_reports_accept_installed_and_missing_packages(self): + self.assertEqual(browser.dependency_packages("All system dependencies are installed.\n", 0), []) + self.assertEqual(browser.dependency_packages( + "Missing system dependencies (3):\n libasound2t64\n libx11-6:amd64\n fonts-liberation\n", 1), + ["libasound2t64", "libx11-6:amd64", "fonts-liberation"]) + + def test_changed_counts_packages_format_and_exit_codes_fail_closed(self): + reports = [ + ("", 1), + ("Missing system dependencies (0):", 1), + ("Missing system dependencies (02):\n liba\n libb", 1), + ("Missing system dependencies (2):\n liba", 1), + ("Missing system dependencies (1):\n liba\n libb", 1), + ("Missing system dependencies (2):\n liba\n liba", 1), + ("Missing system dependencies (1):\n --unsafe-option", 1), + ("Missing system dependencies (1):\n liba;unexpected", 1), + ("Missing system dependencies (1):\n liba extra", 1), + ("Missing system dependencies (1):\n liba", 1), + ("Missing system dependencies (1):\n liba\nunknown output", 1), + ("sudo apt-get install liba", 0), + ("All system dependencies are installed.", 1), + ("Missing system dependencies (1):\n liba", 0), + ("Missing system dependencies (1):\n liba", 2), + ] + for output, code in reports: + with self.subTest(output=output, code=code): + with self.assertRaisesRegex(browser.BrowserError, "Cannot interpret"): + browser.dependency_packages(output, code) + + def test_apt_fresh_and_upgrade_rows_produce_exact_deduplicated_specs(self): + output = ( + "Reading package lists...\n" + "Inst libnss3 [2:3.110-1] (2:3.112-1 Ubuntu:26.04 [amd64])\n" + "Inst libatk1.0-0t64 (2.58.0-1 Ubuntu:26.04 [amd64])\n" + "Inst libx11-6:amd64 (2:1.8.12-1 Ubuntu:26.04 [amd64])\n" + "Inst libatk1.0-0t64 (2.58.0-1 Ubuntu:26.04 [amd64])\n" + "Conf libnss3 (2:3.112-1 Ubuntu:26.04 [amd64])\n" + ) + self.assertEqual(browser.apt_plan(output), [ + "libatk1.0-0t64=2.58.0-1", "libnss3=2:3.112-1", "libx11-6:amd64=2:1.8.12-1"]) + + def test_apt_refuses_removal_malformed_rows_and_core_glibc_changes(self): + for package in ["libc6", "libc6:amd64", "libc-bin", "libc6-dev", "libc-dev-bin"]: + with self.subTest(package=package): + with self.assertRaisesRegex(browser.BrowserError, "glibc"): + browser.apt_plan(f"Inst {package} [2.41-1] (2.42-1 Ubuntu [amd64])") + for row in ["Remv libold [1.0]", "Inst liba", "Inst --unsafe (1.0 Ubuntu [amd64])", "Inst liba (../bad Ubuntu [amd64])"]: + with self.subTest(row=row): + with self.assertRaises(browser.BrowserError): + browser.apt_plan(row) + + +class BrowserFixture(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.directory = self.root / "cache" + self.info = { + "project": self.root, "node": "/fixture/node", "cli": "/fixture/project/node_modules/playwright/cli.js", + "api": "/fixture/project/node_modules/playwright/index.js", "version": "1.64.1", "executable": "/fixture/chromium", + } + # Mocked subprocess arguments must never contain the developer's real secrets. + environment = patch.dict(os.environ, {"PATH": "/fixture/bin", "KEEP_ME": "original"}, clear=True) + environment.start() + self.addCleanup(environment.stop) + + def ready_runtime(self, digest="a" * 64): + runtime = self.directory / digest + (runtime / "root/usr/lib").mkdir(parents=True) + (runtime / "ready.json").write_text(json.dumps({"format": 1, "packages": ["libold=1.0"]})) + (self.directory / "active.json").write_text(json.dumps({"runtime": digest})) + return runtime + + +class CacheTests(BrowserFixture): + def test_missing_selector_means_no_cached_runtime(self): + self.assertIsNone(browser.active_runtime(self.directory)) + + def test_active_selector_cannot_escape_cache_or_name_an_incomplete_runtime(self): + runtime = self.ready_runtime() + active = self.directory / "active.json" + for entry in ["invalid json", "[]", "{}", '{"runtime":"../outside"}', '{"runtime":17}', json.dumps({"runtime": "b" * 64})]: + with self.subTest(entry=entry): + active.write_text(entry) + with self.assertRaises(browser.BrowserError): + browser.active_runtime(self.directory) + active.write_text(json.dumps({"runtime": runtime.name})) + for marker in ["invalid json", "[]", "null", "{}", '{"format":2}']: + with self.subTest(marker=marker): + (runtime / "ready.json").write_text(marker) + with self.assertRaises(browser.BrowserError): + browser.active_runtime(self.directory) + + def test_ready_marker_without_payload_is_incomplete(self): + runtime = self.ready_runtime() + shutil.rmtree(runtime / "root") + with self.assertRaisesRegex(browser.BrowserError, "incomplete"): + browser.active_runtime(self.directory) + + def test_symlinked_selector_runtime_and_cache_ancestor_are_rejected(self): + runtime = self.ready_runtime() + outside = self.root / "outside" + outside.mkdir() + selector = self.directory / "active.json" + selector.unlink() + selector.symlink_to(outside / "missing.json") + with self.assertRaises(browser.BrowserError): + browser.active_runtime(self.directory) + selector.unlink() + selector.write_text(json.dumps({"runtime": runtime.name})) + shutil.rmtree(runtime) + runtime.symlink_to(outside, target_is_directory=True) + with self.assertRaises(browser.BrowserError): + browser.active_runtime(self.directory) + linked_parent = self.root / "linked-cache" + linked_parent.symlink_to(self.directory, target_is_directory=True) + with self.assertRaises(browser.BrowserError): + browser.active_runtime(linked_parent / "child") + + def test_runtime_environment_is_a_scoped_copy_and_preserves_existing_libraries(self): + runtime = self.ready_runtime() + (runtime / "root/lib").mkdir() + multiarch = runtime / "root/usr/lib/x86_64-linux-gnu" + multiarch.mkdir() + fonts = runtime / "fonts.conf" + fonts.write_text("") + original = {"KEEP_ME": "original", "LD_LIBRARY_PATH": "/existing/lib", "FONTCONFIG_FILE": "/existing/fonts.conf"} + environment = browser.runtime_environment(runtime, original) + self.assertIsNot(environment, original) + self.assertEqual(environment["LD_LIBRARY_PATH"].split(":"), [ + str(runtime / "root/lib"), str(runtime / "root/usr/lib"), str(multiarch), "/existing/lib"]) + self.assertEqual(environment["FONTCONFIG_FILE"], str(fonts)) + self.assertEqual(environment["KEEP_ME"], "original") + self.assertEqual(original["LD_LIBRARY_PATH"], "/existing/lib") + self.assertEqual(original["FONTCONFIG_FILE"], "/existing/fonts.conf") + self.assertNotIn("LD_LIBRARY_PATH", os.environ) + self.assertNotIn("FONTCONFIG_FILE", os.environ) + + def test_symlinked_payload_library_is_not_added_to_child_environment(self): + runtime = self.ready_runtime() + outside = self.root / "outside-libraries" + outside.mkdir() + (runtime / "root/usr/lib/x86_64-linux-gnu").symlink_to(outside, target_is_directory=True) + with self.assertRaises(browser.BrowserError): + browser.runtime_environment(runtime) + + +class InstallTests(BrowserFixture): + def test_unrecognized_playwright_report_does_not_run_apt_or_create_cache(self): + with patch.object(browser.shutil, "which", return_value="/fixture/tool"), patch.object(browser, "capture", return_value=result(0, "future format")) as capture: + with self.assertRaises(browser.BrowserError): + browser.install_user_dependencies(self.info, self.directory) + self.assertEqual(capture.call_count, 1) + self.assertFalse(self.directory.exists()) + + def test_glibc_plan_does_not_download_or_create_cache(self): + with patch.object(browser.shutil, "which", return_value="/fixture/tool"), patch.object(browser, "capture", side_effect=[ + result(1, "Missing system dependencies (1):\n libc6"), + result(0, "Inst libc6 [2.41-1] (2.42-1 Ubuntu [amd64])"), + ]) as capture: + with self.assertRaisesRegex(browser.BrowserError, "glibc"): + browser.install_user_dependencies(self.info, self.directory) + self.assertEqual(capture.call_count, 2) + self.assertFalse(self.directory.exists()) + + def test_extraction_failure_keeps_previous_runtime_and_selector(self): + previous = self.ready_runtime() + selector = (self.directory / "active.json").read_bytes() + + def capture(args, **kwargs): + if "install-deps" in args: + return result(1, "Missing system dependencies (1):\n libfixture") + if args[:2] == ["apt-get", "-s"]: + return result(0, "Inst libfixture (1.0 Ubuntu [amd64])") + if args[:2] == ["apt-get", "download"]: + (kwargs["cwd"] / "libfixture_1.0_amd64.deb").write_bytes(b"synthetic archive") + return result() + self.assertEqual(args[:2], ["dpkg-deb", "-x"]) + return result(1, stderr="synthetic extraction failure") + + with patch.object(browser.shutil, "which", return_value="/fixture/tool"), patch.object(browser, "capture", side_effect=capture), contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(browser.BrowserError, "previous runtime was kept"): + browser.install_user_dependencies(self.info, self.directory) + self.assertEqual((self.directory / "active.json").read_bytes(), selector) + self.assertEqual(browser.active_runtime(self.directory), previous) + expected_digest = hashlib.sha256(b"libfixture=1.0").hexdigest() + self.assertFalse((self.directory / expected_digest).exists()) + self.assertEqual(list(self.directory.glob(".prepare-*")), []) + + +class CommandTests(BrowserFixture): + def main_patches(self, stack): + stack.enter_context(patch.object(browser, "resolve_project", return_value=self.info)) + stack.enter_context(patch.object(browser, "cache_directory", return_value=self.directory)) + stack.enter_context(patch.object(browser, "active_runtime", return_value=None)) + + def test_run_preserves_arguments_and_delegates_process_exit_to_exec(self): + command = ["pnpm", "test:e2e", "--", "--project=chromium", "name with spaces"] + child_environment = {"PATH": "/fixture/bin", "SCOPED": "browser-only"} + with contextlib.ExitStack() as stack: + self.main_patches(stack) + stack.enter_context(patch.object(browser, "runtime_environment", return_value=child_environment)) + chdir = stack.enter_context(patch.object(browser.os, "chdir")) + execute = stack.enter_context(patch.object(browser.os, "execvpe", side_effect=SystemExit(23))) + with self.assertRaises(SystemExit) as exit_status: + browser.main(["run", "--", *command]) + self.assertEqual(exit_status.exception.code, 23) + chdir.assert_called_once_with(self.root) + execute.assert_called_once_with("pnpm", command, child_environment) + self.assertNotIn("SCOPED", os.environ) + + def test_empty_run_is_usage_error_and_cannot_execute(self): + with contextlib.ExitStack() as stack: + self.main_patches(stack) + execute = stack.enter_context(patch.object(browser.os, "execvpe")) + stack.enter_context(contextlib.redirect_stderr(io.StringIO())) + with self.assertRaises(SystemExit) as exit_status: + browser.main(["run", "--"]) + self.assertEqual(exit_status.exception.code, 2) + execute.assert_not_called() + + def test_failed_exec_reports_generic_error_and_interrupt_returns_130(self): + for error, expected in [(OSError("synthetic-private-detail"), 1), (KeyboardInterrupt(), 130)]: + with self.subTest(expected=expected), contextlib.ExitStack() as stack: + self.main_patches(stack) + stack.enter_context(patch.object(browser.os, "chdir")) + stack.enter_context(patch.object(browser.os, "execvpe", side_effect=error)) + stderr = stack.enter_context(contextlib.redirect_stderr(io.StringIO())) + self.assertEqual(browser.main(["run", "--", "test-command"]), expected) + self.assertNotIn("synthetic-private-detail", stderr.getvalue()) + + def test_browser_install_preserves_failed_child_exit_and_signal_status(self): + for code, expected in [(7, 7), (-15, 143)]: + with self.subTest(code=code), contextlib.ExitStack() as stack: + self.main_patches(stack) + run = stack.enter_context(patch.object(browser.subprocess, "run", return_value=result(code))) + doctor = stack.enter_context(patch.object(browser, "doctor")) + self.assertEqual(browser.main(["install"]), expected) + self.assertEqual(run.call_args.args[0], [self.info["node"], self.info["cli"], "install", "chromium"]) + doctor.assert_not_called() + + def test_doctor_distinguishes_missing_browser_and_launch_failure_without_raw_errors(self): + for output, message in [("missing-browser", "Chromium is missing"), ("launch-failed", "Chromium could not launch")]: + with self.subTest(output=output), patch.object(browser, "capture", return_value=result(1, output, "synthetic-private-detail")), contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(browser.BrowserError, message) as error: + browser.doctor(self.info, {"SCOPED": "value"}) + self.assertNotIn("synthetic-private-detail", str(error.exception)) + + def test_doctor_performs_headless_probe_with_scoped_environment(self): + environment = {"SCOPED": "value"} + with patch.object(browser, "capture", return_value=result()) as capture, contextlib.redirect_stdout(io.StringIO()) as output: + browser.doctor(self.info, environment) + self.assertIn("headless Chromium launched and closed", output.getvalue()) + self.assertEqual(capture.call_args.args[0], [self.info["node"], "-e", browser.PROBE, self.info["api"]]) + self.assertIs(capture.call_args.kwargs["env"], environment) + self.assertEqual(capture.call_args.kwargs["cwd"], self.root) + + def test_project_resolution_failures_do_not_dump_subprocess_output(self): + for stdout, code in [("synthetic-private-detail", 1), ("[]", 0), (json.dumps({**self.info, "project": str(self.root)}), 1)]: + with self.subTest(stdout=stdout), patch.object(browser.shutil, "which", return_value="/fixture/node"), patch.object(browser, "capture", return_value=result(code, stdout, "synthetic-private-detail")): + with self.assertRaisesRegex(browser.BrowserError, "project-local Playwright") as error: + browser.resolve_project(self.root) + self.assertNotIn("synthetic-private-detail", str(error.exception)) + + def test_capture_sanitizes_launch_errors(self): + with patch.object(browser.subprocess, "Popen", side_effect=OSError("synthetic-private-detail")): + with self.assertRaises(browser.BrowserError) as sanitized: + browser.capture(["fixture"]) + self.assertNotIn("synthetic-private-detail", str(sanitized.exception)) + + def test_capture_timeout_kills_process_group_and_bounds_cleanup(self): + for cleanup_times_out in [False, True]: + process = Mock(pid=12345) + timeout = subprocess.TimeoutExpired("synthetic-private-detail", 5, output="synthetic-private-detail") + cleanup = subprocess.TimeoutExpired("fixture", 1) if cleanup_times_out else ("", "") + process.communicate.side_effect = [timeout, cleanup] + with self.subTest(cleanup_times_out=cleanup_times_out), patch.object(browser.subprocess, "Popen", return_value=process) as popen, patch.object(browser.os, "killpg") as killpg: + with self.assertRaisesRegex(browser.BrowserError, "timed out") as sanitized: + browser.capture(["fixture"], timeout=5) + self.assertNotIn("synthetic-private-detail", str(sanitized.exception)) + self.assertTrue(popen.call_args.kwargs["start_new_session"]) + killpg.assert_called_once_with(12345, signal.SIGKILL) + self.assertEqual(process.communicate.call_args_list, [call(timeout=5), call(timeout=1)]) + self.assertEqual(process.stdout.close.call_count, int(cleanup_times_out)) + self.assertEqual(process.stderr.close.call_count, int(cleanup_times_out)) + + def test_capture_interrupt_cleans_group_before_propagating(self): + process = Mock(pid=12345) + process.communicate.side_effect = [KeyboardInterrupt(), ("", "")] + with patch.object(browser.subprocess, "Popen", return_value=process), patch.object(browser.os, "killpg") as killpg: + with self.assertRaises(KeyboardInterrupt): + browser.capture(["fixture"], timeout=5) + killpg.assert_called_once_with(12345, signal.SIGKILL) + self.assertEqual(process.communicate.call_args_list, [call(timeout=5), call(timeout=1)]) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index e2f58b5..aa25aec 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -33,6 +33,12 @@ def test_explicit_variable_and_unrelated_settings(self): connections = self.load('UNRELATED=${NOT_DEFINED}\nCONNECTION=postgres://localhost/app\n', ["CONNECTION"]) self.assertEqual(connections[0].database, "app") + def test_both_test_database_url_names_are_discovered_by_default(self): + connections = self.load('TEST_DB_URL=postgresql://fixture:synthetic-secret@localhost/legacy_test\nTEST_DATABASE_URL=postgresql://fixture:synthetic-secret@localhost/app_test\n') + self.assertEqual([c.name for c in connections], ["TEST_DB_URL", "TEST_DATABASE_URL"]) + self.assertEqual([c.database for c in connections], ["legacy_test", "app_test"]) + self.assertTrue(all("synthetic-secret" not in c.label for c in connections)) + def test_commands_are_not_executed(self): with tempfile.TemporaryDirectory() as temp: marker = Path(temp) / "executed" diff --git a/tests/test_project_check.py b/tests/test_project_check.py new file mode 100644 index 0000000..795fe73 --- /dev/null +++ b/tests/test_project_check.py @@ -0,0 +1,203 @@ +import importlib.util +import io +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import unittest +from unittest.mock import Mock, patch +import urllib.error + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("project_check", ROOT / "scripts/project_check.py") +project_check = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(project_check) + + +class ProjectCheckTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.which = patch.object(project_check.shutil, "which", side_effect=lambda name: "/usr/bin/" + name) + self.which.start() + + def tearDown(self): + self.which.stop() + self.temporary.cleanup() + + def write_package(self, directory=None, **values): + directory = directory or self.root + directory.mkdir(parents=True, exist_ok=True) + (directory / "package.json").write_text(json.dumps(values)) + return directory + + def commands(self, arguments, **kwargs): + if "rev-parse" in arguments: + return 0, str(self.root), None + if "context" in arguments: + return 0, "unix:///var/run/docker.sock", None + return 0, "", None + + def check(self, path=None, **kwargs): + with patch.object(project_check, "run_command", side_effect=self.commands): + return project_check.check_project(path or self.root, **kwargs) + + def statuses(self, report): + return {item["check"]: item["status"] for item in report["checks"]} + + def test_missing_lockfile_and_empty_node_modules_are_reported(self): + self.write_package(packageManager="pnpm@12.15.0", dependencies={"react": "19.3.1"}) + (self.root / "node_modules").mkdir() + report = self.check() + self.assertFalse(report["ready"]) + self.assertEqual(self.statuses(report)["lockfile"], "fail") + self.assertEqual(self.statuses(report)["dependencies"], "fail") + + def test_nested_package_selects_workspace_lock_and_installed_direct_dependencies(self): + self.write_package(packageManager="pnpm@12.15.0") + (self.root / "pnpm-workspace.yaml").write_text("packages: ['apps/*']\n") + (self.root / "pnpm-lock.yaml").write_text("lockfileVersion: '9.0'\n") + app = self.write_package(self.root / "apps/web", dependencies={"react": "19.3.1"}) + dependency = self.root / "node_modules/react" + dependency.mkdir(parents=True) + (dependency / "package.json").write_text("{}") + report = self.check(app) + self.assertEqual(report["package"], str(app / "package.json")) + self.assertEqual(report["workspace"], str(self.root)) + self.assertTrue(report["ready"]) + + def test_nested_non_javascript_project_checks_compose_at_git_root(self): + nested = self.root / "src" / "service" + nested.mkdir(parents=True) + (self.root / "compose.yaml").write_text("services: {}\n") + with patch.object(project_check.shutil, "which", side_effect=lambda name: None if name == "docker" else "/usr/bin/" + name): + report = self.check(nested) + self.assertEqual(report["project"], str(nested)) + self.assertEqual(report["workspace"], str(self.root)) + self.assertEqual(self.statuses(report)["compose-file"], "pass") + self.assertEqual(self.statuses(report)["docker"], "fail") + self.assertFalse(report["ready"]) + + def test_invalid_json_does_not_leak_contents_or_exception(self): + (self.root / "package.json").write_text('{"token":"super-private-token", invalid') + report = self.check() + self.assertFalse(report["ready"]) + self.assertEqual(self.statuses(report)["package"], "fail") + self.assertNotIn("super-private-token", json.dumps(report)) + + def test_no_environment_file_reads_or_project_commands(self): + self.write_package(scripts={"preinstall": "touch SHOULD_NOT_EXIST"}) + (self.root / "package-lock.json").write_text("{}") + (self.root / ".env").write_text("TOKEN=super-private-token\n") + (self.root / "compose.yaml").write_text("services: {}\n") + original_open = Path.open + opened = [] + + def guarded_open(path, *args, **kwargs): + self.assertFalse(path.name.startswith(".env")) + opened.append(path) + return original_open(path, *args, **kwargs) + + with patch.object(Path, "open", guarded_open), patch.object(project_check, "run_command", side_effect=self.commands) as execute: + report = project_check.check_project(self.root) + self.assertEqual(opened, [self.root / "package.json"]) + self.assertFalse((self.root / "SHOULD_NOT_EXIST").exists()) + self.assertNotIn("super-private-token", json.dumps(report)) + for call in execute.call_args_list: + arguments = call.args[0] + self.assertNotIn("install", arguments) + self.assertNotIn("config", arguments) + self.assertNotIn("--_registry-probe", arguments) + self.assertNotEqual(Path(arguments[0]).name, "pnpm") + self.assertNotEqual(Path(arguments[0]).name, "vp") + + def test_environment_file_manifest_symlink_is_not_read(self): + environment = self.root / ".env" + environment.write_text('{"private":"secret"}') + (self.root / "package.json").symlink_to(environment) + with patch.object(Path, "open", side_effect=AssertionError("No file should be opened")): + report = self.check() + self.assertEqual(self.statuses(report)["package"], "fail") + + def test_local_docker_failure_is_safe_and_actionable(self): + (self.root / "compose.yaml").write_text("services: {}\n") + + def commands(arguments, **kwargs): + if "info" in arguments: + self.assertNotIn("DOCKER_CONTEXT", kwargs["environment"]) + self.assertEqual(kwargs["environment"]["COMPOSE_DISABLE_ENV_FILE"], "true") + return 1, "password=super-private-token", None + return self.commands(arguments, **kwargs) + + with patch.dict(os.environ, {"DOCKER_CONTEXT": "local-test", "COMPOSE_ENV_FILES": str(self.root / ".env")}), patch.object(project_check, "run_command", side_effect=commands): + report = project_check.check_project(self.root) + encoded = json.dumps(report) + self.assertNotIn("super-private-token", encoded) + self.assertIn("WSL", encoded) + self.assertEqual(self.statuses(report)["docker-daemon"], "fail") + + def test_remote_docker_context_is_never_contacted(self): + (self.root / "compose.yml").write_text("services: {}\n") + + def commands(arguments, **kwargs): + self.assertNotIn("info", arguments) + if "context" in arguments: + return 0, "ssh://private-user@private-host", None + return self.commands(arguments, **kwargs) + + with patch.dict(os.environ, {"DOCKER_CONTEXT": "remote"}), patch.object(project_check, "run_command", side_effect=commands): + report = project_check.check_project(self.root) + self.assertEqual(self.statuses(report)["docker-daemon"], "skip") + self.assertNotIn("private-host", json.dumps(report)) + + def test_network_is_opt_in_and_bounded(self): + with patch.object(project_check, "run_command", side_effect=self.commands) as execute: + project_check.check_project(self.root) + self.assertFalse(any("--_registry-probe" in call.args[0] for call in execute.call_args_list)) + report = project_check.check_project(self.root, network=True) + network = [call for call in execute.call_args_list if "--_registry-probe" in call.args[0]] + self.assertEqual(len(network), 1) + self.assertEqual(network[0].kwargs["timeout"], 10) + self.assertEqual(self.statuses(report)["registry"], "pass") + + def test_registry_probe_uses_verified_https_and_does_not_expose_proxy_errors(self): + context = Mock() + opener = Mock() + opener.open.side_effect = urllib.error.URLError("https://user:super-private-token@proxy") + with patch.object(project_check.ssl, "create_default_context", return_value=context), patch.object(project_check.urllib.request, "build_opener", return_value=opener), patch.dict(os.environ, {"NODE_EXTRA_CA_CERTS": "/tmp/public-ca.pem"}, clear=True), patch.object(project_check.sys, "stdout", new_callable=io.StringIO) as output: + self.assertEqual(project_check.registry_probe(), 1) + context.load_verify_locations.assert_called_once_with(cafile="/tmp/public-ca.pem") + request = opener.open.call_args.args[0] + self.assertEqual(request.full_url, "https://registry.npmjs.org/-/ping") + self.assertEqual(opener.open.call_args.kwargs["timeout"], 6) + self.assertEqual(output.getvalue(), "") + + def test_subprocess_timeout_kills_owned_group_and_discards_partial_output(self): + process = Mock(pid=12345) + process.communicate.side_effect = [subprocess.TimeoutExpired(["docker"], 5, output="super-private-token"), ("", None)] + with patch.object(project_check.subprocess, "Popen", return_value=process) as launch, patch.object(project_check.os, "killpg") as kill: + self.assertEqual(project_check.run_command(["docker", "info"]), (None, "", "timeout")) + kill.assert_called_once_with(12345, signal.SIGKILL) + self.assertEqual(launch.call_args.kwargs["stderr"], subprocess.DEVNULL) + self.assertTrue(launch.call_args.kwargs["start_new_session"]) + + def test_tool_availability_does_not_execute_package_manager_shims(self): + self.write_package(packageManager="pnpm@12.15.0", devDependencies={"vite-plus": "1.4.0"}) + with patch.object(project_check.shutil, "which", side_effect=lambda name: None if name in ("node", "pnpm", "vp") else "/usr/bin/" + name): + report = self.check() + statuses = self.statuses(report) + self.assertEqual((statuses["node"], statuses["pnpm"], statuses["vp"]), ("fail", "fail", "fail")) + + def test_json_output_and_nonzero_status_for_failed_checks(self): + (self.root / "package.json").write_text("invalid") + with patch.object(project_check, "run_command", side_effect=self.commands), patch.object(project_check.sys, "stdout", new_callable=io.StringIO) as output: + code = project_check.main(["--json", str(self.root)]) + self.assertEqual(code, 1) + self.assertFalse(json.loads(output.getvalue())["ready"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py index 9027a65..db33320 100644 --- a/tests/test_uninstall.py +++ b/tests/test_uninstall.py @@ -146,6 +146,48 @@ def test_cache_selection_is_independent_and_allowlisted(self): self.assertIn("bat", self.deploy.state["programs"]) self.assertTrue(report["unresolved"]) + def test_browser_cache_is_available_only_at_its_current_exact_path(self): + cache = uninstall.setup.CACHE / "browser-deps" + legacy = uninstall.setup.CACHE.parent / "dev-setup/browser-deps" + self.assertNotIn("browser-deps", uninstall.cache_options({})) + legacy.mkdir(parents=True) + self.assertNotIn("browser-deps", uninstall.cache_options({})) + cache.mkdir() + self.assertEqual(uninstall.cache_options({})["browser-deps"]["paths"], [cache]) + self.assertTrue(uninstall.safe_cache_path(cache, "browser-deps")) + for other in [legacy, self.root / "cache/ms-playwright", cache / "nested", uninstall.setup.CACHE]: + self.assertFalse(uninstall.safe_cache_path(other, "browser-deps")) + selected = {"browser-deps": [str(legacy)]} + self.assertEqual(uninstall.canonical_cache_paths(selected), selected) + + def test_program_removal_keeps_browser_caches_when_unselected(self): + self.program("fd") + cache = uninstall.setup.CACHE / "browser-deps" + browsers = self.root / "cache/ms-playwright" + cache.mkdir() + browsers.mkdir() + report = uninstall.apply_plan(self.deploy, {"fd"}, {}) + self.assertTrue(cache.exists()) + self.assertTrue(browsers.exists()) + self.assertEqual(report["unresolved"], []) + self.assertEqual(report["removed"], ["fd"]) + + def test_browser_cache_selection_preserves_programs_and_playwright_browsers(self): + program, link, _ = self.program("vp") + cache = uninstall.setup.CACHE / "browser-deps" + browsers = self.root / "cache/ms-playwright" + cache.mkdir() + (cache / "library").write_text("fixture library") + browsers.mkdir() + (browsers / "browser").write_text("fixture browser") + report = uninstall.apply_plan(self.deploy, set(), {"browser-deps": [str(cache)]}) + self.assertFalse(cache.exists()) + self.assertTrue(program.exists()) + self.assertTrue(link.exists()) + self.assertEqual((browsers / "browser").read_text(), "fixture browser") + self.assertEqual(report["unresolved"], []) + self.assertEqual(report["removed"], ["browser-deps: " + str(cache)]) + def test_cache_symlink_and_parent_escape_are_refused(self): outside = self.root / "important" outside.mkdir()