From 07d5181146bd70a1466f025ba79b31efbfa78cf3 Mon Sep 17 00:00:00 2001 From: Hai Lang Date: Tue, 26 May 2026 14:51:19 +0700 Subject: [PATCH 1/2] fix(all commands): migrate CLI from click to typer Replace click with typer across all command modules, utilities, and tests. Use Annotated[type, typer.Option/Argument()] for parameter declarations, typer.Exit(code=1) for error exits, and invoke the full app in tests via typer.testing.CliRunner. Co-Authored-By: Claude Sonnet 4.6 Forge ID: F#T67797 --- pyproject.toml | 4 +- tests/test_status_watch.py | 9 +- tests/test_update_watch.py | 9 +- trobz_deploy/cli.py | 41 ++++---- trobz_deploy/command/configure.py | 154 ++++++++++++---------------- trobz_deploy/command/status.py | 54 ++++------ trobz_deploy/command/update.py | 162 ++++++++++++------------------ trobz_deploy/utils/executor.py | 12 +-- trobz_deploy/utils/venv.py | 8 +- uv.lock | 30 +++++- 10 files changed, 220 insertions(+), 263 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7c0d3fd..a0d33a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ license = "AGPL-3.0" license-files = ["LICENSE"] dependencies = [ - "click>=8.0", + "typer>=0.20", "jinja2>=3.0", "pyyaml>=6.0", ] @@ -17,7 +17,7 @@ dependencies = [ [project.urls] Repository = "https://github.com/trobz/deploy.py" [project.scripts] -deploy = "trobz_deploy.cli:cli" +deploy = "trobz_deploy.cli:app" [dependency-groups] dev = [ diff --git a/tests/test_status_watch.py b/tests/test_status_watch.py index b110b89..f932a43 100644 --- a/tests/test_status_watch.py +++ b/tests/test_status_watch.py @@ -3,9 +3,9 @@ from unittest.mock import MagicMock, patch import pytest -from click.testing import CliRunner +from typer.testing import CliRunner -from trobz_deploy.command.status import status +from trobz_deploy.cli import app @pytest.fixture @@ -34,9 +34,8 @@ def _invoke(runner, extra_args: list[str], side_effect=None): mock_exec.stream.side_effect = side_effect MockExecutor.return_value = mock_exec result = runner.invoke( - status, - ["service-myapp-production", *extra_args], - obj={"config": "", "verbose": False}, + app, + ["status", "service-myapp-production", *extra_args], ) return result, mock_exec diff --git a/tests/test_update_watch.py b/tests/test_update_watch.py index 90d2e3f..f04c053 100644 --- a/tests/test_update_watch.py +++ b/tests/test_update_watch.py @@ -3,9 +3,9 @@ from unittest.mock import MagicMock, patch import pytest -from click.testing import CliRunner +from typer.testing import CliRunner -from trobz_deploy.command.update import update +from trobz_deploy.cli import app @pytest.fixture @@ -29,9 +29,8 @@ def _invoke(runner, extra_args: list[str], side_effect=None): mock_exec.stream.side_effect = side_effect MockExecutor.return_value = mock_exec result = runner.invoke( - update, - ["service-myapp-production", "--type", "service", *extra_args], - obj={"config": "", "verbose": False}, + app, + ["update", "service-myapp-production", "--type", "service", *extra_args], ) return result, mock_exec diff --git a/trobz_deploy/cli.py b/trobz_deploy/cli.py index 84a39a5..21e715f 100644 --- a/trobz_deploy/cli.py +++ b/trobz_deploy/cli.py @@ -1,34 +1,33 @@ from __future__ import annotations -import click +from typing import Annotated + +import typer from trobz_deploy.command.configure import configure from trobz_deploy.command.status import status from trobz_deploy.command.update import update +app = typer.Typer(help="Deploy and manage applications on remote servers over SSH.") + -@click.group() -@click.option( - "--config", - default="deploy.yml", - show_default=True, - metavar="FILE", - help="Path to the configuration file.", -) -@click.option( - "--verbose", - is_flag=True, - default=False, - help="Print each remote command and its output as it runs.", -) -@click.pass_context -def cli(ctx: click.Context, config: str, verbose: bool) -> None: - """Deploy and manage applications on remote servers over SSH.""" +@app.callback() +def cli( + ctx: typer.Context, + config: Annotated[ + str, + typer.Option(show_default=True, metavar="FILE", help="Path to the configuration file."), + ] = "deploy.yml", + verbose: Annotated[ + bool, + typer.Option("--verbose", help="Print each remote command and its output as it runs."), + ] = False, +) -> None: ctx.ensure_object(dict) ctx.obj["config"] = config ctx.obj["verbose"] = verbose -cli.add_command(configure) -cli.add_command(update) -cli.add_command(status) +app.command()(configure) +app.command()(update) +app.command()(status) diff --git a/trobz_deploy/command/configure.py b/trobz_deploy/command/configure.py index fa13932..8bd658e 100644 --- a/trobz_deploy/command/configure.py +++ b/trobz_deploy/command/configure.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import Any +from enum import Enum +from typing import Annotated, Any -import click +import typer from trobz_deploy.utils.config import load_config, resolve_options from trobz_deploy.utils.executor import Executor, ExecutorError @@ -10,6 +11,12 @@ from trobz_deploy.utils.venv import setup_odoo_venv, setup_package_venv, setup_python_venv +class DeployType(str, Enum): + odoo = "odoo" + python = "python" + service = "service" + + def _is_git_repo(executor: Executor, path: str) -> bool: try: executor.run(f"test -d {path}/.git") @@ -19,54 +26,28 @@ def _is_git_repo(executor: Executor, path: str) -> bool: return True -@click.command() -@click.argument("instance_name") -@click.argument("ssh_host", required=False) -@click.argument("repo_url", required=False) -@click.option( - "--type", - "deploy_type", - type=click.Choice(["odoo", "python", "service"]), - default=None, - help="Deployment type (auto-detected from instance name prefix if omitted).", -) -@click.option( - "-p", - "--port", - "ssh_port", - type=int, - default=None, - help="SSH port on the remote host.", -) -@click.option( - "--force", - is_flag=True, - default=False, - help="Re-run setup steps even if the instance directory already exists.", -) -@click.option( - "--repo-subdir", - "repo_subdir", - default=None, - help="Subdirectory within the repo to use as the service root (for monorepos).", -) -@click.option( - "--repo-branch", - "repo_branch", - default=None, - help="Git branch to clone and track (defaults to the repository's default branch).", -) -@click.pass_context def configure( # noqa: C901 - ctx: click.Context, - instance_name: str, - ssh_host: str | None, - repo_url: str | None, - deploy_type: str | None, - ssh_port: int | None, - force: bool, - repo_subdir: str | None, - repo_branch: str | None, + ctx: typer.Context, + instance_name: Annotated[str, typer.Argument()], + ssh_host: Annotated[str | None, typer.Argument()] = None, + repo_url: Annotated[str | None, typer.Argument()] = None, + deploy_type: Annotated[ + DeployType | None, + typer.Option("--type", help="Deployment type (auto-detected from instance name prefix if omitted)."), + ] = None, + ssh_port: Annotated[int | None, typer.Option("-p", "--port", help="SSH port on the remote host.")] = None, + force: Annotated[ + bool, + typer.Option("--force", help="Re-run setup steps even if the instance directory already exists."), + ] = False, + repo_subdir: Annotated[ + str | None, + typer.Option(help="Subdirectory within the repo to use as the service root (for monorepos)."), + ] = None, + repo_branch: Annotated[ + str | None, + typer.Option(help="Git branch to clone and track (defaults to the repository's default branch)."), + ] = None, ) -> None: """Configure a new deployment instance.""" cfg = load_config(ctx.obj["config"], instance_name) @@ -78,11 +59,12 @@ def configure( # noqa: C901 ssh_port=ssh_port, repo_url=repo_url, repo_branch=repo_branch, - deploy_type=deploy_type, + deploy_type=deploy_type.value if deploy_type else None, repo_subdir=repo_subdir, ) except ValueError as exc: - raise click.ClickException(click.style(str(exc), fg="red")) from exc + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc eff_ssh_host: str | None = opts.get("ssh_host") eff_ssh_port: int | None = opts.get("ssh_port") @@ -93,11 +75,9 @@ def configure( # noqa: C901 eff_requirements: list[str] = ([_req] if isinstance(_req, str) else _req) if _req else [] if eff_type == "python" and eff_requirements and eff_repo_url: - msg = click.style( - "requirements and repo_url are mutually exclusive for python type.", - fg="red", - ) - raise click.ClickException(msg) + msg = "requirements and repo_url are mutually exclusive for python type." + typer.echo(typer.style(msg, fg="red"), err=True) + raise typer.Exit(code=1) executor = Executor(eff_ssh_host, ctx.obj["verbose"], ssh_port=eff_ssh_port) home_dir = executor.capture("echo $HOME") @@ -111,47 +91,43 @@ def configure( # noqa: C901 try: executor.run(f"test -d {instance_path}") if not force: - msg = click.style( - f"Instance directory already exists: ~/{instance_name}\nUse --force to re-run setup.", - fg="yellow", - ) - raise click.ClickException(msg) - click.secho("\nDirectory exists, skipping mkdir (--force).", fg="yellow") + msg = f"Instance directory already exists: ~/{instance_name}\nUse --force to re-run setup." + typer.echo(typer.style(msg, fg="yellow"), err=True) + raise typer.Exit(code=1) + typer.secho("\nDirectory exists, skipping mkdir (--force).", fg="yellow") except ExecutorError: - click.secho(f"\nCreating instance directory ~/{instance_name}…", fg="green") + typer.secho(f"\nCreating instance directory ~/{instance_name}…", fg="green") executor.run(f"mkdir -p {instance_path}") elif eff_type == "service" and not eff_repo_url: # Binary/system service mode: no repo, just ensure a working directory exists - click.secho(f"\nCreating instance directory ~/{instance_name}…", fg="green") + typer.secho(f"\nCreating instance directory ~/{instance_name}…", fg="green") executor.run(f"mkdir -p {instance_path}") else: if not eff_repo_url: - msg = click.style( - "repo_url is required. Provide it as an argument or set it in deploy.yml.", - fg="red", - ) - raise click.ClickException(msg) + msg = "repo_url is required. Provide it as an argument or set it in deploy.yml." + typer.echo(typer.style(msg, fg="red"), err=True) + raise typer.Exit(code=1) # Repo mode: clone if _is_git_repo(executor, instance_path): if not force: - msg = click.style( + msg = ( f"Instance directory already exists: ~/{instance_name}\n" - "Use --force to skip cloning and re-run setup.", - fg="yellow", + "Use --force to skip cloning and re-run setup." ) - raise click.ClickException(msg) - click.secho("\nDirectory exists, skipping clone (--force).", fg="yellow") + typer.echo(typer.style(msg, fg="yellow"), err=True) + raise typer.Exit(code=1) + typer.secho("\nDirectory exists, skipping clone (--force).", fg="yellow") else: - click.secho(f"\nCloning {eff_repo_url} into ~/{instance_name}…", fg="green") + typer.secho(f"\nCloning {eff_repo_url} into ~/{instance_name}…", fg="green") try: clone_cmd = f"git clone --recurse-submodules {eff_repo_url} $HOME/{instance_name}" if eff_repo_branch: clone_cmd += f" --branch {eff_repo_branch}" executor.run(clone_cmd) except ExecutorError as exc: - msg = click.style(f"Git clone failed: {exc}", fg="red") - raise click.ClickException(msg) from exc + typer.echo(typer.style(f"Git clone failed: {exc}", fg="red"), err=True) + raise typer.Exit(code=1) from exc if eff_type == "odoo": executor.run( @@ -160,7 +136,7 @@ def configure( # noqa: C901 ) # Step 3: Set up environment - click.secho(f"\nSetting up {eff_type} environment…", fg="green") + typer.secho(f"\nSetting up {eff_type} environment…", fg="green") try: if eff_type == "odoo": setup_odoo_venv(executor, instance_path, force=force) @@ -178,10 +154,11 @@ def configure( # noqa: C901 if build_cmd: executor.run(build_cmd, cwd=service_path) except ExecutorError as exc: - raise click.ClickException(click.style(str(exc), fg="red")) from exc + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc # Step 4: Install systemd unit - click.secho("\nInstalling systemd unit…", fg="green") + typer.secho("\nInstalling systemd unit…", fg="green") unit_instance_path = instance_path if eff_requirements else service_path venv_path = f"{unit_instance_path}/.venv" @@ -203,11 +180,9 @@ def configure( # noqa: C901 if res == "server.py": exec_start = "python server.py" if not exec_start: - msg = click.style( - "exec_start is required for service or python type. Set it in deploy.yml.", - fg="red", - ) - raise click.ClickException(msg) + msg = "exec_start is required for service or python type. Set it in deploy.yml." + typer.echo(typer.style(msg, fg="red"), err=True) + raise typer.Exit(code=1) if eff_type == "python": template_vars["venv_path"] = venv_path template_vars["exec_start"] = exec_start @@ -215,8 +190,8 @@ def configure( # noqa: C901 try: unit_content = render_unit(eff_type, **template_vars) except Exception as exc: - msg = click.style(f"Template rendering failed: {exc}", fg="red") - raise click.ClickException(msg) from exc + typer.echo(typer.style(f"Template rendering failed: {exc}", fg="red"), err=True) + raise typer.Exit(code=1) from exc unit_dir = "$HOME/.config/systemd/user" unit_path = f"{unit_dir}/{instance_name}.service" @@ -227,6 +202,7 @@ def configure( # noqa: C901 executor.run("systemctl --user daemon-reload") executor.run(f"systemctl --user enable --now {instance_name}") except ExecutorError as exc: - raise click.ClickException(click.style(str(exc), fg="red")) from exc + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc - click.secho(f"\nInstance {instance_name!r} configured successfully.", fg="green") + typer.secho(f"\nInstance {instance_name!r} configured successfully.", fg="green") diff --git a/trobz_deploy/command/status.py b/trobz_deploy/command/status.py index b0c6817..c2962a0 100644 --- a/trobz_deploy/command/status.py +++ b/trobz_deploy/command/status.py @@ -1,6 +1,8 @@ from __future__ import annotations -import click +from typing import Annotated + +import typer from trobz_deploy.utils.config import load_config from trobz_deploy.utils.executor import Executor, ExecutorError @@ -36,31 +38,15 @@ def _get_git_info(executor: Executor, instance_path: str) -> tuple[str, str, str return remote_url, branch, commit -@click.command() -@click.argument("instance_name") -@click.argument("ssh_host", required=False) -@click.option( - "-p", - "--port", - "ssh_port", - type=int, - default=None, - help="SSH port on the remote host.", -) -@click.option( - "--watch", - "watch", - is_flag=True, - default=False, - help="Stream service logs with journalctl after showing status.", -) -@click.pass_context def status( - ctx: click.Context, - instance_name: str, - ssh_host: str | None, - ssh_port: int | None, - watch: bool, + ctx: typer.Context, + instance_name: Annotated[str, typer.Argument()], + ssh_host: Annotated[str | None, typer.Argument()] = None, + ssh_port: Annotated[int | None, typer.Option("-p", "--port", help="SSH port on the remote host.")] = None, + watch: Annotated[ + bool, + typer.Option("--watch", help="Stream service logs with journalctl after showing status."), + ] = False, ) -> None: """Show status of a deployment instance.""" cfg = load_config(ctx.obj["config"], instance_name) @@ -77,7 +63,8 @@ def status( executor.run(f"test -d {instance_path}") except ExecutorError: msg = f"Instance directory not found: ~/{instance_name}" - raise click.ClickException(msg) from None + typer.echo(typer.style(msg, fg="red"), err=True) + raise typer.Exit(code=1) from None # Step 3: Git info (skipped for no-repo service instances) has_repo = bool(cfg.get("repo_url")) @@ -87,20 +74,21 @@ def status( remote_url, branch, commit = _get_git_info(executor, instance_path) except ExecutorError as exc: msg = f"Failed to get git info: {exc}" - raise click.ClickException(msg) from exc + typer.echo(typer.style(msg, fg="red"), err=True) + raise typer.Exit(code=1) from exc # Step 4: systemd unit status unit_line = _get_unit_line(executor, instance_name) - click.echo(f"Instance: {instance_name}") + typer.echo(f"Instance: {instance_name}") if has_repo: - click.echo(f"Remote: {remote_url}") - click.echo(f"Branch: {branch} ({commit})") - click.echo(f"Unit: {unit_line}") + typer.echo(f"Remote: {remote_url}") + typer.echo(f"Branch: {branch} ({commit})") + typer.echo(f"Unit: {unit_line}") if watch: - click.secho("\nWatching service logs (Ctrl+C to stop)…", fg="cyan") + typer.secho("\nWatching service logs (Ctrl+C to stop)…", fg="cyan") try: executor.stream(f"journalctl --user -u {instance_name} -f") except KeyboardInterrupt: - click.echo() + typer.echo() diff --git a/trobz_deploy/command/update.py b/trobz_deploy/command/update.py index 78376b8..c12bd5b 100644 --- a/trobz_deploy/command/update.py +++ b/trobz_deploy/command/update.py @@ -1,6 +1,9 @@ from __future__ import annotations -import click +from enum import Enum +from typing import Annotated + +import typer from trobz_deploy.utils.addons import get_addons_path from trobz_deploy.utils.config import load_config, resolve_options @@ -8,67 +11,40 @@ from trobz_deploy.utils.venv import setup_python_deps, upgrade_package -@click.command() -@click.argument("instance_name") -@click.argument("ssh_host", required=False) -@click.option( - "--type", - "deploy_type", - type=click.Choice(["odoo", "python", "service"]), - default=None, - help="Deployment type (auto-detected from instance name prefix if omitted).", -) -@click.option( - "--db", - default=None, - help="Override the target database name (Odoo only). Can be comma-separated for multiple databases.", -) -@click.option( - "-p", - "--port", - "ssh_port", - type=int, - default=None, - help="SSH port on the remote host.", -) -@click.option( - "--ignore-hooks", - "ignore_hooks", - is_flag=True, - default=False, - help="Skip all hook execution.", -) -@click.option( - "--repo-subdir", - "repo_subdir", - default=None, - help="Subdirectory within the repo to use as the service root (for monorepos).", -) -@click.option( - "--repo-branch", - "repo_branch", - default=None, - help="Git branch to pull (defaults to the currently checked-out branch).", -) -@click.option( - "--watch", - "watch", - is_flag=True, - default=False, - help="Stream service logs with journalctl after a successful update.", -) -@click.pass_context +class DeployType(str, Enum): + odoo = "odoo" + python = "python" + service = "service" + + def update( # noqa: C901 - ctx: click.Context, - instance_name: str, - ssh_host: str | None, - deploy_type: str | None, - db: str | None, - ssh_port: int | None, - ignore_hooks: bool, - repo_subdir: str | None, - repo_branch: str | None, - watch: bool, + ctx: typer.Context, + instance_name: Annotated[str, typer.Argument()], + ssh_host: Annotated[str | None, typer.Argument()] = None, + deploy_type: Annotated[ + DeployType | None, + typer.Option("--type", help="Deployment type (auto-detected from instance name prefix if omitted)."), + ] = None, + db: Annotated[ + str | None, + typer.Option( + help="Override the target database name (Odoo only). Can be comma-separated for multiple databases." + ), + ] = None, + ssh_port: Annotated[int | None, typer.Option("-p", "--port", help="SSH port on the remote host.")] = None, + ignore_hooks: Annotated[bool, typer.Option("--ignore-hooks", help="Skip all hook execution.")] = False, + repo_subdir: Annotated[ + str | None, + typer.Option(help="Subdirectory within the repo to use as the service root (for monorepos)."), + ] = None, + repo_branch: Annotated[ + str | None, + typer.Option(help="Git branch to pull (defaults to the currently checked-out branch)."), + ] = None, + watch: Annotated[ + bool, + typer.Option("--watch", help="Stream service logs with journalctl after a successful update."), + ] = False, ) -> None: """Update an existing deployment instance.""" cfg = load_config(ctx.obj["config"], instance_name) @@ -78,13 +54,14 @@ def update( # noqa: C901 instance_name, ssh_host=ssh_host, ssh_port=ssh_port, - deploy_type=deploy_type, + deploy_type=deploy_type.value if deploy_type else None, db=db, repo_subdir=repo_subdir, repo_branch=repo_branch, ) except ValueError as exc: - raise click.ClickException(click.style(str(exc), fg="red")) from exc + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc eff_ssh_host: str | None = opts.get("ssh_host") eff_ssh_port: int | None = opts.get("ssh_port") @@ -111,27 +88,24 @@ def run_hooks(hook_name: str) -> bool: try: executor.run(cmd, cwd=instance_path) except ExecutorError as exc: - click.secho( - f"Hook {hook_name!r} failed: {exc}", - fg="red", + typer.echo( + typer.style(f"Hook {hook_name!r} failed: {exc}", fg="red"), err=True, ) return False return True # Step 2: pre-update hooks (non-blocking) - click.secho("\nRunning pre-update hooks…", fg="green") + typer.secho("\nRunning pre-update hooks…", fg="green") run_hooks("pre-update") # Step 3: pre-update-required hooks (blocking on failure) - click.secho("\nRunning pre-update-required hooks…", fg="green") + typer.secho("\nRunning pre-update-required hooks…", fg="green") if not run_hooks("pre-update-required"): run_hooks("pre-update-fail") - msg = click.style( - "pre-update-required hook failed. Update aborted.", - fg="red", - ) - raise click.ClickException(msg) + msg = "pre-update-required hook failed. Update aborted." + typer.echo(typer.style(msg, fg="red"), err=True) + raise typer.Exit(code=1) # Step 4: pre-update-success run_hooks("pre-update-success") @@ -139,29 +113,27 @@ def run_hooks(hook_name: str) -> bool: # Step 5+6: Pull/upgrade code and update dependencies if eff_type == "python" and eff_requirements: # Package mode: upgrade pip package directly, no git pull - click.secho("\nUpgrading package…", fg="green") + typer.secho("\nUpgrading package…", fg="green") try: upgrade_package(executor, instance_path, eff_requirements) except ExecutorError as exc: run_hooks("post-update") run_hooks("post-update-fail") - msg = click.style(f"Package upgrade failed: {exc}", fg="red") - raise click.ClickException(msg) from exc + typer.echo(typer.style(f"Package upgrade failed: {exc}", fg="red"), err=True) + raise typer.Exit(code=1) from exc elif eff_type == "service" and not opts.get("repo_url"): # Binary/system service mode: no repo to pull, skip straight to restart - click.secho("\nNo repository to update, skipping pull…", fg="yellow") + typer.secho("\nNo repository to update, skipping pull…", fg="yellow") else: # Repo mode: git pull then update deps try: executor.run(f"test -d {instance_path}/.git") except ExecutorError: - msg = click.style( - f"Instance directory not found or not a git repo: ~/{instance_name}", - fg="red", - ) - raise click.ClickException(msg) from None + msg = f"Instance directory not found or not a git repo: ~/{instance_name}" + typer.echo(typer.style(msg, fg="red"), err=True) + raise typer.Exit(code=1) from None - click.secho("\nPulling latest code…", fg="green") + typer.secho("\nPulling latest code…", fg="green") try: if eff_repo_branch: executor.run( @@ -173,10 +145,10 @@ def run_hooks(hook_name: str) -> bool: except ExecutorError as exc: run_hooks("post-update") run_hooks("post-update-fail") - msg = click.style(f"git pull failed: {exc}", fg="red") - raise click.ClickException(msg) from exc + typer.echo(typer.style(f"git pull failed: {exc}", fg="red"), err=True) + raise typer.Exit(code=1) from exc - click.secho("\nUpdating dependencies…", fg="green") + typer.secho("\nUpdating dependencies…", fg="green") try: if eff_type == "odoo": executor.run( @@ -193,16 +165,16 @@ def run_hooks(hook_name: str) -> bool: except ExecutorError as exc: run_hooks("post-update") run_hooks("post-update-fail") - msg = click.style(f"Dependency update failed: {exc}", fg="red") - raise click.ClickException(msg) from exc + typer.echo(typer.style(f"Dependency update failed: {exc}", fg="red"), err=True) + raise typer.Exit(code=1) from exc # Step 7: Apply changes - click.secho("\nApplying changes…", fg="green") + typer.secho("\nApplying changes…", fg="green") try: if eff_type == "odoo": addons_path = get_addons_path(executor, instance_path) for db in eff_db: - click.secho(f"\nUpdating database {db!r}…", fg="green") + typer.secho(f"\nUpdating database {db!r}…", fg="green") executor.run( f".venv/bin/click-odoo-update -d {db} --addons-path={addons_path}", cwd=instance_path, @@ -211,18 +183,18 @@ def run_hooks(hook_name: str) -> bool: except ExecutorError as exc: run_hooks("post-update") run_hooks("post-update-fail") - msg = click.style(f"Restart/upgrade failed: {exc}", fg="red") - raise click.ClickException(msg) from exc + typer.echo(typer.style(f"Restart/upgrade failed: {exc}", fg="red"), err=True) + raise typer.Exit(code=1) from exc # Step 8: post-update hooks run_hooks("post-update") run_hooks("post-update-success") - click.secho(f"\nInstance {instance_name!r} updated successfully.", fg="green") + typer.secho(f"\nInstance {instance_name!r} updated successfully.", fg="green") if watch: - click.secho("\nWatching service logs (Ctrl+C to stop)…", fg="cyan") + typer.secho("\nWatching service logs (Ctrl+C to stop)…", fg="cyan") try: executor.stream(f"journalctl --user -u {instance_name} -f") except KeyboardInterrupt: - click.echo() + typer.echo() diff --git a/trobz_deploy/utils/executor.py b/trobz_deploy/utils/executor.py index f93e18b..6bc7a0a 100644 --- a/trobz_deploy/utils/executor.py +++ b/trobz_deploy/utils/executor.py @@ -3,7 +3,7 @@ import base64 import subprocess -import click +import typer class ExecutorError(Exception): @@ -57,7 +57,7 @@ def run(self, command: str, cwd: str | None = None, check: bool = True) -> str: if self.verbose: display = argv[-1] if is_remote else command - click.echo(f"$ {display}", err=True) + typer.echo(f"$ {display}", err=True) result = subprocess.run( # noqa: S603 argv, @@ -68,7 +68,7 @@ def run(self, command: str, cwd: str | None = None, check: bool = True) -> str: ) if self.verbose and result.stdout: - click.echo(result.stdout, nl=False) + typer.echo(result.stdout, nl=False) if check and result.returncode != 0: err = (result.stderr or "").strip() @@ -89,7 +89,7 @@ def capture(self, command: str, cwd: str | None = None) -> str: if self.verbose: display = argv[-1] if is_remote else command - click.echo(f"$ {display}", err=True) + typer.echo(f"$ {display}", err=True) result = subprocess.run( # noqa: S603 argv, @@ -118,7 +118,7 @@ def stream(self, command: str, cwd: str | None = None) -> None: is_remote = isinstance(argv, list) if self.verbose: display = argv[-1] if is_remote else command - click.echo(f"$ {display}", err=True) + typer.echo(f"$ {display}", err=True) subprocess.run(argv, shell=not is_remote, cwd=cwd if not is_remote else None) # noqa: S603 def write_file(self, content: str, remote_path: str) -> None: @@ -129,7 +129,7 @@ def write_file(self, content: str, remote_path: str) -> None: """ b64 = base64.b64encode(content.encode("utf-8")).decode("ascii") if self.verbose: - click.echo(f"Writing to {remote_path}\n{content}") + typer.echo(f"Writing to {remote_path}\n{content}") verbose = self.verbose self.verbose = False self.run(f"echo '{b64}' | base64 -d > {remote_path}") diff --git a/trobz_deploy/utils/venv.py b/trobz_deploy/utils/venv.py index dd1c5fd..1c89ee0 100644 --- a/trobz_deploy/utils/venv.py +++ b/trobz_deploy/utils/venv.py @@ -1,6 +1,6 @@ from __future__ import annotations -import click +import typer from trobz_deploy.utils.executor import Executor, ExecutorError @@ -16,7 +16,7 @@ def _venv_exists(executor: Executor, instance_path: str) -> bool: def setup_odoo_venv(executor: Executor, instance_path: str, force: bool = False) -> None: """Create or update an Odoo virtual environment using ``odoo-venv``.""" if force and _venv_exists(executor, instance_path): - click.secho("Venv exists, skipping venv creation (--force).", fg="yellow") + typer.secho("Venv exists, skipping venv creation (--force).", fg="yellow") return executor.run( f"odoo-venv create --project-dir {instance_path} --preset project", @@ -27,7 +27,7 @@ def setup_odoo_venv(executor: Executor, instance_path: str, force: bool = False) def setup_python_venv(executor: Executor, instance_path: str, force: bool = False) -> None: """Create a venv with ``uv`` and install dependencies from requirements.txt.""" if force and _venv_exists(executor, instance_path): - click.secho("Venv exists, skipping venv creation (--force).", fg="yellow") + typer.secho("Venv exists, skipping venv creation (--force).", fg="yellow") else: executor.run("uv venv .venv", cwd=instance_path) setup_python_deps(executor, instance_path) @@ -41,7 +41,7 @@ def setup_python_deps(executor: Executor, instance_path: str) -> None: def setup_package_venv(executor: Executor, instance_path: str, requirements: list[str], force: bool = False) -> None: """Create a venv with ``uv`` and install pip packages directly.""" if force and _venv_exists(executor, instance_path): - click.secho("Venv exists, skipping venv creation (--force).", fg="yellow") + typer.secho("Venv exists, skipping venv creation (--force).", fg="yellow") else: executor.run("uv venv .venv", cwd=instance_path) executor.run(f"uv pip install {' '.join(requirements)}", cwd=instance_path) diff --git a/uv.lock b/uv.lock index f1ab3b8..43940df 100644 --- a/uv.lock +++ b/uv.lock @@ -1,7 +1,16 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -888,9 +897,9 @@ name = "trobz-deploy" version = "0.15.0" source = { editable = "." } dependencies = [ - { name = "click" }, { name = "jinja2" }, { name = "pyyaml" }, + { name = "typer" }, ] [package.optional-dependencies] @@ -910,9 +919,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "click", specifier = ">=8.0" }, { name = "jinja2", specifier = ">=3.0" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "typer", specifier = ">=0.20" }, { name = "uv", marker = "extra == 'build'", specifier = "~=0.7.12" }, ] provides-extras = ["build"] @@ -951,6 +960,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/b8/25e0adc404bbf986977657b25318991f93097b49f8aea640d93c0b0db68e/ty-0.0.21-py3-none-win_arm64.whl", hash = "sha256:f72047996598ac20553fb7e21ba5741e3c82dee4e9eadf10d954551a5fe09391", size = 10104161, upload-time = "2026-03-06T01:57:06.072Z" }, ] +[[package]] +name = "typer" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From fbabbfc83ce92c4664af89126a7cc2b8e86a9c6c Mon Sep 17 00:00:00 2001 From: Hai Lang Date: Tue, 26 May 2026 17:03:00 +0700 Subject: [PATCH 2/2] chore: update docs pipeline and site config follow new template Pin setup-uv to v8.1.0 by commit hash, replace docs.yml with docs.yaml (adds deploy job for GitHub Pages), move zensical.toml under site-docs/, add gen_cli_reference.py to scripts/, regenerate cli-reference.md, and update Makefile/AGENTS.md/.gitignore accordingly. Co-Authored-By: Claude Sonnet 4.6 Forge ID: F#T67797 --- .github/actions/setup-python-env/action.yaml | 2 +- .github/workflows/docs.yaml | 43 ++ .github/workflows/docs.yml | 29 -- .gitignore | 1 + AGENTS.md | 5 +- Makefile | 13 +- scripts/gen_cli_reference.py | 53 +++ site-docs/docs/cli-reference.md | 107 +++++ site-docs/docs/commands/configure.md | 91 ---- site-docs/docs/commands/index.md | 28 -- site-docs/docs/commands/status.md | 66 --- site-docs/docs/commands/update.md | 101 ----- site-docs/docs/getting-started.md | 11 +- site-docs/docs/index.md | 1 + site-docs/docs/javascripts/hero-scene.js | 168 +++++-- site-docs/docs/stylesheets/hero.css | 445 ++++++++++++++++--- site-docs/docs/stylesheets/site.css | 5 + site-docs/overrides/landing.html | 25 +- site-docs/zensical.toml | 66 +++ zensical.toml | 97 ---- 20 files changed, 811 insertions(+), 546 deletions(-) create mode 100644 .github/workflows/docs.yaml delete mode 100644 .github/workflows/docs.yml create mode 100644 scripts/gen_cli_reference.py create mode 100644 site-docs/docs/cli-reference.md delete mode 100644 site-docs/docs/commands/configure.md delete mode 100644 site-docs/docs/commands/index.md delete mode 100644 site-docs/docs/commands/status.md delete mode 100644 site-docs/docs/commands/update.md create mode 100644 site-docs/zensical.toml delete mode 100644 zensical.toml diff --git a/.github/actions/setup-python-env/action.yaml b/.github/actions/setup-python-env/action.yaml index ccaceea..5cfb3fa 100644 --- a/.github/actions/setup-python-env/action.yaml +++ b/.github/actions/setup-python-env/action.yaml @@ -17,7 +17,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: ${{ inputs.uv-version }} enable-cache: 'true' diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..55512e6 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,43 @@ +name: Documentation +on: + push: + branches: + - main + - master + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: pages + cancel-in-progress: false +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: 3.x + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - run: uv sync --frozen + - run: uv run python scripts/gen_cli_reference.py + - run: uv run zensical build --clean -f site-docs/zensical.toml + - uses: actions/upload-pages-artifact@v5 + with: + path: site-docs/site + + deploy: + needs: build + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/configure-pages@v6 + - uses: actions/deploy-pages@v5 + id: deployment diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 47ee722..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Documentation -on: - push: - branches: - - master - - main -permissions: - contents: read - pages: write - id-token: write -jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - uses: actions/configure-pages@v6 - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 - with: - python-version: 3.x - - run: pip install zensical - - run: zensical build --clean - - uses: actions/upload-pages-artifact@v5 - with: - path: site - - uses: actions/deploy-pages@v5 - id: deployment diff --git a/.gitignore b/.gitignore index 354d9a8..f5ff83d 100644 --- a/.gitignore +++ b/.gitignore @@ -167,6 +167,7 @@ venv.bak/ # mkdocs documentation /site +/site-docs/site # mypy .mypy_cache/ diff --git a/AGENTS.md b/AGENTS.md index 35aa32d..06c9494 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,6 @@ ## Project - - **Type**: CLI (Typer) - **Language**: Python 3.10+ @@ -12,9 +11,7 @@ ## Entry Points - -- `deploy/cli.py` — CLI entry point - +- `trobz_deploy/cli.py` — CLI entry point ## Commands diff --git a/Makefile b/Makefile index e229a62..29100f9 100644 --- a/Makefile +++ b/Makefile @@ -51,14 +51,17 @@ help: .DEFAULT_GOAL := help - +.PHONY: cli-docs +cli-docs: ## Regenerate site-docs/docs/cli-reference.md from Typer --help + @echo "🚀 Regenerating CLI reference from --help" + @uv run python scripts/gen_cli_reference.py .PHONY: docs -docs: ## Build the documentation site +docs: cli-docs ## Build the documentation site (regenerates CLI reference first) @echo "🚀 Building documentation site" - @uv run zensical build + @uv run zensical build -f site-docs/zensical.toml .PHONY: docs-serve -docs-serve: ## Serve the documentation site locally +docs-serve: cli-docs ## Serve the documentation site locally @echo "🚀 Serving documentation site" - @uv run zensical serve + @uv run zensical serve -f site-docs/zensical.toml diff --git a/scripts/gen_cli_reference.py b/scripts/gen_cli_reference.py new file mode 100644 index 0000000..aa3f72d --- /dev/null +++ b/scripts/gen_cli_reference.py @@ -0,0 +1,53 @@ +"""Regenerate site-docs/docs/cli-reference.md from the Typer app. + +Invoked by `make cli-docs` (and transitively `make docs`). Keeps the public +docs site in sync with whatever flags/commands exist in trobz_deploy.main +without manual edits — add a flag, run `make docs`, commit the diff. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +OUT = Path(__file__).resolve().parent.parent / "site-docs" / "docs" / "cli-reference.md" + +HEADER = """--- +icon: lucide/book +description: Complete reference for every trobz-deploy command, auto-generated from --help. +tags: + - reference + - cli +--- + +!!! info "Auto-generated" + This page is regenerated from `deploy --help` by `make cli-docs`. + Do not edit by hand — your changes will be overwritten on the next build. + +""" + + +def main() -> None: + # `uv` is on PATH in the dev (`make`) and CI (`setup-uv`) environments. + body = subprocess.check_output( + ["uv", "run", "typer", "trobz_deploy.cli", "utils", "docs", "--name", "deploy"], # noqa: S607 + text=True, + ) + # Typer emits `[default: X]`, `[required]`, etc. which Zensical/Markdown + # parses as link references. Escape the brackets so they render literally. + body = re.sub( + r"\[(default|env var|required)([^\]]*)\]", + lambda m: rf"\[{m.group(1)}{m.group(2)}\]", + body, + ) + OUT.write_text(HEADER + body) + try: + rel = OUT.relative_to(Path.cwd()) + except ValueError: + rel = OUT + print(f"Wrote {rel} ({len(body.splitlines())} lines from typer)") + + +if __name__ == "__main__": + main() diff --git a/site-docs/docs/cli-reference.md b/site-docs/docs/cli-reference.md new file mode 100644 index 0000000..fc72458 --- /dev/null +++ b/site-docs/docs/cli-reference.md @@ -0,0 +1,107 @@ +--- +icon: lucide/book +description: Complete reference for every trobz-deploy command, auto-generated from --help. +tags: + - reference + - cli +--- + +!!! info "Auto-generated" + This page is regenerated from `deploy --help` by `make cli-docs`. + Do not edit by hand — your changes will be overwritten on the next build. + +# `deploy` + +Deploy and manage applications on remote servers over SSH. + +**Usage**: + +```console +$ deploy [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--config FILE`: Path to the configuration file. \[default: deploy.yml\] +* `--verbose`: Print each remote command and its output as it runs. +* `--install-completion`: Install completion for the current shell. +* `--show-completion`: Show completion for the current shell, to copy it or customize the installation. +* `--help`: Show this message and exit. + +**Commands**: + +* `configure`: Configure a new deployment instance. +* `update`: Update an existing deployment instance. +* `status`: Show status of a deployment instance. + +## `deploy configure` + +Configure a new deployment instance. + +**Usage**: + +```console +$ deploy configure [OPTIONS] INSTANCE_NAME [SSH_HOST] [REPO_URL] +``` + +**Arguments**: + +* `INSTANCE_NAME`: \[required\] +* `[SSH_HOST]` +* `[REPO_URL]` + +**Options**: + +* `--type [odoo|python|service]`: Deployment type (auto-detected from instance name prefix if omitted). +* `-p, --port INTEGER`: SSH port on the remote host. +* `--force`: Re-run setup steps even if the instance directory already exists. +* `--repo-subdir TEXT`: Subdirectory within the repo to use as the service root (for monorepos). +* `--repo-branch TEXT`: Git branch to clone and track (defaults to the repository's default branch). +* `--help`: Show this message and exit. + +## `deploy update` + +Update an existing deployment instance. + +**Usage**: + +```console +$ deploy update [OPTIONS] INSTANCE_NAME [SSH_HOST] +``` + +**Arguments**: + +* `INSTANCE_NAME`: \[required\] +* `[SSH_HOST]` + +**Options**: + +* `--type [odoo|python|service]`: Deployment type (auto-detected from instance name prefix if omitted). +* `--db TEXT`: Override the target database name (Odoo only). Can be comma-separated for multiple databases. +* `-p, --port INTEGER`: SSH port on the remote host. +* `--ignore-hooks`: Skip all hook execution. +* `--repo-subdir TEXT`: Subdirectory within the repo to use as the service root (for monorepos). +* `--repo-branch TEXT`: Git branch to pull (defaults to the currently checked-out branch). +* `--watch`: Stream service logs with journalctl after a successful update. +* `--help`: Show this message and exit. + +## `deploy status` + +Show status of a deployment instance. + +**Usage**: + +```console +$ deploy status [OPTIONS] INSTANCE_NAME [SSH_HOST] +``` + +**Arguments**: + +* `INSTANCE_NAME`: \[required\] +* `[SSH_HOST]` + +**Options**: + +* `-p, --port INTEGER`: SSH port on the remote host. +* `--watch`: Stream service logs with journalctl after showing status. +* `--help`: Show this message and exit. diff --git a/site-docs/docs/commands/configure.md b/site-docs/docs/commands/configure.md deleted file mode 100644 index 20174fa..0000000 --- a/site-docs/docs/commands/configure.md +++ /dev/null @@ -1,91 +0,0 @@ -# deploy configure - -Clone a repository, set up the application environment, and install a -systemd user unit on the target host. - -## Signature - -```bash -deploy [--config FILE] configure [] [] \ - [--type odoo|python|service] [-p PORT] [--force] -``` - -## Arguments - -| Argument | Required without config | Description | -|----------|------------------------|-------------| -| `instance_name` | Always | Logical name for the instance | -| `ssh_host` | If not in config | SSH target, or `localhost` / omit for local execution | -| `repo_url` | If not in config | Git repository URL | - -## Options - -| Option | Default | Description | -|--------|---------|-------------| -| `--type` | auto | Deployment type: `odoo`, `python`, or `service` | -| `-p`, `--port` | — | SSH port on the remote host | -| `--force` | `False` | Re-run steps 3–4 even if the instance directory already exists | - -## Steps - -1. **Connect** — open SSH connection if `ssh_host` is set and is not `localhost`. -2. **Clone repository** — clone `repo_url` into `~/`. - - If the directory already exists without `--force`: abort with exit 1. - - With `--force`: skip clone, proceed to step 3. -3. **Set up environment** - - === "odoo" - - ```bash - odoo-venv create --project-dir ~/ --preset project - odoo-addons-path ~/ - ``` - - === "python" - - ```bash - uv venv .venv - uv pip install -r requirements.txt # if requirements.txt exists - uv sync # if pyproject.toml exists - ``` - - === "service" - - Runs the `build` command defined in `deploy.yml`. - -4. **Install systemd unit** — render the bundled template and register it: - - ```bash - mkdir -p ~/.config/systemd/user/ - # write ~/.service - loginctl enable-linger - systemctl --user daemon-reload - systemctl --user enable --now - ``` - -## Exit codes - -| Condition | Exit code | -|-----------|-----------| -| All steps succeeded | 0 | -| SSH connection failed | 1 | -| Repository already exists (without `--force`) | 1 | -| Git clone failed | 1 | -| Virtual environment step failed | 1 | -| Template rendering / write failed | 1 | - -## Examples - -```bash -# Auto-detect type from prefix, read config from deploy.yml -deploy configure odoo-myproject-production - -# Explicit SSH host and repo -deploy configure service-api-staging deploy@host.example.com git@github.com:org/api.git - -# Custom SSH port -deploy configure odoo-myproject-staging -p 2222 - -# Re-run environment setup without re-cloning -deploy configure odoo-myproject-production --force -``` diff --git a/site-docs/docs/commands/index.md b/site-docs/docs/commands/index.md deleted file mode 100644 index ffbf56e..0000000 --- a/site-docs/docs/commands/index.md +++ /dev/null @@ -1,28 +0,0 @@ -# Commands - -`deploy` provides three commands. All commands accept global options: - -| Option | Default | Description | -|--------|---------|-------------| -| `--config FILE` | `deploy.yml` | Path to the configuration file (resolved locally) | -| `--verbose` | `False` | Print each remote command and its output as it runs | - -## Overview - -| Command | Purpose | -|---------|---------| -| [`configure`](configure.md) | Clone repo, set up environment, install systemd unit | -| [`update`](update.md) | Pull latest code, sync deps, restart service | -| [`status`](status.md) | Show git info and systemd unit state | - -## Global usage - -```bash -deploy [--config FILE] [--verbose] [args...] -``` - -Example using a custom config path: - -```bash -deploy --config /etc/deploy/myserver.yml update odoo-myproject-production -``` diff --git a/site-docs/docs/commands/status.md b/site-docs/docs/commands/status.md deleted file mode 100644 index df60774..0000000 --- a/site-docs/docs/commands/status.md +++ /dev/null @@ -1,66 +0,0 @@ -# deploy status - -Show the current state of a deployment instance: git info and systemd unit status. - -## Signature - -```bash -deploy [--config FILE] status [] [-p PORT] [--watch] -``` - -## Arguments - -| Argument | Required without config | Description | -|----------|------------------------|-------------| -| `instance_name` | Always | Name of the previously configured instance | -| `ssh_host` | If not in config | SSH target, or `localhost` / omit for local execution | - -## Options - -| Option | Default | Description | -|--------|---------|-------------| -| `-p`, `--port` | — | SSH port on the remote host | -| `--watch` | `False` | Stream service logs with journalctl after showing status | - -## Output - -``` -Instance: odoo-myproject-production -Remote: git@github.com:org/repo.git -Branch: main (abc1234) -Unit: active (running) since 2026-03-09 08:12:03 -``` - -## Steps - -1. **Connect** — open SSH connection if `ssh_host` is not `localhost`. -2. **Git info** — inside `~/`: - - `git remote get-url origin` → remote URL - - `git rev-parse --abbrev-ref HEAD` → branch - - `git rev-parse --short HEAD` → commit hash -3. **Unit status** — via `systemctl --user show`. -4. If **`--watch`**, stream live logs via `journalctl --user -u -f`. Press `Ctrl+C` to exit. - -## Exit codes - -| Condition | Exit code | -|-----------|-----------| -| All steps succeeded | 0 | -| SSH connection failed | 1 | -| Instance directory not found | 1 | - -## Examples - -```bash -# Read ssh_host from deploy.yml -deploy status odoo-myproject-production - -# Explicit host -deploy status odoo-myproject-production deploy@myserver.example.com - -# Custom SSH port -deploy status odoo-myproject-production -p 2222 - -# Show status then tail the logs -deploy status odoo-myproject-production --watch -``` diff --git a/site-docs/docs/commands/update.md b/site-docs/docs/commands/update.md deleted file mode 100644 index 84d3c88..0000000 --- a/site-docs/docs/commands/update.md +++ /dev/null @@ -1,101 +0,0 @@ -# deploy update - -Pull the latest code, update dependencies, and restart the service on the -target host. - -## Signature - -```bash -deploy [--config FILE] update [] \ - [--type odoo|python|service] [-p PORT] [--db DATABASE] [--ignore-hooks] [--watch] -``` - -## Arguments - -| Argument | Required without config | Description | -|----------|------------------------|-------------| -| `instance_name` | Always | Name of the previously configured instance | -| `ssh_host` | If not in config | SSH target, or `localhost` / omit for local execution | - -## Options - -| Option | Default | Description | -|--------|---------|-------------| -| `--type` | auto | Deployment type: `odoo`, `python`, or `service` | -| `-p`, `--port` | — | SSH port on the remote host | -| `--db` | `` | Override target database name (Odoo only). Can be a list of comma-separated names | -| `--ignore-hooks` | `False` | Skip all hook execution | -| `--watch` | `False` | Watch service logs for working information | - -## Steps - -1. **Connect** — open SSH connection if `ssh_host` is not `localhost`. -2. **`pre-update` hooks** — non-blocking. -3. **`pre-update-required` hooks** — abort with exit 1 on failure; runs `pre-update-fail` first. -4. **`pre-update-success` or `pre-update-fail`** — based on steps 2–3 outcome. -5. **Pull latest code** — `git pull` inside `~/`. -6. **Update dependencies / rebuild** - - === "odoo" - ```bash - odoo-venv update .venv --backup --yes - ``` - - === "python" - ```bash - uv pip install -r requirements.txt # if requirements.txt exists - uv sync # if pyproject.toml exists - ``` - - === "service" - Runs the `build` command from `deploy.yml`. - -7. **Apply changes** - - === "odoo" - ```bash - ~//.venv/bin/click-odoo-upgrade -d - systemctl --user restart - ``` - - === "python / service" - ```bash - systemctl --user restart - ``` - -8. **`post-update` hooks**, then `post-update-success` or `post-update-fail`. -9. If there is **`--watch`**, watch service logs for working information. Press `Ctrl+C` to exit. - -## Exit codes - -| Condition | Exit code | -|-----------|-----------| -| All steps succeeded | 0 | -| SSH connection failed | 1 | -| Instance directory not found | 1 | -| `pre-update-required` hook failed | 1 | -| `git pull` failed | 1 | -| Dependency update failed | 1 | -| Upgrade / restart command failed | 1 | - -## Examples - -```bash -# Standard update — reads all values from deploy.yml -deploy update odoo-myproject-production - -# Override database name -deploy update odoo-myproject-production --db myproject_alt - -# Multiple database names -deploy update odoo-myproject-production --db myproject_alt,myproject_staging - -# Custom SSH port -deploy update odoo-myproject-staging -p 2222 - -# Skip hooks for emergency deploys -deploy update odoo-myproject-production --ignore-hooks - -# Verbose output to see every remote command -deploy --verbose update odoo-myproject-production -``` diff --git a/site-docs/docs/getting-started.md b/site-docs/docs/getting-started.md index 3379fb5..3be1a1a 100644 --- a/site-docs/docs/getting-started.md +++ b/site-docs/docs/getting-started.md @@ -1,3 +1,12 @@ +--- +icon: lucide/rocket +description: CLI tool for deploying and managing applications on remote servers over SSH. +tags: + - installation + - quickstart + - presets +--- + # Getting Started ## Installation @@ -69,5 +78,5 @@ deploy status odoo-myproject-production - [Instance Names](instance-names.md) — understand the naming convention - [Configuration](configuration.md) — full `deploy.yml` reference -- [Commands](commands/index.md) — detailed command documentation +- [Commands](cli-reference.md) — detailed command documentation - [Hooks](hooks.md) — automate pre/post update tasks diff --git a/site-docs/docs/index.md b/site-docs/docs/index.md index d39c509..bec229e 100644 --- a/site-docs/docs/index.md +++ b/site-docs/docs/index.md @@ -1,4 +1,5 @@ --- +icon: lucide/rocket template: landing.html --- diff --git a/site-docs/docs/javascripts/hero-scene.js b/site-docs/docs/javascripts/hero-scene.js index 5ebc263..61d780a 100644 --- a/site-docs/docs/javascripts/hero-scene.js +++ b/site-docs/docs/javascripts/hero-scene.js @@ -4,59 +4,92 @@ * A grid of particles undulates using layered sine waves, creating an * organic, ocean-like surface. Particles are orange (#F97316), connections * are white with low opacity. Mouse movement warps the wave locally. + * */ ;(function () { + // ── Bookkeeping for teardown ── let animationId = null; let renderer = null; let mouseMoveHandler = null; let resizeHandler = null; function teardown() { - if (animationId) { cancelAnimationFrame(animationId); animationId = null; } - if (renderer) { renderer.dispose(); renderer = null; } - if (mouseMoveHandler) { document.removeEventListener('mousemove', mouseMoveHandler); mouseMoveHandler = null; } - if (resizeHandler) { window.removeEventListener('resize', resizeHandler); resizeHandler = null; } + if (animationId) { + cancelAnimationFrame(animationId); + animationId = null; + } + if (renderer) { + renderer.dispose(); + renderer = null; + } + if (mouseMoveHandler) { + document.removeEventListener('mousemove', mouseMoveHandler); + mouseMoveHandler = null; + } + if (resizeHandler) { + window.removeEventListener('resize', resizeHandler); + resizeHandler = null; + } } function init() { teardown(); + const canvas = document.getElementById('hero-canvas'); if (!canvas) return; + // ── Scene & Renderer ── const scene = new THREE.Scene(); renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 1000); + // ── Camera — angled top-down for wave perspective ── + const camera = new THREE.PerspectiveCamera( + 60, + window.innerWidth / window.innerHeight, + 1, + 1000 + ); camera.position.set(0, 120, 200); camera.lookAt(0, 0, 0); - const COLS = 50, ROWS = 50, SPACING = 8; + // ── Wave grid configuration ── + const COLS = 50; + const ROWS = 50; + const SPACING = 8; const PARTICLE_COUNT = COLS * ROWS; + + // Center the grid so it looks symmetric const offsetX = ((COLS - 1) * SPACING) / 2; const offsetZ = ((ROWS - 1) * SPACING) / 2; + // ── Create particles (Points) ── const geometry = new THREE.BufferGeometry(); const positions = new Float32Array(PARTICLE_COUNT * 3); + + // Initialize flat grid positions for (let row = 0; row < ROWS; row++) { for (let col = 0; col < COLS; col++) { const i = (row * COLS + col) * 3; - positions[i] = col * SPACING - offsetX; - positions[i + 1] = 0; - positions[i + 2] = row * SPACING - offsetZ; + positions[i] = col * SPACING - offsetX; // x + positions[i + 1] = 0; // y (will be animated) + positions[i + 2] = row * SPACING - offsetZ; // z } } geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + // Circle texture for soft round particles function createCircleTexture() { const c = document.createElement('canvas'); - c.width = 32; c.height = 32; + c.width = 32; + c.height = 32; const ctx = c.getContext('2d'); + // Soft radial gradient for glow effect const grad = ctx.createRadialGradient(16, 16, 0, 16, 16, 16); - grad.addColorStop(0, 'rgba(255,255,255,1)'); - grad.addColorStop(0.4, 'rgba(255,255,255,0.8)'); - grad.addColorStop(1, 'rgba(255,255,255,0)'); + grad.addColorStop(0, 'rgba(255, 255, 255, 1)'); + grad.addColorStop(0.4, 'rgba(255, 255, 255, 0.8)'); + grad.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, 32, 32); const tex = new THREE.Texture(c); @@ -64,100 +97,155 @@ return tex; } - const points = new THREE.Points(geometry, new THREE.PointsMaterial({ - color: 0xF97316, size: 2.5, + const pointsMaterial = new THREE.PointsMaterial({ + color: 0xF97316, // Orange accent + size: 2.5, map: createCircleTexture(), - transparent: true, alphaTest: 0.01, opacity: 0.9, - depthWrite: false, blending: THREE.AdditiveBlending - })); + transparent: true, + alphaTest: 0.01, + opacity: 0.9, + depthWrite: false, + blending: THREE.AdditiveBlending + }); + + const points = new THREE.Points(geometry, pointsMaterial); scene.add(points); + // ── Connection lines between nearby particles ── + const lineGeometry = new THREE.BufferGeometry(); + // Max possible lines: each particle connects to right + down neighbor const maxLines = (COLS - 1) * ROWS + COLS * (ROWS - 1); const linePositions = new Float32Array(maxLines * 6); - const lineGeometry = new THREE.BufferGeometry(); - lineGeometry.setAttribute('position', - new THREE.BufferAttribute(linePositions, 3).setUsage(THREE.DynamicDrawUsage)); - const lines = new THREE.LineSegments(lineGeometry, - new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.08, depthWrite: false })); + lineGeometry.setAttribute( + 'position', + new THREE.BufferAttribute(linePositions, 3).setUsage(THREE.DynamicDrawUsage) + ); + + const lineMaterial = new THREE.LineBasicMaterial({ + color: 0xffffff, // White connections + transparent: true, + opacity: 0.08, + depthWrite: false + }); + + const lines = new THREE.LineSegments(lineGeometry, lineMaterial); scene.add(lines); + // ── Mouse tracking ── const mouse = { x: 9999, y: 9999 }; mouseMoveHandler = (e) => { + // Normalize mouse to [-1, 1] range relative to canvas const rect = canvas.parentNode.getBoundingClientRect(); - mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; - mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; }; document.addEventListener('mousemove', mouseMoveHandler); + // ── Resize handler ── resizeHandler = () => { const container = canvas.parentNode; if (!container) return; - renderer.setSize(container.clientWidth, container.clientHeight); - camera.aspect = container.clientWidth / container.clientHeight; + const w = container.clientWidth; + const h = container.clientHeight; + renderer.setSize(w, h); + camera.aspect = w / h; camera.updateProjectionMatrix(); }; window.addEventListener('resize', resizeHandler); resizeHandler(); + // ── Animation loop ── const clock = new THREE.Clock(); + function animate() { animationId = requestAnimationFrame(animate); const t = clock.getElapsedTime(); const pos = points.geometry.attributes.position.array; + // Project mouse into world space for local wave distortion const mouseVec = new THREE.Vector3(mouse.x, mouse.y, 0.5); mouseVec.unproject(camera); const dir = mouseVec.sub(camera.position).normalize(); + // Intersect with y=0 plane const dist = -camera.position.y / dir.y; const mouseWorld = camera.position.clone().add(dir.multiplyScalar(dist)); + // Update each particle's Y using layered sine waves for (let row = 0; row < ROWS; row++) { for (let col = 0; col < COLS; col++) { const i = (row * COLS + col) * 3; - const x = pos[i], z = pos[i + 2]; - let y = Math.sin(x * 0.04 + t * 0.8) * 12 - + Math.sin(z * 0.06 + t * 1.2) * 8 - + Math.sin((x + z) * 0.05 + t * 0.6) * 5; - const dx = x - mouseWorld.x, dz = z - mouseWorld.z; + const x = pos[i]; + const z = pos[i + 2]; + + // Layer 1: primary wave (large, slow) + let y = Math.sin(x * 0.04 + t * 0.8) * 12; + // Layer 2: cross wave (medium, faster) + y += Math.sin(z * 0.06 + t * 1.2) * 8; + // Layer 3: diagonal ripple (detail) + y += Math.sin((x + z) * 0.05 + t * 0.6) * 5; + + // Mouse influence — push wave up near cursor + const dx = x - mouseWorld.x; + const dz = z - mouseWorld.z; const mouseDist = Math.sqrt(dx * dx + dz * dz); - if (mouseDist < 60) y += (1 - mouseDist / 60) * 20; + if (mouseDist < 60) { + const influence = 1 - mouseDist / 60; + y += influence * 20; + } + pos[i + 1] = y; } } points.geometry.attributes.position.needsUpdate = true; + // Update connection lines between grid neighbors const lnPos = lines.geometry.attributes.position.array; let li = 0; + for (let row = 0; row < ROWS; row++) { for (let col = 0; col < COLS; col++) { const i = (row * COLS + col) * 3; + + // Connect to right neighbor if (col < COLS - 1) { const j = (row * COLS + col + 1) * 3; - lnPos[li++]=pos[i]; lnPos[li++]=pos[i+1]; lnPos[li++]=pos[i+2]; - lnPos[li++]=pos[j]; lnPos[li++]=pos[j+1]; lnPos[li++]=pos[j+2]; + lnPos[li++] = pos[i]; lnPos[li++] = pos[i + 1]; lnPos[li++] = pos[i + 2]; + lnPos[li++] = pos[j]; lnPos[li++] = pos[j + 1]; lnPos[li++] = pos[j + 2]; } + // Connect to bottom neighbor if (row < ROWS - 1) { const j = ((row + 1) * COLS + col) * 3; - lnPos[li++]=pos[i]; lnPos[li++]=pos[i+1]; lnPos[li++]=pos[i+2]; - lnPos[li++]=pos[j]; lnPos[li++]=pos[j+1]; lnPos[li++]=pos[j+2]; + lnPos[li++] = pos[i]; lnPos[li++] = pos[i + 1]; lnPos[li++] = pos[i + 2]; + lnPos[li++] = pos[j]; lnPos[li++] = pos[j + 1]; lnPos[li++] = pos[j + 2]; } } } + lines.geometry.attributes.position.needsUpdate = true; lines.geometry.setDrawRange(0, li / 3); + // Slow rotation for dynamism scene.rotation.y += 0.0008; + renderer.render(scene, camera); } + animate(); } + // ── Hook into Zensical SPA navigation ── function safeInit() { if (typeof THREE === 'undefined') return; init(); } - if (typeof document$ !== 'undefined') { document$.subscribe(safeInit); } - if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', safeInit); } - else { safeInit(); } + if (typeof document$ !== 'undefined') { + document$.subscribe(safeInit); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', safeInit); + } else { + safeInit(); + } })(); diff --git a/site-docs/docs/stylesheets/hero.css b/site-docs/docs/stylesheets/hero.css index 6720e9d..fd1d759 100644 --- a/site-docs/docs/stylesheets/hero.css +++ b/site-docs/docs/stylesheets/hero.css @@ -1,5 +1,11 @@ +/* ============================================================ + Landing page — Techy / Abstract dark theme for odoo-venv + Typography: JetBrains Mono (code) + IBM Plex Sans (body) + Colors: Dark bg, orange/white accents (Trobz brand) + ============================================================ */ @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"); +/* ---------- CSS Custom Properties ---------- */ :root { --ov-bg: #111111; --ov-text: #f5f5f5; @@ -11,46 +17,83 @@ --ov-border: rgba(255, 255, 255, 0.12); --ov-font-mono: "JetBrains Mono", "Fira Code", monospace; --ov-font-sans: "IBM Plex Sans", system-ui, sans-serif; + + /* Layout */ --ov-container-width: 1440px; - --ov-split-ratio: minmax(0, 1fr) minmax(0, 2fr); + --ov-split-ratio: 0.9fr 2.1fr; } +/* ---------- Fullscreen layout reset (landing only) ---------- */ body:has(.ov-landing), body:has(.ov-landing) .md-main__inner, body:has(.ov-landing) .md-main, body:has(.ov-landing) .md-content, body:has(.ov-landing) .md-content__inner { - margin: 0 !important; padding: 0 !important; + margin: 0 !important; + padding: 0 !important; max-width: none !important; background: var(--ov-bg) !important; - height: 100vh; overflow: hidden; + height: 100vh; + overflow: hidden; } +/* ---------- Landing wrapper ---------- */ .ov-landing { - position: relative; display: flex; - align-items: center; justify-content: center; - width: 100%; height: 100vh; - background: var(--ov-bg); color: var(--ov-text); overflow: hidden; + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100vh; + background: var(--ov-bg); + color: var(--ov-text); + overflow: hidden; } +/* ---------- Three.js canvas (background) ---------- */ .ov-landing__canvas { - position: absolute; inset: 0; - width: 100%; height: 100%; - z-index: 0; opacity: 0.6; pointer-events: none; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; + opacity: 0.6; + pointer-events: none; } +/* ══════════════════════════════════════════════════════ + Split layout — left: copy, right: demo + ══════════════════════════════════════════════════════ */ + .ov-split { - position: relative; z-index: 1; - display: grid; grid-template-columns: var(--ov-split-ratio); - align-items: center; gap: 4rem; - width: 100%; max-width: var(--ov-container-width); padding: 0 4rem; + position: relative; + z-index: 1; + display: grid; + grid-template-columns: var(--ov-split-ratio); + align-items: center; + gap: 4rem; + width: 100%; + max-width: var(--ov-container-width); + padding: 0 4rem; } +/* ---------- Left column — hero copy ---------- */ .ov-split__copy { - display: flex; flex-direction: column; - align-items: flex-start; gap: 1.5rem; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1.5rem; } +/* ---------- Right column — demo frame ---------- */ +.ov-split__demo { + display: flex; + align-items: center; + justify-content: center; + animation: ease-out 0.8s both; +} + +/* ---------- GitHub badge ---------- */ .ov-github-badge { position: fixed; top: 1.5rem; @@ -77,108 +120,368 @@ body:has(.ov-landing) .md-content__inner { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3); } + .ov-split__demo { display: flex; align-items: center; justify-content: center; } +/* ---------- Terminal badge ---------- */ .ov-hero__badge { - display: inline-flex; align-items: center; gap: 0.5rem; + display: inline-flex; + align-items: center; + gap: 0.5rem; padding: 0.5rem 1.25rem; - background: var(--ov-surface); border: 1px solid var(--ov-border); - border-radius: 999px; font-family: var(--ov-font-mono); - font-size: 0.8rem; color: var(--ov-text-muted); + background: var(--ov-surface); + border: 1px solid var(--ov-border); + border-radius: 999px; + font-family: var(--ov-font-mono); + font-size: 0.875rem; + color: var(--ov-text-muted); backdrop-filter: blur(8px); animation: ov-fadeDown 0.6s ease-out both; } -.ov-hero__prompt { color: var(--ov-accent); font-weight: 600; } + +.ov-hero__prompt { + color: var(--ov-accent); + font-weight: 600; +} + +.ov-hero__cmd { + color: var(--ov-text); +} + +.ov-hero__cmd { + color: var(--ov-text); +} + +/* Blinking cursor — CSS only */ .ov-hero__cursor { - display: inline-block; width: 2px; height: 1em; - background: var(--ov-accent); animation: ov-blink 1s step-end infinite; + display: inline-block; + width: 2px; + height: 1em; + background: var(--ov-accent); + animation: ov-blink 1s step-end infinite; +} + +/* ---------- Title ---------- */ +.ov-hero__title { + margin: 0 !important; + animation: ov-fadeUp 0.7s ease-out 0.2s both; } -.ov-hero__title { margin: 0 !important; animation: ov-fadeUp 0.7s ease-out 0.2s both; } .ov-hero__line { - display: block; font-family: var(--ov-font-mono); - font-weight: 700; letter-spacing: -0.03em; line-height: 1.1 !important; + display: block; + font-family: var(--ov-font-mono); + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.1 !important; + white-space: nowrap; } + .ov-hero__line--1 { - font-size: clamp(1.8rem, 3vw, 3rem) !important; + font-size: clamp(2.5rem, 6vw, 5rem) !important; background: linear-gradient(135deg, #fff 0%, #f97316 50%, #ea580c 100%); - -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; } +/* ---------- Subtitle ---------- */ .ov-hero__subtitle { - font-family: var(--ov-font-sans); font-size: clamp(0.95rem, 2vw, 1.15rem); - font-weight: 300; line-height: 1.6; color: var(--ov-text-muted); margin: 0; + font-family: var(--ov-font-sans); + font-size: clamp(0.95rem, 2vw, 1.15rem); + font-weight: 300; + line-height: 1.6; + color: var(--ov-text-muted); + margin: 0; animation: ov-fadeUp 0.7s ease-out 0.4s both; } -.ov-hero__accent { color: var(--ov-accent); font-weight: 500; } +.ov-hero__accent { + color: var(--ov-accent); + font-weight: 500; +} + +/* ---------- CTA buttons ---------- */ .ov-hero__actions { - display: flex; gap: 1rem; flex-wrap: wrap; + display: flex; + gap: 1rem; + flex-wrap: wrap; animation: ov-fadeUp 0.7s ease-out 0.6s both; } .ov-btn { - display: inline-flex; align-items: center; gap: 0.5rem; - padding: 0.75rem 1.75rem; font-family: var(--ov-font-mono); - font-weight: 500; font-size: 0.9rem; - text-decoration: none; border-radius: 8px; cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.75rem; + font-family: var(--ov-font-mono); + font-weight: 500; + font-size: 0.9rem; + text-decoration: none; + border-radius: 8px; + cursor: pointer; transition: all 0.2s ease-out; } + +.ov-btn__icon { + flex-shrink: 0; +} + +/* Primary — solid accent button */ .ov-btn--primary { - background: var(--ov-primary); color: #fff !important; - border: 1px solid transparent; box-shadow: 0 0 20px rgba(249, 115, 22, 0.3); + background: var(--ov-primary); + color: #fff !important; + border: 1px solid transparent; + box-shadow: 0 0 20px rgba(249, 115, 22, 0.3); } -.ov-btn--primary:hover { background: #c2410c; box-shadow: 0 0 30px rgba(249, 115, 22, 0.5); transform: translateY(-2px); } -.ov-btn--ghost { background: transparent; color: var(--ov-text) !important; border: 1px solid var(--ov-border); } -.ov-btn--ghost:hover { background: var(--ov-surface); border-color: var(--ov-text-muted); transform: translateY(-2px); } + +.ov-btn--primary:hover { + background: #c2410c; + box-shadow: 0 0 30px rgba(249, 115, 22, 0.5); + transform: translateY(-2px); +} + +/* Ghost — outlined button */ +.ov-btn--ghost { + background: transparent; + color: var(--ov-text) !important; + border: 1px solid var(--ov-border); +} + +.ov-btn--ghost:hover { + background: var(--ov-surface); + border-color: var(--ov-text-muted); + transform: translateY(-2px); +} + +/* ══════════════════════════════════════════════════════ + Terminal / macOS window frame (CSS only) + ══════════════════════════════════════════════════════ */ .ov-terminal-frame { - width: 100%; border-radius: 12px; - background: #1a1a1a; border: 1px solid var(--ov-border); - box-shadow: 0 32px 64px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04), 0 0 60px var(--ov-accent-glow); - overflow: hidden; transition: box-shadow 0.3s ease; + width: 100%; + border-radius: 12px; + background: #1a1a1a; + border: 1px solid var(--ov-border); + box-shadow: + 0 32px 64px rgba(0, 0, 0, 0.6), + 0 0 0 1px rgba(255, 255, 255, 0.04), + 0 0 60px var(--ov-accent-glow); + overflow: hidden; + transition: box-shadow 0.3s ease; } + .ov-terminal-frame:hover { - box-shadow: 0 40px 80px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.06), 0 0 90px rgba(249,115,22,0.55); + box-shadow: + 0 40px 80px rgba(0, 0, 0, 0.7), + 0 0 0 1px rgba(255, 255, 255, 0.06), + 0 0 90px rgba(249, 115, 22, 0.55); } -.ov-terminal-frame__body { display: block; line-height: 0; background: #0d0d0d; } -.ov-terminal-frame__gif { display: block; width: 100%; height: auto; } + +/* Title bar with traffic lights */ +.ov-terminal-frame__bar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1rem; + background: #242424; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + user-select: none; +} + +/* Traffic-light dots */ +.ov-terminal-frame__dot { + display: block; + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.ov-terminal-frame__dot--red { + background: #ff5f57; +} + +.ov-terminal-frame__dot--yellow { + background: #ffbd2e; +} + +.ov-terminal-frame__dot--green { + background: #28c941; +} + +.ov-terminal-frame__bar-title { + flex: 1; + text-align: center; + font-family: var(--ov-font-sans); + font-size: 0.75rem; + font-weight: 400; + color: var(--ov-text-muted); + margin-right: calc(3 * 12px + 2 * 0.5rem); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ov-terminal-frame__body { + display: block; + line-height: 0; + background: #0d0d0d; +} + +.ov-terminal-frame__gif { + display: block; + width: 100%; + height: auto; +} + +/* ══════════════════════════════════════════════════════ + Feature highlights (unused currently, kept for future) + ══════════════════════════════════════════════════════ */ .ov-hero__features { - display: flex; gap: 2rem; flex-wrap: wrap; margin-top: 0.5rem; + display: flex; + gap: 2rem; + flex-wrap: wrap; + margin-top: 0.5rem; animation: ov-fadeUp 0.7s ease-out 0.8s both; } -.ov-feature { display: flex; align-items: center; gap: 0.5rem; font-family: var(--ov-font-sans); font-size: 0.875rem; color: var(--ov-text-muted); } -.ov-feature__icon { color: var(--ov-accent); flex-shrink: 0; } -@keyframes ov-fadeDown { from { opacity: 0; transform: translateY(-16px); } to { opacity: 1; transform: translateY(0); } } -@keyframes ov-fadeUp { from { opacity: 0; transform: translateY( 16px); } to { opacity: 1; transform: translateY(0); } } -@keyframes ov-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } +.ov-feature { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--ov-font-sans); + font-size: 0.875rem; + color: var(--ov-text-muted); +} +.ov-feature__icon { + color: var(--ov-accent); + flex-shrink: 0; +} + +/* ---------- Animations ---------- */ +@keyframes ov-fadeDown { + from { + opacity: 0; + transform: translateY(-16px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes ov-fadeUp { + from { + opacity: 0; + transform: translateY(16px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes ov-blink { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0; + } +} + +/* ---------- Reduced motion (accessibility) ---------- */ @media (prefers-reduced-motion: reduce) { - .ov-hero__badge, .ov-hero__title, .ov-hero__subtitle, - .ov-hero__actions, .ov-hero__features, .ov-split__demo { animation: none !important; } - .ov-hero__cursor { animation: none !important; opacity: 1; } - .ov-btn:hover { transform: none; } + + .ov-hero__badge, + .ov-hero__title, + .ov-hero__subtitle, + .ov-hero__actions, + .ov-hero__features, + .ov-split__demo { + animation: none !important; + } + + .ov-hero__cursor { + animation: none !important; + opacity: 1; + } + + .ov-btn:hover { + transform: none; + } } +/* ---------- Responsive: tablet (≤ 960px) — stack vertically ---------- */ @media screen and (max-width: 60rem) { - .ov-split { grid-template-columns: 1fr; gap: 2.5rem; padding: 2rem; justify-items: center; overflow-y: auto; max-height: 100vh; } - .ov-split__copy { align-items: center; text-align: center; } - .ov-hero__actions { justify-content: center; } - .ov-hero__line--1 { font-size: clamp(2.5rem, 10vw, 4rem) !important; } - .ov-terminal-frame { max-width: 600px; } - body:has(.ov-landing), body:has(.ov-landing) .md-main__inner, - body:has(.ov-landing) .md-main, body:has(.ov-landing) .md-content, - body:has(.ov-landing) .md-content__inner { overflow: auto; height: auto; } + .ov-split { + grid-template-columns: 1fr; + gap: 2.5rem; + padding: 2rem 2rem; + justify-items: center; + overflow-y: auto; + max-height: 100vh; + } + + .ov-split__copy { + align-items: center; + text-align: center; + } + + .ov-hero__actions { + justify-content: center; + } + + .ov-hero__line--1 { + font-size: clamp(2.5rem, 10vw, 4rem) !important; + } + + .ov-terminal-frame { + max-width: 600px; + } + + body:has(.ov-landing), + body:has(.ov-landing) .md-main__inner, + body:has(.ov-landing) .md-main, + body:has(.ov-landing) .md-content, + body:has(.ov-landing) .md-content__inner { + overflow: auto; + height: auto; + } } +/* ---------- Responsive: mobile (≤ 480px) ---------- */ @media screen and (max-width: 30rem) { - .ov-split { padding: 1.5rem 1.25rem; gap: 2rem; } - .ov-hero__badge { font-size: 0.75rem; padding: 0.4rem 1rem; } - .ov-hero__actions { flex-direction: column; width: 100%; } - .ov-btn { justify-content: center; width: 100%; } - .ov-terminal-frame { border-radius: 8px; } + .ov-split { + padding: 1.5rem 1.25rem; + gap: 2rem; + } + + .ov-hero__badge { + font-size: 0.75rem; + padding: 0.4rem 1rem; + } + + .ov-hero__actions { + flex-direction: column; + width: 100%; + } + + .ov-btn { + justify-content: center; + width: 100%; + } + + .ov-terminal-frame { + border-radius: 8px; + } } diff --git a/site-docs/docs/stylesheets/site.css b/site-docs/docs/stylesheets/site.css index 3b82ab2..a8929ee 100644 --- a/site-docs/docs/stylesheets/site.css +++ b/site-docs/docs/stylesheets/site.css @@ -1,3 +1,8 @@ +/* ============================================================ + Global site overrides — applied across all pages + ============================================================ */ + +/* Hide the logo + site name in the header, keep search & theme toggle */ .md-header__button.md-logo, .md-header__topic { display: none; diff --git a/site-docs/overrides/landing.html b/site-docs/overrides/landing.html index 660366a..ef86bd1 100644 --- a/site-docs/overrides/landing.html +++ b/site-docs/overrides/landing.html @@ -20,7 +20,7 @@ - {# ── Split layout: left = copy, right = demo/screenshot ── #} + {# ── Split layout: left = copy, right = demo ── #}
{# ── LEFT — hero copy ── #} @@ -37,7 +37,7 @@

- Deploy and manage applications on remote servers over SSH.
+ CLI tool for deploying and managing applications on remote servers over SSH.
Odoo, Python, and service deployments

@@ -68,15 +68,16 @@

- {# ── RIGHT — demo / screenshot ── #} + {# ── RIGHT — terminal demo frame ── #}
diff --git a/site-docs/zensical.toml b/site-docs/zensical.toml new file mode 100644 index 0000000..b600983 --- /dev/null +++ b/site-docs/zensical.toml @@ -0,0 +1,66 @@ +[project] +site_name = "trobz-deploy" +site_url = "https://trobz.github.io/deploy.py" +site_description = "CLI tool for deploying and managing applications on remote servers over SSH." +site_author = "Trobz" +docs_dir = "docs" +site_dir = "site" +copyright = "Copyright © 2026 Trobz" +nav = [ + {Home = "index.md"}, + {"Getting Started" = "getting-started.md"}, + { "Instance Names" = "instance-names.md" }, + { "Configuration" = "configuration.md" }, + { "Hooks" = "hooks.md" }, + {"CLI Reference" = "cli-reference.md"}, +] +extra_css = [ + "stylesheets/hero.css", + "stylesheets/site.css", +] + +# Three.js CDN — loaded globally so the hero scene can use it +[[project.extra_javascript]] +path = "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" + +# Hero particle network animation — requires Three.js above +[[project.extra_javascript]] +path = "javascripts/hero-scene.js" + +[project.theme] +language = "en" +custom_dir = "overrides" +features = [ + "content.code.copy", + "content.code.select", + "navigation.footer", + "navigation.indexes", + "navigation.path", + "navigation.sections", + "navigation.tabs", + "navigation.top", + "navigation.tracking", + "search.highlight", + "header.autohide" +] + +[project.theme.icon] +logo = "lucide/terminal" + +[[project.theme.palette]] +scheme = "default" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +scheme = "slate" +toggle.icon = "lucide/moon" +toggle.name = "Switch to light mode" + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/trobz/deploy.py" + +[[project.extra.social]] +icon = "fontawesome/brands/python" +link = "https://pypi.org/project/trobz-deploy/" diff --git a/zensical.toml b/zensical.toml deleted file mode 100644 index d5eaf96..0000000 --- a/zensical.toml +++ /dev/null @@ -1,97 +0,0 @@ -[project] -site_name = "deploy" -site_description = "CLI tool for deploying and managing applications on remote servers over SSH." -site_author = "Trobz" -site_url = "https://trobz.github.io/deploy.py/" -docs_dir = "site-docs/docs" -site_dir = "site" -copyright = "Copyright © 2026 Trobz" - -extra_css = ["stylesheets/hero.css", "stylesheets/site.css"] - -[[project.extra_javascript]] -path = "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" -defer = true - -[[project.extra_javascript]] -path = "javascripts/hero-scene.js" -defer = true - -nav = [ - { "Home" = "index.md" }, - { "Getting Started" = "getting-started.md" }, - { "Instance Names" = "instance-names.md" }, - { "Configuration" = "configuration.md" }, - { "Commands" = [ - { "Overview" = "commands/index.md" }, - { "configure" = "commands/configure.md" }, - { "update" = "commands/update.md" }, - { "status" = "commands/status.md" }, - ]}, - { "Hooks" = "hooks.md" }, -] - -[project.theme] -custom_dir = "site-docs/overrides" -language = "en" -features = [ - "announce.dismiss", - "content.code.annotate", - "content.code.copy", - "content.code.select", - "content.footnote.tooltips", - "content.tabs.link", - "content.tooltips", - "navigation.footer", - "navigation.indexes", - "navigation.instant", - "navigation.instant.prefetch", - "navigation.path", - "navigation.sections", - "navigation.top", - "navigation.tracking", - "search.highlight", -] - -[[project.theme.palette]] -scheme = "default" -toggle.icon = "lucide/sun" -toggle.name = "Switch to dark mode" - -[[project.theme.palette]] -scheme = "slate" -toggle.icon = "lucide/moon" -toggle.name = "Switch to light mode" - -[project.markdown_extensions.abbr] -[project.markdown_extensions.admonition] -[project.markdown_extensions.attr_list] -[project.markdown_extensions.def_list] -[project.markdown_extensions.footnotes] -[project.markdown_extensions.md_in_html] -[project.markdown_extensions.toc] -permalink = true -[project.markdown_extensions.pymdownx.betterem] -[project.markdown_extensions.pymdownx.caret] -[project.markdown_extensions.pymdownx.details] -[project.markdown_extensions.pymdownx.emoji] -emoji_generator = "zensical.extensions.emoji.to_svg" -emoji_index = "zensical.extensions.emoji.twemoji" -[project.markdown_extensions.pymdownx.highlight] -anchor_linenums = true -line_spans = "__span" -pygments_lang_class = true -[project.markdown_extensions.pymdownx.inlinehilite] -[project.markdown_extensions.pymdownx.keys] -[project.markdown_extensions.pymdownx.mark] -[project.markdown_extensions.pymdownx.smartsymbols] -[project.markdown_extensions.pymdownx.superfences] -custom_fences = [ - { name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" } -] -[project.markdown_extensions.pymdownx.tabbed] -alternate_style = true -combine_header_slug = true -[project.markdown_extensions.pymdownx.tasklist] -custom_checkbox = true -[project.markdown_extensions.pymdownx.tilde]