diff --git a/src/pobsync/commands/install.py b/src/pobsync/commands/install.py index 795d186..ebcbc89 100644 --- a/src/pobsync/commands/install.py +++ b/src/pobsync/commands/install.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from pathlib import Path from typing import Any @@ -41,6 +42,24 @@ DEFAULT_RSYNC_ARGS = [ ] +# NOTE: This wrapper exists so cron can reliably call /opt/pobsync/bin/pobsync. +# It forwards to the "real" pobsync on PATH when possible, and falls back to python -m pobsync. +WRAPPER_SH_TEMPLATE = """#!/bin/sh +# managed-by=pobsync +set -eu + +SELF="{self_path}" +REAL="$(command -v pobsync || true)" + +# Avoid recursion if PATH includes /opt/pobsync/bin +if [ -n "${{REAL}}" ] && [ "${{REAL}}" != "${{SELF}}" ]; then + exec "${{REAL}}" "$@" +fi + +exec python3 -m pobsync "$@" +""" + + def build_default_global_config(pobsync_home: Path, backup_root: str, retention: dict[str, int]) -> dict[str, Any]: return { "backup_root": backup_root, @@ -99,6 +118,63 @@ def write_yaml(path: Path, data: dict[str, Any], dry_run: bool, force: bool) -> return f"write {path}" +def _write_text_file(path: Path, content: str, dry_run: bool, force: bool) -> str: + """ + Write a plain text file with optional backup behavior similar to write_yaml(). + """ + if path.exists() and not force: + return f"skip existing {path}" + if path.exists() and force: + bak = path.with_suffix(path.suffix + ".bak") + if not dry_run: + bak.write_text(path.read_text(encoding="utf-8"), encoding="utf-8") + if not dry_run: + path.write_text(content, encoding="utf-8") + return f"overwrite {path} (backup {bak})" + + if not dry_run: + path.write_text(content, encoding="utf-8") + return f"write {path}" + + +def _install_wrapper(prefix: Path, dry_run: bool, force: bool) -> list[str]: + """ + Ensure prefix/bin/pobsync exists so cron entries can use it. + """ + actions: list[str] = [] + + bin_dir = prefix / "bin" + actions.append(f"mkdir -p {bin_dir}") + if not dry_run: + ensure_dir(bin_dir) + + wrapper_path = bin_dir / "pobsync" + content = WRAPPER_SH_TEMPLATE.format(self_path=str(wrapper_path)) + actions.append(_write_text_file(wrapper_path, content, dry_run=dry_run, force=force)) + + actions.append(f"chmod 0755 {wrapper_path}") + if not dry_run: + os.chmod(wrapper_path, 0o755) + + return actions + + +def _ensure_system_log_dir(dry_run: bool) -> list[str]: + """ + Best-effort: create /var/log/pobsync to match cron redirection. + Not fatal if it fails (e.g., insufficient permissions in a non-root install attempt). + """ + actions: list[str] = [] + log_dir = Path("/var/log/pobsync") + actions.append(f"mkdir -p {log_dir}") + if not dry_run: + try: + ensure_dir(log_dir) + except OSError as e: + actions.append(f"warn: cannot create {log_dir}: {e}") + return actions + + def run_install( prefix: Path, backup_root: str | None, @@ -118,6 +194,10 @@ def run_install( global_cfg = build_default_global_config(paths.home, backup_root=backup_root, retention=retention) actions.append(write_yaml(paths.global_config_path, global_cfg, dry_run=dry_run, force=force)) + # Install polish: make cron-compatible wrapper path and cron log directory. + actions.extend(_install_wrapper(prefix, dry_run=dry_run, force=force)) + actions.extend(_ensure_system_log_dir(dry_run=dry_run)) + return { "ok": True, "actions": actions,