From 2b2b33463eae4083885ba3ee9030236ca1264d63 Mon Sep 17 00:00:00 2001 From: Nils Hamerlinck Date: Fri, 27 Feb 2026 11:12:10 +0700 Subject: [PATCH 1/4] feat: add [[tools.github]] installer type Support installing tools from GitHub releases with three modes: - version = "latest-release": auto-resolve to the latest tag via GitHub API - script defined: download and execute install script from raw.githubusercontent.com - script not defined: download binary asset matching the current OS/arch Co-Authored-By: Claude Sonnet 4.6 --- trobz_local/assets/odoo_dev.toml | 6 +- trobz_local/installers.py | 181 +++++++++++++++++++++++++++++++ trobz_local/main.py | 38 ++++++- trobz_local/utils.py | 25 +++++ 4 files changed, 243 insertions(+), 7 deletions(-) diff --git a/trobz_local/assets/odoo_dev.toml b/trobz_local/assets/odoo_dev.toml index a31eb98..314edf2 100644 --- a/trobz_local/assets/odoo_dev.toml +++ b/trobz_local/assets/odoo_dev.toml @@ -16,9 +16,11 @@ npm = [ name = "uv" url = "https://astral.sh/uv/install.sh" -[[tools.script]] +[[tools.github]] name = "nvm" -url = "https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh" +repo = "https://github.com/nvm-sh/nvm" +version = "latest-release" +script = "install.sh" system_packages = [] diff --git a/trobz_local/installers.py b/trobz_local/installers.py index cce5efe..9ee6a12 100644 --- a/trobz_local/installers.py +++ b/trobz_local/installers.py @@ -1,7 +1,13 @@ +import json import logging +import platform import shutil import subprocess +import tarfile import tempfile +import urllib.error +import urllib.request +import zipfile from pathlib import Path import typer @@ -16,6 +22,7 @@ ) from .utils import ( ARCH_PACKAGES, + GITHUB_LATEST, MACOS_PACKAGES, UBUNTU_PACKAGES, get_os_info, @@ -389,3 +396,177 @@ def install_uv_tools(tools: list[str], dry_run: bool = False) -> list: }) return run_tasks(tasks) + + +# --- GitHub tool helpers --- + +_SYSTEM_KEYWORDS = { + "linux": ["linux"], + "darwin": ["darwin", "macos", "osx"], +} + +_ARCH_KEYWORDS = { + "x86_64": ["x86_64", "amd64"], + "aarch64": ["aarch64", "arm64"], + "arm64": ["arm64", "aarch64"], +} + + +def _parse_github_owner_repo(repo_url: str) -> tuple[str, str]: + """Extract (owner, repo) from a GitHub URL.""" + parts = repo_url.removeprefix("https://github.com/").split("/") + return parts[0], parts[1] + + +def _fetch_latest_release_tag(owner: str, repo: str) -> str: + """Return the tag name of the latest GitHub release.""" + url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" + req = urllib.request.Request( # noqa: S310 + url, + headers={"Accept": "application/vnd.github+json", "User-Agent": "trobz-local"}, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 + data = json.loads(resp.read()) + return data["tag_name"] + except (urllib.error.URLError, json.JSONDecodeError, KeyError) as e: + raise DownloadError(url, str(e)) from e + + +def _find_matching_asset(assets: list[dict], system: str, machine: str) -> dict | None: + """Find a release asset matching the current OS and architecture.""" + sys_keywords = _SYSTEM_KEYWORDS.get(system, [system]) + arch_keywords = _ARCH_KEYWORDS.get(machine, [machine]) + + for asset in assets: + name = asset["name"].lower() + if any(k in name for k in sys_keywords) and any(k in name for k in arch_keywords): + return asset + return None + + +def _find_binary_in_names(names: list[str], tool_name: str) -> str | None: + """Find the best matching binary path in a list of archive member names.""" + for name in names: + if Path(name).name == tool_name: + return name + # Fallback: first file without extension + for name in names: + base = Path(name).name + if base and "." not in base and not name.endswith("/"): + return name + return None + + +def _install_github_binary( + progress: Progress, task_id: TaskID, owner: str, repo: str, version: str, name: str, temp_dir: str +): + """Download and install a binary asset from a GitHub release.""" + api_url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{version}" + req = urllib.request.Request( # noqa: S310 + api_url, + headers={"Accept": "application/vnd.github+json", "User-Agent": "trobz-local"}, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 + release_data = json.loads(resp.read()) + except (urllib.error.URLError, json.JSONDecodeError) as e: + raise DownloadError(api_url, str(e)) from e + + assets = release_data.get("assets", []) + if not assets: + raise DownloadError(api_url, "No release assets found") + + system = platform.system().lower() + machine = platform.machine().lower() + asset = _find_matching_asset(assets, system, machine) + if asset is None: + available = [a["name"] for a in assets] + raise DownloadError(api_url, f"No asset matched {system}/{machine}. Available: {available}") + + download_url = asset["browser_download_url"] + asset_name = asset["name"] + download_path = Path(temp_dir) / asset_name + + progress.update(task_id, description=f"Downloading {name} {version}...", completed=30) + download_cmd = _get_download_command(download_url, str(download_path)) + try: + subprocess.run(download_cmd, check=True, capture_output=True, text=True) # noqa: S603 + except subprocess.CalledProcessError as e: + raise DownloadError(download_url, e.stderr) from e + + progress.update(task_id, description=f"Installing {name}...", completed=70) + install_dir = Path.home() / ".local" / "bin" + install_dir.mkdir(parents=True, exist_ok=True) + install_path = install_dir / name + + if asset_name.endswith((".tar.gz", ".tgz")): + with tarfile.open(download_path) as tar: + member_name = _find_binary_in_names(tar.getnames(), name) + if member_name: + f = tar.extractfile(tar.getmember(member_name)) + if f: + install_path.write_bytes(f.read()) + elif asset_name.endswith(".zip"): + with zipfile.ZipFile(download_path) as zf: + binary_name = _find_binary_in_names(zf.namelist(), name) + if binary_name: + install_path.write_bytes(zf.read(binary_name)) + else: + shutil.copy2(str(download_path), str(install_path)) + + install_path.chmod(0o755) + progress.update(task_id, description=f"✓ {name} installed to {install_path}.", completed=100) + + +def _install_github_tool(progress: Progress, task_id: TaskID, tool: dict, temp_dir: str): + name = tool["name"] + repo_url = tool["repo"] + version = tool["version"] + script = tool.get("script") + + progress.update(task_id, description=f"Installing {name}...", total=100, completed=0) + + owner, repo = _parse_github_owner_repo(repo_url) + + if version == GITHUB_LATEST: + progress.update(task_id, description=f"Fetching latest release for {name}...") + version = _fetch_latest_release_tag(owner, repo) + + if script: + raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{version}/{script}" + _install_script(progress, task_id, {"url": raw_url, "name": name}, temp_dir) + else: + _install_github_binary(progress, task_id, owner, repo, version, name, temp_dir) + + +def install_github_tools(tools: list[dict], dry_run: bool = False) -> list: + """Install tools from GitHub releases (via install script or binary asset). + + Args: + tools: List of tool dicts with keys: name, repo, version, script (optional). + dry_run: If True, only show what would be installed. + + """ + if not tools: + return [] + + if dry_run: + typer.echo("\n[GitHub tools - would be installed from GitHub releases]") + for tool in tools: + version_label = "latest release" if tool["version"] == GITHUB_LATEST else tool["version"] + method = f"script: {tool['script']}" if tool.get("script") else "binary asset" + typer.echo(f" - {tool['name']} ({tool['repo']}, {version_label}, {method})") + return [] + + typer.secho("\n--- Installing GitHub Tools ---", fg=typer.colors.BLUE, bold=True) + + with tempfile.TemporaryDirectory() as temp_dir: + tasks = [] + for tool in tools: + tasks.append({ + "name": tool["name"], + "func": _install_github_tool, + "args": {"tool": tool, "temp_dir": temp_dir}, + }) + return run_tasks(tasks) diff --git a/trobz_local/main.py b/trobz_local/main.py index 8c41ca0..863b06e 100644 --- a/trobz_local/main.py +++ b/trobz_local/main.py @@ -10,6 +10,7 @@ from .concurrency import TaskResult, run_tasks from .installers import ( + install_github_tools, install_npm_packages, install_scripts, install_system_packages, @@ -256,7 +257,7 @@ def _pull_repo(progress: Progress, task_id: TaskID, repo_info: dict): raise # to be caught by run_tasks -def _build_install_message(tools_config: dict) -> str: +def _build_install_message(tools_config: dict) -> str: # noqa: C901 msg = "This command will install tools in the following order:\n" if tools_config.get("script"): @@ -269,25 +270,36 @@ def _build_install_message(tools_config: dict) -> str: hash_status = "✓ verified" if sha256 else "⚠ no hash" msg += f" - {display_name} ({hash_status})\n" + if tools_config.get("github"): + msg += "\n[2] GitHub tools:\n" + for tool in tools_config["github"]: + name = tool["name"] if isinstance(tool, dict) else tool.name + repo = tool["repo"] if isinstance(tool, dict) else tool.repo + version = tool["version"] if isinstance(tool, dict) else tool.version + script = tool.get("script") if isinstance(tool, dict) else tool.script + version_label = "latest release" if version == "latest-release" else version + method = f"script: {script}" if script else "binary asset" + msg += f" - {name} ({repo}, {version_label}, {method})\n" + if tools_config.get("system_packages"): - msg += "\n[2] System packages:\n" + msg += "\n[3] System packages:\n" for pkg in tools_config["system_packages"]: msg += f" - {pkg}\n" if tools_config.get("npm"): - msg += "\n[3] NPM packages (via npm -g):\n" + msg += "\n[4] NPM packages (via npm -g):\n" for pkg in tools_config["npm"]: msg += f" - {pkg}\n" if tools_config.get("uv"): - msg += "\n[4] UV tools:\n" + msg += "\n[5] UV tools:\n" for tool in tools_config["uv"]: msg += f" - {tool}\n" return msg -def _run_installers( +def _run_installers( # noqa: C901 tools_config: dict, dry_run: bool, install_default_system_packages: bool = True ) -> tuple[list, bool]: all_results = [] @@ -307,6 +319,21 @@ def _run_installers( if any(not r.success for r in results): any_failed = True + if tools_config.get("github"): + github_tools = [ + { + "name": t["name"] if isinstance(t, dict) else t.name, + "repo": t["repo"] if isinstance(t, dict) else t.repo, + "version": t["version"] if isinstance(t, dict) else t.version, + "script": t.get("script") if isinstance(t, dict) else t.script, + } + for t in tools_config["github"] + ] + results = install_github_tools(github_tools, dry_run) + all_results.extend(results) + if any(not r.success for r in results): + any_failed = True + # Setup PostgreSQL repository before system package installation if not dry_run: setup_postgresql_repo() @@ -347,6 +374,7 @@ def install_tools( has_any = any([ tools_config.get("script"), + tools_config.get("github"), tools_config.get("system_packages"), tools_config.get("npm"), tools_config.get("uv"), diff --git a/trobz_local/utils.py b/trobz_local/utils.py index 88ebdd9..593ecb1 100644 --- a/trobz_local/utils.py +++ b/trobz_local/utils.py @@ -74,6 +74,11 @@ def __init__(self, url: str): super().__init__(f"Script URL must use HTTPS for security: {url}") +class InvalidGithubRepoError(ValueError): + def __init__(self, url: str): + super().__init__(f"repo must be a GitHub URL (https://github.com/owner/repo): {url}") + + class InvalidNpmPackageError(ValueError): def __init__(self, pkg: str): super().__init__(f"Invalid npm package name: {pkg}") @@ -92,6 +97,9 @@ def validate_repo_names(cls, v: list[str]): return v +GITHUB_LATEST = "latest-release" + + class ScriptItem(BaseModel): """Configuration for a script to download and execute.""" @@ -107,12 +115,29 @@ def validate_url(cls, v: str): return v +class GithubToolItem(BaseModel): + """Configuration for a tool installed from a GitHub release.""" + + name: str + repo: str + version: str + script: str | None = None + + @field_validator("repo") + @classmethod + def validate_repo_url(cls, v: str): + if not re.match(r"^https://github\.com/[^/]+/[^/]+$", v.rstrip("/")): + raise InvalidGithubRepoError(v) + return v.rstrip("/") + + class ToolsConfig(BaseModel): """Configuration for tools to install.""" uv: list[str] = Field(default_factory=list) npm: list[str] = Field(default_factory=list) script: list[ScriptItem] = Field(default_factory=list) + github: list[GithubToolItem] = Field(default_factory=list) system_packages: list[str] = Field(default_factory=list) @field_validator("uv") From 4c5e3a04207c16ec708860af7b6815f27322efec Mon Sep 17 00:00:00 2001 From: Nils Hamerlinck Date: Fri, 27 Feb 2026 11:19:26 +0700 Subject: [PATCH 2/4] docs: never push to main, always use feature branches Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index fb64e62..28c1406 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,11 @@ make test # Run pytest ``` +## Git Workflow + +- **Never push directly to `main`** — always create a feature branch and open a PR +- Branch naming: `feat/`, `fix/`, etc. + ## Key Files - `Makefile` — Project commands From 84a2585a9de2604da74eafd049aecb8053bb6907 Mon Sep 17 00:00:00 2001 From: Nils Hamerlinck Date: Fri, 27 Feb 2026 12:37:39 +0700 Subject: [PATCH 3/4] fix: pick largest non-text file as binary fallback in archives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old fallback grabbed the first file without an extension (e.g. LICENSE) instead of the actual binary. Now we build a (size, path) list filtered by known text extensions and filenames, and pick the largest candidate — which is always the real binary. Co-Authored-By: Claude Sonnet 4.6 --- trobz_local/installers.py | 44 ++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/trobz_local/installers.py b/trobz_local/installers.py index 9ee6a12..d741992 100644 --- a/trobz_local/installers.py +++ b/trobz_local/installers.py @@ -445,16 +445,30 @@ def _find_matching_asset(assets: list[dict], system: str, machine: str) -> dict return None -def _find_binary_in_names(names: list[str], tool_name: str) -> str | None: - """Find the best matching binary path in a list of archive member names.""" - for name in names: - if Path(name).name == tool_name: - return name - # Fallback: first file without extension - for name in names: - base = Path(name).name - if base and "." not in base and not name.endswith("/"): - return name +_TEXT_EXTENSIONS = {".md", ".txt", ".rst", ".html", ".json", ".yaml", ".yml", ".xml", ".toml", ".ini", ".cfg"} +_TEXT_FILENAMES = {"LICENSE", "README", "CHANGELOG", "NOTICE", "AUTHORS", "CONTRIBUTING", "INSTALL", "COPYRIGHT"} + + +def _find_binary_member(members: list[tuple[str, int]], tool_name: str) -> str | None: + """Find the best matching binary in a list of (archive_path, size) members. + + Priority: + 1. Exact basename match for tool_name + 2. Largest file that has no text extension and no known text filename + """ + for member_path, _ in members: + if Path(member_path).name == tool_name: + return member_path + + candidates = [ + (size, member_path) + for member_path, size in members + if Path(member_path).suffix not in _TEXT_EXTENSIONS + and Path(member_path).name not in _TEXT_FILENAMES + and not member_path.endswith("/") + ] + if candidates: + return max(candidates)[1] # largest file wins return None @@ -502,16 +516,18 @@ def _install_github_binary( if asset_name.endswith((".tar.gz", ".tgz")): with tarfile.open(download_path) as tar: - member_name = _find_binary_in_names(tar.getnames(), name) + members = [(m.name, m.size) for m in tar.getmembers() if m.isfile()] + member_name = _find_binary_member(members, name) if member_name: f = tar.extractfile(tar.getmember(member_name)) if f: install_path.write_bytes(f.read()) elif asset_name.endswith(".zip"): with zipfile.ZipFile(download_path) as zf: - binary_name = _find_binary_in_names(zf.namelist(), name) - if binary_name: - install_path.write_bytes(zf.read(binary_name)) + members = [(i.filename, i.file_size) for i in zf.infolist() if not i.is_dir()] + member_name = _find_binary_member(members, name) + if member_name: + install_path.write_bytes(zf.read(member_name)) else: shutil.copy2(str(download_path), str(install_path)) From 75f6c27d8207d1ec98c72a2c3622be67c99c7ed2 Mon Sep 17 00:00:00 2001 From: Nils Hamerlinck Date: Fri, 27 Feb 2026 12:38:59 +0700 Subject: [PATCH 4/4] feat: default version to latest-release in [[tools.github]] Co-Authored-By: Claude Sonnet 4.6 --- trobz_local/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trobz_local/utils.py b/trobz_local/utils.py index 593ecb1..b0819c5 100644 --- a/trobz_local/utils.py +++ b/trobz_local/utils.py @@ -120,7 +120,7 @@ class GithubToolItem(BaseModel): name: str repo: str - version: str + version: str = GITHUB_LATEST script: str | None = None @field_validator("repo")