From fbee908a53bb4e4589f964613b977e1fed73941c Mon Sep 17 00:00:00 2001 From: Nils Hamerlinck Date: Tue, 27 Jan 2026 20:47:11 +0700 Subject: [PATCH 1/7] [IMP] defaults --- tests/test_create_venvs.py | 5 ++--- trobz_local/main.py | 7 +++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/test_create_venvs.py b/tests/test_create_venvs.py index a2ec4ca..fca92ae 100644 --- a/tests/test_create_venvs.py +++ b/tests/test_create_venvs.py @@ -59,9 +59,8 @@ def test_create_venv_success(tmp_path, mock_get_uv_path): "--venv-dir", str(venv_dir_base / version), "--preset", - "demo", - "-p", - "3.12", + "local", + "--verbose", ] mock_subprocess_run.assert_called_once_with( expected_cmd, diff --git a/trobz_local/main.py b/trobz_local/main.py index a885dec..a2c4ebe 100644 --- a/trobz_local/main.py +++ b/trobz_local/main.py @@ -373,7 +373,7 @@ def create_venvs(ctx: typer.Context): msg = ( "This command will create Python virtual environments for the following Odoo versions " - "using 'odoo-venv' with the 'demo' preset, using Python '3.12':\n\n" + "using 'odoo-venv' with the 'local' preset:\n\n" ) for version in versions: msg += f"- {version} -> {venv_dir_base / version}\n" @@ -435,9 +435,8 @@ def _create_venvs( "--venv-dir", str(venv_dir), "--preset", - "demo", - "-p", - "3.12", + "local", + "--verbose", ] subprocess.run( # noqa: S603 From 57d4a0c4c0f3208461269a9558038f27b115f93d Mon Sep 17 00:00:00 2001 From: Nils Hamerlinck Date: Tue, 27 Jan 2026 21:04:19 +0700 Subject: [PATCH 2/7] [ADD] bootstrap.sh --- README.md | 20 +++++-- bootstrap.sh | 146 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 4 deletions(-) create mode 100755 bootstrap.sh diff --git a/README.md b/README.md index 90ba55e..e830d08 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,22 @@ A developer tool for automating setup and management of local Odoo development e ## Installation -Install globally using [uv](https://docs.astral.sh/uv/): +### Quick Install (Recommended) + +Run the bootstrap script to install all dependencies and `tlc` in one command: + +```bash +curl -fsSL https://raw.githubusercontent.com/trobz/local.py/main/bootstrap.sh | bash +``` + +This installs: `git`, `gh`, `uv`, configures PostgreSQL APT repository, and sets up SSH for GitHub. + +### Manual Install + +If you already have `uv` installed: ```bash -uv tool install git+ssh://git@github.com:trobz/local.py.git +uv tool install git+https://github.com/trobz/local.py.git ``` ## Quick Start @@ -106,9 +118,9 @@ See [Configuration Schema](./docs/project-overview-pdr.md#configuration-schema) ## System Requirements - Python 3.10+ -- `uv` package manager - Linux (Arch, Ubuntu) or macOS -- System tools: `git`, `wget` or `curl`, `sh` +- `curl` +- Sudo privileges (for bootstrap script) ## Documentation diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..e1e19b2 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,146 @@ +#!/bin/bash +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +export PATH="$HOME/.local/bin:$PATH" + +# Bootstrap script for trobz_local (tlc) +# Installs: uv, git, gh, and the trobz_local package + +echo "=== Bootstrap trobz_local ===" + +# Check if user can sudo +check_sudo() { + if ! sudo -v &>/dev/null; then + echo "Error: This script requires sudo privileges." + echo "Please run as a user with sudo access." + exit 1 + fi +} + +check_sudo + +# Detect OS +detect_os() { + if [[ "$OSTYPE" == "linux-gnu"* ]]; then + if [ -f /etc/debian_version ]; then + echo "debian" + elif [ -f /etc/fedora-release ]; then + echo "fedora" + elif [ -f /etc/arch-release ]; then + echo "arch" + else + echo "linux" + fi + elif [[ "$OSTYPE" == "darwin"* ]]; then + echo "macos" + else + echo "unknown" + fi +} + +OS=$(detect_os) +echo "Detected OS: $OS" + +# Install prerequisite packages (Debian/Ubuntu only) +install_prerequisites() { + if [[ "$OS" != "debian" ]]; then + return + fi + echo "Installing prerequisite packages..." + sudo apt-get update + sudo apt-get install -y apt-utils apt-transport-https lsb-release +} + +# Add PostgreSQL APT repository (Debian/Ubuntu only) +setup_postgresql_repo() { + if [[ "$OS" != "debian" ]]; then + return + fi + if [ -f /usr/share/keyrings/postgresql-keyring.gpg ]; then + echo "PostgreSQL APT repository already configured" + return + fi + echo "Adding PostgreSQL APT repository..." + local codename + codename=$(lsb_release -cs) + curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /usr/share/keyrings/postgresql-keyring.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/postgresql-keyring.gpg] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list > /dev/null + sudo apt-get update +} + +# Install git +install_git() { + if command -v git &>/dev/null; then + echo "git is already installed" + return + fi + echo "Installing git..." + case $OS in + debian) sudo apt-get update && sudo apt-get install -y git ;; + fedora) sudo dnf install -y git ;; + arch) sudo pacman -S --noconfirm git ;; + macos) brew install git ;; + *) echo "Please install git manually"; exit 1 ;; + esac +} + +# Install gh (GitHub CLI) +install_gh() { + if command -v gh &>/dev/null; then + echo "gh is already installed" + return + fi + echo "Installing gh (GitHub CLI)..." + case $OS in + debian) + sudo mkdir -p -m 755 /etc/apt/keyrings + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null + sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + sudo apt-get update && sudo apt-get install -y gh + ;; + fedora) sudo dnf install -y gh ;; + arch) sudo pacman -S --noconfirm github-cli ;; + macos) brew install gh ;; + *) echo "Please install gh manually from https://cli.github.com/"; exit 1 ;; + esac +} + +# Install uv +install_uv() { + if command -v uv &>/dev/null; then + echo "uv is already installed" + return + fi + echo "Installing uv..." + curl -LsSf https://astral.sh/uv/install.sh | sh +} + +# Setup SSH known_hosts for GitHub +setup_github_ssh() { + echo "Setting up SSH known_hosts for GitHub..." + mkdir -p ~/.ssh + chmod 700 ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null + echo "GitHub added to known_hosts" +} + +# Install trobz_local +install_trobz_local() { + echo "Installing trobz_local..." + uv tool install git+https://github.com/trobz/local.py.git + echo "trobz_local installed (CLI: tlc)" +} + +# Main +install_prerequisites +setup_postgresql_repo +install_git +install_gh +install_uv +setup_github_ssh +install_trobz_local + +echo "" +echo "=== Bootstrap complete ===" +echo "Run 'tlc --help' to get started" From 949567f064035184452a136bfbb00cde5ce19dc7 Mon Sep 17 00:00:00 2001 From: Nils Hamerlinck Date: Tue, 27 Jan 2026 21:41:41 +0700 Subject: [PATCH 3/7] refactor(init): use hardcoded defaults with configurable extra dirs Default dirs: venvs, oca, odoo, odoo/odoo Additional dirs can be added via init_dirs in config.toml Co-Authored-By: Claude Opus 4.5 --- trobz_local/main.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/trobz_local/main.py b/trobz_local/main.py index a2c4ebe..ce91703 100644 --- a/trobz_local/main.py +++ b/trobz_local/main.py @@ -63,15 +63,15 @@ def _run_init(ctx: typer.Context): "init", ) config = get_config() + # Hardcoded defaults dirs = [ "venvs", "oca", "odoo", "odoo/odoo", - "odoo/enterprise", - "trobz/projects", - "trobz/packages", ] + # Additional directories from config + dirs.extend(config.get("init_dirs", [])) for d in dirs: (code_root / d).mkdir(parents=True, exist_ok=True) @@ -97,13 +97,11 @@ def _run_init(ctx: typer.Context): community = odoo_tree.add("odoo [dim]# Odoo Community[/dim]") for version in odoo_versions: community.add(f"{version}") - ent = odoo_tree.add("enterprise [dim]# Odoo Enterprise[/dim]") - for version in odoo_versions: - ent.add(f"{version}") - trobz_tree = tree.add("trobz [dim]# Trobz repositories[/dim]") - trobz_tree.add("projects") - trobz_tree.add("packages [dim]# Internal packages[/dim]") + # Show additional directories from config + extra_dirs = config.get("init_dirs", []) + for d in extra_dirs: + tree.add(f"{d}") rprint(tree) From 661a40f8a892c4c2e94f5a13642f5fddb3400cdb Mon Sep 17 00:00:00 2001 From: trisdoan Date: Wed, 11 Feb 2026 12:11:57 +0700 Subject: [PATCH 4/7] feat(db): add ensure-db-user CLI command for PostgreSQL user management Add new PostgreSQL user management module with OS-aware implementation (Linux/macOS). Includes CLI command for ensuring database users exist with proper access controls --- docs/code-standards.md | 17 +- docs/codebase-summary.md | 44 ++++- docs/project-overview-pdr.md | 54 +++++- tests/test_ensure_db_user.py | 335 +++++++++++++++++++++++++++++++++++ trobz_local/main.py | 68 ++++++- trobz_local/postgres.py | 173 ++++++++++++++++++ 6 files changed, 671 insertions(+), 20 deletions(-) create mode 100644 tests/test_ensure_db_user.py create mode 100644 trobz_local/postgres.py diff --git a/docs/code-standards.md b/docs/code-standards.md index 517bc38..ec04a35 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -9,7 +9,7 @@ Four-layer modular design with clear separation of concerns: | Layer | Module(s) | Responsibility | |---|---|---| | **CLI Layer** | `main.py` | Command routing, user interaction, newcomer mode | -| **Implementation** | `installers.py` | Four installation strategies (script, system, npm, uv) | +| **Implementation** | `installers.py`, `postgres.py` | Installation strategies (script, system, npm, uv); PostgreSQL user management | | **Utility Layer** | `utils.py` | Config validation, platform detection, helpers | | **Infrastructure** | `concurrency.py`, `exceptions.py` | Parallel execution, custom exceptions | @@ -67,6 +67,7 @@ Config validated at startup. Early detection prevents side effects on invalid in - **Never use shell=True** - Always pass arguments as list - **Full paths only** - Use shutil.which() instead of relying on PATH - **No user input in commands** - Build commands from validated config only +- **SQL injection prevention** - Use psql variable binding (`:\"identifier\"` for names, `:'string'` for values) instead of string interpolation ### Download Security - **HTTPS enforcement** - Pydantic validator rejects non-HTTPS URLs @@ -77,6 +78,7 @@ Config validated at startup. Early detection prevents side effects on invalid in - Versions: `^(?:\d+\.\d+|master)$` (supports semver and "master" branch) - UV tools: `^[a-zA-Z0-9][a-zA-Z0-9._\-\[\]@=<>!,]*$` - Repo names: `^[a-zA-Z0-9._-]+$` + - PostgreSQL usernames: `^[a-zA-Z_][a-zA-Z0-9_]{0,62}$` (max 63 chars, alphanumeric + underscore) - **Pydantic models** - No ad-hoc parsing of config - **Ruff S* rules** - Security linting enabled in make check @@ -169,12 +171,13 @@ Follow [Conventional Commits](https://www.conventionalcommits.org/): ## File Structure **Max file size**: 500 LOC (split if larger) -- `main.py`: CLI commands and orchestration (455 LOC) -- `installers.py`: Installation strategies (283 LOC) -- `utils.py`: Config, platform detection, helpers (275 LOC) -- `concurrency.py`: Task runner with progress (61 LOC) -- `exceptions.py`: Custom exception classes (39 LOC) -- `tests/`: pytest unit tests (613 LOC total, 69% coverage) +- `main.py`: CLI commands and orchestration (473+ LOC) +- `installers.py`: Installation strategies (282 LOC) +- `postgres.py`: PostgreSQL user management (191 LOC) +- `utils.py`: Config, platform detection, helpers (264 LOC) +- `concurrency.py`: Task runner with progress (60 LOC) +- `exceptions.py`: Custom exception classes (38 LOC) +- `tests/`: pytest unit tests for all modules **Imports in each module**: - No circular imports diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 80a61aa..7eb2fca 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -6,24 +6,23 @@ Technical overview of the `trobz_local` codebase structure, implementation patte | Metric | Value | |---|---| -| **Language** | Python 3.10+ | -| **Core LOC** | 1,109 lines (production code across 6 files) | -| **Test LOC** | 613 lines (4 test files, 69% coverage, 20 passing tests) | -| **Core Modules** | 6 files: main (455), installers (282), utils (274), concurrency (60), exceptions (38), __init__ (0) | -| **Test Modules** | test_pull_repos (211), test_install_tools (253), test_create_venvs (123), test_utils (26) | +| **Language** | Python 3.12+ | +| **Total LOC** | ~1,287 lines (core logic) + tests | +| **Core Modules** | 7 files (main, installers, utils, postgres, concurrency, exceptions, \__init\_\_) | +| **Test Modules** | tests/ directory with pytest unit tests | | **Primary Frameworks** | Typer (CLI), Pydantic (validation), Rich (UI), GitPython (git) | | **Concurrency Model** | ThreadPoolExecutor, max 4 workers, I/O-bound tasks | | **License** | AGPL-3.0 | ## Module Breakdown -### `main.py` (455 LOC) +### `main.py` (473+ LOC) **Purpose**: CLI entry point and command orchestration **Responsibilities**: -- Define Typer application with 4 main commands +- Define Typer application with 5 main commands - Manage "newcomer mode" state (interactive confirmations) -- Orchestrate calls to installers, git operations, and venv creation +- Orchestrate calls to installers, git operations, venv creation, and PostgreSQL user management - Handle user interaction and progress reporting **Key Commands**: @@ -31,6 +30,7 @@ Technical overview of the `trobz_local` codebase structure, implementation patte - `pull-repos`: Clone/update repositories - `create-venvs`: Create Python virtual environments - `install-tools`: Install from four sources +- `ensure-db-user`: Verify/create PostgreSQL user **Key Functions**: - `main()` - Typer callback and default behavior @@ -39,6 +39,7 @@ Technical overview of the `trobz_local` codebase structure, implementation patte - `_create_venvs()` - venv creation worker via odoo-venv - `_run_installers()` - Orchestrate four-stage installer pipeline - `_build_install_message()` - Format preview message for tools +- `ensure_db_user()` - PostgreSQL user management orchestrator --- @@ -127,6 +128,33 @@ class TaskResult: --- +### `postgres.py` (191 LOC) +**Purpose**: PostgreSQL user management for Odoo development + +**Responsibilities**: +- OS-aware PostgreSQL operations (Linux sudo vs macOS direct) +- Validate PostgreSQL identifiers and credentials +- Check PostgreSQL availability and user existence +- Create PostgreSQL users with CREATEDB permission +- Test database connections with created credentials + +**Key Functions**: +- `_get_psql_base_cmd(system)` - Get OS-specific psql command prefix +- `validate_username(username)` - Validate PostgreSQL identifier format +- `validate_password(password)` - Validate password non-empty +- `check_postgres_running()` - Test PostgreSQL availability via pg_isready +- `check_user_exists(username, system)` - Check if user exists in PostgreSQL +- `create_user(username, password, system)` - Create user with CREATEDB, returns (success, error_msg) +- `verify_connection(host, user, password)` - Test connection with credentials + +**Security Pattern**: +- Input validation: Regex-validated usernames (max 63 chars, alphanumeric + underscore) +- SQL injection prevention: psql variable binding (`:\"varname\"` for identifiers, `:'varname'` for strings) +- Environment isolation: Only PGPASSWORD env var passed during connection test +- No shell=True: All subprocess calls use argument lists (noqa: S603) + +--- + ## Architecture Patterns | Pattern | Implementation | Purpose | diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index e42a00c..2089391 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -58,18 +58,29 @@ Creates Odoo-specific environments: - Parallelized creation for multiple versions - Preset: demo, Python: 3.12 -### 5. Interactive Mode +### 5. PostgreSQL User Management (`ensure-db-user`) +Verify or create PostgreSQL user for Odoo development: +- Checks PostgreSQL availability via `pg_isready` +- Verifies "odoo" user exists with CREATEDB permission +- Creates user if missing (hardcoded credentials: odoo/odoo for dev-only) +- OS-aware execution (Linux: sudo -u postgres | macOS: direct access) +- Connection testing with created credentials +- Security: Input validation, SQL injection prevention via psql variable binding + +### 6. Interactive Mode **Newcomer Mode** (default enabled, can disable with `--newcomer=false`): - Confirmation prompts before operations - Detailed messages explaining actions - Helps new developers understand workflow - Can be disabled via flag or `NEWCOMER_MODE` environment variable -### 6. Security +### 7. Security - HTTPS-only enforcement for script downloads - No shell injection vulnerabilities (no shell=True) - Subprocess safety with explicit executable paths - Configuration validation via Pydantic +- SQL injection prevention via psql variable binding (ensure-db-user) +- Input validation for PostgreSQL identifiers (usernames) ## Functional Requirements @@ -79,8 +90,9 @@ Creates Odoo-specific environments: | **FR-2: Repository Operations** | Clone repos with `depth=1`, update via fetch+reset, support parallelization, allow name filtering | | **FR-3: Virtual Environments** | Create venvs for each Odoo version using `odoo-venv`, support parallel creation | | **FR-4: Tool Installation** | Four-stage pipeline: scripts → system packages → npm → uv tools; OS-aware package managers | -| **FR-5: Configuration** | TOML config at `{CODE_ROOT}/config.toml` (default: `~/code/config.toml`), strict Pydantic validation, clear error messages with examples | -| **FR-6: User Interaction** | Interactive "newcomer mode", dry-run preview, rich console UI (progress bars, trees, colors) | +| **FR-5: PostgreSQL User** | Verify/create PostgreSQL "odoo" user with CREATEDB permission; OS-aware execution (Linux sudo, macOS direct); connection validation | +| **FR-6: Configuration** | TOML config at `{CODE_ROOT}/config.toml` (default: `~/code/config.toml`), strict Pydantic validation, clear error messages with examples | +| **FR-7: User Interaction** | Interactive "newcomer mode", dry-run preview, rich console UI (progress bars, trees, colors) | ## Non-Functional Requirements @@ -294,6 +306,40 @@ Install tools from four sources in order: scripts, system packages, npm, uv. --- +### `tlc ensure-db-user` +Verify or create PostgreSQL user "odoo" for Odoo development. + +**Usage**: `tlc ensure-db-user` + +**Behavior**: +- Checks if PostgreSQL is running on localhost via `pg_isready` +- Verifies PostgreSQL user "odoo" exists +- Creates user with CREATEDB permission if missing (hardcoded dev credentials) +- Tests connection with created user +- OS-aware execution: + - **Linux**: Executes as `postgres` user via `sudo -n -u postgres` + - **macOS**: Direct execution (PostgreSQL via Homebrew runs as current user) + +**Security**: +- Input validation for PostgreSQL identifiers (max 63 chars, alphanumeric + underscore) +- SQL injection prevention via psql variable binding (`:\"username\"` for identifiers, `:'password'` for values) +- Secure environment variable handling for credentials + +**Exit Codes**: +- `0` - User ready for Odoo development +- `1` - PostgreSQL not running +- `2` - Sudo authentication failed (Linux) +- `3` - User creation failed or connection test failed + +**Prerequisites**: +- PostgreSQL server running on localhost +- `pg_isready` and `psql` installed +- On Linux: user must have passwordless sudo access to postgres user + +**Security Warning**: Uses hardcoded dev-only credentials (odoo:odoo). Never use in production. + +--- + ### Global Options **`--newcomer / --no-newcomer`** (all commands) diff --git a/tests/test_ensure_db_user.py b/tests/test_ensure_db_user.py new file mode 100644 index 0000000..b2a76c4 --- /dev/null +++ b/tests/test_ensure_db_user.py @@ -0,0 +1,335 @@ +"""Tests for ensure-db-user command and postgres module.""" + +import subprocess +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from trobz_local.main import app +from trobz_local.postgres import ( + check_postgres_running, + check_user_exists, + create_user, + validate_password, + validate_username, + verify_connection, +) + +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def mock_typer_confirm(): + with patch("typer.confirm", return_value=True) as mock_tc: + yield mock_tc + + +# ============================================================================= +# Validation Tests +# ============================================================================= + + +@pytest.mark.parametrize("username", ["odoo", "test_user", "_user", "a" * 63]) +def test_validate_username_valid(username): + assert validate_username(username) is True + + +@pytest.mark.parametrize("username", ["", "123abc", "a" * 64, None, "user-name", "user.name"]) +def test_validate_username_invalid(username): + assert validate_username(username) is False + + +def test_validate_password_valid(): + assert validate_password("odoo") is True + assert validate_password("complex_password_123") is True + + +@pytest.mark.parametrize("password", ["", None]) +def test_validate_password_invalid(password): + assert validate_password(password) is False + + +# ============================================================================= +# check_postgres_running Tests +# ============================================================================= + + +@patch("trobz_local.postgres.shutil.which") +@patch("trobz_local.postgres.subprocess.run") +def test_pg_running_success(mock_run, mock_which): + mock_which.return_value = "/usr/bin/pg_isready" + mock_run.return_value = MagicMock(returncode=0) + assert check_postgres_running() is True + + +@patch("trobz_local.postgres.shutil.which") +@patch("trobz_local.postgres.subprocess.run") +def test_pg_running_not_running(mock_run, mock_which): + mock_which.return_value = "/usr/bin/pg_isready" + mock_run.return_value = MagicMock(returncode=2) + assert check_postgres_running() is False + + +@patch("trobz_local.postgres.shutil.which") +@patch("trobz_local.postgres.subprocess.run") +def test_pg_running_timeout(mock_run, mock_which): + mock_which.return_value = "/usr/bin/pg_isready" + mock_run.side_effect = subprocess.TimeoutExpired("pg_isready", 5) + assert check_postgres_running() is False + + +@patch("trobz_local.postgres.shutil.which") +def test_pg_running_not_installed(mock_which): + mock_which.return_value = None + assert check_postgres_running() is False + + +# ============================================================================= +# check_user_exists Tests +# ============================================================================= + + +@patch("trobz_local.postgres.subprocess.run") +def test_user_exists_linux(mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="1\n") + assert check_user_exists("odoo", "Linux") is True + # Verify sudo command was used + args = mock_run.call_args[0][0] + assert args[0:3] == ["sudo", "-n", "-u"] + + +@patch("trobz_local.postgres.shutil.which") +@patch("trobz_local.postgres.subprocess.run") +def test_user_exists_macos(mock_run, mock_which): + mock_which.return_value = "/usr/local/bin/psql" + mock_run.return_value = MagicMock(returncode=0, stdout="1\n") + assert check_user_exists("odoo", "Darwin") is True + # Verify direct psql command was used (no sudo) + args = mock_run.call_args[0][0] + assert "sudo" not in args + + +@patch("trobz_local.postgres.subprocess.run") +def test_user_not_exists(mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="") + assert check_user_exists("odoo", "Linux") is False + + +def test_user_exists_invalid_username(): + # Should return False without making subprocess call + assert check_user_exists("", "Linux") is False + assert check_user_exists("123invalid", "Linux") is False + + +# ============================================================================= +# create_user Tests +# ============================================================================= + + +@patch("trobz_local.postgres.subprocess.run") +def test_create_user_linux_success(mock_run): + mock_run.return_value = MagicMock(returncode=0, stderr="") + success, error = create_user("odoo", "odoo", "Linux") + assert success is True + assert error == "" + # Verify sudo command + args = mock_run.call_args[0][0] + assert args[0:3] == ["sudo", "-n", "-u"] + + +@patch("trobz_local.postgres.shutil.which") +@patch("trobz_local.postgres.subprocess.run") +def test_create_user_macos_success(mock_run, mock_which): + mock_which.return_value = "/usr/local/bin/psql" + mock_run.return_value = MagicMock(returncode=0, stderr="") + success, error = create_user("odoo", "odoo", "Darwin") + assert success is True + assert error == "" + # Verify no sudo + args = mock_run.call_args[0][0] + assert "sudo" not in args + + +@patch("trobz_local.postgres.subprocess.run") +def test_create_user_failure(mock_run): + mock_run.return_value = MagicMock(returncode=1, stderr="ERROR: permission denied") + success, error = create_user("odoo", "odoo", "Linux") + assert success is False + assert "permission denied" in error + + +def test_create_user_invalid_input(): + success, error = create_user("", "odoo", "Linux") + assert success is False + assert "Invalid" in error + + success, error = create_user("odoo", "", "Linux") + assert success is False + assert "Invalid" in error + + +@patch("trobz_local.postgres.subprocess.run") +def test_create_user_timeout(mock_run): + mock_run.side_effect = subprocess.TimeoutExpired("psql", 10) + success, error = create_user("odoo", "odoo", "Linux") + assert success is False + assert "timed out" in error + + +# ============================================================================= +# verify_connection Tests +# ============================================================================= + + +@patch("trobz_local.postgres.shutil.which") +@patch("trobz_local.postgres.subprocess.run") +def test_verify_connection_success(mock_run, mock_which): + mock_which.return_value = "/usr/local/bin/psql" + mock_run.return_value = MagicMock(returncode=0) + assert verify_connection("localhost", "odoo", "odoo") is True + # Verify PGPASSWORD was set in env + env = mock_run.call_args[1]["env"] + assert "PGPASSWORD" in env + assert env["PGPASSWORD"] == "odoo" + + +@patch("trobz_local.postgres.shutil.which") +@patch("trobz_local.postgres.subprocess.run") +def test_verify_connection_failure(mock_run, mock_which): + mock_which.return_value = "/usr/local/bin/psql" + mock_run.return_value = MagicMock(returncode=2) + assert verify_connection("localhost", "odoo", "odoo") is False + + +@patch("trobz_local.postgres.shutil.which") +def test_verify_connection_psql_not_found(mock_which): + mock_which.return_value = None + assert verify_connection("localhost", "odoo", "odoo") is False + + +# ============================================================================= +# CLI Integration Tests +# ============================================================================= + + +@patch("trobz_local.main.get_os_info") +@patch("trobz_local.main.check_postgres_running") +def test_ensure_db_user_pg_not_running(mock_check_pg, mock_get_os): + mock_get_os.return_value = {"system": "Linux"} + mock_check_pg.return_value = False + + result = runner.invoke(app, ["--no-newcomer", "ensure-db-user"]) + + assert result.exit_code == 1 + assert "PostgreSQL is not running" in result.stdout + + +@patch("trobz_local.main.get_os_info") +@patch("trobz_local.main.check_postgres_running") +def test_ensure_db_user_pg_not_running_macos(mock_check_pg, mock_get_os): + mock_get_os.return_value = {"system": "Darwin"} + mock_check_pg.return_value = False + + result = runner.invoke(app, ["--no-newcomer", "ensure-db-user"]) + + assert result.exit_code == 1 + assert "brew services start postgresql" in result.stdout + + +@patch("trobz_local.main.get_os_info") +@patch("trobz_local.main.check_postgres_running") +def test_ensure_db_user_pg_not_running_linux(mock_check_pg, mock_get_os): + mock_get_os.return_value = {"system": "Linux"} + mock_check_pg.return_value = False + + result = runner.invoke(app, ["--no-newcomer", "ensure-db-user"]) + + assert result.exit_code == 1 + assert "sudo systemctl start postgresql" in result.stdout + + +@patch("trobz_local.main.get_os_info") +@patch("trobz_local.main.verify_connection") +@patch("trobz_local.main.check_user_exists") +@patch("trobz_local.main.check_postgres_running") +def test_ensure_db_user_user_exists(mock_check_pg, mock_check_user, mock_test_conn, mock_get_os): + mock_get_os.return_value = {"system": "Linux"} + mock_check_pg.return_value = True + mock_check_user.return_value = True + mock_test_conn.return_value = True + + result = runner.invoke(app, ["--no-newcomer", "ensure-db-user"]) + + assert result.exit_code == 0 + assert "already exists" in result.stdout + assert "Connection successful" in result.stdout + + +@patch("trobz_local.main.get_os_info") +@patch("trobz_local.main.verify_connection") +@patch("trobz_local.main.create_user") +@patch("trobz_local.main.check_user_exists") +@patch("trobz_local.main.check_postgres_running") +def test_ensure_db_user_create_success(mock_check_pg, mock_check_user, mock_create, mock_test_conn, mock_get_os): + mock_get_os.return_value = {"system": "Linux"} + mock_check_pg.return_value = True + mock_check_user.return_value = False + mock_create.return_value = (True, "") + mock_test_conn.return_value = True + + result = runner.invoke(app, ["--no-newcomer", "ensure-db-user"]) + + assert result.exit_code == 0 + assert "created successfully" in result.stdout + + +@patch("trobz_local.main.get_os_info") +@patch("trobz_local.main.create_user") +@patch("trobz_local.main.check_user_exists") +@patch("trobz_local.main.check_postgres_running") +def test_ensure_db_user_create_failure(mock_check_pg, mock_check_user, mock_create, mock_get_os): + mock_get_os.return_value = {"system": "Linux"} + mock_check_pg.return_value = True + mock_check_user.return_value = False + mock_create.return_value = (False, "permission denied") + + result = runner.invoke(app, ["--no-newcomer", "ensure-db-user"]) + + assert result.exit_code == 1 + assert "Failed to create user" in result.stdout + + +@patch("trobz_local.main.get_os_info") +@patch("trobz_local.main.create_user") +@patch("trobz_local.main.check_user_exists") +@patch("trobz_local.main.check_postgres_running") +def test_ensure_db_user_create_failure_linux_sudo(mock_check_pg, mock_check_user, mock_create, mock_get_os): + mock_get_os.return_value = {"system": "Linux"} + mock_check_pg.return_value = True + mock_check_user.return_value = False + mock_create.return_value = (False, "sudo: no tty present") + + result = runner.invoke(app, ["--no-newcomer", "ensure-db-user"]) + + assert result.exit_code == 1 + assert "Manual instructions" in result.stdout + assert "sudo -u postgres createuser" in result.stdout + + +@patch("trobz_local.main.get_os_info") +@patch("trobz_local.main.verify_connection") +@patch("trobz_local.main.create_user") +@patch("trobz_local.main.check_user_exists") +@patch("trobz_local.main.check_postgres_running") +def test_ensure_db_user_connection_failure(mock_check_pg, mock_check_user, mock_create, mock_test_conn, mock_get_os): + mock_get_os.return_value = {"system": "Linux"} + mock_check_pg.return_value = True + mock_check_user.return_value = False + mock_create.return_value = (True, "") + mock_test_conn.return_value = False + + result = runner.invoke(app, ["--no-newcomer", "ensure-db-user"]) + + assert result.exit_code == 1 + assert "Connection test failed" in result.stdout diff --git a/trobz_local/main.py b/trobz_local/main.py index ce91703..980e01a 100644 --- a/trobz_local/main.py +++ b/trobz_local/main.py @@ -15,11 +15,18 @@ install_system_packages, install_uv_tools, ) +from .postgres import ( + check_postgres_running, + check_user_exists, + create_user, + verify_connection, +) from .utils import ( GitProgress, confirm_step, get_code_root, get_config, + get_os_info, get_uv_path, ) @@ -261,7 +268,7 @@ def _build_install_message(tools_config: dict) -> str: msg += f" - {pkg}\n" if tools_config.get("npm"): - msg += "\n[3] NPM packages (via pnpm -g):\n" + msg += "\n[3] NPM packages (via npm -g):\n" for pkg in tools_config["npm"]: msg += f" - {pkg}\n" @@ -450,3 +457,62 @@ def _create_venvs( except Exception as e: progress.update(task_id, description=f"[red]✗ Error venv {version}: {e}") raise + + +@app.command() +def ensure_db_user(ctx: typer.Context): + """Ensure PostgreSQL user exists for Odoo development.""" + username = "odoo" + password = "odoo" # noqa: S105 + host = "localhost" + + confirm_step( + ctx, + "This command will verify/create PostgreSQL user 'odoo' with CREATEDB permission.\n" + "Credentials: odoo:odoo (dev-only, never use in production)", + "ensure-db-user", + ) + + os_info = get_os_info() + system = os_info["system"] + + # Check PostgreSQL is running + typer.echo("Checking PostgreSQL status...") + if not check_postgres_running(): + typer.secho("✗ PostgreSQL is not running on localhost", fg=typer.colors.RED) + if system == "Darwin": + typer.echo("Try: brew services start postgresql") + elif system == "Linux": + typer.echo("Try: sudo systemctl start postgresql") + raise typer.Exit(code=1) + typer.secho("✓ PostgreSQL is running", fg=typer.colors.GREEN) + + # Check if user exists + typer.echo(f"Checking if user '{username}' exists...") + if check_user_exists(username, system): + typer.secho(f"✓ User '{username}' already exists", fg=typer.colors.GREEN) + else: + typer.echo(f"User '{username}' not found, creating...") + success, error_msg = create_user(username, password, system) + if not success: + typer.secho(f"✗ Failed to create user '{username}'", fg=typer.colors.RED) + if system == "Linux" and "sudo" in error_msg.lower(): + typer.echo("Manual instructions:") + typer.echo(" sudo -u postgres createuser -s odoo") + typer.echo(" sudo -u postgres psql -c \"ALTER USER odoo WITH PASSWORD 'odoo';\"") + else: + typer.echo(f"Error: {error_msg}") + raise typer.Exit(code=1) + typer.secho(f"✓ User '{username}' created successfully", fg=typer.colors.GREEN) + + # Test connection + typer.echo("Testing connection...") + if not verify_connection(host, username, password): + typer.secho("✗ Connection test failed", fg=typer.colors.RED) + raise typer.Exit(code=1) + typer.secho("✓ Connection successful", fg=typer.colors.GREEN) + + typer.echo() + typer.secho(f"✓ PostgreSQL user '{username}' is ready for Odoo development", fg=typer.colors.GREEN) + typer.echo() + typer.secho("⚠️ WARNING: Using dev-only credentials (odoo:odoo). Never use in production!", fg=typer.colors.YELLOW) diff --git a/trobz_local/postgres.py b/trobz_local/postgres.py new file mode 100644 index 0000000..118c0e3 --- /dev/null +++ b/trobz_local/postgres.py @@ -0,0 +1,173 @@ +"""PostgreSQL user management functions for Odoo development. + +OS-aware utilities for verifying and creating PostgreSQL users with CREATEDB permission. +Supports Linux (sudo -u postgres) and macOS (Homebrew direct access). +""" + +import re +import shutil +import subprocess + +# PostgreSQL identifier: alphanumeric and underscore, max 63 chars +_PG_USERNAME_REGEX = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,62}$") + + +def _get_psql_base_cmd(system: str) -> list[str]: + """Get OS-specific base psql command. + + Args: + system: OS name from platform.system() ("Darwin" or "Linux") + + Returns: + Base command list for psql execution + + Raises: + ValueError: If system is not supported + FileNotFoundError: If psql is not installed + """ + if system == "Darwin": + # macOS: Homebrew PostgreSQL runs as current user + psql_path = shutil.which("psql") + if not psql_path: + msg = "psql not found - install PostgreSQL via Homebrew" + raise FileNotFoundError(msg) + return [psql_path, "-h", "localhost", "-d", "postgres"] + if system == "Linux": + # Linux: Use sudo to run as postgres user + return ["sudo", "-n", "-u", "postgres", "psql"] + msg = f"Unsupported OS: {system}" + raise ValueError(msg) + + +def validate_username(username: str) -> bool: + """Validate PostgreSQL username format. + + Args: + username: Username to validate + + Returns: + True if valid PostgreSQL identifier + """ + if not username or not isinstance(username, str): + return False + return bool(_PG_USERNAME_REGEX.match(username)) + + +def validate_password(password: str) -> bool: + """Validate password is not empty. + + Args: + password: Password to validate + + Returns: + True if password is non-empty string + """ + return bool(password and isinstance(password, str)) + + +def check_postgres_running() -> bool: + """Check if PostgreSQL is running using pg_isready. + + Returns: + True if PostgreSQL is accepting connections on localhost + """ + try: + pg_isready = shutil.which("pg_isready") + if not pg_isready: + return False + result = subprocess.run( # noqa: S603 + [pg_isready, "-h", "localhost"], capture_output=True, timeout=5 + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + else: + return result.returncode == 0 + + +def check_user_exists(username: str, system: str) -> bool: + """Check if PostgreSQL user exists. + + Args: + username: Username to check + system: OS name from platform.system() + + Returns: + True if user exists + """ + if not validate_username(username): + return False + try: + base_cmd = _get_psql_base_cmd(system) + query = "SELECT 1 FROM pg_roles WHERE rolname = :'username'" + cmd = [*base_cmd, "-v", f"username={username}", "-tA", "-f", "-"] + result = subprocess.run( # noqa: S603 + cmd, capture_output=True, text=True, timeout=5, input=query + ) + return result.returncode == 0 and result.stdout.strip() == "1" + except (subprocess.TimeoutExpired, ValueError, FileNotFoundError): + return False + + +def create_user(username: str, password: str, system: str) -> tuple[bool, str]: + """Create PostgreSQL user with CREATEDB permission. + + Args: + username: Username to create + password: Password for the user + system: OS name from platform.system() + + Returns: + Tuple of (success, error_message) + """ + if not validate_username(username) or not validate_password(password): + return False, "Invalid username or password format" + + try: + base_cmd = _get_psql_base_cmd(system) + # Use psql variable binding to prevent SQL injection + # :"varname" for identifier, :'varname' for string value + # Use -f - (stdin) because -c doesn't support variable interpolation + sql = "CREATE USER :\"username\" WITH PASSWORD :'password' CREATEDB;" + cmd = [*base_cmd, "-v", f"username={username}", "-v", f"password={password}", "-f", "-"] + result = subprocess.run( # noqa: S603 + cmd, capture_output=True, text=True, timeout=10, input=sql + ) + except subprocess.TimeoutExpired: + return False, "Command timed out" + except ValueError as e: + return False, str(e) + except FileNotFoundError: + return False, "psql not found" + else: + if result.returncode != 0: + return False, result.stderr + return True, "" + + +def verify_connection(host: str, user: str, password: str) -> bool: + """Test PostgreSQL connection with provided credentials. + + Args: + host: Database host + user: Username + password: Password + + Returns: + True if connection successful + """ + try: + psql_path = shutil.which("psql") + if not psql_path: + return False + # Only pass PGPASSWORD, no other environment variables + env = {"PGPASSWORD": password} + result = subprocess.run( # noqa: S603 + [psql_path, "-h", host, "-U", user, "-d", "postgres", "-c", "SELECT 1;"], + capture_output=True, + env=env, + timeout=5, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + else: + return result.returncode == 0 From a408d99c483dd3d1aef7348d31f6316dd12fa737 Mon Sep 17 00:00:00 2001 From: trisdoan Date: Thu, 29 Jan 2026 18:12:21 +0700 Subject: [PATCH 5/7] refactor(install): improve bootstrap and installer architecture - Move PostgreSQL repo setup from bootstrap.sh to Python installers module - Add odoo_minimal.toml for bootstrap initial setup - Replace pnpm references with npm for better compatibility - Improve error handling with graceful degradation for missing tools - Update .gitignore to exclude .claude/ and .opencode/ directories - Simplify bootstrap.sh by removing inline PostgreSQL setup logic Breaking changes: - NPM packages installed via npm instead of pnpm by default --- .gitignore | 1 - assets/odoo_minimal.toml | 10 +++ bootstrap.sh | 55 +++++++-------- docs/code-standards.md | 11 +-- docs/codebase-summary.md | 30 ++++---- docs/project-overview-pdr.md | 39 ++++++++--- docs/system-architecture.md | 89 ++++++++++++++++++++++-- tests/test_install_tools.py | 17 ++--- trobz_local/installers.py | 129 ++++++++++++++++++++++++++++++++--- trobz_local/main.py | 5 ++ trobz_local/utils.py | 11 +-- 11 files changed, 311 insertions(+), 86 deletions(-) create mode 100644 assets/odoo_minimal.toml diff --git a/.gitignore b/.gitignore index 6679fc3..a98857a 100644 --- a/.gitignore +++ b/.gitignore @@ -219,7 +219,6 @@ __marimo__/ plans/**/* !plans/templates/* repomix-output.xml -AGENTS.md .claude/ .opencode/ release-manifest.json diff --git a/assets/odoo_minimal.toml b/assets/odoo_minimal.toml new file mode 100644 index 0000000..f20f74b --- /dev/null +++ b/assets/odoo_minimal.toml @@ -0,0 +1,10 @@ +# Minimal config to set up latest Odoo for development +# Place this file at ~/code/config.toml (or set TLC_CODE_DIR) + +versions = ["18.0"] + +[tools] +uv = ["odoo-venv", "odoo-addons-path"] + +[repos] +odoo = ["odoo"] diff --git a/bootstrap.sh b/bootstrap.sh index e1e19b2..a45acf5 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -4,7 +4,8 @@ export DEBIAN_FRONTEND=noninteractive export PATH="$HOME/.local/bin:$PATH" # Bootstrap script for trobz_local (tlc) -# Installs: uv, git, gh, and the trobz_local package +# Installs prerequisites: git, gh, uv, and trobz_local CLI +# Note: Tool installation (Odoo, PostgreSQL, etc.) is done separately via `tlc install-tools` echo "=== Bootstrap trobz_local ===" @@ -41,33 +42,6 @@ detect_os() { OS=$(detect_os) echo "Detected OS: $OS" -# Install prerequisite packages (Debian/Ubuntu only) -install_prerequisites() { - if [[ "$OS" != "debian" ]]; then - return - fi - echo "Installing prerequisite packages..." - sudo apt-get update - sudo apt-get install -y apt-utils apt-transport-https lsb-release -} - -# Add PostgreSQL APT repository (Debian/Ubuntu only) -setup_postgresql_repo() { - if [[ "$OS" != "debian" ]]; then - return - fi - if [ -f /usr/share/keyrings/postgresql-keyring.gpg ]; then - echo "PostgreSQL APT repository already configured" - return - fi - echo "Adding PostgreSQL APT repository..." - local codename - codename=$(lsb_release -cs) - curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /usr/share/keyrings/postgresql-keyring.gpg - echo "deb [arch=amd64 signed-by=/usr/share/keyrings/postgresql-keyring.gpg] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list > /dev/null - sudo apt-get update -} - # Install git install_git() { if command -v git &>/dev/null; then @@ -132,9 +106,7 @@ install_trobz_local() { echo "trobz_local installed (CLI: tlc)" } -# Main -install_prerequisites -setup_postgresql_repo +# Main execution install_git install_gh install_uv @@ -143,4 +115,23 @@ install_trobz_local echo "" echo "=== Bootstrap complete ===" -echo "Run 'tlc --help' to get started" +echo "" +echo "Prerequisites installed successfully!" +echo " ✓ git" +echo " ✓ gh (GitHub CLI)" +echo " ✓ uv" +echo " ✓ trobz_local (tlc command available)" +echo "" +echo "Next steps:" +echo "" +echo " 1. Install development tools (Odoo, PostgreSQL, etc.):" +echo " tlc install-tools" +echo "" +echo " 2. Initialize your development environment:" +echo " tlc init # Create directory structure" +echo " tlc pull-repos # Clone repositories" +echo " tlc create-venvs # Create virtual environments" +echo "" +echo " 3. Get help:" +echo " tlc --help" +echo "" diff --git a/docs/code-standards.md b/docs/code-standards.md index ec04a35..fbb1ec9 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -23,6 +23,7 @@ Four-layer modular design with clear separation of concerns: - **Line Length**: Max 120 characters (ruff.toml config) - **Import Order**: stdlib → third-party → local (ruff-managed) - **Active Rules**: YTT, S (security), B, A, C4, T10, SIM, I, C90, E, W, F, PGH, UP, RUF, TRY +- **UI Library**: Rich (progress bars, colored output, trees) ### Type Safety (Mandatory) - All function signatures must have type hints @@ -170,11 +171,11 @@ Follow [Conventional Commits](https://www.conventionalcommits.org/): ## File Structure -**Max file size**: 500 LOC (split if larger) -- `main.py`: CLI commands and orchestration (473+ LOC) -- `installers.py`: Installation strategies (282 LOC) -- `postgres.py`: PostgreSQL user management (191 LOC) -- `utils.py`: Config, platform detection, helpers (264 LOC) +**Target file size**: 500 LOC; main.py at 523 LOC is exception due to command density. +- `main.py`: CLI commands and orchestration (523 LOC - consolidates 5 command implementations) +- `installers.py`: Installation strategies (389 LOC - 5 installation strategies) +- `postgres.py`: PostgreSQL user management (173 LOC) +- `utils.py`: Config, platform detection, helpers (277 LOC) - `concurrency.py`: Task runner with progress (60 LOC) - `exceptions.py`: Custom exception classes (38 LOC) - `tests/`: pytest unit tests for all modules diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 7eb2fca..3955101 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -6,8 +6,9 @@ Technical overview of the `trobz_local` codebase structure, implementation patte | Metric | Value | |---|---| -| **Language** | Python 3.12+ | -| **Total LOC** | ~1,287 lines (core logic) + tests | +| **Version** | 0.2.0 | +| **Language** | Python 3.10+ | +| **Total LOC** | ~1,460 lines (core logic) + tests | | **Core Modules** | 7 files (main, installers, utils, postgres, concurrency, exceptions, \__init\_\_) | | **Test Modules** | tests/ directory with pytest unit tests | | **Primary Frameworks** | Typer (CLI), Pydantic (validation), Rich (UI), GitPython (git) | @@ -16,7 +17,7 @@ Technical overview of the `trobz_local` codebase structure, implementation patte ## Module Breakdown -### `main.py` (473+ LOC) +### `main.py` (523 LOC) **Purpose**: CLI entry point and command orchestration **Responsibilities**: @@ -24,6 +25,7 @@ Technical overview of the `trobz_local` codebase structure, implementation patte - Manage "newcomer mode" state (interactive confirmations) - Orchestrate calls to installers, git operations, venv creation, and PostgreSQL user management - Handle user interaction and progress reporting +- Coordinate PostgreSQL repo setup before system package installation **Key Commands**: - `init`: Create directory structure @@ -43,18 +45,20 @@ Technical overview of the `trobz_local` codebase structure, implementation patte --- -### `installers.py` (283 LOC) +### `installers.py` (389 LOC) **Purpose**: Multi-source tool installation strategies **Strategies**: -1. **Scripts**: Download via wget/curl, execute with /bin/sh -2. **System Packages**: OS-aware (apt-get, pacman, brew) with platform defaults -3. **NPM Packages**: Global via pnpm install -g -4. **UV Tools**: Global via uv tool install +1. **PostgreSQL Repository Setup**: Idempotent APT repo configuration with GPG verification (Debian/Ubuntu) +2. **Scripts**: Download via wget/curl, execute with /bin/sh +3. **System Packages**: OS-aware (apt-get, pacman, brew) with platform defaults +4. **NPM Packages**: Global via pnpm install -g +5. **UV Tools**: Global via uv tool install **Key Functions**: +- `setup_postgresql_repo()` - Configure PGDG repository with GPG verification (idempotent) - `install_scripts()` - Download and execute shell scripts with progress -- `install_system_packages()` - OS detection and package manager invocation +- `install_system_packages()` - OS detection and package manager invocation (runs after PostgreSQL repo setup) - `install_npm_packages()` - Parallel npm package installation - `install_uv_tools()` - Parallel UV tool installation @@ -66,7 +70,7 @@ Technical overview of the `trobz_local` codebase structure, implementation patte --- -### `utils.py` (275 LOC) +### `utils.py` (277 LOC) **Purpose**: Configuration validation, platform detection, utilities **Pydantic Models**: @@ -128,7 +132,7 @@ class TaskResult: --- -### `postgres.py` (191 LOC) +### `postgres.py` (173 LOC) **Purpose**: PostgreSQL user management for Odoo development **Responsibilities**: @@ -205,7 +209,9 @@ This enables flexible deployment: developers can customize the base directory vi | `rich` | Latest | Terminal UI (progress bars, colors, trees) | | `gitpython` | Latest | Programmatic git operations | | `tomllib` | Built-in (Python 3.11+) | TOML parsing for Python 3.11+ | -| `tomli` | Latest (optional) | TOML parsing fallback for Python < 3.11 | +| `tomli` | Latest | TOML parsing fallback for Python < 3.11 | + +**Note**: Rich is used extensively throughout for progress bars, colored output, and tree display. --- diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 2089391..c81387e 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -22,6 +22,23 @@ The tool uses declarative configuration: developers specify desired environment ## Core Features +### 0. Bootstrap Script (`bootstrap.sh`) +Automated installation of prerequisites before using `tlc`: +- **Dependencies**: Installs git, gh (GitHub CLI), and uv +- **SSH Setup**: Configures GitHub SSH keys in known_hosts +- **Installation**: Installs `trobz_local` CLI (tlc) via uv +- **Idempotent**: Skips already-installed tools +- **OS-aware**: Supports macOS (brew), Debian/Ubuntu (apt), Fedora (dnf), Arch (pacman) + +**Usage**: +```bash +curl -fsSL https://raw.githubusercontent.com/trobz/local.py/main/bootstrap.sh | sh +``` + +After bootstrap, use `tlc` commands to complete environment setup. + +--- + ### 1. Environment Initialization (`init`) Creates standardized directory structure (default: `~/code/`): ``` @@ -46,11 +63,12 @@ Clones or updates Odoo and OCA repositories: - **Operations**: Clone new repos, fetch and hard-reset existing ones ### 3. Tool Installation (`install-tools`) -Four-stage installation pipeline: -1. **Shell Scripts**: Download and execute scripts (e.g., uv installer) -2. **System Packages**: OS-aware installation via apt/pacman/brew -3. **NPM Packages**: Global packages via pnpm -4. **UV Tools**: Python tools via uv tool install +Five-stage installation pipeline: +1. **PostgreSQL Repository** (Debian/Ubuntu only): Setup PGDG APT repository with GPG verification (idempotent) +2. **Shell Scripts**: Download and execute scripts (e.g., uv installer) +3. **System Packages**: OS-aware installation via apt/pacman/brew (runs after PostgreSQL repo setup on Debian/Ubuntu) +4. **NPM Packages**: Global packages via pnpm +5. **UV Tools**: Python tools via uv tool install ### 4. Virtual Environment Management (`create-venvs`) Creates Odoo-specific environments: @@ -280,7 +298,7 @@ Create Python virtual environments for each Odoo version. --- ### `tlc install-tools` -Install tools from four sources in order: scripts, system packages, npm, uv. +Install tools from five sources in order: PostgreSQL repo, scripts, system packages, npm, uv. **Usage**: `tlc install-tools [OPTIONS]` @@ -289,10 +307,11 @@ Install tools from four sources in order: scripts, system packages, npm, uv. - `--newcomer / --no-newcomer`: Enable interactive mode (default: True) **Execution Order**: -1. **Scripts**: Download and execute via `wget` or `curl`, then `sh` -2. **System Packages**: OS-aware installation (apt/pacman/brew) -3. **NPM Packages**: Global installation via `pnpm install -g` -4. **UV Tools**: Global installation via `uv tool install` +1. **PostgreSQL Repository** (Debian/Ubuntu): Setup PGDG APT repository with GPG verification +2. **Scripts**: Download and execute via `wget` or `curl`, then `sh` +3. **System Packages**: OS-aware installation (apt/pacman/brew) - runs after PostgreSQL repo on Debian/Ubuntu +4. **NPM Packages**: Global installation via `pnpm install -g` +5. **UV Tools**: Global installation via `uv tool install` **Behavior**: - Reads `[tools]` section from `{CODE_ROOT}/config.toml` (default: `~/code/config.toml`) diff --git a/docs/system-architecture.md b/docs/system-architecture.md index df11561..a524f61 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -62,6 +62,43 @@ High-level design and component interactions in `trobz_local`. ## Command Flow Diagrams +### `bootstrap.sh` (Prerequisites Installation) +``` +User runs bootstrap.sh + │ + ├─ Check sudo privileges + │ └─ Verify user can run sudo without password + │ + ├─ Detect OS + │ ├─ Debian/Ubuntu: apt/dpkg detection + │ ├─ Fedora: dnf detection + │ ├─ Arch: pacman detection + │ └─ macOS: brew detection + │ + ├─ Install git (skip if already installed) + │ └─ OS-specific package manager + │ + ├─ Install gh (GitHub CLI, skip if already installed) + │ ├─ Debian: Add GitHub official APT repository with GPG verification + │ ├─ Fedora/Arch: Package manager + │ └─ macOS: brew + │ + ├─ Install uv (skip if already installed) + │ └─ curl -LsSf https://astral.sh/uv/install.sh | sh + │ + ├─ Setup SSH known_hosts for GitHub + │ ├─ Create ~/.ssh directory (700 permissions) + │ └─ Add github.com to known_hosts via ssh-keyscan + │ + ├─ Install trobz_local (tlc) + │ └─ uv tool install git+https://github.com/trobz/local.py.git + │ + └─ Display completion message with next steps + └─ Recommend: tlc install-tools, tlc init, tlc pull-repos, tlc create-venvs +``` + +--- + ### `tlc init` Flow ``` init command @@ -119,6 +156,7 @@ install-tools command ├─ Validate config │ ├─ Build installation preview: + │ - PostgreSQL repo (Debian/Ubuntu only) │ - Scripts: list URLs │ - System packages: list packages │ - NPM packages: list packages @@ -127,25 +165,31 @@ install-tools command ├─ Show confirmation (newcomer mode) │ └─ If --dry-run: show preview, exit 0 │ - ├─ Execute four installers in sequence: - │ 1. install_scripts() + ├─ Execute five installers in sequence: + │ 1. setup_postgresql_repo() [Debian/Ubuntu only, idempotent] + │ ├─ Check if PGDG repo already configured + │ ├─ If missing, add PGDG APT repository + │ ├─ Download and verify GPG key + │ └─ Update apt sources + │ + │ 2. install_scripts() │ ├─ Create temp directory │ ├─ For each script: │ │ ├─ Download via wget or curl │ │ └─ Execute with /bin/sh │ └─ Clean up temp directory │ - │ 2. install_system_packages() + │ 3. install_system_packages() │ ├─ Detect OS (Arch, Ubuntu, macOS) │ ├─ Merge user packages with platform defaults │ ├─ Run package manager with sudo │ └─ Return success/failure boolean │ - │ 3. install_npm_packages() + │ 4. install_npm_packages() │ ├─ Check if pnpm exists │ └─ Parallel: run_tasks() with pnpm install -g │ - │ 4. install_uv_tools() + │ 5. install_uv_tools() │ └─ Parallel: run_tasks() with uv tool install │ ├─ Aggregate all results @@ -178,6 +222,41 @@ create-venvs command --- +### `tlc ensure-db-user` Flow +``` +ensure-db-user command + │ + ├─ Check PostgreSQL availability + │ └─ Run pg_isready on localhost + │ └─ If failed: print error message, exit 1 + │ + ├─ Detect OS (Darwin vs Linux) + │ └─ Set execution method: + │ ├─ macOS (Darwin): Direct psql execution + │ └─ Linux: sudo -n -u postgres psql (requires passwordless sudo) + │ + ├─ Check if PostgreSQL user "odoo" exists + │ └─ Query system catalog via psql + │ + ├─ If user missing: + │ ├─ Create user "odoo" with hardcoded dev password + │ ├─ Grant CREATEDB privilege + │ └─ Validate user creation via psql query + │ + ├─ Test connection with created credentials + │ ├─ Connect as "odoo" user to postgres database + │ ├─ If successful: print ✓ message + │ └─ If failed: print error, exit 3 + │ + └─ Exit with appropriate code: + ├─ 0 = User ready + ├─ 1 = PostgreSQL not running + ├─ 2 = Sudo auth failed (Linux) + └─ 3 = User creation/connection failed +``` + +--- + ## Component Interaction Details ### Configuration Pipeline diff --git a/tests/test_install_tools.py b/tests/test_install_tools.py index d9f39e1..93367de 100644 --- a/tests/test_install_tools.py +++ b/tests/test_install_tools.py @@ -23,10 +23,11 @@ def mock_typer_confirm(): # ============================================================================= +@patch("trobz_local.main.setup_postgresql_repo", return_value=True) @patch("trobz_local.installers.shutil.which") @patch("trobz_local.main.get_config") @patch("trobz_local.installers.subprocess.run") -def test_install_uv_tools(mock_subprocess, mock_get_config, mock_which): +def test_install_uv_tools(mock_subprocess, mock_get_config, mock_which, _mock_pg): mock_which.return_value = "/usr/bin/uv" mock_get_config.return_value = { "tools": { @@ -102,7 +103,7 @@ def test_install_tools_dry_run(mock_get_config, mock_which): # Verify dry-run output contains all categories assert "Scripts - would be downloaded" in result.stdout assert "System packages - would be installed" in result.stdout - assert "NPM packages - would be installed" in result.stdout + assert "NPM packages - would be installed globally via npm" in result.stdout assert "UV tools - would be installed" in result.stdout @@ -114,9 +115,8 @@ def test_install_tools_dry_run(mock_get_config, mock_which): @patch("trobz_local.installers.shutil.which") @patch("trobz_local.main.get_config") @patch("trobz_local.installers.subprocess.run") -def test_install_npm_packages_pnpm_missing(mock_subprocess, mock_get_config, mock_which): - # pnpm not found - mock_which.side_effect = lambda cmd: None if cmd == "pnpm" else "/usr/bin/uv" +def test_install_npm_packages_npm_missing(mock_subprocess, mock_get_config, mock_which): + mock_which.side_effect = lambda cmd: None if cmd == "npm" else "/usr/bin/uv" mock_get_config.return_value = { "tools": { "uv": [], @@ -129,14 +129,15 @@ def test_install_npm_packages_pnpm_missing(mock_subprocess, mock_get_config, moc result = runner.invoke(app, ["--no-newcomer", "install-tools"]) assert result.exit_code == 1 - assert "pnpm is not installed" in result.stdout + assert "npm is not installed" in result.stdout +@patch("trobz_local.main.setup_postgresql_repo", return_value=True) @patch("trobz_local.installers.shutil.which") @patch("trobz_local.main.get_config") @patch("trobz_local.installers.subprocess.run") -def test_install_npm_packages_success(mock_subprocess, mock_get_config, mock_which): - mock_which.return_value = "/usr/bin/pnpm" +def test_install_npm_packages_success(mock_subprocess, mock_get_config, mock_which, _mock_pg): + mock_which.return_value = "/usr/bin/npm" mock_get_config.return_value = { "tools": { "uv": [], diff --git a/trobz_local/installers.py b/trobz_local/installers.py index 72ec22d..26dd68a 100644 --- a/trobz_local/installers.py +++ b/trobz_local/installers.py @@ -94,6 +94,7 @@ def install_scripts(scripts: list[dict], dry_run: bool = False) -> list: - url: HTTPS URL to download - name: Optional display name dry_run: If True, only show what would be installed + """ if not scripts: return [] @@ -123,8 +124,13 @@ def install_scripts(scripts: list[dict], dry_run: bool = False) -> list: def _get_package_manager_config(system: str, distro: str) -> tuple[list[str], list[str]] | None: """Get package manager command and default packages for the OS. + Default package lists (MACOS_PACKAGES, UBUNTU_PACKAGES, ARCH_PACKAGES) + include postgresql. On Debian/Ubuntu, setup_postgresql_repo() must run + first to add the PGDG repo, otherwise apt installs the older distro version. + Returns: Tuple of (command_prefix, default_packages) or None if unsupported. + """ if system == "Darwin": if not shutil.which("brew"): @@ -191,12 +197,118 @@ def install_system_packages(packages: list[str], dry_run: bool = False) -> bool: return False -def _install_npm_package(progress: Progress, task_id: TaskID, package: str, pnpm_path: str): +def setup_postgresql_repo() -> bool: + """Setup official PGDG APT repository for Debian/Ubuntu. + + On Debian/Ubuntu, the default distro repos ship older PostgreSQL versions. + This adds the official PGDG repo so `apt-get install postgresql` pulls + the latest version. Must run before install_system_packages(). + + macOS/Arch: No-op — Homebrew and pacman already provide latest PostgreSQL, + so the package lists in _get_package_manager_config() are sufficient. + + Idempotent: Skips if keyring already exists. + + Returns: + True on success or if already configured (never fails the pipeline) + + """ + os_info = get_os_info() + system = os_info["system"] + distro = os_info["distro"] + + # macOS/Arch: no separate repo setup needed — brew/pacman handle it + if system == "Darwin" or distro not in ["debian", "ubuntu"]: + logger.debug(f"Skipping PostgreSQL repo setup (system: {system}, distro: {distro})") + return True + + # Idempotent check: skip if PGDG keyring already installed + keyring_path = Path("/usr/share/keyrings/postgresql-keyring.gpg") + if keyring_path.exists(): + typer.echo("✓ PostgreSQL APT repository already configured") + return True + + typer.secho("\n--- Setting up PostgreSQL APT Repository ---", fg=typer.colors.BLUE, bold=True) + + try: + # Get distribution codename (e.g. "jammy", "bookworm") + lsb_release_path = shutil.which("lsb_release") + if not lsb_release_path: + typer.secho("Warning: lsb_release not found, skipping PostgreSQL repo setup", fg=typer.colors.YELLOW) + return True + + result = subprocess.run([lsb_release_path, "-cs"], check=True, capture_output=True, text=True) # noqa: S603 + codename = result.stdout.strip() + + curl_path = shutil.which("curl") + if not curl_path: + typer.secho("Warning: curl not found, skipping PostgreSQL repo setup", fg=typer.colors.YELLOW) + return True + + gpg_path = shutil.which("gpg") + if not gpg_path: + typer.secho("Warning: gpg not found, skipping PostgreSQL repo setup", fg=typer.colors.YELLOW) + return True + + # Download and import the official PGDG GPG key + typer.echo("Downloading PostgreSQL GPG key...") + download_result = subprocess.run( # noqa: S603 + [curl_path, "-fsSL", "https://www.postgresql.org/media/keys/ACCC4CF8.asc"], + check=True, + capture_output=True, + text=True, + ) + + subprocess.run( # noqa: S603 + ["sudo", gpg_path, "--dearmor", "-o", str(keyring_path)], # noqa: S607 + input=download_result.stdout, + check=True, + text=True, + ) + + # Add PGDG APT source list + typer.echo("Adding PostgreSQL APT repository...") + repo_line = ( + f"deb [arch=amd64 signed-by={keyring_path}] https://apt.postgresql.org/pub/repos/apt {codename}-pgdg main" + ) + + tee_path = shutil.which("tee") + if not tee_path: + typer.secho("Warning: tee not found, skipping PostgreSQL repo setup", fg=typer.colors.YELLOW) + return True + + subprocess.run( # noqa: S603 + ["sudo", tee_path, "/etc/apt/sources.list.d/pgdg.list"], # noqa: S607 + input=repo_line, + check=True, + capture_output=True, + text=True, + ) + + # Refresh package index to include PGDG packages + typer.echo("Updating package list...") + subprocess.run(["sudo", "apt-get", "update"], check=True, capture_output=True) # noqa: S607 + + typer.secho("✓ PostgreSQL APT repository configured successfully", fg=typer.colors.GREEN) + + except subprocess.CalledProcessError as e: + typer.secho( + f"Warning: Failed to setup PostgreSQL repository: {e}\n" + "You may need to configure it manually if you need PostgreSQL.", + fg=typer.colors.YELLOW, + ) + except Exception as e: + typer.secho(f"Warning: Unexpected error during PostgreSQL repo setup: {e}", fg=typer.colors.YELLOW) + + return True # Always succeeds — never fails the install-tools pipeline + + +def _install_npm_package(progress: Progress, task_id: TaskID, package: str, npm_path: str): progress.update(task_id, description=f"Installing {package}...", total=100, completed=0) try: subprocess.run( # noqa: S603 - [pnpm_path, "install", "-g", package], + [npm_path, "install", "-g", package], check=True, capture_output=True, text=True, @@ -212,17 +324,16 @@ def install_npm_packages(packages: list[str], dry_run: bool = False) -> list: if not packages: return [] - pnpm_path = shutil.which("pnpm") - if not pnpm_path: + npm_path = shutil.which("npm") + if not npm_path: typer.secho( - "Error: pnpm is not installed.\n" - "Please add 'pnpm' to system_packages in your config.toml and rerun install-tools.", + "Error: npm is not installed. Please install Node.js first.", fg=typer.colors.RED, ) - return [TaskResult(name="pnpm-check", success=False, message="pnpm is not installed")] + return [TaskResult(name="npm-check", success=False, message="npm is not installed")] if dry_run: - typer.echo("\n[NPM packages - would be installed globally via pnpm]") + typer.echo("\n[NPM packages - would be installed globally via npm]") for pkg in packages: typer.echo(f" - {pkg}") return [] @@ -234,7 +345,7 @@ def install_npm_packages(packages: list[str], dry_run: bool = False) -> list: tasks.append({ "name": package, "func": _install_npm_package, - "args": {"package": package, "pnpm_path": pnpm_path}, + "args": {"package": package, "npm_path": npm_path}, }) return run_tasks(tasks) diff --git a/trobz_local/main.py b/trobz_local/main.py index 980e01a..a892d92 100644 --- a/trobz_local/main.py +++ b/trobz_local/main.py @@ -14,6 +14,7 @@ install_scripts, install_system_packages, install_uv_tools, + setup_postgresql_repo, ) from .postgres import ( check_postgres_running, @@ -298,6 +299,10 @@ def _run_installers(tools_config: dict, dry_run: bool) -> tuple[list, bool]: 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() + if tools_config.get("system_packages"): success = install_system_packages(tools_config["system_packages"], dry_run) if not success: diff --git a/trobz_local/utils.py b/trobz_local/utils.py index 3a64543..4603cd4 100644 --- a/trobz_local/utils.py +++ b/trobz_local/utils.py @@ -183,9 +183,7 @@ def show_config_instructions(): name = "nvm" url = "https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh" -system_packages = [ - "pnpm", -] +system_packages = [] [repos] odoo = [ @@ -205,8 +203,13 @@ def show_config_instructions(): def get_config(): - """Loads and validates configuration from code_dir/config.toml.""" + """Loads and validates configuration from default location. + + Returns: + Validated config dict + """ config_path = get_code_root() / "config.toml" + if not config_path.exists(): show_config_instructions() raise typer.Exit(code=1) From 90d2396e50fa1e97654f70273bef2b4a107b3230 Mon Sep 17 00:00:00 2001 From: trisdoan Date: Wed, 11 Feb 2026 17:21:03 +0700 Subject: [PATCH 6/7] chore: update copier and add AGENTS.md --- .copier-answers.yml | 5 +++-- AGENTS.md | 36 ++++++++++++++++++++++++++++++++++++ CLAUDE.md | 23 +---------------------- 3 files changed, 40 insertions(+), 24 deletions(-) create mode 100644 AGENTS.md mode change 100644 => 120000 CLAUDE.md diff --git a/.copier-answers.yml b/.copier-answers.yml index a271d8a..eaddb2b 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,12 +1,13 @@ # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY -_commit: v1.0.2-4-g02eefe5 -_src_path: /home/trisdoan/projects/trobz-python-template +_commit: v1.1.0-3-g2dda840 +_src_path: https://github.com/trobz/trobz-python-template.git author_email: doaminhtri8183@gmail.com author_username: trisdoan enable_github_action: true package_name: trobz_local project_description: '' project_name: trobz_local +project_type: cli publish_to_pypi: false repository_name: local.py repository_namespace: trobz diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fb64e62 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# AGENTS.md + +> Quick reference for AI coding agents. + +## Project + + +- **Type**: CLI (Typer) + +- **Language**: Python 3.10+ +- **Package manager**: [uv](https://docs.astral.sh/uv/) + +## Entry Points + + +- `trobz_local/main.py` — CLI entry point + + +## Commands + +Run `make help` for all commands. Key ones: + +``` +make install # Install deps + pre-commit hooks +make check # Lint, format, type-check +make test # Run pytest + +``` + +## Key Files + +- `Makefile` — Project commands +- `pyproject.toml` — Dependencies and build config +- `ruff.toml` — Linter/formatter rules + +- `tests/` — Test suite (pytest) diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ab696b8..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,22 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Quick Reference - -`trobz_local` (CLI: `tlc`) - Odoo dev environment automation tool. - -```bash -uv sync && uv run pre-commit install # Setup -make check # Lint + type check -make test # Run tests -uv run pytest tests/test_file.py -v # Single test -make build # Build wheel -``` - -## Documentation - -**Read `docs/` for details** - architecture, code standards, config examples: -- `docs/project-overview-pdr.md` - Features, requirements, config schema -- `docs/codebase-summary.md` - Module analysis -- `docs/code-standards.md` - Conventions, security practices diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 593ea78b97bf0be4ca0c62e6227aea4735d6291f Mon Sep 17 00:00:00 2001 From: trisdoan Date: Thu, 12 Feb 2026 15:18:51 +0700 Subject: [PATCH 7/7] fix: update command running odoo-venv --- tests/test_create_venvs.py | 1 + trobz_local/main.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/test_create_venvs.py b/tests/test_create_venvs.py index fca92ae..2477f84 100644 --- a/tests/test_create_venvs.py +++ b/tests/test_create_venvs.py @@ -53,6 +53,7 @@ def test_create_venv_success(tmp_path, mock_get_uv_path): "tool", "run", "odoo-venv", + "create", version, "--odoo-dir", str(odoo_dir_base / version), diff --git a/trobz_local/main.py b/trobz_local/main.py index a892d92..fbf31d0 100644 --- a/trobz_local/main.py +++ b/trobz_local/main.py @@ -439,6 +439,7 @@ def _create_venvs( "tool", "run", "odoo-venv", + "create", version, "--odoo-dir", str(odoo_dir),