Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<short-description>`, `fix/<short-description>`, etc.

## Key Files

- `Makefile` — Project commands
Expand Down
6 changes: 4 additions & 2 deletions trobz_local/assets/odoo_dev.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand Down
197 changes: 197 additions & 0 deletions trobz_local/installers.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,6 +22,7 @@
)
from .utils import (
ARCH_PACKAGES,
GITHUB_LATEST,
MACOS_PACKAGES,
UBUNTU_PACKAGES,
get_os_info,
Expand Down Expand Up @@ -389,3 +396,193 @@ 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


_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


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:
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:
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))

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)
38 changes: 33 additions & 5 deletions trobz_local/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from .concurrency import TaskResult, run_tasks
from .installers import (
install_github_tools,
install_npm_packages,
install_scripts,
install_system_packages,
Expand Down Expand Up @@ -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"):
Expand All @@ -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 = []
Expand All @@ -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()
Expand Down Expand Up @@ -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"),
Expand Down
25 changes: 25 additions & 0 deletions trobz_local/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand All @@ -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."""

Expand All @@ -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 = GITHUB_LATEST
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")
Expand Down