From c0b378c42435eb4e2d71bbc336bc7c54d7d16f2b Mon Sep 17 00:00:00 2001 From: Tomas Mirchev Date: Sun, 15 Mar 2026 21:46:50 +0200 Subject: [PATCH] refactor-1 --- README.md | 14 +- docs/architecture.md | 52 + docs/flows.md | 86 ++ src/flow/commands/bootstrap.py | 1087 ++--------------- src/flow/commands/container.py | 315 +---- src/flow/commands/dotfiles.py | 1610 ++---------------------- src/flow/commands/enter.py | 186 +-- src/flow/commands/package.py | 86 +- src/flow/commands/sync.py | 65 +- src/flow/core/config.py | 2 + src/flow/core/errors.py | 6 + src/flow/core/process.py | 44 +- src/flow/core/system.py | 327 +++++ src/flow/services/__init__.py | 2 + src/flow/services/bootstrap.py | 1001 +++++++++++++++ src/flow/services/containers.py | 321 +++++ src/flow/services/dotfiles.py | 1697 ++++++++++++++++++++++++++ src/flow/services/package_defs.py | 350 ++++++ src/flow/services/packages.py | 113 ++ src/flow/services/projects.py | 174 +++ src/flow/services/ssh.py | 184 +++ tests/test_bootstrap.py | 12 +- tests/test_dotfiles.py | 4 +- tests/test_dotfiles_e2e_container.py | 68 +- tests/test_dotfiles_folding.py | 169 ++- 25 files changed, 4839 insertions(+), 3136 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/flows.md create mode 100644 src/flow/core/errors.py create mode 100644 src/flow/core/system.py create mode 100644 src/flow/services/__init__.py create mode 100644 src/flow/services/bootstrap.py create mode 100644 src/flow/services/containers.py create mode 100644 src/flow/services/dotfiles.py create mode 100644 src/flow/services/package_defs.py create mode 100644 src/flow/services/packages.py create mode 100644 src/flow/services/projects.py create mode 100644 src/flow/services/ssh.py diff --git a/README.md b/README.md index 2a00e22..26206b2 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,14 @@ linux-auto/ ### External module packages -Packages can be backed by an external git repository using `_module.yaml`: +Any directory inside a package can be backed by an external git repository using `_module.yaml`: + +```text +_shared/ + nvim/ + .config/nvim/ + _module.yaml +``` ```yaml source: github:org/nvim-config @@ -79,8 +86,9 @@ ref: branch: main ``` -- If a package directory contains `_module.yaml`, flow uses the fetched module content as package source. -- Any sibling files in that package directory are ignored (shown only in `--verbose`). +- Flow mounts the module repo root at the directory containing `_module.yaml` (e.g. the example mounts into `~/.config/nvim/`). +- Local files under that directory are ignored (shown only in `--verbose`). +- Only one `_module.yaml` per package is supported. - Modules are refreshed on `flow dotfiles init` and `flow dotfiles sync` (not on `link`). ## Manifest model diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..65e3a73 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,52 @@ +## Flow CLI Architecture + +### Layers + +`flow` now follows a stricter adapter/service/runtime split: + +- `flow.cli` + - global startup, platform/config loading, top-level error handling +- `flow.commands.*` + - argparse registration and compatibility wrappers only +- `flow.services.*` + - domain behavior for SSH entry, containers, dotfiles, bootstrap, packages, and project sync +- `flow.core.system` + - shared process, git, filesystem, and JSON state primitives +- `flow.core.*` + - config loading, platform detection, console output, variables + +### Runtime Safety + +Mutating operations are centralized behind `flow.core.system`: + +- `CommandRunner` + - subprocess execution and shell streaming +- `GitClient` + - repository-scoped git execution +- `FileSystem` + - directory creation, copy, symlink, removal, JSON/text writes +- `JsonStateStore` + - state persistence with explicit paths + +This keeps command handlers out of the business of directly creating, deleting, or overwriting filesystem state. + +### Domain Boundaries + +- `services.ssh` + - parses enter targets, resolves host templates, builds the SSH handoff +- `services.containers` + - owns container create/exec/connect/list/stop/remove/respawn logic +- `services.projects` + - owns git status/fetch/summary logic for project directories +- `services.package_defs` + - normalizes manifest package definitions and binary package install logic +- `services.packages` + - package state listing/install/remove behavior +- `services.bootstrap` + - provisioning orchestration, package-manager resolution, hooks, shell/locale/hostname setup +- `services.dotfiles` + - repo sync, module discovery, link planning, transactional undo, status, edit flow + +### Compatibility Strategy + +The current CLI surface is preserved. The command modules still expose a small set of legacy helper symbols because the existing tests use them directly, but the behavioral implementation now lives in the service layer. diff --git a/docs/flows.md b/docs/flows.md new file mode 100644 index 0000000..42ec5b7 --- /dev/null +++ b/docs/flows.md @@ -0,0 +1,86 @@ +## Feature Inventory + +### Core Features + +- `enter` + - SSH into a named environment target with optional tmux auto-attach +- `dev` + - create, exec into, attach to, list, stop, remove, and respawn development containers +- `dotfiles` + - clone the dotfiles repo, link/unlink/relink configs, undo link transactions, inspect status, sync modules, clean broken links, edit packages, and interact with repo state +- `bootstrap` + - run machine bootstrap profiles with packages, env validation, hostname/locale/shell setup, ssh-keygen, `runcmd`, config linking, and post-link hooks +- `package` + - install/list/remove binary packages defined in the manifest +- `sync` + - inspect git project health, fetch remotes, and summarize project state +- `completion` + - dynamic zsh completion generation and installation + +### Supported Flows + +#### Access a host + +1. Resolve `[user@]namespace@platform` +2. Expand platform host template or configured target override +3. Optionally warn about missing remote terminfo +4. Open SSH, optionally into tmux + +#### Start a dev container + +1. Resolve runtime (`docker` or `podman`) +2. Normalize image shorthand +3. Apply labels and common host mounts +4. Start the container +5. `flow dev connect` attaches through tmux or falls back to direct exec + +#### Manage dotfiles + +1. Clone dotfiles repo +2. Optionally sync external module repos +3. Resolve shared + profile packages +4. Validate target conflicts +5. Snapshot replaced targets +6. Apply links transactionally +7. Undo from persisted transaction state if needed + +#### Bootstrap a machine + +1. Load and validate a profile +2. Detect or select the package manager +3. Check required environment variables +4. Apply hostname/locale/shell prerequisites +5. Install profile packages +6. Run package hooks +7. Generate SSH keys +8. Run `runcmd` +9. Link dotfiles for the profile +10. Run post-link hooks + +### Command Surface Review + +### Keep + +- `enter` +- `dev` +- `dotfiles` +- `bootstrap` +- `package` +- `sync` +- `completion` + +### Keep But Treat As Convenience Aliases + +- `dotfiles sync` + - effectively `repo pull` + `modules sync` +- `dotfiles relink` + - effectively `unlink` + `link` +- `sync summary` + - effectively `sync check --no-fetch` + +### Commands That Need Follow-Up Product Decisions + +- `package remove` + - today it forgets install state but does not uninstall files; either rename it to `forget` or implement real uninstall semantics +- `dotfiles edit` + - current auto-commit/push behavior is powerful but risky; it may deserve an explicit confirm-or-dry-run mode before wider use diff --git a/src/flow/commands/bootstrap.py b/src/flow/commands/bootstrap.py index a2de7c1..65e31e9 100644 --- a/src/flow/commands/bootstrap.py +++ b/src/flow/commands/bootstrap.py @@ -1,1000 +1,105 @@ -"""flow bootstrap — environment provisioning from unified YAML config.""" +"""flow bootstrap — thin CLI adapter over the bootstrap service.""" -import argparse -import os -import re -import shlex import shutil -import sys -import tempfile import urllib.request -from pathlib import Path -from typing import Any, Dict, List, Optional -import yaml +from flow.services import bootstrap as _service -from flow.commands import dotfiles as dotfiles_cmd -from flow.core.config import FlowContext -from flow.core.process import run_command -from flow.core.variables import substitute_template +DEFAULT_LOCALE = _service.DEFAULT_LOCALE +PACKAGE_TYPES = _service.PACKAGE_TYPES +_SERVICE_COPY_INSTALL_ITEM = _service._copy_install_item -DEFAULT_LOCALE = "en_US.UTF-8" -PACKAGE_TYPES = {"pkg", "binary", "cask"} + +def _sync_service_module() -> None: + _service.shutil = shutil + _service.urllib = urllib + _service._copy_install_item = _copy_install_item def register(subparsers): - p = subparsers.add_parser( - "bootstrap", - aliases=["setup", "provision"], - help="Environment provisioning", - ) - sub = p.add_subparsers(dest="bootstrap_command") - - run_p = sub.add_parser("run", help="Run bootstrap actions") - run_p.add_argument("--profile", help="Profile name to use") - run_p.add_argument("--dry-run", action="store_true", help="Show plan without executing") - run_p.add_argument("--var", action="append", default=[], help="Set variable KEY=VALUE") - run_p.set_defaults(handler=run_bootstrap) - - ls = sub.add_parser("list", help="List available profiles") - ls.set_defaults(handler=run_list) - - show = sub.add_parser("show", help="Show profile configuration") - show.add_argument("profile", help="Profile name") - show.set_defaults(handler=run_show) - - packages = sub.add_parser("packages", help="List packages defined in profiles") - packages.add_argument("--profile", help="Profile name (default: all profiles)") - packages.add_argument( - "--resolved", - action="store_true", - help="Show resolved package names for detected package manager", - ) - packages.set_defaults(handler=run_packages) - - p.set_defaults(handler=lambda ctx, args: p.print_help()) - - -def _get_profiles(ctx: FlowContext) -> dict: - profiles = ctx.manifest.get("profiles") - if profiles is None: - if "environments" in ctx.manifest: - raise RuntimeError( - "Manifest key 'environments' is no longer supported. Rename it to 'profiles'." - ) - return {} - - if not isinstance(profiles, dict): - raise RuntimeError("Manifest key 'profiles' must be a mapping") - - return profiles - - -def _parse_variables(var_args: list) -> dict: - variables = {} - for item in var_args: - if "=" not in item: - raise ValueError(f"Invalid --var value '{item}'. Expected KEY=VALUE") - key, value = item.split("=", 1) - if not key: - raise ValueError(f"Invalid --var value '{item}'. KEY cannot be empty") - variables[key] = value - return variables - - -def _profile_template_context( - ctx: FlowContext, - extra_env: Dict[str, str], - extra: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - env_map = dict(os.environ) - env_map.update(extra_env) - - template_ctx: Dict[str, Any] = { - "env": env_map, - "os": ctx.platform.os, - "arch": ctx.platform.arch, - } - if extra: - template_ctx.update(extra) - return template_ctx - - -def _render_template_value(value: Any, template_ctx: Dict[str, Any]) -> Any: - if isinstance(value, str): - return substitute_template(value, template_ctx) - if isinstance(value, list): - return [_render_template_value(item, template_ctx) for item in value] - if isinstance(value, dict): - return {k: _render_template_value(v, template_ctx) for k, v in value.items()} - return value - - -def _linux_detect_package_manager() -> Optional[str]: - if shutil.which("apt") or shutil.which("apt-get"): - return "apt" - if shutil.which("dnf"): - return "dnf" - return None - - -def _resolve_package_manager(ctx: FlowContext, profile_cfg: dict) -> str: - explicit = profile_cfg.get("package-manager") - if isinstance(explicit, str) and explicit: - return explicit - - profile_os = profile_cfg.get("os") - if profile_os == "macos": - return "brew" - if profile_os == "linux": - detected = _linux_detect_package_manager() - if detected: - return detected - raise RuntimeError("Unable to auto-detect package manager (expected apt or dnf)") - - raise RuntimeError("Profile 'os' must be set to 'linux' or 'macos'") - - -def _get_package_catalog(ctx: FlowContext) -> Dict[str, Dict[str, Any]]: - raw = ctx.manifest.get("packages", []) - catalog: Dict[str, Dict[str, Any]] = {} - - if isinstance(raw, dict): - # Also support mapping form: packages: {name: {...}} - for name, definition in raw.items(): - if not isinstance(definition, dict): - continue - pkg = dict(definition) - pkg["name"] = str(pkg.get("name") or name) - pkg.setdefault("type", "pkg") - catalog[pkg["name"]] = pkg - return catalog - - if not isinstance(raw, list): - return catalog - - for item in raw: - if not isinstance(item, dict): - continue - name = item.get("name") - if not isinstance(name, str) or not name: - continue - pkg = dict(item) - pkg.setdefault("type", "pkg") - catalog[name] = pkg - - return catalog - - -def _normalize_profile_package_entry(entry: Any) -> Dict[str, Any]: - if isinstance(entry, str): - if "/" in entry: - prefix, name = entry.split("/", 1) - if prefix in PACKAGE_TYPES and name: - return {"name": name, "type": prefix} - return {"name": entry} - - if isinstance(entry, dict): - if not isinstance(entry.get("name"), str) or not entry["name"]: - raise RuntimeError("Package object entries must include a non-empty 'name'") - return dict(entry) - - raise RuntimeError(f"Unsupported package entry: {entry!r}") - - -def _resolve_package_spec( - catalog: Dict[str, Dict[str, Any]], - profile_entry: Dict[str, Any], -) -> Dict[str, Any]: - name = profile_entry["name"] - base = dict(catalog.get(name, {})) - merged = dict(base) - merged.update(profile_entry) - merged["name"] = name - - pkg_type = merged.get("type") or "pkg" - if pkg_type not in PACKAGE_TYPES: - raise RuntimeError(f"Unsupported package type '{pkg_type}' for package '{name}'") - merged["type"] = pkg_type - - return merged - - -def _resolve_pkg_source_name(spec: Dict[str, Any], package_manager: str) -> str: - sources = spec.get("sources", {}) - if not isinstance(sources, dict): - return spec["name"] - - keys = [package_manager] - if package_manager == "apt": - keys.append("apt-get") - if package_manager == "apt-get": - keys.append("apt") - - for key in keys: - value = sources.get(key) - if isinstance(value, str) and value: - return value - - return spec["name"] - - -def _platform_lookup_keys(ctx: FlowContext) -> List[str]: - keys = [ctx.platform.platform] - - if ctx.platform.os == "macos": - keys.append(f"darwin-{ctx.platform.arch}") - - if ctx.platform.arch == "x64": - keys.append(f"{ctx.platform.os}-amd64") - if ctx.platform.os == "macos": - keys.append("darwin-amd64") - - unique: List[str] = [] - for key in keys: - if key not in unique: - unique.append(key) - return unique - - -def _resolve_binary_platform_vars(ctx: FlowContext, spec: Dict[str, Any]) -> Dict[str, str]: - platform_vars = { - "os": ctx.platform.os, - "arch": ctx.platform.arch, - } - - platform_map = spec.get("platform-map", {}) - if isinstance(platform_map, dict): - for key in _platform_lookup_keys(ctx): - mapping = platform_map.get(key) - if isinstance(mapping, dict): - for mk, mv in mapping.items(): - if isinstance(mv, str): - platform_vars[mk] = mv - break - - return platform_vars - - -def _resolve_binary_asset(ctx: FlowContext, spec: Dict[str, Any], template_ctx: Dict[str, Any]) -> str: - assets = spec.get("assets", {}) - if isinstance(assets, dict) and assets: - for key in _platform_lookup_keys(ctx): - value = assets.get(key) - if isinstance(value, str) and value: - return substitute_template(value, template_ctx) - raise RuntimeError( - f"No binary asset mapping for platform {ctx.platform.platform} in package '{spec['name']}'" - ) - - pattern = spec.get("asset-pattern") - if not isinstance(pattern, str) or not pattern: - raise RuntimeError( - f"Binary package '{spec['name']}' must define either 'assets' or 'asset-pattern'" - ) - - return substitute_template(pattern, template_ctx) - - -def _resolve_binary_download_url( - spec: Dict[str, Any], - asset_name: str, - template_ctx: Dict[str, Any], -) -> str: - source = spec.get("source") - if not isinstance(source, str) or not source: - raise RuntimeError(f"Binary package '{spec['name']}' is missing 'source'") - - version = str(spec.get("version", "")) - if source.startswith("github:"): - owner_repo = source[len("github:") :] - if not owner_repo: - raise RuntimeError(f"Invalid github source in package '{spec['name']}'") - if not version: - raise RuntimeError(f"Binary package '{spec['name']}' requires 'version'") - return f"https://github.com/{owner_repo}/releases/download/v{version}/{asset_name}" - - rendered_source = substitute_template(source, template_ctx) - if not asset_name: - return rendered_source - - if rendered_source.endswith(asset_name): - return rendered_source - - if rendered_source.endswith("/"): - return rendered_source + asset_name - - return f"{rendered_source}/{asset_name}" - - -def _strip_prefix(path: Path, prefix: Path) -> Path: - try: - return path.relative_to(prefix) - except ValueError: - return path - - -def _is_under(path: Path, root: Path) -> bool: - try: - path.resolve().relative_to(root.resolve()) - return True - except ValueError: - return False - - -def _validate_declared_install_path(package_name: str, declared_path: Path) -> None: - if declared_path.is_absolute(): - raise RuntimeError( - f"Install path for '{package_name}' must be relative: {declared_path}" - ) - if any(part == ".." for part in declared_path.parts): - raise RuntimeError( - f"Install path for '{package_name}' must not include parent traversal: {declared_path}" - ) - - -def _install_destination(kind: str) -> Path: - home = Path.home() - if kind == "bin": - return home / ".local" / "bin" - if kind == "share": - return home / ".local" / "share" - if kind == "man": - return home / ".local" / "share" / "man" - if kind == "lib": - return home / ".local" / "lib" - raise RuntimeError(f"Unsupported install section: {kind}") - - -def _install_strip_prefix(kind: str) -> Path: - if kind == "bin": - return Path("bin") - if kind == "share": - return Path("share") - if kind == "man": - return Path("share") / "man" - if kind == "lib": - return Path("lib") - return Path(".") - - -def _copy_install_item(kind: str, src: Path, declared_path: Path) -> None: - destination_root = _install_destination(kind) - stripped = _strip_prefix(declared_path, _install_strip_prefix(kind)) - destination = destination_root / stripped - - destination.parent.mkdir(parents=True, exist_ok=True) - if src.is_dir(): - shutil.copytree(src, destination, dirs_exist_ok=True) - else: - shutil.copy2(src, destination) - if kind == "bin": - destination.chmod(destination.stat().st_mode | 0o111) - - -def _install_binary_package( - ctx: FlowContext, - spec: Dict[str, Any], - extra_env: Dict[str, str], - dry_run: bool, -) -> None: - version = str(spec.get("version", "")) - platform_vars = _resolve_binary_platform_vars(ctx, spec) - template_ctx = _profile_template_context( - ctx, - extra_env, - { - "name": spec["name"], - "version": version, - **platform_vars, - }, - ) - - asset_name = _resolve_binary_asset(ctx, spec, template_ctx) - template_ctx["asset"] = asset_name - download_url = _resolve_binary_download_url(spec, asset_name, template_ctx) - template_ctx["downloadUrl"] = download_url - - if dry_run: - ctx.console.info(f"[{spec['name']}] Would download: {download_url}") - return - - install = spec.get("install", {}) - if not isinstance(install, dict) or not install: - raise RuntimeError(f"Binary package '{spec['name']}' must define non-empty 'install'") - - with tempfile.TemporaryDirectory(prefix=f"flow-{spec['name']}-") as tmp: - tmp_dir = Path(tmp) - archive_path = tmp_dir / asset_name - - ctx.console.info(f"Downloading {spec['name']} from {download_url}") - with urllib.request.urlopen(download_url, timeout=60) as response: - archive_path.write_bytes(response.read()) - - extracted = tmp_dir / "extract" - extracted.mkdir(parents=True, exist_ok=True) - try: - shutil.unpack_archive(str(archive_path), str(extracted)) - except (shutil.ReadError, ValueError) as e: - raise RuntimeError( - f"Could not extract archive for '{spec['name']}': {e}" - ) from e - - extract_dir_value = str(spec.get("extract-dir", ".")) - extract_dir_value = substitute_template(extract_dir_value, template_ctx) - if extract_dir_value == ".": - source_root = extracted - else: - source_root = extracted / extract_dir_value - - if not source_root.exists(): - raise RuntimeError( - f"extract-dir '{extract_dir_value}' not found for package '{spec['name']}'" - ) - source_root_resolved = source_root.resolve() - - for kind in ("bin", "share", "man", "lib"): - items = install.get(kind, []) - if not isinstance(items, list): - continue - - for raw_item in items: - if not isinstance(raw_item, str): - continue - - rendered = substitute_template(raw_item, template_ctx) - declared_path = Path(rendered) - _validate_declared_install_path(spec["name"], declared_path) - - src = (source_root / declared_path).resolve() - if not _is_under(src, source_root_resolved): - raise RuntimeError( - f"Install path escapes extract-dir for '{spec['name']}': {declared_path}" - ) - if not src.exists(): - raise RuntimeError( - f"Install path not found for '{spec['name']}': {declared_path}" - ) - _copy_install_item(kind, src, declared_path) - - -def _script_uses_sudo(script: str) -> bool: - return re.search(r"(^|\s)sudo(\s|$)", script) is not None - - -def _run_script( - ctx: FlowContext, - script: str, - template_ctx: Dict[str, Any], - *, - dry_run: bool, - allow_sudo: bool, - description: str, -) -> None: - rendered = substitute_template(script, template_ctx) - if not allow_sudo and _script_uses_sudo(rendered): - ctx.console.warn(f"Skipping {description}: sudo is blocked (set allow_sudo: true)") - return - - if dry_run: - ctx.console.info(f"Would run {description}:") - for line in rendered.splitlines(): - if line.strip(): - print(f" {line}") - return - - run_command(rendered, ctx.console) - - -def _run_one_command(ctx: FlowContext, command: str, dry_run: bool) -> None: - if dry_run: - print(f" $ {command}") - return - run_command(command, ctx.console) - - -def _ensure_shell_installed( - ctx: FlowContext, - shell_name: str, - package_manager: str, - package_catalog: Dict[str, Dict[str, Any]], - extra_env: Dict[str, str], - *, - dry_run: bool, - pm_state: Dict[str, bool], -) -> None: - if shutil.which(shell_name): - return - - shell_spec = package_catalog.get(shell_name, {"name": shell_name, "type": "pkg"}) - shell_spec = dict(shell_spec) - shell_spec["name"] = shell_name - shell_spec.setdefault("type", "pkg") - - ctx.console.info(f"Shell '{shell_name}' is missing; installing it first") - _install_package( - ctx, - shell_spec, - package_manager, - extra_env, - dry_run=dry_run, - pm_state=pm_state, - ) - - -def _set_shell(ctx: FlowContext, shell_name: str, *, dry_run: bool) -> None: - shell_path = shutil.which(shell_name) - if not shell_path: - raise RuntimeError(f"Shell not found after installation: {shell_name}") - - quoted_path = shlex.quote(shell_path) - quoted_user = shlex.quote(os.environ.get("USER", "")) - - try: - with open("/etc/shells", "r", encoding="utf-8") as handle: - shell_lines = handle.read() - except OSError: - shell_lines = "" - - if shell_path not in shell_lines: - _run_one_command( - ctx, - f"echo {quoted_path} | sudo tee -a /etc/shells >/dev/null", - dry_run, - ) - - _run_one_command(ctx, f"sudo chsh -s {quoted_path} {quoted_user}", dry_run) - - -def _set_hostname(ctx: FlowContext, hostname: str, *, dry_run: bool) -> None: - quoted = shlex.quote(hostname) - if ctx.platform.os == "macos": - _run_one_command(ctx, f"sudo scutil --set ComputerName {quoted}", dry_run) - _run_one_command(ctx, f"sudo scutil --set HostName {quoted}", dry_run) - _run_one_command(ctx, f"sudo scutil --set LocalHostName {quoted}", dry_run) - else: - _run_one_command(ctx, f"sudo hostnamectl set-hostname {quoted}", dry_run) - - -def _set_locale(ctx: FlowContext, locale: str, *, dry_run: bool) -> None: - if ctx.platform.os != "linux": - return - quoted = shlex.quote(locale) - _run_one_command(ctx, f"sudo locale-gen {quoted}", dry_run) - _run_one_command(ctx, f"sudo update-locale LANG={quoted}", dry_run) - - -def _ensure_required_variables(profile_cfg: Dict[str, Any], env_map: Dict[str, str]) -> None: - requires = profile_cfg.get("requires", []) - if not isinstance(requires, list): - raise RuntimeError("Profile 'requires' must be a list") - - missing = [] - for key in requires: - if not isinstance(key, str) or not key: - continue - if env_map.get(key, "") == "": - missing.append(key) - - if missing: - raise RuntimeError( - "Missing required environment variables: " - + ", ".join(missing) - + ". Export them or pass with --var KEY=VALUE." - ) - - -def _pm_update_command(pm: str) -> str: - if pm in ("apt", "apt-get"): - return "sudo apt update -qq" - if pm == "dnf": - return "sudo dnf makecache -q" - if pm == "brew": - return "brew update" - return f"sudo {shlex.quote(pm)} update" - - -def _pm_install_command(pm: str, packages: List[str], pkg_type: str) -> str: - pkg_args = " ".join(shlex.quote(pkg) for pkg in packages) - if pm in ("apt", "apt-get"): - return f"sudo apt install -y {pkg_args}" - if pm == "dnf": - return f"sudo dnf install -y {pkg_args}" - if pm == "brew" and pkg_type == "cask": - return f"brew install --cask {pkg_args}" - if pm == "brew": - return f"brew install {pkg_args}" - return f"sudo {shlex.quote(pm)} install {pkg_args}" - - -def _install_package( - ctx: FlowContext, - spec: Dict[str, Any], - package_manager: str, - extra_env: Dict[str, str], - *, - dry_run: bool, - pm_state: Dict[str, bool], -) -> None: - pkg_type = spec.get("type", "pkg") - - if pkg_type in {"pkg", "cask"} and not pm_state.get("updated"): - _run_one_command(ctx, _pm_update_command(package_manager), dry_run) - pm_state["updated"] = True - - if pkg_type == "pkg": - package_name = _resolve_pkg_source_name(spec, package_manager) - _run_one_command( - ctx, - _pm_install_command(package_manager, [package_name], "pkg"), - dry_run, - ) - return - - if pkg_type == "cask": - if package_manager != "brew": - ctx.console.warn(f"Skipping cask package on non-brew system: {spec['name']}") - return - package_name = _resolve_pkg_source_name(spec, "brew") - _run_one_command( - ctx, - _pm_install_command(package_manager, [package_name], "cask"), - dry_run, - ) - return - - if pkg_type == "binary": - _install_binary_package(ctx, spec, extra_env, dry_run) - return - - raise RuntimeError(f"Unsupported package type: {pkg_type}") - - -def _run_package_post_install( - ctx: FlowContext, - spec: Dict[str, Any], - extra_env: Dict[str, str], - *, - dry_run: bool, -) -> None: - script = spec.get("post-install") - if not isinstance(script, str) or not script.strip(): - return - - allow_sudo = bool(spec.get("allow_sudo", False)) - extra_ctx = { - "name": spec["name"], - "version": str(spec.get("version", "")), - } - if spec.get("type") == "binary": - extra_ctx.update(_resolve_binary_platform_vars(ctx, spec)) - - template_ctx = _profile_template_context( - ctx, - extra_env, - extra_ctx, - ) - _run_script( - ctx, - script, - template_ctx, - dry_run=dry_run, - allow_sudo=allow_sudo, - description=f"post-install hook for {spec['name']}", - ) - - -def _run_runcmd( - ctx: FlowContext, - profile_cfg: Dict[str, Any], - extra_env: Dict[str, str], - *, - dry_run: bool, -) -> None: - commands = profile_cfg.get("runcmd", []) - if not isinstance(commands, list): - raise RuntimeError("Profile 'runcmd' must be a list") - - template_ctx = _profile_template_context(ctx, extra_env) - for command in commands: - if not isinstance(command, str) or not command.strip(): - continue - rendered = substitute_template(command, template_ctx) - _run_one_command(ctx, rendered, dry_run) - - -def _run_ssh_keygen( - ctx: FlowContext, - profile_cfg: Dict[str, Any], - extra_env: Dict[str, str], - *, - dry_run: bool, -) -> None: - ssh_keygen = profile_cfg.get("ssh-keygen", profile_cfg.get("ssh_keygen", [])) - if not isinstance(ssh_keygen, list): - raise RuntimeError("Profile 'ssh-keygen' must be a list") - - template_ctx = _profile_template_context(ctx, extra_env) - ssh_dir = Path.home() / ".ssh" - if dry_run: - print(f" $ mkdir -p {ssh_dir}") - else: - ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - - for entry in ssh_keygen: - if not isinstance(entry, dict): - continue - - key_type = str(entry.get("type", "ed25519")) - filename = str(entry.get("filename", f"id_{key_type}")) - key_path = ssh_dir / filename - if key_path.exists(): - ctx.console.warn(f"SSH key already exists: {key_path}") - continue - - comment = str(_render_template_value(entry.get("comment", ""), template_ctx)) - bits = entry.get("bits") - - command = [ - "ssh-keygen", - "-t", - shlex.quote(key_type), - "-f", - shlex.quote(str(key_path)), - "-N", - '""', - "-C", - shlex.quote(comment), - ] - if bits: - command.extend(["-b", shlex.quote(str(bits))]) - - _run_one_command(ctx, " ".join(command), dry_run) - _run_one_command(ctx, f"chmod 600 {shlex.quote(str(key_path))}", dry_run) - - -def _run_post_link( - ctx: FlowContext, - profile_cfg: Dict[str, Any], - extra_env: Dict[str, str], - *, - dry_run: bool, -) -> None: - script = profile_cfg.get("post-link") - if not script: - script = profile_cfg.get("post-config") - - if not isinstance(script, str) or not script.strip(): - return - - template_ctx = _profile_template_context(ctx, extra_env) - _run_script( - ctx, - script, - template_ctx, - dry_run=dry_run, - allow_sudo=True, - description="post-link hook", - ) - - -def _auto_link_profile_configs(ctx: FlowContext, profile_name: str, *, dry_run: bool) -> None: - link_args = argparse.Namespace( - packages=[], - profile=profile_name, - copy=False, - force=False, - dry_run=dry_run, - ) - dotfiles_cmd.run_link(ctx, link_args) - - -def run_bootstrap(ctx: FlowContext, args): - profiles = _get_profiles(ctx) - if not profiles: - ctx.console.error("No profiles found in manifest.") - sys.exit(1) - - profile_name = args.profile - if not profile_name: - if len(profiles) == 1: - profile_name = next(iter(profiles)) - else: - ctx.console.error( - f"Multiple profiles available. Specify with --profile: {', '.join(sorted(profiles.keys()))}" - ) - sys.exit(1) - - if profile_name not in profiles: - ctx.console.error( - f"Profile not found: {profile_name}. Available: {', '.join(sorted(profiles.keys()))}" - ) - sys.exit(1) - - profile_cfg = profiles[profile_name] - if not isinstance(profile_cfg, dict): - ctx.console.error(f"Profile '{profile_name}' must be a mapping") - sys.exit(1) - - profile_os = profile_cfg.get("os") - if profile_os not in {"linux", "macos"}: - ctx.console.error( - f"Profile '{profile_name}' must define os: linux|macos" - ) - sys.exit(1) - - if profile_os != ctx.platform.os: - ctx.console.error( - f"Profile '{profile_name}' targets '{profile_os}', current OS is '{ctx.platform.os}'" - ) - sys.exit(1) - - try: - cli_vars = _parse_variables(args.var) - package_manager = _resolve_package_manager(ctx, profile_cfg) - _ensure_required_variables(profile_cfg, {**os.environ, **cli_vars}) - except ValueError as e: - ctx.console.error(str(e)) - sys.exit(1) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - package_catalog = _get_package_catalog(ctx) - pm_state = {"updated": False} - - template_ctx = _profile_template_context(ctx, cli_vars) - - if "hostname" in profile_cfg: - hostname = str(_render_template_value(profile_cfg["hostname"], template_ctx)) - _set_hostname(ctx, hostname, dry_run=args.dry_run) - - locale = str(profile_cfg.get("locale", DEFAULT_LOCALE)) - _set_locale(ctx, locale, dry_run=args.dry_run) - - shell_name = profile_cfg.get("shell") - if isinstance(shell_name, str) and shell_name: - _ensure_shell_installed( - ctx, - shell_name, - package_manager, - package_catalog, - cli_vars, - dry_run=args.dry_run, - pm_state=pm_state, - ) - - profile_packages = profile_cfg.get("packages", []) - if not isinstance(profile_packages, list): - ctx.console.error("Profile 'packages' must be a list") - sys.exit(1) - - for raw_entry in profile_packages: - try: - normalized = _normalize_profile_package_entry(raw_entry) - spec = _resolve_package_spec(package_catalog, normalized) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - if spec.get("skip"): - ctx.console.info(f"Skipping package {spec['name']} (skip=true)") - continue - - ctx.console.info(f"Installing package: {spec['name']} ({spec['type']})") - try: - _install_package( - ctx, - spec, - package_manager, - cli_vars, - dry_run=args.dry_run, - pm_state=pm_state, - ) - _run_package_post_install(ctx, spec, cli_vars, dry_run=args.dry_run) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - if isinstance(shell_name, str) and shell_name: - try: - _set_shell(ctx, shell_name, dry_run=args.dry_run) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - try: - _run_ssh_keygen(ctx, profile_cfg, cli_vars, dry_run=args.dry_run) - _run_runcmd(ctx, profile_cfg, cli_vars, dry_run=args.dry_run) - _auto_link_profile_configs(ctx, profile_name, dry_run=args.dry_run) - _run_post_link(ctx, profile_cfg, cli_vars, dry_run=args.dry_run) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - except SystemExit: - raise - - -def run_list(ctx: FlowContext, args): - profiles = _get_profiles(ctx) - if not profiles: - ctx.console.info("No profiles defined in manifest.") - return - - headers = ["PROFILE", "OS", "PM", "PACKAGES", "REQUIRES"] - rows = [] - for name, profile_cfg in sorted(profiles.items()): - if not isinstance(profile_cfg, dict): - continue - os_name = str(profile_cfg.get("os", "?")) - pm = str(profile_cfg.get("package-manager", "auto")) - packages = profile_cfg.get("packages", []) - package_count = len(packages) if isinstance(packages, list) else 0 - requires = profile_cfg.get("requires", []) - requires_count = len(requires) if isinstance(requires, list) else 0 - rows.append([name, os_name, pm, str(package_count), str(requires_count)]) - - ctx.console.table(headers, rows) - - -def run_show(ctx: FlowContext, args): - profiles = _get_profiles(ctx) - profile_name = args.profile - - if profile_name not in profiles: - ctx.console.error( - f"Profile not found: {profile_name}. Available: {', '.join(sorted(profiles.keys()))}" - ) - sys.exit(1) - - print(yaml.safe_dump({profile_name: profiles[profile_name]}, sort_keys=False).rstrip()) - - -def run_packages(ctx: FlowContext, args): - profiles = _get_profiles(ctx) - if not profiles: - ctx.console.info("No profiles defined in manifest.") - return - - if args.profile: - if args.profile not in profiles: - ctx.console.error( - f"Profile not found: {args.profile}. Available: {', '.join(sorted(profiles.keys()))}" - ) - sys.exit(1) - selected_profiles = [(args.profile, profiles[args.profile])] - else: - selected_profiles = sorted(profiles.items()) - - package_catalog = _get_package_catalog(ctx) - rows = [] - for profile_name, profile_cfg in selected_profiles: - if not isinstance(profile_cfg, dict): - continue - - pm = _resolve_package_manager(ctx, profile_cfg) - profile_packages = profile_cfg.get("packages", []) - if not isinstance(profile_packages, list): - continue - - for raw_entry in profile_packages: - normalized = _normalize_profile_package_entry(raw_entry) - spec = _resolve_package_spec(package_catalog, normalized) - - if args.resolved: - if spec["type"] in {"pkg", "cask"}: - resolved = _resolve_pkg_source_name(spec, pm) - else: - resolved = spec.get("asset-pattern", spec.get("source", "")) - rows.append([profile_name, pm, spec["type"], spec["name"], str(resolved)]) - else: - rows.append([profile_name, spec["type"], spec["name"]]) - - if not rows: - ctx.console.info("No packages defined in selected profile(s).") - return - - if args.resolved: - ctx.console.table(["PROFILE", "PM", "TYPE", "PACKAGE", "RESOLVED"], rows) - else: - ctx.console.table(["PROFILE", "TYPE", "PACKAGE"], rows) + _sync_service_module() + return _service.register(subparsers) + + +def _get_profiles(ctx): + _sync_service_module() + return _service._get_profiles(ctx) + + +def _parse_variables(var_args: list): + _sync_service_module() + return _service._parse_variables(var_args) + + +def _profile_template_context(ctx, extra_env, extra=None): + _sync_service_module() + return _service._profile_template_context(ctx, extra_env, extra) + + +def _render_template_value(value, template_ctx): + _sync_service_module() + return _service._render_template_value(value, template_ctx) + + +def _linux_detect_package_manager(): + _sync_service_module() + return _service._linux_detect_package_manager() + + +def _resolve_package_manager(ctx, profile_cfg): + _sync_service_module() + return _service._resolve_package_manager(ctx, profile_cfg) + + +def _get_package_catalog(ctx): + _sync_service_module() + return _service._get_package_catalog(ctx) + + +def _normalize_profile_package_entry(entry): + _sync_service_module() + return _service._normalize_profile_package_entry(entry) + + +def _resolve_package_spec(catalog, profile_entry): + _sync_service_module() + return _service._resolve_package_spec(catalog, profile_entry) + + +def _resolve_pkg_source_name(spec, package_manager): + _sync_service_module() + return _service._resolve_pkg_source_name(spec, package_manager) + + +def _install_binary_package(ctx, spec, extra_env, dry_run): + _sync_service_module() + return _service._install_binary_package(ctx, spec, extra_env, dry_run) + + +def _copy_install_item(kind, src, declared_path): + return _SERVICE_COPY_INSTALL_ITEM(kind, src, declared_path) + + +def _ensure_required_variables(profile_cfg, env_map): + _sync_service_module() + return _service._ensure_required_variables(profile_cfg, env_map) + + +def run_bootstrap(ctx, args): + _sync_service_module() + return _service.run_bootstrap(ctx, args) + + +def run_list(ctx, args): + _sync_service_module() + return _service.run_list(ctx, args) + + +def run_show(ctx, args): + _sync_service_module() + return _service.run_show(ctx, args) + + +def run_packages(ctx, args): + _sync_service_module() + return _service.run_packages(ctx, args) diff --git a/src/flow/commands/container.py b/src/flow/commands/container.py index 8eaeb94..d43cf32 100644 --- a/src/flow/commands/container.py +++ b/src/flow/commands/container.py @@ -3,350 +3,119 @@ import os import shutil import subprocess -import sys -from flow.core.config import FlowContext - -DEFAULT_REGISTRY = "registry.tomastm.com" -DEFAULT_TAG = "latest" -CONTAINER_HOME = "/home/dev" +from flow.services.containers import ( + CONTAINER_HOME, + DEFAULT_REGISTRY, + DEFAULT_TAG, + ContainerService, + container_name as _cname, + parse_image_ref as _parse_image_ref, + runtime as _runtime_service, +) def register(subparsers): - p = subparsers.add_parser("dev", help="Manage development containers") - sub = p.add_subparsers(dest="dev_command") + parser = subparsers.add_parser("dev", help="Manage development containers") + sub = parser.add_subparsers(dest="dev_command") - # create create = sub.add_parser("create", help="Create and start a development container") create.add_argument("name", help="Container name") create.add_argument("-i", "--image", required=True, help="Container image") create.add_argument("-p", "--project", help="Path to project directory") create.set_defaults(handler=run_create) - # exec exec_cmd = sub.add_parser("exec", help="Execute command in a container") exec_cmd.add_argument("name", help="Container name") exec_cmd.add_argument("cmd", nargs="*", help="Command to run (default: interactive shell)") exec_cmd.set_defaults(handler=run_exec) - # connect connect = sub.add_parser("connect", help="Attach to container tmux session") connect.add_argument("name", help="Container name") connect.set_defaults(handler=run_connect) - # list - ls = sub.add_parser("list", help="List development containers") - ls.set_defaults(handler=run_list) + list_parser = sub.add_parser("list", help="List development containers") + list_parser.set_defaults(handler=run_list) - # stop stop = sub.add_parser("stop", help="Stop a development container") stop.add_argument("name", help="Container name") stop.add_argument("--kill", action="store_true", help="Kill instead of graceful stop") stop.set_defaults(handler=run_stop) - # remove remove = sub.add_parser("remove", aliases=["rm"], help="Remove a development container") remove.add_argument("name", help="Container name") remove.add_argument("-f", "--force", action="store_true", help="Force removal") remove.set_defaults(handler=run_remove) - # respawn respawn = sub.add_parser("respawn", help="Respawn all tmux panes for a session") respawn.add_argument("name", help="Session/container name") respawn.set_defaults(handler=run_respawn) - p.set_defaults(handler=lambda ctx, args: p.print_help()) + parser.set_defaults(handler=lambda ctx, args: parser.print_help()) def _runtime(): - for rt in ("docker", "podman"): - if shutil.which(rt): - return rt - raise RuntimeError("No container runtime found (docker or podman)") - - -def _cname(name: str) -> str: - """Normalize to dev- prefix.""" - return name if name.startswith("dev-") else f"dev-{name}" - - -def _parse_image_ref( - image: str, - *, - default_registry: str = DEFAULT_REGISTRY, - default_tag: str = DEFAULT_TAG, -): - """Parse image shorthand into (full_ref, repo, tag, label).""" - registry = default_registry - tag = default_tag - - if image.startswith("docker/"): - registry = "docker.io" - image = f"library/{image.split('/', 1)[1]}" - elif image.startswith("tm0/"): - registry = default_registry - image = image.split("/", 1)[1] - elif "/" in image: - prefix, remainder = image.split("/", 1) - if "." in prefix or ":" in prefix or prefix == "localhost": - registry = prefix - image = remainder - - if ":" in image.split("/")[-1]: - tag = image.rsplit(":", 1)[1] - image = image.rsplit(":", 1)[0] - - repo = image - full_ref = f"{registry}/{repo}:{tag}" - label_prefix = registry.rsplit(".", 1)[0].rsplit(".", 1)[-1] if "." in registry else registry - label = f"{label_prefix}/{repo.split('/')[-1]}" - - return full_ref, repo, tag, label + return _runtime_service() def _container_exists(rt: str, cname: str) -> bool: result = subprocess.run( [rt, "container", "ls", "-a", "--format", "{{.Names}}"], - capture_output=True, text=True, + capture_output=True, + text=True, + check=False, ) - return cname in result.stdout.strip().split("\n") + return cname in result.stdout.strip().splitlines() def _container_running(rt: str, cname: str) -> bool: result = subprocess.run( [rt, "container", "ls", "--format", "{{.Names}}"], - capture_output=True, text=True, + capture_output=True, + text=True, + check=False, ) - return cname in result.stdout.strip().split("\n") + return cname in result.stdout.strip().splitlines() -def run_create(ctx: FlowContext, args): - rt = _runtime() - cname = _cname(args.name) - - if _container_exists(rt, cname): - ctx.console.error(f"Container already exists: {cname}") - sys.exit(1) - - project_path = os.path.realpath(args.project) if args.project else None - if project_path and not os.path.isdir(project_path): - ctx.console.error(f"Invalid project path: {project_path}") - sys.exit(1) - - full_ref, _, _, _ = _parse_image_ref( - args.image, - default_registry=ctx.config.container_registry, - default_tag=ctx.config.container_tag, - ) - - cmd = [ - rt, "run", "-d", - "--name", cname, - "--label", "dev=true", - "--label", f"dev.name={args.name}", - "--label", f"dev.image_ref={full_ref}", - "--network", "host", - "--init", - ] - - if project_path: - cmd.extend(["-v", f"{project_path}:/workspace"]) - cmd.extend(["--label", f"dev.project_path={project_path}"]) - - docker_sock = "/var/run/docker.sock" - if os.path.exists(docker_sock): - cmd.extend(["-v", f"{docker_sock}:{docker_sock}"]) - - home = os.path.expanduser("~") - if os.path.isdir(f"{home}/.ssh"): - cmd.extend(["-v", f"{home}/.ssh:{CONTAINER_HOME}/.ssh:ro"]) - if os.path.isfile(f"{home}/.npmrc"): - cmd.extend(["-v", f"{home}/.npmrc:{CONTAINER_HOME}/.npmrc:ro"]) - if os.path.isdir(f"{home}/.npm"): - cmd.extend(["-v", f"{home}/.npm:{CONTAINER_HOME}/.npm"]) - - # Add docker group if available - try: - import grp - docker_gid = str(grp.getgrnam("docker").gr_gid) - cmd.extend(["--group-add", docker_gid]) - except (KeyError, ImportError): - pass - - cmd.extend([full_ref, "sleep", "infinity"]) - subprocess.run(cmd, check=True) - ctx.console.success(f"Created and started container: {cname}") +def run_create(ctx, args): + ContainerService(ctx).run_create(args) -def run_exec(ctx: FlowContext, args): - rt = _runtime() - cname = _cname(args.name) - - if not _container_running(rt, cname): - ctx.console.error(f"Container {cname} not running") - sys.exit(1) - - if args.cmd: - exec_cmd = [rt, "exec"] - if sys.stdin.isatty(): - exec_cmd.extend(["-it"]) - exec_cmd.append(cname) - exec_cmd.extend(args.cmd) - result = subprocess.run(exec_cmd) - sys.exit(result.returncode) - - # No command — try shells in order; 126/127 means the shell binary - # wasn't found, so we fall through. Any other exit code means the user - # exited the shell normally and we respect it. - for shell in ("zsh -l", "bash -l", "sh"): - parts = shell.split() - exec_cmd = [rt, "exec", "--detach-keys", "ctrl-q,ctrl-p", "-it", cname] + parts - result = subprocess.run(exec_cmd) - if result.returncode not in (126, 127): - sys.exit(result.returncode) - - ctx.console.error(f"Unable to start an interactive shell in {cname}") - sys.exit(1) +def run_exec(ctx, args): + ContainerService(ctx).run_exec(args) -def run_connect(ctx: FlowContext, args): - rt = _runtime() - cname = _cname(args.name) - - if not _container_exists(rt, cname): - ctx.console.error(f"Container does not exist: {cname}") - sys.exit(1) - - if not _container_running(rt, cname): - subprocess.run([rt, "start", cname], capture_output=True) - - if not shutil.which("tmux"): - ctx.console.warn("tmux not found; falling back to direct exec") - args.cmd = [] - run_exec(ctx, args) - return - - # Get image label for env - result = subprocess.run( - [rt, "container", "inspect", cname, "--format", "{{ .Config.Image }}"], - capture_output=True, text=True, - ) - image_ref = result.stdout.strip() - _, _, _, image_label = _parse_image_ref(image_ref) - - # Create tmux session if needed - check = subprocess.run(["tmux", "has-session", "-t", cname], capture_output=True) - if check.returncode != 0: - ns = os.environ.get("DF_NAMESPACE", "") - plat = os.environ.get("DF_PLATFORM", "") - subprocess.run([ - "tmux", "new-session", "-ds", cname, - "-e", f"DF_IMAGE={image_label}", - "-e", f"DF_NAMESPACE={ns}", - "-e", f"DF_PLATFORM={plat}", - f"flow dev exec {args.name}", - ]) - subprocess.run([ - "tmux", "set-option", "-t", cname, - "default-command", f"flow dev exec {args.name}", - ]) - - if os.environ.get("TMUX"): - os.execvp("tmux", ["tmux", "switch-client", "-t", cname]) - else: - os.execvp("tmux", ["tmux", "attach", "-t", cname]) +def run_connect(ctx, args): + ContainerService(ctx).run_connect(args) -def run_list(ctx: FlowContext, args): - rt = _runtime() - result = subprocess.run( - [rt, "ps", "-a", "--filter", "label=dev=true", - "--format", '{{.Label "dev.name"}}|{{.Image}}|{{.Label "dev.project_path"}}|{{.Status}}'], - capture_output=True, text=True, - ) - - headers = ["NAME", "IMAGE", "PROJECT", "STATUS"] - rows = [] - for line in result.stdout.strip().split("\n"): - if not line: - continue - parts = line.split("|") - if len(parts) >= 4: - name, image, project, status = parts[0], parts[1], parts[2], parts[3] - # Shorten paths - home = os.path.expanduser("~") - if project.startswith(home): - project = "~" + project[len(home):] - rows.append([name, image, project, status]) - - if not rows: - ctx.console.info("No development containers found.") - return - - ctx.console.table(headers, rows) +def run_list(ctx, args): + ContainerService(ctx).run_list(args) -def run_stop(ctx: FlowContext, args): - rt = _runtime() - cname = _cname(args.name) - - if not _container_exists(rt, cname): - ctx.console.error(f"Container {cname} does not exist") - sys.exit(1) - - if args.kill: - ctx.console.info(f"Killing container {cname}...") - subprocess.run([rt, "kill", cname], check=True) - else: - ctx.console.info(f"Stopping container {cname}...") - subprocess.run([rt, "stop", cname], check=True) - - _tmux_fallback(cname) +def run_stop(ctx, args): + ContainerService(ctx).run_stop(args) -def run_remove(ctx: FlowContext, args): - rt = _runtime() - cname = _cname(args.name) - - if not _container_exists(rt, cname): - ctx.console.error(f"Container {cname} does not exist") - sys.exit(1) - - if args.force: - ctx.console.info(f"Removing container {cname} (force)...") - subprocess.run([rt, "rm", "-f", cname], check=True) - else: - ctx.console.info(f"Removing container {cname}...") - subprocess.run([rt, "rm", cname], check=True) - - _tmux_fallback(cname) +def run_remove(ctx, args): + ContainerService(ctx).run_remove(args) -def run_respawn(ctx: FlowContext, args): - if not shutil.which("tmux"): - ctx.console.error("tmux is required for respawn but was not found") - sys.exit(1) - cname = _cname(args.name) - result = subprocess.run( - ["tmux", "list-panes", "-t", cname, "-s", - "-F", "#{session_name}:#{window_index}.#{pane_index}"], - capture_output=True, text=True, - ) - for pane in result.stdout.strip().split("\n"): - if pane: - ctx.console.info(f"Respawning {pane}...") - subprocess.run(["tmux", "respawn-pane", "-t", pane]) +def run_respawn(ctx, args): + ContainerService(ctx).run_respawn(args) def _tmux_fallback(cname: str): - """If inside tmux in the target session, switch to default.""" if not os.environ.get("TMUX"): return result = subprocess.run( ["tmux", "display-message", "-p", "#S"], - capture_output=True, text=True, + capture_output=True, + text=True, + check=False, ) current = result.stdout.strip() if current == cname: - subprocess.run(["tmux", "new-session", "-ds", "default"], capture_output=True) - subprocess.run(["tmux", "switch-client", "-t", "default"]) + subprocess.run(["tmux", "new-session", "-ds", "default"], capture_output=True, check=False) + subprocess.run(["tmux", "switch-client", "-t", "default"], check=False) diff --git a/src/flow/commands/dotfiles.py b/src/flow/commands/dotfiles.py index 791d1f7..5c635b2 100644 --- a/src/flow/commands/dotfiles.py +++ b/src/flow/commands/dotfiles.py @@ -1,1105 +1,97 @@ -"""flow dotfiles — dotfile management with flat repo layout.""" +"""flow dotfiles — thin CLI adapter over the dotfiles service.""" -import argparse -import json -import os -import shlex -import shutil -import subprocess -import sys -from dataclasses import dataclass -from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple -import yaml - -from flow.core.config import FlowContext from flow.core.paths import DOTFILES_DIR, LINKED_STATE, MODULES_DIR +from flow.services import dotfiles as _service RESERVED_SHARED = "_shared" RESERVED_ROOT = "_root" MODULE_FILE = "_module.yaml" LINK_BACKUP_DIR = LINKED_STATE.parent / "link-backups" - -@dataclass -class LinkSpec: - source: Path - target: Path - package: str - is_directory_link: bool = False +LinkSpec = _service.LinkSpec +ModuleSpec = _service.ModuleSpec -@dataclass -class ModuleSpec: - package: str - source: str - ref_type: str - ref_value: str - package_dir: Path +def _sync_service_module() -> None: + _service.DOTFILES_DIR = DOTFILES_DIR + _service.MODULES_DIR = MODULES_DIR + _service.LINKED_STATE = LINKED_STATE + _service.LINK_BACKUP_DIR = LINKED_STATE.parent / "link-backups" + _service.RESERVED_SHARED = RESERVED_SHARED + _service.RESERVED_ROOT = RESERVED_ROOT + _service.MODULE_FILE = MODULE_FILE def register(subparsers): - p = subparsers.add_parser("dotfiles", aliases=["dot"], help="Manage dotfiles") - p.add_argument("--verbose", action="store_true", help="Show detailed output") - sub = p.add_subparsers(dest="dotfiles_command") - - init = sub.add_parser("init", help="Clone dotfiles repository") - init.add_argument("--repo", help="Override repository URL") - init.set_defaults(handler=run_init) - - link = sub.add_parser("link", help="Create symlinks for dotfile packages") - link.add_argument("packages", nargs="*", help="Specific packages to link (default: all)") - link.add_argument("--profile", help="Profile to use") - link.add_argument("--copy", action="store_true", help="Copy instead of symlink") - link.add_argument("--force", action="store_true", help="Overwrite existing files") - link.add_argument("--dry-run", action="store_true", help="Show what would be done") - link.set_defaults(handler=run_link) - - unlink = sub.add_parser("unlink", help="Remove dotfile symlinks") - unlink.add_argument("packages", nargs="*", help="Specific packages to unlink (default: all)") - unlink.set_defaults(handler=run_unlink) - - undo = sub.add_parser("undo", help="Undo latest dotfiles link transaction") - undo.set_defaults(handler=run_undo) - - status = sub.add_parser("status", help="Show dotfiles link status") - status.set_defaults(handler=run_status) - - sync = sub.add_parser("sync", help="Pull latest dotfiles from remote") - sync.add_argument("--relink", action="store_true", help="Run relink after pull") - sync.add_argument("--profile", help="Profile to use when relinking") - sync.set_defaults(handler=run_sync) - - modules = sub.add_parser("modules", help="Inspect and refresh external modules") - modules_sub = modules.add_subparsers(dest="dotfiles_modules_command") - - modules_list = modules_sub.add_parser("list", help="List detected module packages") - modules_list.add_argument("packages", nargs="*", help="Filter by package name") - modules_list.add_argument("--profile", help="Limit to shared + one profile") - modules_list.set_defaults(handler=run_modules_list) - - modules_sync = modules_sub.add_parser("sync", help="Refresh module checkouts") - modules_sync.add_argument("packages", nargs="*", help="Filter by package name") - modules_sync.add_argument("--profile", help="Limit to shared + one profile") - modules_sync.set_defaults(handler=run_modules_sync) - - modules.set_defaults(handler=run_modules_list) - - repo = sub.add_parser("repo", help="Manage dotfiles repository") - repo_sub = repo.add_subparsers(dest="dotfiles_repo_command") - - repo_status = repo_sub.add_parser("status", help="Show git status for dotfiles repo") - repo_status.set_defaults(handler=run_repo_status) - - repo_pull = repo_sub.add_parser("pull", help="Pull latest changes") - repo_pull.add_argument( - "--rebase", - dest="rebase", - action="store_true", - help="Use rebase strategy (default)", - ) - repo_pull.add_argument( - "--no-rebase", - dest="rebase", - action="store_false", - help="Disable rebase strategy", - ) - repo_pull.add_argument("--relink", action="store_true", help="Run relink after pull") - repo_pull.add_argument("--profile", help="Profile to use when relinking") - repo_pull.set_defaults(rebase=True) - repo_pull.set_defaults(handler=run_repo_pull) - - repo_push = repo_sub.add_parser("push", help="Push local changes") - repo_push.set_defaults(handler=run_repo_push) - - repo.set_defaults(handler=lambda ctx, args: repo.print_help()) - - relink = sub.add_parser("relink", help="Refresh symlinks after changes") - relink.add_argument("packages", nargs="*", help="Specific packages to relink (default: all)") - relink.add_argument("--profile", help="Profile to use") - relink.set_defaults(handler=run_relink) - - clean = sub.add_parser("clean", help="Remove broken symlinks") - clean.add_argument("--dry-run", action="store_true", help="Show what would be done") - clean.set_defaults(handler=run_clean) - - edit = sub.add_parser("edit", help="Edit package or path with auto-commit") - edit.add_argument("target", help="Package name or path inside dotfiles repo") - edit.add_argument("--no-commit", action="store_true", help="Skip auto-commit") - edit.set_defaults(handler=run_edit) - - p.set_defaults(handler=lambda ctx, args: p.print_help()) + _sync_service_module() + return _service.register(subparsers) -def _flow_config_dir(dotfiles_dir: Optional[Path] = None) -> Path: - return dotfiles_dir or DOTFILES_DIR - - -def _insert_spec( - desired: Dict[Path, LinkSpec], - *, - target: Path, - source: Path, - package: str, -) -> None: - existing = desired.get(target) - if existing is not None: - raise RuntimeError( - "Conflicting dotfile targets are not allowed: " - f"{target} from {existing.package} and {package}" - ) - - desired[target] = LinkSpec(source=source, target=target, package=package) - - -def _is_path_like_target(target: str) -> bool: - raw = Path(target) - return "/" in target or target.startswith(".") or raw.suffix != "" - - -def _module_cache_dir(spec: ModuleSpec) -> Path: - key = spec.package.replace("/", "--") - return MODULES_DIR / key - - -def _normalize_module_source(source: str, *, package_dir: Optional[Path] = None) -> str: - if source.startswith("github:"): - repo = source.split(":", 1)[1] - return f"https://github.com/{repo}.git" - - if "://" in source or source.startswith("git@"): - return source - - raw = Path(source).expanduser() - if raw.is_absolute(): - return str(raw) - - if package_dir is None: - return source - - return str((package_dir / raw).resolve()) - - -def _load_module_spec(package_dir: Path, package: str) -> ModuleSpec: - module_file = package_dir / MODULE_FILE - if not module_file.exists(): - raise RuntimeError(f"Module file not found: {module_file}") - - try: - with open(module_file, "r", encoding="utf-8") as handle: - raw = yaml.safe_load(handle) or {} - except yaml.YAMLError as e: - raise RuntimeError(f"Invalid YAML in {module_file}: {e}") from e - - if not isinstance(raw, dict): - raise RuntimeError(f"{module_file} must contain a mapping") - - source = raw.get("source") - if not isinstance(source, str) or not source: - raise RuntimeError(f"{module_file} must define non-empty 'source'") - - ref = raw.get("ref") - if not isinstance(ref, dict): - raise RuntimeError(f"{module_file} must define 'ref' mapping") - - choices = [key for key in ("branch", "tag", "commit") if isinstance(ref.get(key), str) and ref.get(key)] - if len(choices) != 1: - raise RuntimeError(f"{module_file} 'ref' must include exactly one of: branch, tag, commit") - - ref_type = choices[0] - ref_value = str(ref[ref_type]) - return ModuleSpec( - package=package, - source=_normalize_module_source(source, package_dir=package_dir), - ref_type=ref_type, - ref_value=ref_value, - package_dir=package_dir, - ) - - -def _run_git(dir_path: Path, *args: str, capture: bool = True) -> subprocess.CompletedProcess: - return subprocess.run( - ["git", "-C", str(dir_path)] + list(args), - capture_output=capture, - text=True, - check=False, - ) +def _flow_config_dir(dotfiles_dir=None): + _sync_service_module() + return _service._flow_config_dir(dotfiles_dir) def _pull_requires_ack(stdout: str, stderr: str) -> bool: - text = f"{stdout}\n{stderr}".strip() - if not text: - return False - - lowered = text.lower() - if "already up to date" in lowered or "already up-to-date" in lowered: - return False - - return True - - -def _pull_repo_before_edit(ctx: FlowContext, repo_dir: Path, *, verbose: bool = False) -> None: - ctx.console.info(f"Pulling latest changes in {repo_dir}...") - result = _run_git(repo_dir, "pull", "--rebase", capture=True) - if result.returncode != 0: - ctx.console.warn(f"Git pull failed: {result.stderr.strip()}") - return - - if verbose: - output = result.stdout.strip() - if output: - print(output) - - if _pull_requires_ack(result.stdout, result.stderr): - ctx.console.info("Repository updated before edit. Review incoming changes first.") - try: - input("Press Enter to continue editing... ") - except (EOFError, KeyboardInterrupt): - print() - - -def _refresh_module(spec: ModuleSpec) -> None: - module_dir = _module_cache_dir(spec) - module_dir.parent.mkdir(parents=True, exist_ok=True) - - if not module_dir.exists(): - clone = subprocess.run( - ["git", "clone", "--recurse-submodules", spec.source, str(module_dir)], - capture_output=True, - text=True, - check=False, - ) - if clone.returncode != 0: - raise RuntimeError( - f"Failed to clone module {spec.package} from {spec.source}: {clone.stderr.strip()}" - ) - - if spec.ref_type == "branch": - fetch = _run_git(module_dir, "fetch", "origin", spec.ref_value) - if fetch.returncode != 0: - raise RuntimeError( - f"Failed to fetch module {spec.package} branch {spec.ref_value}: {fetch.stderr.strip()}" - ) - - checkout = _run_git(module_dir, "checkout", spec.ref_value) - if checkout.returncode != 0: - create = _run_git(module_dir, "checkout", "-B", spec.ref_value, f"origin/{spec.ref_value}") - if create.returncode != 0: - raise RuntimeError( - f"Failed to checkout branch {spec.ref_value} for module {spec.package}: " - f"{create.stderr.strip()}" - ) - - pull = _run_git(module_dir, "pull", "--ff-only", "origin", spec.ref_value) - if pull.returncode != 0: - raise RuntimeError( - f"Failed to update module {spec.package} branch {spec.ref_value}: {pull.stderr.strip()}" - ) - - elif spec.ref_type == "tag": - fetch = _run_git(module_dir, "fetch", "--tags", "origin") - if fetch.returncode != 0: - raise RuntimeError( - f"Failed to fetch tags for module {spec.package}: {fetch.stderr.strip()}" - ) - - checkout = _run_git(module_dir, "checkout", f"tags/{spec.ref_value}") - if checkout.returncode != 0: - raise RuntimeError( - f"Failed to checkout tag {spec.ref_value} for module {spec.package}: " - f"{checkout.stderr.strip()}" - ) - - else: - fetch = _run_git(module_dir, "fetch", "origin") - if fetch.returncode != 0: - raise RuntimeError( - f"Failed to fetch module {spec.package}: {fetch.stderr.strip()}" - ) - - checkout = _run_git(module_dir, "checkout", spec.ref_value) - if checkout.returncode != 0: - raise RuntimeError( - f"Failed to checkout commit {spec.ref_value} for module {spec.package}: " - f"{checkout.stderr.strip()}" - ) - - update = _run_git(module_dir, "submodule", "update", "--init", "--recursive") - if update.returncode != 0: - raise RuntimeError( - f"Failed to update nested submodules for module {spec.package}: {update.stderr.strip()}" - ) - - -def _iter_package_dirs( - dotfiles_dir: Path, - *, - profile: Optional[str] = None, - package_filter: Optional[Set[str]] = None, -) -> List[tuple[str, Path]]: - out: List[tuple[str, Path]] = [] - flow_dir = _flow_config_dir(dotfiles_dir) - - shared = flow_dir / RESERVED_SHARED - if shared.is_dir(): - for pkg_dir in sorted(shared.iterdir()): - if pkg_dir.is_dir() and not pkg_dir.name.startswith("."): - if package_filter and pkg_dir.name not in package_filter: - continue - out.append((f"{RESERVED_SHARED}/{pkg_dir.name}", pkg_dir)) - - profiles = [profile] if profile else _list_profiles(flow_dir) - for profile_name in profiles: - profile_dir = flow_dir / profile_name - if not profile_dir.is_dir(): - continue - for pkg_dir in sorted(profile_dir.iterdir()): - if pkg_dir.is_dir() and not pkg_dir.name.startswith("."): - if package_filter and pkg_dir.name not in package_filter: - continue - out.append((f"{profile_name}/{pkg_dir.name}", pkg_dir)) - - return out - - -def _collect_module_specs( - dotfiles_dir: Path, - *, - profile: Optional[str] = None, - package_filter: Optional[Set[str]] = None, -) -> List[ModuleSpec]: - specs: List[ModuleSpec] = [] - for package, package_dir in _iter_package_dirs( - dotfiles_dir, - profile=profile, - package_filter=package_filter, - ): - module_file = package_dir / MODULE_FILE - if not module_file.exists(): - continue - specs.append(_load_module_spec(package_dir, package)) - return specs - - -def _sync_modules( - ctx: FlowContext, - *, - verbose: bool = False, - profile: Optional[str] = None, - package_filter: Optional[Set[str]] = None, -) -> None: - _ensure_flow_dir(ctx) - - for spec in _collect_module_specs( - DOTFILES_DIR, - profile=profile, - package_filter=package_filter, - ): - if verbose: - ctx.console.info( - f"Updating module {spec.package} from {spec.source} ({spec.ref_type}={spec.ref_value})" - ) - _refresh_module(spec) - - -def _module_ref_label(spec: ModuleSpec) -> str: - return f"{spec.ref_type}:{spec.ref_value}" - - -def _module_head_short(module_dir: Path) -> str: - result = _run_git(module_dir, "rev-parse", "--short", "HEAD") - if result.returncode != 0: - return "unknown" - return result.stdout.strip() or "unknown" - - -def _resolved_package_source( - ctx: FlowContext, - package: str, - package_dir: Path, - *, - verbose: bool = False, -) -> Path: - module_file = package_dir / MODULE_FILE - if not module_file.exists(): - return package_dir - - if verbose: - extras = [p.name for p in package_dir.iterdir() if p.name != MODULE_FILE] - if extras: - ctx.console.info( - f"Package {package} uses {MODULE_FILE}; ignoring local files: {', '.join(sorted(extras))}" - ) - - spec = _load_module_spec(package_dir, package) - module_dir = _module_cache_dir(spec) - if not module_dir.exists(): - raise RuntimeError( - f"Module source missing for package '{package}'. Run 'flow dotfiles sync' first." - ) - - return module_dir + _sync_service_module() + return _service._pull_requires_ack(stdout, stderr) def _load_state() -> dict: - if LINKED_STATE.exists(): - with open(LINKED_STATE, "r", encoding="utf-8") as handle: - return json.load(handle) - return {"version": 2, "links": {}} + _sync_service_module() + return _service._load_state() def _save_state(state: dict) -> None: - LINKED_STATE.parent.mkdir(parents=True, exist_ok=True) - with open(LINKED_STATE, "w", encoding="utf-8") as handle: - json.dump(state, handle, indent=2) + _sync_service_module() + return _service._save_state(state) -def _parse_link_specs(links: Any) -> Dict[Path, LinkSpec]: - if not isinstance(links, dict): - raise RuntimeError("Unsupported linked state format. Remove linked.json and relink dotfiles.") - - resolved: Dict[Path, LinkSpec] = {} - for package, pkg_links in links.items(): - if not isinstance(pkg_links, dict): - raise RuntimeError("Unsupported linked state format. Remove linked.json and relink dotfiles.") - - for target_str, link_info in pkg_links.items(): - if not isinstance(link_info, dict) or "source" not in link_info: - raise RuntimeError( - "Unsupported linked state format. Remove linked.json and relink dotfiles." - ) - - target = Path(target_str) - resolved[target] = LinkSpec( - source=Path(link_info["source"]), - target=target, - package=str(package), - is_directory_link=bool(link_info.get("is_directory_link", False)), - ) - - return resolved +def _load_link_specs_from_state(): + _sync_service_module() + return _service._load_link_specs_from_state() -def _serialize_link_specs(specs: Dict[Path, LinkSpec]) -> Dict[str, Dict[str, dict]]: - grouped: Dict[str, Dict[str, dict]] = {} - for spec in sorted(specs.values(), key=lambda s: str(s.target)): - grouped.setdefault(spec.package, {})[str(spec.target)] = { - "source": str(spec.source), - "is_directory_link": spec.is_directory_link, - } - return grouped +def _save_link_specs_to_state(specs): + _sync_service_module() + return _service._save_link_specs_to_state(specs) -def _cleanup_link_transaction_files(transaction: Optional[dict]) -> None: - if not isinstance(transaction, dict): - return - - backup_dir = transaction.get("backup_dir") - if isinstance(backup_dir, str) and backup_dir: - shutil.rmtree(Path(backup_dir), ignore_errors=True) - - -def _load_last_link_transaction() -> Optional[dict]: - state = _load_state() - transaction = state.get("last_transaction") - if not isinstance(transaction, dict): - return None - return transaction - - -def _save_last_link_transaction(transaction: dict) -> None: - state = _load_state() - previous = state.get("last_transaction") - if isinstance(previous, dict): - _cleanup_link_transaction_files(previous) - state["last_transaction"] = transaction - _save_state(state) - - -def _clear_last_link_transaction(*, remove_backups: bool = True) -> None: - state = _load_state() - transaction = state.get("last_transaction") - if remove_backups and isinstance(transaction, dict): - _cleanup_link_transaction_files(transaction) - state.pop("last_transaction", None) - _save_state(state) - - -def _start_link_transaction(previous_links: Dict[Path, LinkSpec]) -> dict: - now = datetime.now(timezone.utc) - tx_id = now.strftime("%Y%m%dT%H%M%S%fZ") - backup_dir = LINK_BACKUP_DIR / tx_id - return { - "id": tx_id, - "created_at": now.isoformat(), - "backup_dir": str(backup_dir), - "previous_links": _serialize_link_specs(previous_links), - "targets": [], - } - - -def _snapshot_target( - target: Path, - *, - use_sudo: bool, - backup_dir: Path, - index: int, -) -> dict: - if target.is_symlink(): - return {"kind": "symlink", "source": os.readlink(target)} - - if target.exists(): - if target.is_dir(): - raise RuntimeError(f"Cannot snapshot directory target: {target}") - - backup_dir.mkdir(parents=True, exist_ok=True) - backup_path = backup_dir / f"{index:06d}" - if use_sudo: - _run_sudo(["cp", "-a", str(target), str(backup_path)], dry_run=False) - else: - shutil.copy2(target, backup_path) - return {"kind": "file", "backup": str(backup_path)} - - return {"kind": "missing"} - - -def _restore_target_snapshot(target: Path, snapshot: dict) -> None: - if not isinstance(snapshot, dict): - raise RuntimeError(f"Unsupported transaction snapshot for {target}") - - use_sudo = not _is_in_home(target, Path.home()) - - if target.exists() or target.is_symlink(): - if target.is_dir() and not target.is_symlink(): - raise RuntimeError(f"Cannot restore {target}; a directory now exists at that path") - _remove_target(target, use_sudo=use_sudo, dry_run=False) - - kind = snapshot.get("kind") - if kind == "missing": - return - - if kind == "symlink": - source = snapshot.get("source") - if not isinstance(source, str): - raise RuntimeError(f"Unsupported transaction snapshot for {target}") - if use_sudo: - _run_sudo(["mkdir", "-p", str(target.parent)], dry_run=False) - _run_sudo(["ln", "-sfn", source, str(target)], dry_run=False) - else: - target.parent.mkdir(parents=True, exist_ok=True) - target.symlink_to(source) - return - - if kind == "file": - backup = snapshot.get("backup") - if not isinstance(backup, str): - raise RuntimeError(f"Unsupported transaction snapshot for {target}") - backup_path = Path(backup) - if not backup_path.exists(): - raise RuntimeError(f"Backup missing for {target}: {backup_path}") - if use_sudo: - _run_sudo(["mkdir", "-p", str(target.parent)], dry_run=False) - _run_sudo(["cp", "-a", str(backup_path), str(target)], dry_run=False) - else: - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(backup_path, target) - return - - raise RuntimeError(f"Unsupported transaction snapshot kind for {target}: {kind}") - - -def _load_link_specs_from_state() -> Dict[Path, LinkSpec]: - state = _load_state() - links = state.get("links", {}) - return _parse_link_specs(links) - - -def _save_link_specs_to_state(specs: Dict[Path, LinkSpec]) -> None: - state = _load_state() - state["version"] = 2 - state["links"] = _serialize_link_specs(specs) - _save_state(state) - - -def _list_profiles(flow_dir: Path) -> List[str]: - if not flow_dir.exists() or not flow_dir.is_dir(): - return [] - - profiles: List[str] = [] - for child in flow_dir.iterdir(): - if not child.is_dir(): - continue - if child.name.startswith("."): - continue - if child.name.startswith("_"): - continue - profiles.append(child.name) - return sorted(profiles) +def _list_profiles(flow_dir: Path): + _sync_service_module() + return _service._list_profiles(flow_dir) def _walk_package(source_dir: Path): - for root, dirs, files in os.walk(source_dir): - # Never traverse git metadata from module-backed package sources. - dirs[:] = [entry for entry in dirs if entry != ".git"] - for fname in files: - if fname == ".git": - continue - src = Path(root) / fname - rel = src.relative_to(source_dir) - yield src, rel + _sync_service_module() + return _service._walk_package(source_dir) -def _profile_skip_set(ctx: FlowContext, profile: Optional[str]) -> Set[str]: - if not profile: - return set() +def _discover_packages(dotfiles_dir: Path, profile=None): + _sync_service_module() + return _service._discover_packages(dotfiles_dir, profile) - profiles = ctx.manifest.get("profiles", {}) - if not isinstance(profiles, dict): - return set() - profile_cfg = profiles.get(profile, {}) - if not isinstance(profile_cfg, dict): - return set() +def _resolve_edit_target(target: str, dotfiles_dir=None): + _sync_service_module() + return _service._resolve_edit_target(target, dotfiles_dir) - configs = profile_cfg.get("configs", {}) - if not isinstance(configs, dict): - return set() - skip = configs.get("skip", []) - if not isinstance(skip, list): - return set() +def _resolved_package_source(ctx, package: str, package_dir: Path, *, verbose: bool = False): + _sync_service_module() + return _service._resolved_package_source(ctx, package, package_dir, verbose=verbose) - return {str(item) for item in skip if item} +def _run_sudo(cmd, *, dry_run: bool = False): + _sync_service_module() + return _service._run_sudo(cmd, dry_run=dry_run) -def _discover_packages(dotfiles_dir: Path, profile: Optional[str] = None) -> dict: - flow_dir = _flow_config_dir(dotfiles_dir) - packages = {} - shared = flow_dir / RESERVED_SHARED - if shared.is_dir(): - for pkg in sorted(shared.iterdir()): - if pkg.is_dir() and not pkg.name.startswith("."): - packages[pkg.name] = pkg - - if profile: - profile_dir = flow_dir / profile - if profile_dir.is_dir(): - for pkg in sorted(profile_dir.iterdir()): - if pkg.is_dir() and not pkg.name.startswith("."): - packages[pkg.name] = pkg - - return packages - - -def _find_package_dir(package_name: str, dotfiles_dir: Optional[Path] = None) -> Optional[Path]: - flow_dir = _flow_config_dir(dotfiles_dir) - - shared_dir = flow_dir / RESERVED_SHARED / package_name - if shared_dir.exists(): - return shared_dir - - for profile in _list_profiles(flow_dir): - profile_pkg = flow_dir / profile / package_name - if profile_pkg.exists(): - return profile_pkg - - return None - - -def _resolve_edit_target(target: str, dotfiles_dir: Optional[Path] = None) -> Optional[Path]: - dotfiles_dir = dotfiles_dir or DOTFILES_DIR - base_dir = dotfiles_dir.resolve() - raw = Path(target).expanduser() - if raw.is_absolute(): - if not _is_under(raw, base_dir): - return None - return raw - - is_path_like = _is_path_like_target(target) - if is_path_like: - candidate = dotfiles_dir / raw - if not _is_under(candidate, base_dir): - return None - if candidate.exists() or candidate.parent.exists(): - return candidate - return None - - package_dir = _find_package_dir(target, dotfiles_dir=dotfiles_dir) - if package_dir is not None: - return package_dir - - candidate = dotfiles_dir / raw - if candidate.exists(): - return candidate - - return None - - -def _ensure_dotfiles_dir(ctx: FlowContext): - if not DOTFILES_DIR.exists(): - ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.") - sys.exit(1) - - -def _ensure_flow_dir(ctx: FlowContext): - _ensure_dotfiles_dir(ctx) - flow_dir = _flow_config_dir() - if not flow_dir.exists() or not flow_dir.is_dir(): - ctx.console.error(f"Dotfiles repository not found at {flow_dir}") - sys.exit(1) - - -def _run_dotfiles_git(*cmd, capture: bool = True) -> subprocess.CompletedProcess: - return subprocess.run( - ["git", "-C", str(DOTFILES_DIR)] + list(cmd), - capture_output=capture, - text=True, - ) - - -def _pull_dotfiles(ctx: FlowContext, *, rebase: bool = True) -> None: - pull_cmd = ["pull"] - if rebase: - pull_cmd.append("--rebase") - - strategy = "with rebase" if rebase else "without rebase" - ctx.console.info(f"Pulling latest dotfiles ({strategy})...") - result = _run_dotfiles_git(*pull_cmd, capture=True) - - if result.returncode != 0: - raise RuntimeError(f"Git pull failed: {result.stderr.strip()}") - - output = result.stdout.strip() - if output: - print(output) - - ctx.console.success("Dotfiles synced.") - - -def _resolve_profile(ctx: FlowContext, requested: Optional[str]) -> Optional[str]: - flow_dir = _flow_config_dir() - profiles = _list_profiles(flow_dir) - - if requested: - if requested not in profiles: - raise RuntimeError(f"Profile not found: {requested}") - return requested - - if len(profiles) == 1: - return profiles[0] - - if len(profiles) > 1: - raise RuntimeError(f"Multiple profiles available. Use --profile: {', '.join(profiles)}") - - return None - - -def _is_in_home(path: Path, home: Path) -> bool: - try: - path.relative_to(home) - return True - except ValueError: - return False - - -def _is_under(path: Path, parent: Path) -> bool: - try: - path.resolve().relative_to(parent.resolve()) - return True - except ValueError: - return False - - -def _run_sudo(cmd: List[str], *, dry_run: bool = False) -> None: - if dry_run: - print(" " + " ".join(shlex.quote(part) for part in (["sudo"] + cmd))) - return - if shutil.which("sudo") is None: - raise RuntimeError("sudo is required for root-targeted dotfiles, but it was not found in PATH") - subprocess.run(["sudo"] + cmd, check=True) - - -def _remove_target(path: Path, *, use_sudo: bool, dry_run: bool) -> None: - if not (path.exists() or path.is_symlink()): - return - - if path.is_dir() and not path.is_symlink(): - raise RuntimeError(f"Cannot overwrite directory: {path}") - - if use_sudo: - _run_sudo(["rm", "-f", str(path)], dry_run=dry_run) - return - - if dry_run: - print(f" REMOVE: {path}") - return - path.unlink() - - -def _same_symlink(target: Path, source: Path) -> bool: - if not target.is_symlink(): - return False - return target.resolve(strict=False) == source.resolve(strict=False) - - -def _collect_home_specs( - ctx: FlowContext, - flow_dir: Path, - home: Path, - profile: Optional[str], - skip: Set[str], - package_filter: Optional[Set[str]], - *, - verbose: bool = False, -) -> Dict[Path, LinkSpec]: - desired: Dict[Path, LinkSpec] = {} - - if RESERVED_SHARED not in skip: - shared_dir = flow_dir / RESERVED_SHARED - if shared_dir.is_dir(): - for pkg_dir in sorted(shared_dir.iterdir()): - if not pkg_dir.is_dir() or pkg_dir.name.startswith("."): - continue - if package_filter and pkg_dir.name not in package_filter: - continue - if pkg_dir.name in skip: - continue - - package_name = f"{RESERVED_SHARED}/{pkg_dir.name}" - source_dir = _resolved_package_source(ctx, package_name, pkg_dir, verbose=verbose) - for src, rel in _walk_package(source_dir): - if rel.parts and rel.parts[0] == RESERVED_ROOT: - if RESERVED_ROOT in skip: - continue - if len(rel.parts) < 2: - continue - target = Path("/") / Path(*rel.parts[1:]) - else: - target = home / rel - - _insert_spec( - desired, - target=target, - source=src, - package=package_name, - ) - - if profile and "_profile" not in skip: - profile_dir = flow_dir / profile - if profile_dir.is_dir(): - for pkg_dir in sorted(profile_dir.iterdir()): - if not pkg_dir.is_dir() or pkg_dir.name.startswith("."): - continue - if package_filter and pkg_dir.name not in package_filter: - continue - if pkg_dir.name in skip: - continue - - package_name = f"{profile}/{pkg_dir.name}" - source_dir = _resolved_package_source(ctx, package_name, pkg_dir, verbose=verbose) - for src, rel in _walk_package(source_dir): - if rel.parts and rel.parts[0] == RESERVED_ROOT: - if RESERVED_ROOT in skip: - continue - if len(rel.parts) < 2: - continue - target = Path("/") / Path(*rel.parts[1:]) - else: - target = home / rel - - _insert_spec( - desired, - target=target, - source=src, - package=package_name, - ) - - return desired - - -def _validate_conflicts( - desired: Dict[Path, LinkSpec], - current: Dict[Path, LinkSpec], -) -> tuple[List[str], List[str]]: - force_required: List[str] = [] - fatal: List[str] = [] - - # Validate removals for targets currently tracked in state. - # If a managed path was changed on disk (regular file or different symlink), - # require --force before deleting it. - for target, spec in current.items(): - if target in desired: - continue - if not (target.exists() or target.is_symlink()): - continue - if _same_symlink(target, spec.source): - continue - if target.is_dir() and not target.is_symlink(): - fatal.append(f"Conflict: {target} is a directory and cannot be overwritten") - continue - force_required.append(f"Conflict: {target} differs from managed link and would be removed") - - for target, spec in desired.items(): - if not (target.exists() or target.is_symlink()): - continue - - if _same_symlink(target, spec.source): - continue - - if target in current: - current_spec = current[target] - if _same_symlink(target, current_spec.source): - # Existing managed link can be replaced by desired link. - continue - if target.is_dir() and not target.is_symlink(): - fatal.append(f"Conflict: {target} is a directory and cannot be overwritten") - continue - force_required.append(f"Conflict: {target} differs from managed link and would be replaced") - continue - - if target.is_dir() and not target.is_symlink(): - fatal.append(f"Conflict: {target} is a directory and cannot be overwritten") - continue - - force_required.append(f"Conflict: {target} already exists and is not managed by flow") - - return force_required, fatal - - -def _apply_link_spec(spec: LinkSpec, *, copy: bool, dry_run: bool) -> bool: - use_sudo = not _is_in_home(spec.target, Path.home()) - - if copy and use_sudo: - print(f" SKIP COPY (root target): {spec.target}") - return False - - if use_sudo: - _run_sudo(["mkdir", "-p", str(spec.target.parent)], dry_run=dry_run) - _run_sudo(["ln", "-sfn", str(spec.source), str(spec.target)], dry_run=dry_run) - return True - - if dry_run: - if copy: - print(f" COPY: {spec.source} -> {spec.target}") - else: - print(f" LINK: {spec.target} -> {spec.source}") - return True - - spec.target.parent.mkdir(parents=True, exist_ok=True) - if copy: - shutil.copy2(spec.source, spec.target) - return True - spec.target.symlink_to(spec.source) - return True - - -def _sync_to_desired( - ctx: FlowContext, - desired: Dict[Path, LinkSpec], - *, - force: bool, - dry_run: bool, - copy: bool, -) -> None: - current = _load_link_specs_from_state() - previous = dict(current) - force_required, fatal = _validate_conflicts(desired, current) - - if fatal: - for conflict in fatal: - ctx.console.error(conflict) - raise RuntimeError("One or more targets are existing directories and cannot be overwritten") - - if force_required and not force: - for conflict in force_required: - ctx.console.error(conflict) - raise RuntimeError("Use --force to overwrite existing files") - - transaction: Optional[dict] = None - snapshots: Dict[Path, dict] = {} - if not dry_run: - transaction = _start_link_transaction(previous) - backup_dir = Path(transaction["backup_dir"]) - - def snapshot_before_change(target: Path) -> None: - if target in snapshots: - return - use_sudo = not _is_in_home(target, Path.home()) - snapshots[target] = _snapshot_target( - target, - use_sudo=use_sudo, - backup_dir=backup_dir, - index=len(snapshots) + 1, - ) - - try: - for target in sorted(current.keys(), key=str): - if target in desired: - continue - if not dry_run and transaction is not None and (target.exists() or target.is_symlink()): - snapshot_before_change(target) - use_sudo = not _is_in_home(target, Path.home()) - _remove_target(target, use_sudo=use_sudo, dry_run=dry_run) - del current[target] - - for target in sorted(desired.keys(), key=str): - spec = desired[target] - - if _same_symlink(target, spec.source): - current[target] = spec - continue - - if not dry_run and transaction is not None: - snapshot_before_change(target) - - exists = target.exists() or target.is_symlink() - if exists: - use_sudo = not _is_in_home(target, Path.home()) - _remove_target(target, use_sudo=use_sudo, dry_run=dry_run) - - applied = _apply_link_spec(spec, copy=copy, dry_run=dry_run) - if applied: - current[target] = spec - except Exception: - if not dry_run and transaction is not None: - transaction["targets"] = [ - {"target": str(target), "before": snapshots[target]} - for target in sorted(snapshots.keys(), key=str) - ] - transaction["incomplete"] = True - try: - _save_link_specs_to_state(current) - _save_last_link_transaction(transaction) - except Exception: - pass - raise - - if not dry_run: - _save_link_specs_to_state(current) - if transaction is not None: - transaction["targets"] = [ - {"target": str(target), "before": snapshots[target]} - for target in sorted(snapshots.keys(), key=str) - ] - transaction["incomplete"] = False - _save_last_link_transaction(transaction) - - -def _desired_links_for_profile( - ctx: FlowContext, - profile: Optional[str], - package_filter: Optional[Set[str]], - *, - verbose: bool = False, -) -> Dict[Path, LinkSpec]: - flow_dir = _flow_config_dir() - home = Path.home() - - skip = _profile_skip_set(ctx, profile) - return _collect_home_specs( +def _collect_home_specs(ctx, flow_dir, home, profile, skip, package_filter, *, verbose: bool = False): + _sync_service_module() + return _service._collect_home_specs( ctx, flow_dir, home, @@ -1110,486 +102,92 @@ def _desired_links_for_profile( ) -def run_init(ctx: FlowContext, args): - repo_url = args.repo or ctx.config.dotfiles_url - if not repo_url: - ctx.console.error("No dotfiles repository URL. Set it in YAML config or pass --repo.") - sys.exit(1) - - if DOTFILES_DIR.exists(): - ctx.console.warn(f"Dotfiles directory already exists: {DOTFILES_DIR}") - return - - DOTFILES_DIR.parent.mkdir(parents=True, exist_ok=True) - branch = ctx.config.dotfiles_branch - cmd = ["git", "clone", "-b", branch, "--recurse-submodules", repo_url, str(DOTFILES_DIR)] - ctx.console.info(f"Cloning {repo_url} (branch: {branch})...") - subprocess.run(cmd, check=True) - - try: - _sync_modules(ctx, verbose=bool(getattr(args, "verbose", False))) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - ctx.console.success(f"Dotfiles cloned to {DOTFILES_DIR}") - - -def run_link(ctx: FlowContext, args): - _ensure_flow_dir(ctx) - - try: - profile = _resolve_profile(ctx, args.profile) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - package_filter = set(args.packages) if args.packages else None - - try: - desired = _desired_links_for_profile( - ctx, - profile, - package_filter, - verbose=bool(getattr(args, "verbose", False)), - ) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - if not desired: - ctx.console.warn("No link targets found for selected profile/filters") - return - - try: - _sync_to_desired( - ctx, - desired, - force=args.force, - dry_run=args.dry_run, - copy=args.copy, - ) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - if args.dry_run: - return - - ctx.console.success(f"Linked {len(desired)} item(s)") - - -def _package_match(package_id: str, filters: Set[str]) -> bool: - if package_id in filters: - return True - - # Allow users to pass just package basename (e.g. zsh) - base = package_id.split("/", 1)[-1] - return base in filters - - -def run_unlink(ctx: FlowContext, args): - try: - current = _load_link_specs_from_state() - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - if not current: - ctx.console.info("No linked dotfiles found.") - return - - filters = set(args.packages) if args.packages else None - removed = 0 - - for target in sorted(list(current.keys()), key=str): - spec = current[target] - if filters and not _package_match(spec.package, filters): - continue - - use_sudo = not _is_in_home(target, Path.home()) - try: - _remove_target(target, use_sudo=use_sudo, dry_run=False) - except RuntimeError as e: - ctx.console.warn(str(e)) - continue - - removed += 1 - del current[target] - - _save_link_specs_to_state(current) - _clear_last_link_transaction(remove_backups=True) - ctx.console.success(f"Removed {removed} symlink(s)") - - -def run_undo(ctx: FlowContext, args): - transaction = _load_last_link_transaction() - if transaction is None: - ctx.console.info("No dotfiles link transaction to undo.") - return - - raw_targets = transaction.get("targets") - if not isinstance(raw_targets, list): - ctx.console.error("Invalid undo state format. Remove linked.json and relink dotfiles.") - sys.exit(1) - - restore_plan: List[Tuple[Path, dict]] = [] - for entry in raw_targets: - if not isinstance(entry, dict): - ctx.console.error("Invalid undo state format. Remove linked.json and relink dotfiles.") - sys.exit(1) - - target_raw = entry.get("target") - before = entry.get("before") - if not isinstance(target_raw, str) or not isinstance(before, dict): - ctx.console.error("Invalid undo state format. Remove linked.json and relink dotfiles.") - sys.exit(1) - restore_plan.append((Path(target_raw), before)) - - try: - # Restore deeper paths first to avoid parent/child ordering issues. - for target, snapshot in sorted( - restore_plan, - key=lambda item: (len(item[0].parts), str(item[0])), - reverse=True, - ): - _restore_target_snapshot(target, snapshot) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - previous_links = transaction.get("previous_links", {}) - try: - _parse_link_specs(previous_links) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - state = _load_state() - state["version"] = 2 - state["links"] = previous_links - _save_state(state) - _clear_last_link_transaction(remove_backups=True) - ctx.console.success(f"Undid {len(restore_plan)} change(s)") - - -def run_status(ctx: FlowContext, args): - try: - current = _load_link_specs_from_state() - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - if not current: - ctx.console.info("No linked dotfiles.") - return - - grouped: Dict[str, List[LinkSpec]] = {} - for spec in current.values(): - grouped.setdefault(spec.package, []).append(spec) - - for package in sorted(grouped.keys()): - ctx.console.info(f"[{package}]") - for spec in sorted(grouped[package], key=lambda s: str(s.target)): - if spec.target.is_symlink(): - if _same_symlink(spec.target, spec.source): - print(f" OK: {spec.target} -> {spec.source}") - else: - print(f" CHANGED: {spec.target}") - elif spec.target.exists(): - print(f" NOT SYMLINK: {spec.target}") - else: - print(f" BROKEN: {spec.target} (missing)") - - -def run_sync(ctx: FlowContext, args): - _ensure_dotfiles_dir(ctx) - - try: - _pull_dotfiles(ctx, rebase=True) - _sync_modules(ctx, verbose=bool(getattr(args, "verbose", False))) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - if args.relink: - relink_args = argparse.Namespace(packages=[], profile=args.profile) - run_relink(ctx, relink_args) - - -def _validated_profile_name(profile: Optional[str]) -> Optional[str]: - if not profile: - return None - - profiles = _list_profiles(_flow_config_dir()) - if profile not in profiles: - raise RuntimeError(f"Profile not found: {profile}") - return profile - - -def _package_filter_from_args(args) -> Optional[Set[str]]: - packages = getattr(args, "packages", []) - if not packages: - return None - return {str(pkg) for pkg in packages} - - -def run_modules_list(ctx: FlowContext, args): - _ensure_flow_dir(ctx) - - try: - profile = _validated_profile_name(getattr(args, "profile", None)) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - package_filter = _package_filter_from_args(args) - - specs = _collect_module_specs( - DOTFILES_DIR, +def _sync_modules(ctx, *, verbose: bool = False, profile=None, package_filter=None): + _sync_service_module() + return _service._sync_modules( + ctx, + verbose=verbose, profile=profile, package_filter=package_filter, ) - if not specs: - ctx.console.info("No module packages found.") - return - rows = [] - for spec in sorted(specs, key=lambda item: item.package): - module_dir = _module_cache_dir(spec) - if module_dir.exists(): - status = f"ready@{_module_head_short(module_dir)}" - else: - status = "missing" - rows.append([spec.package, _module_ref_label(spec), spec.source, status]) - - ctx.console.table(["PACKAGE", "REF", "SOURCE", "STATUS"], rows) - - -def run_modules_sync(ctx: FlowContext, args): - _ensure_flow_dir(ctx) - - try: - profile = _validated_profile_name(getattr(args, "profile", None)) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - package_filter = _package_filter_from_args(args) - specs = _collect_module_specs( - DOTFILES_DIR, - profile=profile, - package_filter=package_filter, +def _sync_to_desired(ctx, desired, *, force: bool, dry_run: bool, copy: bool): + _sync_service_module() + return _service._sync_to_desired( + ctx, + desired, + force=force, + dry_run=dry_run, + copy=copy, ) - if not specs: - ctx.console.info("No module packages to sync.") - return - try: - _sync_modules( - ctx, - verbose=bool(getattr(args, "verbose", False)), - profile=profile, - package_filter=package_filter, - ) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - ctx.console.success(f"Synced {len(specs)} module(s)") +def run_init(ctx, args): + _sync_service_module() + return _service.run_init(ctx, args) -def run_repo_status(ctx: FlowContext, args): - _ensure_dotfiles_dir(ctx) - - result = _run_dotfiles_git("status", "--short", "--branch", capture=True) - if result.returncode != 0: - ctx.console.error(result.stderr.strip() or "Failed to read dotfiles git status") - sys.exit(1) - - output = result.stdout.strip() - if output: - print(output) - else: - ctx.console.info("Dotfiles repository is clean.") +def run_link(ctx, args): + _sync_service_module() + return _service.run_link(ctx, args) -def run_repo_pull(ctx: FlowContext, args): - _ensure_dotfiles_dir(ctx) - - try: - _pull_dotfiles(ctx, rebase=args.rebase) - _sync_modules(ctx, verbose=bool(getattr(args, "verbose", False))) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - if args.relink: - relink_args = argparse.Namespace(packages=[], profile=args.profile) - run_relink(ctx, relink_args) +def run_unlink(ctx, args): + _sync_service_module() + return _service.run_unlink(ctx, args) -def run_repo_push(ctx: FlowContext, args): - _ensure_dotfiles_dir(ctx) - - ctx.console.info("Pushing dotfiles changes...") - result = _run_dotfiles_git("push", capture=True) - if result.returncode != 0: - ctx.console.error(f"Git push failed: {result.stderr.strip()}") - sys.exit(1) - - output = result.stdout.strip() - if output: - print(output) - ctx.console.success("Dotfiles pushed.") +def run_undo(ctx, args): + _sync_service_module() + return _service.run_undo(ctx, args) -def run_relink(ctx: FlowContext, args): - _ensure_flow_dir(ctx) - - ctx.console.info("Unlinking current symlinks...") - run_unlink(ctx, args) - - args.copy = False - args.force = False - args.dry_run = False - ctx.console.info("Relinking with updated configuration...") - run_link(ctx, args) +def run_status(ctx, args): + _sync_service_module() + return _service.run_status(ctx, args) -def run_clean(ctx: FlowContext, args): - try: - current = _load_link_specs_from_state() - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) - - if not current: - ctx.console.info("No linked dotfiles found.") - return - - removed = 0 - for target in sorted(list(current.keys()), key=str): - if not target.is_symlink() or target.exists(): - continue - - if args.dry_run: - print(f"Would remove broken symlink: {target}") - else: - use_sudo = not _is_in_home(target, Path.home()) - _remove_target(target, use_sudo=use_sudo, dry_run=False) - del current[target] - removed += 1 - - if not args.dry_run: - _save_link_specs_to_state(current) - if removed > 0: - _clear_last_link_transaction(remove_backups=True) - - if removed > 0: - ctx.console.success(f"Cleaned {removed} broken symlink(s)") - else: - ctx.console.info("No broken symlinks found") +def run_sync(ctx, args): + _sync_service_module() + return _service.run_sync(ctx, args) -def run_edit(ctx: FlowContext, args): - _ensure_dotfiles_dir(ctx) +def run_modules_list(ctx, args): + _sync_service_module() + return _service.run_modules_list(ctx, args) - target_name = args.target - verbose = bool(getattr(args, "verbose", False)) - edit_target = None - if not _is_path_like_target(target_name): - package_dir = _find_package_dir(target_name) - if package_dir is not None: - try: - package_layer = package_dir.parent.name - package_id = f"{package_layer}/{package_dir.name}" - edit_target = _resolved_package_source( - ctx, - package_id, - package_dir, - verbose=verbose, - ) - except RuntimeError as e: - ctx.console.error(str(e)) - sys.exit(1) +def run_modules_sync(ctx, args): + _sync_service_module() + return _service.run_modules_sync(ctx, args) - if edit_target is None: - edit_target = _resolve_edit_target(target_name) - if edit_target is None: - ctx.console.error(f"No matching package or path found for: {target_name}") - sys.exit(1) +def run_repo_status(ctx, args): + _sync_service_module() + return _service.run_repo_status(ctx, args) - module_mode = _is_under(edit_target, MODULES_DIR) - if verbose and module_mode: - ctx.console.info(f"Editing module workspace: {edit_target}") +def run_repo_pull(ctx, args): + _sync_service_module() + return _service.run_repo_pull(ctx, args) - if ctx.config.dotfiles_pull_before_edit: - pull_repo = DOTFILES_DIR - if module_mode: - pull_repo = edit_target if edit_target.is_dir() else edit_target.parent - _pull_repo_before_edit(ctx, pull_repo, verbose=verbose) - editor = os.environ.get("EDITOR", "vim") - ctx.console.info(f"Opening {edit_target} in {editor}...") - edit_result = subprocess.run(shlex.split(editor) + [str(edit_target)]) - if edit_result.returncode != 0: - ctx.console.warn(f"Editor exited with status {edit_result.returncode}") +def run_repo_push(ctx, args): + _sync_service_module() + return _service.run_repo_push(ctx, args) - if module_mode: - module_git_dir = edit_target if edit_target.is_dir() else edit_target.parent - result = _run_git(module_git_dir, "status", "--porcelain", capture=True) - if result.stdout.strip() and not args.no_commit: - ctx.console.info("Module changes detected, committing...") - subprocess.run(["git", "-C", str(module_git_dir), "add", "."], check=True) - subprocess.run( - ["git", "-C", str(module_git_dir), "commit", "-m", f"Update {target_name}"], - check=True, - ) +def run_relink(ctx, args): + _sync_service_module() + return _service.run_relink(ctx, args) - try: - response = input("Push module changes to remote? [Y/n] ") - except (EOFError, KeyboardInterrupt): - response = "n" - print() - if response.lower() != "n": - subprocess.run(["git", "-C", str(module_git_dir), "push"], check=True) - ctx.console.success("Module changes committed and pushed") - else: - ctx.console.info("Module changes committed locally (not pushed)") - elif result.stdout.strip() and args.no_commit: - ctx.console.info("Module changes detected; skipped commit (--no-commit)") - else: - ctx.console.info("No module changes to commit") - return - result = _run_dotfiles_git("status", "--porcelain", capture=True) +def run_clean(ctx, args): + _sync_service_module() + return _service.run_clean(ctx, args) - if result.stdout.strip() and not args.no_commit: - ctx.console.info("Changes detected, committing...") - subprocess.run(["git", "-C", str(DOTFILES_DIR), "add", "."], check=True) - subprocess.run( - ["git", "-C", str(DOTFILES_DIR), "commit", "-m", f"Update {target_name}"], - check=True, - ) - try: - response = input("Push changes to remote? [Y/n] ") - except (EOFError, KeyboardInterrupt): - response = "n" - print() - if response.lower() != "n": - subprocess.run(["git", "-C", str(DOTFILES_DIR), "push"], check=True) - ctx.console.success("Changes committed and pushed") - else: - ctx.console.info("Changes committed locally (not pushed)") - elif result.stdout.strip() and args.no_commit: - ctx.console.info("Changes detected; skipped commit (--no-commit)") - else: - ctx.console.info("No changes to commit") +def run_edit(ctx, args): + _sync_service_module() + return _service.run_edit(ctx, args) diff --git a/src/flow/commands/enter.py b/src/flow/commands/enter.py index 0f335e9..3103dcd 100644 --- a/src/flow/commands/enter.py +++ b/src/flow/commands/enter.py @@ -1,176 +1,30 @@ """flow enter — connect to a development instance via SSH.""" -import getpass -import os -import sys -from typing import Optional - -from flow.core.config import FlowContext - -# Default host templates per platform -HOST_TEMPLATES = { - "orb": ".orb", - "utm": ".utm.local", - "core": ".core.lan", -} +from flow.services.ssh import ( + HOST_TEMPLATES, + EnterService, + build_destination as _build_destination, + handle_terminfo_warning as _handle_terminfo_warning, + parse_target as _parse_target_model, + terminfo_fix_command as _terminfo_fix_command, +) def register(subparsers): - p = subparsers.add_parser("enter", help="Connect to a development instance via SSH") - p.add_argument("target", help="Target: [user@]namespace@platform") - p.add_argument("-u", "--user", help="SSH user (overrides target)") - p.add_argument("-n", "--namespace", help="Namespace (overrides target)") - p.add_argument("-p", "--platform", help="Platform (overrides target)") - p.add_argument("-s", "--session", default="default", help="Tmux session name (default: 'default')") - p.add_argument("--no-tmux", action="store_true", help="Skip tmux attachment") - p.add_argument("-d", "--dry-run", action="store_true", help="Show command without executing") - p.set_defaults(handler=run) + parser = subparsers.add_parser("enter", help="Connect to a development instance via SSH") + parser.add_argument("target", help="Target: [user@]namespace@platform") + parser.add_argument("-u", "--user", help="SSH user (overrides target)") + parser.add_argument("-n", "--namespace", help="Namespace (overrides target)") + parser.add_argument("-p", "--platform", help="Platform (overrides target)") + parser.add_argument("-s", "--session", default="default", help="Tmux session name (default: 'default')") + parser.add_argument("--no-tmux", action="store_true", help="Skip tmux attachment") + parser.add_argument("-d", "--dry-run", action="store_true", help="Show command without executing") + parser.set_defaults(handler=run) def _parse_target(target: str): - """Parse [user@]namespace@platform into (user, namespace, platform).""" - user = None - namespace = None - platform = None - - if "@" in target: - platform = target.rsplit("@", 1)[1] - rest = target.rsplit("@", 1)[0] - else: - rest = target - - if "@" in rest: - user = rest.rsplit("@", 1)[0] - namespace = rest.rsplit("@", 1)[1] - else: - namespace = rest - - return user, namespace, platform + return _parse_target_model(target) -def _build_destination(user: str, host: str, preserve_host_user: bool = False) -> str: - if "@" in host: - host_user, host_name = host.rsplit("@", 1) - effective_user = host_user if preserve_host_user else (user or host_user) - return f"{effective_user}@{host_name}" - if not user: - return host - return f"{user}@{host}" - - -def _terminfo_fix_command(term: Optional[str], destination: str) -> Optional[str]: - normalized_term = (term or "").strip().lower() - - if normalized_term == "xterm-ghostty": - return f"infocmp -x xterm-ghostty | ssh {destination} -- tic -x -" - - if normalized_term == "wezterm": - return ( - f"ssh {destination} -- sh -lc " - "'tempfile=$(mktemp) && curl -fsSL -o \"$tempfile\" " - "https://raw.githubusercontent.com/wezterm/wezterm/main/termwiz/data/wezterm.terminfo " - "&& tic -x -o ~/.terminfo \"$tempfile\" && rm \"$tempfile\"'" - ) - - return None - - -def _handle_terminfo_warning(ctx: FlowContext, term: Optional[str], destination: str, dry_run: bool) -> bool: - install_cmd = _terminfo_fix_command(term, destination) - if not install_cmd: - return True - - ctx.console.warn( - f"Detected TERM={term}. Remote host may be missing this terminfo entry." - ) - ctx.console.info("flow will not install or modify terminfo on the target automatically.") - ctx.console.info("If needed, run this command manually before reconnecting:") - print(f" {install_cmd}") - - if dry_run or not sys.stdin.isatty(): - return True - - response = "" - try: - response = input("Continue with SSH connection? [Y/n] ").strip().lower() - except EOFError: - return True - - if response in {"n", "no"}: - ctx.console.warn("Cancelled before opening SSH session") - return False - - return True - - -def run(ctx: FlowContext, args): - # Warn if already inside an instance - if os.environ.get("DF_NAMESPACE") and os.environ.get("DF_PLATFORM"): - ns = os.environ["DF_NAMESPACE"] - plat = os.environ["DF_PLATFORM"] - ctx.console.error( - f"Not recommended inside an instance. Currently in: {ns}@{plat}" - ) - sys.exit(1) - - user, namespace, platform = _parse_target(args.target) - - # Apply overrides - if args.user: - user = args.user - if args.namespace: - namespace = args.namespace - if args.platform: - platform = args.platform - - user_was_explicit = bool(user) - - if not user: - user = os.environ.get("USER") or getpass.getuser() - if not namespace: - ctx.console.error("Namespace is required in target") - sys.exit(1) - if not platform: - ctx.console.error("Platform is required in target") - sys.exit(1) - - # Resolve SSH host from template or config - host_template = HOST_TEMPLATES.get(platform) - ssh_identity = None - - # Check config targets for override - for tc in ctx.config.targets: - if tc.namespace == namespace and tc.platform == platform: - host_template = tc.ssh_host - ssh_identity = tc.ssh_identity - break - - if not host_template: - ctx.console.error(f"Unknown platform: {platform}") - sys.exit(1) - - ssh_host = host_template.replace("", namespace) - destination = _build_destination(user, ssh_host, preserve_host_user=not user_was_explicit) - - if not _handle_terminfo_warning(ctx, os.environ.get("TERM"), destination, dry_run=args.dry_run): - sys.exit(1) - - # Build SSH command - ssh_cmd = ["ssh", "-tt"] - if ssh_identity: - ssh_cmd.extend(["-i", os.path.expanduser(ssh_identity)]) - ssh_cmd.append(destination) - - if not args.no_tmux: - ssh_cmd.extend([ - "tmux", "new-session", "-As", args.session, - "-e", f"DF_NAMESPACE={namespace}", - "-e", f"DF_PLATFORM={platform}", - ]) - - if args.dry_run: - ctx.console.info("Dry run command:") - print(" " + " ".join(ssh_cmd)) - return - - os.execvp("ssh", ssh_cmd) +def run(ctx, args): + EnterService(ctx).run(args) diff --git a/src/flow/commands/package.py b/src/flow/commands/package.py index d350be4..cd2312a 100644 --- a/src/flow/commands/package.py +++ b/src/flow/commands/package.py @@ -2,11 +2,9 @@ import json import sys -from typing import Any, Dict -from flow.commands.bootstrap import _get_package_catalog, _install_binary_package -from flow.core.config import FlowContext from flow.core.paths import INSTALLED_STATE +from flow.services.package_defs import BinaryInstaller, get_package_catalog def register(subparsers): @@ -50,89 +48,101 @@ def _save_installed(state: dict): json.dump(state, handle, indent=2) -def _get_definitions(ctx: FlowContext) -> Dict[str, Dict[str, Any]]: - return _get_package_catalog(ctx) +def _get_definitions(ctx): + return get_package_catalog(ctx) -def run_install(ctx: FlowContext, args): +def _install_binary_package(ctx, spec, extra_env, dry_run): + return BinaryInstaller(ctx).install(spec, extra_env, dry_run=dry_run) + + +def run_install(ctx, args): definitions = _get_definitions(ctx) installed = _load_installed() had_error = False - for pkg_name in args.packages: - pkg_def = definitions.get(pkg_name) - if not pkg_def: - ctx.console.error(f"Package not found in manifest: {pkg_name}") + for package_name in args.packages: + package_def = definitions.get(package_name) + if not package_def: + ctx.console.error(f"Package not found in manifest: {package_name}") had_error = True continue - pkg_type = pkg_def.get("type", "pkg") - if pkg_type != "binary": + package_type = package_def.get("type", "pkg") + if package_type != "binary": ctx.console.error( f"'flow package install' supports binary packages only. " - f"'{pkg_name}' is type '{pkg_type}'." + f"'{package_name}' is type '{package_type}'." ) had_error = True continue - ctx.console.info(f"Installing {pkg_name}...") + ctx.console.info(f"Installing {package_name}...") try: - _install_binary_package(ctx, pkg_def, extra_env={}, dry_run=args.dry_run) - except RuntimeError as e: - ctx.console.error(str(e)) + _install_binary_package(ctx, package_def, {}, args.dry_run) + except RuntimeError as exc: + ctx.console.error(str(exc)) had_error = True continue if not args.dry_run: - installed[pkg_name] = { - "version": str(pkg_def.get("version", "")), - "type": pkg_type, + installed[package_name] = { + "version": str(package_def.get("version", "")), + "type": package_type, } - ctx.console.success(f"Installed {pkg_name}") + ctx.console.success(f"Installed {package_name}") if not args.dry_run: _save_installed(installed) - if had_error: sys.exit(1) -def run_list(ctx: FlowContext, args): +def run_list(ctx, args): definitions = _get_definitions(ctx) installed = _load_installed() - headers = ["PACKAGE", "TYPE", "INSTALLED", "AVAILABLE"] rows = [] - if args.all: if not definitions: ctx.console.info("No packages defined in manifest.") return - for name, pkg_def in sorted(definitions.items()): - inst_ver = installed.get(name, {}).get("version", "-") - avail_ver = str(pkg_def.get("version", "")) or "-" - rows.append([name, str(pkg_def.get("type", "pkg")), inst_ver, avail_ver]) + for name, package_def in sorted(definitions.items()): + rows.append( + [ + name, + str(package_def.get("type", "pkg")), + str(installed.get(name, {}).get("version", "-")), + str(package_def.get("version", "")) or "-", + ] + ) else: if not installed: ctx.console.info("No packages installed.") return for name, info in sorted(installed.items()): - avail = str(definitions.get(name, {}).get("version", "")) or "-" - rows.append([name, str(info.get("type", "?")), str(info.get("version", "?")), avail]) + rows.append( + [ + name, + str(info.get("type", "?")), + str(info.get("version", "?")), + str(definitions.get(name, {}).get("version", "")) or "-", + ] + ) - ctx.console.table(headers, rows) + ctx.console.table(["PACKAGE", "TYPE", "INSTALLED", "AVAILABLE"], rows) -def run_remove(ctx: FlowContext, args): +def run_remove(ctx, args): installed = _load_installed() - for pkg_name in args.packages: - if pkg_name not in installed: - ctx.console.warn(f"Package not installed: {pkg_name}") + for package_name in args.packages: + if package_name not in installed: + ctx.console.warn(f"Package not installed: {package_name}") continue - del installed[pkg_name] - ctx.console.success(f"Removed {pkg_name} from installed packages") + del installed[package_name] + ctx.console.success(f"Removed {package_name} from installed packages") ctx.console.warn( "Note: installed files were not automatically deleted. Remove manually if needed." ) diff --git a/src/flow/commands/sync.py b/src/flow/commands/sync.py index 8d1844d..2ef769a 100644 --- a/src/flow/commands/sync.py +++ b/src/flow/commands/sync.py @@ -8,22 +8,12 @@ from flow.core.config import FlowContext def register(subparsers): - p = subparsers.add_parser("sync", help="Git sync tools for projects") - sub = p.add_subparsers(dest="sync_command") + parser = subparsers.add_parser("sync", help="Git sync tools for projects") + sub = parser.add_subparsers(dest="sync_command") check = sub.add_parser("check", help="Check all projects status") - check.add_argument( - "--fetch", - dest="fetch", - action="store_true", - help="Run git fetch before checking (default)", - ) - check.add_argument( - "--no-fetch", - dest="fetch", - action="store_false", - help="Skip git fetch", - ) + check.add_argument("--fetch", dest="fetch", action="store_true", help="Run git fetch before checking (default)") + check.add_argument("--no-fetch", dest="fetch", action="store_false", help="Skip git fetch") check.set_defaults(fetch=True) check.set_defaults(handler=run_check) @@ -33,13 +23,14 @@ def register(subparsers): summary = sub.add_parser("summary", help="Quick overview of project status") summary.set_defaults(handler=run_summary) - p.set_defaults(handler=lambda ctx, args: p.print_help()) + parser.set_defaults(handler=lambda ctx, args: parser.print_help()) def _git(repo: str, *cmd, capture: bool = True) -> subprocess.CompletedProcess: return subprocess.run( ["git", "-C", repo] + list(cmd), - capture_output=capture, text=True, + capture_output=capture, + text=True, ) @@ -49,23 +40,19 @@ def _is_git_repo(repo_path: str) -> bool: def _check_repo(repo_path: str, do_fetch: bool = True): - """Check a single repo, return (name, issues list).""" name = os.path.basename(repo_path) if not _is_git_repo(repo_path): - return name, None # Not a git repo + return name, None issues = [] - if do_fetch: fetch_result = _git(repo_path, "fetch", "--all", "--quiet") if fetch_result.returncode != 0: issues.append("git fetch failed") - # Current branch result = _git(repo_path, "rev-parse", "--abbrev-ref", "HEAD") branch = result.stdout.strip() if result.returncode == 0 else "HEAD" - # Uncommitted changes diff_result = _git(repo_path, "diff", "--quiet") cached_result = _git(repo_path, "diff", "--cached", "--quiet") if diff_result.returncode != 0 or cached_result.returncode != 0: @@ -75,28 +62,25 @@ def _check_repo(repo_path: str, do_fetch: bool = True): if untracked.stdout.strip(): issues.append("untracked files") - # Unpushed commits upstream_check = _git(repo_path, "rev-parse", "--abbrev-ref", f"{branch}@{{u}}") if upstream_check.returncode == 0: unpushed = _git(repo_path, "rev-list", "--oneline", f"{branch}@{{u}}..{branch}") if unpushed.stdout.strip(): - count = len(unpushed.stdout.strip().split("\n")) - issues.append(f"{count} unpushed commit(s) on {branch}") + issues.append(f"{len(unpushed.stdout.strip().splitlines())} unpushed commit(s) on {branch}") else: issues.append(f"no upstream for {branch}") - # Unpushed branches branches_result = _git(repo_path, "for-each-ref", "--format=%(refname:short)", "refs/heads") - for b in branches_result.stdout.strip().split("\n"): - if not b or b == branch: + for branch_name in branches_result.stdout.strip().splitlines(): + if not branch_name or branch_name == branch: continue - up = _git(repo_path, "rev-parse", "--abbrev-ref", f"{b}@{{u}}") - if up.returncode == 0: - ahead = _git(repo_path, "rev-list", "--count", f"{b}@{{u}}..{b}") + upstream = _git(repo_path, "rev-parse", "--abbrev-ref", f"{branch_name}@{{u}}") + if upstream.returncode == 0: + ahead = _git(repo_path, "rev-list", "--count", f"{branch_name}@{{u}}..{branch_name}") if ahead.stdout.strip() != "0": - issues.append(f"branch {b}: {ahead.stdout.strip()} ahead") + issues.append(f"branch {branch_name}: {ahead.stdout.strip()} ahead") else: - issues.append(f"branch {b}: no upstream") + issues.append(f"branch {branch_name}: no upstream") return name, issues @@ -116,17 +100,14 @@ def run_check(ctx: FlowContext, args): repo_path = os.path.join(projects_dir, entry) if not os.path.isdir(repo_path): continue - name, issues = _check_repo(repo_path, do_fetch=args.fetch) if issues is None: not_git.append(name) continue checked += 1 + rows.append([name, "; ".join(issues) if issues else "clean and synced"]) if issues: needs_action.append(name) - rows.append([name, "; ".join(issues)]) - else: - rows.append([name, "clean and synced"]) if checked == 0: ctx.console.info("No git repositories found in projects directory.") @@ -135,12 +116,10 @@ def run_check(ctx: FlowContext, args): return ctx.console.table(["PROJECT", "STATUS"], rows) - if needs_action: ctx.console.warn(f"Projects needing action: {', '.join(sorted(needs_action))}") else: ctx.console.success("All repositories clean and synced.") - if not_git: ctx.console.info(f"Skipped non-git directories: {', '.join(sorted(not_git))}") @@ -161,16 +140,14 @@ def run_fetch(ctx: FlowContext, args): result = _git(repo_path, "fetch", "--all", "--quiet") fetched += 1 if result.returncode != 0: - ctx.console.error(f"Failed to fetch {entry}") had_error = True + ctx.console.error(f"Failed to fetch {entry}") if fetched == 0: ctx.console.info("No git repositories found in projects directory.") return - if had_error: sys.exit(1) - ctx.console.success("All remotes fetched.") @@ -180,14 +157,11 @@ def run_summary(ctx: FlowContext, args): ctx.console.error(f"Projects directory not found: {projects_dir}") sys.exit(1) - headers = ["PROJECT", "STATUS"] rows = [] - for entry in sorted(os.listdir(projects_dir)): repo_path = os.path.join(projects_dir, entry) if not os.path.isdir(repo_path): continue - name, issues = _check_repo(repo_path, do_fetch=False) if issues is None: rows.append([name, "not a git repo"]) @@ -199,5 +173,4 @@ def run_summary(ctx: FlowContext, args): if not rows: ctx.console.info("No projects found.") return - - ctx.console.table(headers, rows) + ctx.console.table(["PROJECT", "STATUS"], rows) diff --git a/src/flow/core/config.py b/src/flow/core/config.py index 8a843b5..a7f3d96 100644 --- a/src/flow/core/config.py +++ b/src/flow/core/config.py @@ -9,6 +9,7 @@ import yaml from flow.core import paths from flow.core.console import ConsoleLogger from flow.core.platform import PlatformInfo +from flow.core.system import SystemRuntime @dataclass @@ -316,3 +317,4 @@ class FlowContext: manifest: Dict[str, Any] platform: PlatformInfo console: ConsoleLogger + runtime: SystemRuntime = field(default_factory=SystemRuntime) diff --git a/src/flow/core/errors.py b/src/flow/core/errors.py new file mode 100644 index 0000000..80bbbc9 --- /dev/null +++ b/src/flow/core/errors.py @@ -0,0 +1,6 @@ +"""Project-wide exception types.""" + + +class FlowError(RuntimeError): + """A user-facing operational error.""" + diff --git a/src/flow/core/process.py b/src/flow/core/process.py index 3f28326..e834a33 100644 --- a/src/flow/core/process.py +++ b/src/flow/core/process.py @@ -1,8 +1,9 @@ -"""Command execution with streaming output.""" +"""Command execution helpers.""" import subprocess from flow.core.console import ConsoleLogger +from flow.core.system import CommandRunner def run_command( @@ -14,35 +15,16 @@ def run_command( capture: bool = False, ) -> subprocess.CompletedProcess: """Run a command with real-time streamed output.""" - console.step_command(command) + if not shell: + raise RuntimeError("run_command only supports shell commands") - process = subprocess.Popen( - command, - shell=shell, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - universal_newlines=True, - bufsize=1, - ) + runner = CommandRunner() + if capture: + result = runner.run_shell(command, capture_output=True, check=False) + if check and result.returncode != 0: + raise RuntimeError( + f"Command failed (exit {result.returncode}): {command}" + ) + return result - output_lines = [] - assert process.stdout is not None # guaranteed by stdout=PIPE - try: - for line in process.stdout: - line = line.rstrip() - if line: - if not capture: - console.step_output(line) - output_lines.append(line) - finally: - process.stdout.close() - process.wait() - - if check and process.returncode != 0: - raise RuntimeError( - f"Command failed (exit {process.returncode}): {command}" - ) - - return subprocess.CompletedProcess( - command, process.returncode, stdout="\n".join(output_lines), stderr="" - ) + return runner.stream_shell(command, console, check=check) diff --git a/src/flow/core/system.py b/src/flow/core/system.py new file mode 100644 index 0000000..1842b63 --- /dev/null +++ b/src/flow/core/system.py @@ -0,0 +1,327 @@ +"""Runtime primitives for process, git, state, and filesystem access.""" + +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable, Mapping, Optional, Sequence + +from flow.core.console import ConsoleLogger +from flow.core.errors import FlowError + + +def _as_argv(argv: Sequence[str] | Iterable[str]) -> list[str]: + return [str(part) for part in argv] + + +class CommandRunner: + """Small wrapper around subprocess with consistent defaults.""" + + def format_command(self, argv: Sequence[str] | Iterable[str]) -> str: + return " ".join(shlex.quote(part) for part in _as_argv(argv)) + + def require_binary(self, name: str) -> str: + path = shutil.which(name) + if path is None: + raise FlowError(f"Required executable not found: {name}") + return path + + def run( + self, + argv: Sequence[str] | Iterable[str], + *, + cwd: Optional[Path] = None, + env: Optional[Mapping[str, str]] = None, + capture_output: bool = True, + check: bool = False, + timeout: Optional[int | float] = None, + ) -> subprocess.CompletedProcess[str]: + completed = subprocess.run( + _as_argv(argv), + cwd=str(cwd) if cwd is not None else None, + env=dict(env) if env is not None else None, + capture_output=capture_output, + text=True, + check=False, + timeout=timeout, + ) + if check and completed.returncode != 0: + message = completed.stderr.strip() or completed.stdout.strip() + if not message: + message = f"Command failed with exit code {completed.returncode}" + raise FlowError(message) + return completed + + def run_shell( + self, + command: str, + *, + cwd: Optional[Path] = None, + env: Optional[Mapping[str, str]] = None, + capture_output: bool = True, + check: bool = False, + timeout: Optional[int | float] = None, + ) -> subprocess.CompletedProcess[str]: + completed = subprocess.run( + command, + shell=True, + cwd=str(cwd) if cwd is not None else None, + env=dict(env) if env is not None else None, + capture_output=capture_output, + text=True, + check=False, + timeout=timeout, + ) + if check and completed.returncode != 0: + message = completed.stderr.strip() or completed.stdout.strip() + if not message: + message = f"Command failed with exit code {completed.returncode}" + raise FlowError(message) + return completed + + def stream_shell( + self, + command: str, + console: ConsoleLogger, + *, + check: bool = True, + ) -> subprocess.CompletedProcess[str]: + console.step_command(command) + + process = subprocess.Popen( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + bufsize=1, + ) + + output_lines: list[str] = [] + assert process.stdout is not None + try: + for line in process.stdout: + line = line.rstrip() + if not line: + continue + console.step_output(line) + output_lines.append(line) + finally: + process.stdout.close() + process.wait() + + if check and process.returncode != 0: + raise FlowError( + f"Command failed (exit {process.returncode}): {command}" + ) + + return subprocess.CompletedProcess( + command, + process.returncode, + stdout="\n".join(output_lines), + stderr="", + ) + + +class GitClient: + """Thin git adapter that always scopes commands to a repository root.""" + + def __init__(self, runner: CommandRunner): + self.runner = runner + + def run( + self, + repo_dir: Path, + *args: str, + capture_output: bool = True, + check: bool = False, + ) -> subprocess.CompletedProcess[str]: + return self.runner.run( + ["git", "-C", str(repo_dir), *args], + capture_output=capture_output, + check=check, + ) + + +class FileSystem: + """Filesystem wrapper for all mutating operations.""" + + def ensure_dir( + self, + path: Path, + *, + sudo: bool = False, + runner: Optional[CommandRunner] = None, + mode: Optional[int] = None, + ) -> None: + if sudo: + if runner is None: + raise FlowError("A command runner is required for sudo operations") + runner.require_binary("sudo") + argv = ["sudo", "mkdir", "-p"] + if mode is not None: + argv.extend(["-m", f"{mode:o}"]) + argv.append(str(path)) + runner.run(argv, check=True) + return + + path.mkdir(parents=True, exist_ok=True) + if mode is not None: + path.chmod(mode) + + def remove_file( + self, + path: Path, + *, + sudo: bool = False, + runner: Optional[CommandRunner] = None, + missing_ok: bool = True, + ) -> None: + if sudo: + if runner is None: + raise FlowError("A command runner is required for sudo operations") + runner.require_binary("sudo") + argv = ["sudo", "rm"] + if missing_ok: + argv.append("-f") + argv.append(str(path)) + runner.run(argv, check=True) + return + + try: + path.unlink() + except FileNotFoundError: + if not missing_ok: + raise + + def remove_tree(self, path: Path) -> None: + shutil.rmtree(path, ignore_errors=True) + + def copy_file( + self, + source: Path, + target: Path, + *, + sudo: bool = False, + runner: Optional[CommandRunner] = None, + ) -> None: + if sudo: + if runner is None: + raise FlowError("A command runner is required for sudo operations") + runner.require_binary("sudo") + self.ensure_dir(target.parent, sudo=True, runner=runner) + runner.run(["sudo", "cp", "-a", str(source), str(target)], check=True) + return + + self.ensure_dir(target.parent) + shutil.copy2(source, target) + + def copy_tree(self, source: Path, target: Path) -> None: + self.ensure_dir(target.parent) + shutil.copytree(source, target, dirs_exist_ok=True) + + def create_symlink( + self, + source: Path, + target: Path, + *, + sudo: bool = False, + runner: Optional[CommandRunner] = None, + ) -> None: + if sudo: + if runner is None: + raise FlowError("A command runner is required for sudo operations") + runner.require_binary("sudo") + self.ensure_dir(target.parent, sudo=True, runner=runner) + runner.run(["sudo", "ln", "-sfn", str(source), str(target)], check=True) + return + + self.ensure_dir(target.parent) + target.symlink_to(source) + + def read_text(self, path: Path, *, default: Optional[str] = None) -> str: + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + if default is None: + raise + return default + + def write_text(self, path: Path, content: str) -> None: + self.ensure_dir(path.parent) + path.write_text(content, encoding="utf-8") + + def write_bytes(self, path: Path, content: bytes) -> None: + self.ensure_dir(path.parent) + path.write_bytes(content) + + def write_bytes(self, path: Path, content: bytes) -> None: + self.ensure_dir(path.parent) + path.write_bytes(content) + + def read_json(self, path: Path, *, default: Any = None) -> Any: + try: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + except FileNotFoundError: + return default + + def write_json(self, path: Path, data: Any) -> None: + self.ensure_dir(path.parent) + with open(path, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + + def same_symlink(self, target: Path, source: Path) -> bool: + if not target.is_symlink(): + return False + return target.resolve(strict=False) == source.resolve(strict=False) + + def is_within(self, path: Path, parent: Path) -> bool: + try: + path.resolve(strict=False).relative_to(parent.resolve(strict=False)) + return True + except ValueError: + return False + + def path_in_home(self, path: Path, home: Optional[Path] = None) -> bool: + root = (home or Path.home()).resolve(strict=False) + try: + path.resolve(strict=False).relative_to(root) + return True + except ValueError: + return False + + +@dataclass +class JsonStateStore: + """JSON file-backed state store.""" + + path: Path + fs: FileSystem + default_factory: Any + + def load(self) -> Any: + data = self.fs.read_json(self.path, default=None) + if data is None: + return self.default_factory() + return data + + def save(self, data: Any) -> None: + self.fs.write_json(self.path, data) + + +@dataclass +class SystemRuntime: + """Shared runtime dependencies carried through the command context.""" + + runner: CommandRunner = field(default_factory=CommandRunner) + fs: FileSystem = field(default_factory=FileSystem) + git: GitClient = field(init=False) + + def __post_init__(self) -> None: + self.git = GitClient(self.runner) diff --git a/src/flow/services/__init__.py b/src/flow/services/__init__.py new file mode 100644 index 0000000..5310a5f --- /dev/null +++ b/src/flow/services/__init__.py @@ -0,0 +1,2 @@ +"""Domain services for CLI commands.""" + diff --git a/src/flow/services/bootstrap.py b/src/flow/services/bootstrap.py new file mode 100644 index 0000000..70f2fc3 --- /dev/null +++ b/src/flow/services/bootstrap.py @@ -0,0 +1,1001 @@ +"""Bootstrap domain logic.""" + +import argparse +import os +import re +import shlex +import shutil +import sys +import tempfile +import urllib.request +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +from flow.commands import dotfiles as dotfiles_cmd +from flow.core.config import FlowContext +from flow.core.process import run_command +from flow.core.system import FileSystem +from flow.core.variables import substitute_template + +DEFAULT_LOCALE = "en_US.UTF-8" +PACKAGE_TYPES = {"pkg", "binary", "cask"} +_FS = FileSystem() + + +def register(subparsers): + p = subparsers.add_parser( + "bootstrap", + aliases=["setup", "provision"], + help="Environment provisioning", + ) + sub = p.add_subparsers(dest="bootstrap_command") + + run_p = sub.add_parser("run", help="Run bootstrap actions") + run_p.add_argument("--profile", help="Profile name to use") + run_p.add_argument("--dry-run", action="store_true", help="Show plan without executing") + run_p.add_argument("--var", action="append", default=[], help="Set variable KEY=VALUE") + run_p.set_defaults(handler=run_bootstrap) + + ls = sub.add_parser("list", help="List available profiles") + ls.set_defaults(handler=run_list) + + show = sub.add_parser("show", help="Show profile configuration") + show.add_argument("profile", help="Profile name") + show.set_defaults(handler=run_show) + + packages = sub.add_parser("packages", help="List packages defined in profiles") + packages.add_argument("--profile", help="Profile name (default: all profiles)") + packages.add_argument( + "--resolved", + action="store_true", + help="Show resolved package names for detected package manager", + ) + packages.set_defaults(handler=run_packages) + + p.set_defaults(handler=lambda ctx, args: p.print_help()) + + +def _get_profiles(ctx: FlowContext) -> dict: + profiles = ctx.manifest.get("profiles") + if profiles is None: + if "environments" in ctx.manifest: + raise RuntimeError( + "Manifest key 'environments' is no longer supported. Rename it to 'profiles'." + ) + return {} + + if not isinstance(profiles, dict): + raise RuntimeError("Manifest key 'profiles' must be a mapping") + + return profiles + + +def _parse_variables(var_args: list) -> dict: + variables = {} + for item in var_args: + if "=" not in item: + raise ValueError(f"Invalid --var value '{item}'. Expected KEY=VALUE") + key, value = item.split("=", 1) + if not key: + raise ValueError(f"Invalid --var value '{item}'. KEY cannot be empty") + variables[key] = value + return variables + + +def _profile_template_context( + ctx: FlowContext, + extra_env: Dict[str, str], + extra: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + env_map = dict(os.environ) + env_map.update(extra_env) + + template_ctx: Dict[str, Any] = { + "env": env_map, + "os": ctx.platform.os, + "arch": ctx.platform.arch, + } + if extra: + template_ctx.update(extra) + return template_ctx + + +def _render_template_value(value: Any, template_ctx: Dict[str, Any]) -> Any: + if isinstance(value, str): + return substitute_template(value, template_ctx) + if isinstance(value, list): + return [_render_template_value(item, template_ctx) for item in value] + if isinstance(value, dict): + return {k: _render_template_value(v, template_ctx) for k, v in value.items()} + return value + + +def _linux_detect_package_manager() -> Optional[str]: + if shutil.which("apt") or shutil.which("apt-get"): + return "apt" + if shutil.which("dnf"): + return "dnf" + return None + + +def _resolve_package_manager(ctx: FlowContext, profile_cfg: dict) -> str: + explicit = profile_cfg.get("package-manager") + if isinstance(explicit, str) and explicit: + return explicit + + profile_os = profile_cfg.get("os") + if profile_os == "macos": + return "brew" + if profile_os == "linux": + detected = _linux_detect_package_manager() + if detected: + return detected + raise RuntimeError("Unable to auto-detect package manager (expected apt or dnf)") + + raise RuntimeError("Profile 'os' must be set to 'linux' or 'macos'") + + +def _get_package_catalog(ctx: FlowContext) -> Dict[str, Dict[str, Any]]: + raw = ctx.manifest.get("packages", []) + catalog: Dict[str, Dict[str, Any]] = {} + + if isinstance(raw, dict): + # Also support mapping form: packages: {name: {...}} + for name, definition in raw.items(): + if not isinstance(definition, dict): + continue + pkg = dict(definition) + pkg["name"] = str(pkg.get("name") or name) + pkg.setdefault("type", "pkg") + catalog[pkg["name"]] = pkg + return catalog + + if not isinstance(raw, list): + return catalog + + for item in raw: + if not isinstance(item, dict): + continue + name = item.get("name") + if not isinstance(name, str) or not name: + continue + pkg = dict(item) + pkg.setdefault("type", "pkg") + catalog[name] = pkg + + return catalog + + +def _normalize_profile_package_entry(entry: Any) -> Dict[str, Any]: + if isinstance(entry, str): + if "/" in entry: + prefix, name = entry.split("/", 1) + if prefix in PACKAGE_TYPES and name: + return {"name": name, "type": prefix} + return {"name": entry} + + if isinstance(entry, dict): + if not isinstance(entry.get("name"), str) or not entry["name"]: + raise RuntimeError("Package object entries must include a non-empty 'name'") + return dict(entry) + + raise RuntimeError(f"Unsupported package entry: {entry!r}") + + +def _resolve_package_spec( + catalog: Dict[str, Dict[str, Any]], + profile_entry: Dict[str, Any], +) -> Dict[str, Any]: + name = profile_entry["name"] + base = dict(catalog.get(name, {})) + merged = dict(base) + merged.update(profile_entry) + merged["name"] = name + + pkg_type = merged.get("type") or "pkg" + if pkg_type not in PACKAGE_TYPES: + raise RuntimeError(f"Unsupported package type '{pkg_type}' for package '{name}'") + merged["type"] = pkg_type + + return merged + + +def _resolve_pkg_source_name(spec: Dict[str, Any], package_manager: str) -> str: + sources = spec.get("sources", {}) + if not isinstance(sources, dict): + return spec["name"] + + keys = [package_manager] + if package_manager == "apt": + keys.append("apt-get") + if package_manager == "apt-get": + keys.append("apt") + + for key in keys: + value = sources.get(key) + if isinstance(value, str) and value: + return value + + return spec["name"] + + +def _platform_lookup_keys(ctx: FlowContext) -> List[str]: + keys = [ctx.platform.platform] + + if ctx.platform.os == "macos": + keys.append(f"darwin-{ctx.platform.arch}") + + if ctx.platform.arch == "x64": + keys.append(f"{ctx.platform.os}-amd64") + if ctx.platform.os == "macos": + keys.append("darwin-amd64") + + unique: List[str] = [] + for key in keys: + if key not in unique: + unique.append(key) + return unique + + +def _resolve_binary_platform_vars(ctx: FlowContext, spec: Dict[str, Any]) -> Dict[str, str]: + platform_vars = { + "os": ctx.platform.os, + "arch": ctx.platform.arch, + } + + platform_map = spec.get("platform-map", {}) + if isinstance(platform_map, dict): + for key in _platform_lookup_keys(ctx): + mapping = platform_map.get(key) + if isinstance(mapping, dict): + for mk, mv in mapping.items(): + if isinstance(mv, str): + platform_vars[mk] = mv + break + + return platform_vars + + +def _resolve_binary_asset(ctx: FlowContext, spec: Dict[str, Any], template_ctx: Dict[str, Any]) -> str: + assets = spec.get("assets", {}) + if isinstance(assets, dict) and assets: + for key in _platform_lookup_keys(ctx): + value = assets.get(key) + if isinstance(value, str) and value: + return substitute_template(value, template_ctx) + raise RuntimeError( + f"No binary asset mapping for platform {ctx.platform.platform} in package '{spec['name']}'" + ) + + pattern = spec.get("asset-pattern") + if not isinstance(pattern, str) or not pattern: + raise RuntimeError( + f"Binary package '{spec['name']}' must define either 'assets' or 'asset-pattern'" + ) + + return substitute_template(pattern, template_ctx) + + +def _resolve_binary_download_url( + spec: Dict[str, Any], + asset_name: str, + template_ctx: Dict[str, Any], +) -> str: + source = spec.get("source") + if not isinstance(source, str) or not source: + raise RuntimeError(f"Binary package '{spec['name']}' is missing 'source'") + + version = str(spec.get("version", "")) + if source.startswith("github:"): + owner_repo = source[len("github:") :] + if not owner_repo: + raise RuntimeError(f"Invalid github source in package '{spec['name']}'") + if not version: + raise RuntimeError(f"Binary package '{spec['name']}' requires 'version'") + return f"https://github.com/{owner_repo}/releases/download/v{version}/{asset_name}" + + rendered_source = substitute_template(source, template_ctx) + if not asset_name: + return rendered_source + + if rendered_source.endswith(asset_name): + return rendered_source + + if rendered_source.endswith("/"): + return rendered_source + asset_name + + return f"{rendered_source}/{asset_name}" + + +def _strip_prefix(path: Path, prefix: Path) -> Path: + try: + return path.relative_to(prefix) + except ValueError: + return path + + +def _is_under(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + return True + except ValueError: + return False + + +def _validate_declared_install_path(package_name: str, declared_path: Path) -> None: + if declared_path.is_absolute(): + raise RuntimeError( + f"Install path for '{package_name}' must be relative: {declared_path}" + ) + if any(part == ".." for part in declared_path.parts): + raise RuntimeError( + f"Install path for '{package_name}' must not include parent traversal: {declared_path}" + ) + + +def _install_destination(kind: str) -> Path: + home = Path.home() + if kind == "bin": + return home / ".local" / "bin" + if kind == "share": + return home / ".local" / "share" + if kind == "man": + return home / ".local" / "share" / "man" + if kind == "lib": + return home / ".local" / "lib" + raise RuntimeError(f"Unsupported install section: {kind}") + + +def _install_strip_prefix(kind: str) -> Path: + if kind == "bin": + return Path("bin") + if kind == "share": + return Path("share") + if kind == "man": + return Path("share") / "man" + if kind == "lib": + return Path("lib") + return Path(".") + + +def _copy_install_item(kind: str, src: Path, declared_path: Path) -> None: + destination_root = _install_destination(kind) + stripped = _strip_prefix(declared_path, _install_strip_prefix(kind)) + destination = destination_root / stripped + + if src.is_dir(): + _FS.copy_tree(src, destination) + else: + _FS.copy_file(src, destination) + if kind == "bin": + destination.chmod(destination.stat().st_mode | 0o111) + + +def _install_binary_package( + ctx: FlowContext, + spec: Dict[str, Any], + extra_env: Dict[str, str], + dry_run: bool, +) -> None: + version = str(spec.get("version", "")) + platform_vars = _resolve_binary_platform_vars(ctx, spec) + template_ctx = _profile_template_context( + ctx, + extra_env, + { + "name": spec["name"], + "version": version, + **platform_vars, + }, + ) + + asset_name = _resolve_binary_asset(ctx, spec, template_ctx) + template_ctx["asset"] = asset_name + download_url = _resolve_binary_download_url(spec, asset_name, template_ctx) + template_ctx["downloadUrl"] = download_url + + if dry_run: + ctx.console.info(f"[{spec['name']}] Would download: {download_url}") + return + + install = spec.get("install", {}) + if not isinstance(install, dict) or not install: + raise RuntimeError(f"Binary package '{spec['name']}' must define non-empty 'install'") + + with tempfile.TemporaryDirectory(prefix=f"flow-{spec['name']}-") as tmp: + tmp_dir = Path(tmp) + archive_path = tmp_dir / asset_name + + ctx.console.info(f"Downloading {spec['name']} from {download_url}") + with urllib.request.urlopen(download_url, timeout=60) as response: + _FS.write_bytes(archive_path, response.read()) + + extracted = tmp_dir / "extract" + _FS.ensure_dir(extracted) + try: + shutil.unpack_archive(str(archive_path), str(extracted)) + except (shutil.ReadError, ValueError) as e: + raise RuntimeError( + f"Could not extract archive for '{spec['name']}': {e}" + ) from e + + extract_dir_value = str(spec.get("extract-dir", ".")) + extract_dir_value = substitute_template(extract_dir_value, template_ctx) + if extract_dir_value == ".": + source_root = extracted + else: + source_root = extracted / extract_dir_value + + if not source_root.exists(): + raise RuntimeError( + f"extract-dir '{extract_dir_value}' not found for package '{spec['name']}'" + ) + source_root_resolved = source_root.resolve() + + for kind in ("bin", "share", "man", "lib"): + items = install.get(kind, []) + if not isinstance(items, list): + continue + + for raw_item in items: + if not isinstance(raw_item, str): + continue + + rendered = substitute_template(raw_item, template_ctx) + declared_path = Path(rendered) + _validate_declared_install_path(spec["name"], declared_path) + + src = (source_root / declared_path).resolve() + if not _is_under(src, source_root_resolved): + raise RuntimeError( + f"Install path escapes extract-dir for '{spec['name']}': {declared_path}" + ) + if not src.exists(): + raise RuntimeError( + f"Install path not found for '{spec['name']}': {declared_path}" + ) + _copy_install_item(kind, src, declared_path) + + +def _script_uses_sudo(script: str) -> bool: + return re.search(r"(^|\s)sudo(\s|$)", script) is not None + + +def _run_script( + ctx: FlowContext, + script: str, + template_ctx: Dict[str, Any], + *, + dry_run: bool, + allow_sudo: bool, + description: str, +) -> None: + rendered = substitute_template(script, template_ctx) + if not allow_sudo and _script_uses_sudo(rendered): + ctx.console.warn(f"Skipping {description}: sudo is blocked (set allow_sudo: true)") + return + + if dry_run: + ctx.console.info(f"Would run {description}:") + for line in rendered.splitlines(): + if line.strip(): + print(f" {line}") + return + + run_command(rendered, ctx.console) + + +def _run_one_command(ctx: FlowContext, command: str, dry_run: bool) -> None: + if dry_run: + print(f" $ {command}") + return + run_command(command, ctx.console) + + +def _ensure_shell_installed( + ctx: FlowContext, + shell_name: str, + package_manager: str, + package_catalog: Dict[str, Dict[str, Any]], + extra_env: Dict[str, str], + *, + dry_run: bool, + pm_state: Dict[str, bool], +) -> None: + if shutil.which(shell_name): + return + + shell_spec = package_catalog.get(shell_name, {"name": shell_name, "type": "pkg"}) + shell_spec = dict(shell_spec) + shell_spec["name"] = shell_name + shell_spec.setdefault("type", "pkg") + + ctx.console.info(f"Shell '{shell_name}' is missing; installing it first") + _install_package( + ctx, + shell_spec, + package_manager, + extra_env, + dry_run=dry_run, + pm_state=pm_state, + ) + + +def _set_shell(ctx: FlowContext, shell_name: str, *, dry_run: bool) -> None: + shell_path = shutil.which(shell_name) + if not shell_path: + raise RuntimeError(f"Shell not found after installation: {shell_name}") + + quoted_path = shlex.quote(shell_path) + quoted_user = shlex.quote(os.environ.get("USER", "")) + + try: + with open("/etc/shells", "r", encoding="utf-8") as handle: + shell_lines = handle.read() + except OSError: + shell_lines = "" + + if shell_path not in shell_lines: + _run_one_command( + ctx, + f"echo {quoted_path} | sudo tee -a /etc/shells >/dev/null", + dry_run, + ) + + _run_one_command(ctx, f"sudo chsh -s {quoted_path} {quoted_user}", dry_run) + + +def _set_hostname(ctx: FlowContext, hostname: str, *, dry_run: bool) -> None: + quoted = shlex.quote(hostname) + if ctx.platform.os == "macos": + _run_one_command(ctx, f"sudo scutil --set ComputerName {quoted}", dry_run) + _run_one_command(ctx, f"sudo scutil --set HostName {quoted}", dry_run) + _run_one_command(ctx, f"sudo scutil --set LocalHostName {quoted}", dry_run) + else: + _run_one_command(ctx, f"sudo hostnamectl set-hostname {quoted}", dry_run) + + +def _set_locale(ctx: FlowContext, locale: str, *, dry_run: bool) -> None: + if ctx.platform.os != "linux": + return + quoted = shlex.quote(locale) + _run_one_command(ctx, f"sudo locale-gen {quoted}", dry_run) + _run_one_command(ctx, f"sudo update-locale LANG={quoted}", dry_run) + + +def _ensure_required_variables(profile_cfg: Dict[str, Any], env_map: Dict[str, str]) -> None: + requires = profile_cfg.get("requires", []) + if not isinstance(requires, list): + raise RuntimeError("Profile 'requires' must be a list") + + missing = [] + for key in requires: + if not isinstance(key, str) or not key: + continue + if env_map.get(key, "") == "": + missing.append(key) + + if missing: + raise RuntimeError( + "Missing required environment variables: " + + ", ".join(missing) + + ". Export them or pass with --var KEY=VALUE." + ) + + +def _pm_update_command(pm: str) -> str: + if pm in ("apt", "apt-get"): + return "sudo apt update -qq" + if pm == "dnf": + return "sudo dnf makecache -q" + if pm == "brew": + return "brew update" + return f"sudo {shlex.quote(pm)} update" + + +def _pm_install_command(pm: str, packages: List[str], pkg_type: str) -> str: + pkg_args = " ".join(shlex.quote(pkg) for pkg in packages) + if pm in ("apt", "apt-get"): + return f"sudo apt install -y {pkg_args}" + if pm == "dnf": + return f"sudo dnf install -y {pkg_args}" + if pm == "brew" and pkg_type == "cask": + return f"brew install --cask {pkg_args}" + if pm == "brew": + return f"brew install {pkg_args}" + return f"sudo {shlex.quote(pm)} install {pkg_args}" + + +def _install_package( + ctx: FlowContext, + spec: Dict[str, Any], + package_manager: str, + extra_env: Dict[str, str], + *, + dry_run: bool, + pm_state: Dict[str, bool], +) -> None: + pkg_type = spec.get("type", "pkg") + + if pkg_type in {"pkg", "cask"} and not pm_state.get("updated"): + _run_one_command(ctx, _pm_update_command(package_manager), dry_run) + pm_state["updated"] = True + + if pkg_type == "pkg": + package_name = _resolve_pkg_source_name(spec, package_manager) + _run_one_command( + ctx, + _pm_install_command(package_manager, [package_name], "pkg"), + dry_run, + ) + return + + if pkg_type == "cask": + if package_manager != "brew": + ctx.console.warn(f"Skipping cask package on non-brew system: {spec['name']}") + return + package_name = _resolve_pkg_source_name(spec, "brew") + _run_one_command( + ctx, + _pm_install_command(package_manager, [package_name], "cask"), + dry_run, + ) + return + + if pkg_type == "binary": + _install_binary_package(ctx, spec, extra_env, dry_run) + return + + raise RuntimeError(f"Unsupported package type: {pkg_type}") + + +def _run_package_post_install( + ctx: FlowContext, + spec: Dict[str, Any], + extra_env: Dict[str, str], + *, + dry_run: bool, +) -> None: + script = spec.get("post-install") + if not isinstance(script, str) or not script.strip(): + return + + allow_sudo = bool(spec.get("allow_sudo", False)) + extra_ctx = { + "name": spec["name"], + "version": str(spec.get("version", "")), + } + if spec.get("type") == "binary": + extra_ctx.update(_resolve_binary_platform_vars(ctx, spec)) + + template_ctx = _profile_template_context( + ctx, + extra_env, + extra_ctx, + ) + _run_script( + ctx, + script, + template_ctx, + dry_run=dry_run, + allow_sudo=allow_sudo, + description=f"post-install hook for {spec['name']}", + ) + + +def _run_runcmd( + ctx: FlowContext, + profile_cfg: Dict[str, Any], + extra_env: Dict[str, str], + *, + dry_run: bool, +) -> None: + commands = profile_cfg.get("runcmd", []) + if not isinstance(commands, list): + raise RuntimeError("Profile 'runcmd' must be a list") + + template_ctx = _profile_template_context(ctx, extra_env) + for command in commands: + if not isinstance(command, str) or not command.strip(): + continue + rendered = substitute_template(command, template_ctx) + _run_one_command(ctx, rendered, dry_run) + + +def _run_ssh_keygen( + ctx: FlowContext, + profile_cfg: Dict[str, Any], + extra_env: Dict[str, str], + *, + dry_run: bool, +) -> None: + ssh_keygen = profile_cfg.get("ssh-keygen", profile_cfg.get("ssh_keygen", [])) + if not isinstance(ssh_keygen, list): + raise RuntimeError("Profile 'ssh-keygen' must be a list") + + template_ctx = _profile_template_context(ctx, extra_env) + ssh_dir = Path.home() / ".ssh" + if dry_run: + print(f" $ mkdir -p {ssh_dir}") + else: + _FS.ensure_dir(ssh_dir, mode=0o700) + + for entry in ssh_keygen: + if not isinstance(entry, dict): + continue + + key_type = str(entry.get("type", "ed25519")) + filename = str(entry.get("filename", f"id_{key_type}")) + key_path = ssh_dir / filename + if key_path.exists(): + ctx.console.warn(f"SSH key already exists: {key_path}") + continue + + comment = str(_render_template_value(entry.get("comment", ""), template_ctx)) + bits = entry.get("bits") + + command = [ + "ssh-keygen", + "-t", + shlex.quote(key_type), + "-f", + shlex.quote(str(key_path)), + "-N", + '""', + "-C", + shlex.quote(comment), + ] + if bits: + command.extend(["-b", shlex.quote(str(bits))]) + + _run_one_command(ctx, " ".join(command), dry_run) + _run_one_command(ctx, f"chmod 600 {shlex.quote(str(key_path))}", dry_run) + + +def _run_post_link( + ctx: FlowContext, + profile_cfg: Dict[str, Any], + extra_env: Dict[str, str], + *, + dry_run: bool, +) -> None: + script = profile_cfg.get("post-link") + if not script: + script = profile_cfg.get("post-config") + + if not isinstance(script, str) or not script.strip(): + return + + template_ctx = _profile_template_context(ctx, extra_env) + _run_script( + ctx, + script, + template_ctx, + dry_run=dry_run, + allow_sudo=True, + description="post-link hook", + ) + + +def _auto_link_profile_configs(ctx: FlowContext, profile_name: str, *, dry_run: bool) -> None: + link_args = argparse.Namespace( + packages=[], + profile=profile_name, + copy=False, + force=False, + dry_run=dry_run, + ) + dotfiles_cmd.run_link(ctx, link_args) + + +def run_bootstrap(ctx: FlowContext, args): + profiles = _get_profiles(ctx) + if not profiles: + ctx.console.error("No profiles found in manifest.") + sys.exit(1) + + profile_name = args.profile + if not profile_name: + if len(profiles) == 1: + profile_name = next(iter(profiles)) + else: + ctx.console.error( + f"Multiple profiles available. Specify with --profile: {', '.join(sorted(profiles.keys()))}" + ) + sys.exit(1) + + if profile_name not in profiles: + ctx.console.error( + f"Profile not found: {profile_name}. Available: {', '.join(sorted(profiles.keys()))}" + ) + sys.exit(1) + + profile_cfg = profiles[profile_name] + if not isinstance(profile_cfg, dict): + ctx.console.error(f"Profile '{profile_name}' must be a mapping") + sys.exit(1) + + profile_os = profile_cfg.get("os") + if profile_os not in {"linux", "macos"}: + ctx.console.error( + f"Profile '{profile_name}' must define os: linux|macos" + ) + sys.exit(1) + + if profile_os != ctx.platform.os: + ctx.console.error( + f"Profile '{profile_name}' targets '{profile_os}', current OS is '{ctx.platform.os}'" + ) + sys.exit(1) + + try: + cli_vars = _parse_variables(args.var) + package_manager = _resolve_package_manager(ctx, profile_cfg) + _ensure_required_variables(profile_cfg, {**os.environ, **cli_vars}) + except ValueError as e: + ctx.console.error(str(e)) + sys.exit(1) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + package_catalog = _get_package_catalog(ctx) + pm_state = {"updated": False} + + template_ctx = _profile_template_context(ctx, cli_vars) + + if "hostname" in profile_cfg: + hostname = str(_render_template_value(profile_cfg["hostname"], template_ctx)) + _set_hostname(ctx, hostname, dry_run=args.dry_run) + + locale = str(profile_cfg.get("locale", DEFAULT_LOCALE)) + _set_locale(ctx, locale, dry_run=args.dry_run) + + shell_name = profile_cfg.get("shell") + if isinstance(shell_name, str) and shell_name: + _ensure_shell_installed( + ctx, + shell_name, + package_manager, + package_catalog, + cli_vars, + dry_run=args.dry_run, + pm_state=pm_state, + ) + + profile_packages = profile_cfg.get("packages", []) + if not isinstance(profile_packages, list): + ctx.console.error("Profile 'packages' must be a list") + sys.exit(1) + + for raw_entry in profile_packages: + try: + normalized = _normalize_profile_package_entry(raw_entry) + spec = _resolve_package_spec(package_catalog, normalized) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + if spec.get("skip"): + ctx.console.info(f"Skipping package {spec['name']} (skip=true)") + continue + + ctx.console.info(f"Installing package: {spec['name']} ({spec['type']})") + try: + _install_package( + ctx, + spec, + package_manager, + cli_vars, + dry_run=args.dry_run, + pm_state=pm_state, + ) + _run_package_post_install(ctx, spec, cli_vars, dry_run=args.dry_run) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + if isinstance(shell_name, str) and shell_name: + try: + _set_shell(ctx, shell_name, dry_run=args.dry_run) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + try: + _run_ssh_keygen(ctx, profile_cfg, cli_vars, dry_run=args.dry_run) + _run_runcmd(ctx, profile_cfg, cli_vars, dry_run=args.dry_run) + _auto_link_profile_configs(ctx, profile_name, dry_run=args.dry_run) + _run_post_link(ctx, profile_cfg, cli_vars, dry_run=args.dry_run) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + except SystemExit: + raise + + +def run_list(ctx: FlowContext, args): + profiles = _get_profiles(ctx) + if not profiles: + ctx.console.info("No profiles defined in manifest.") + return + + headers = ["PROFILE", "OS", "PM", "PACKAGES", "REQUIRES"] + rows = [] + for name, profile_cfg in sorted(profiles.items()): + if not isinstance(profile_cfg, dict): + continue + os_name = str(profile_cfg.get("os", "?")) + pm = str(profile_cfg.get("package-manager", "auto")) + packages = profile_cfg.get("packages", []) + package_count = len(packages) if isinstance(packages, list) else 0 + requires = profile_cfg.get("requires", []) + requires_count = len(requires) if isinstance(requires, list) else 0 + rows.append([name, os_name, pm, str(package_count), str(requires_count)]) + + ctx.console.table(headers, rows) + + +def run_show(ctx: FlowContext, args): + profiles = _get_profiles(ctx) + profile_name = args.profile + + if profile_name not in profiles: + ctx.console.error( + f"Profile not found: {profile_name}. Available: {', '.join(sorted(profiles.keys()))}" + ) + sys.exit(1) + + print(yaml.safe_dump({profile_name: profiles[profile_name]}, sort_keys=False).rstrip()) + + +def run_packages(ctx: FlowContext, args): + profiles = _get_profiles(ctx) + if not profiles: + ctx.console.info("No profiles defined in manifest.") + return + + if args.profile: + if args.profile not in profiles: + ctx.console.error( + f"Profile not found: {args.profile}. Available: {', '.join(sorted(profiles.keys()))}" + ) + sys.exit(1) + selected_profiles = [(args.profile, profiles[args.profile])] + else: + selected_profiles = sorted(profiles.items()) + + package_catalog = _get_package_catalog(ctx) + rows = [] + for profile_name, profile_cfg in selected_profiles: + if not isinstance(profile_cfg, dict): + continue + + pm = _resolve_package_manager(ctx, profile_cfg) + profile_packages = profile_cfg.get("packages", []) + if not isinstance(profile_packages, list): + continue + + for raw_entry in profile_packages: + normalized = _normalize_profile_package_entry(raw_entry) + spec = _resolve_package_spec(package_catalog, normalized) + + if args.resolved: + if spec["type"] in {"pkg", "cask"}: + resolved = _resolve_pkg_source_name(spec, pm) + else: + resolved = spec.get("asset-pattern", spec.get("source", "")) + rows.append([profile_name, pm, spec["type"], spec["name"], str(resolved)]) + else: + rows.append([profile_name, spec["type"], spec["name"]]) + + if not rows: + ctx.console.info("No packages defined in selected profile(s).") + return + + if args.resolved: + ctx.console.table(["PROFILE", "PM", "TYPE", "PACKAGE", "RESOLVED"], rows) + else: + ctx.console.table(["PROFILE", "TYPE", "PACKAGE"], rows) diff --git a/src/flow/services/containers.py b/src/flow/services/containers.py new file mode 100644 index 0000000..9c06263 --- /dev/null +++ b/src/flow/services/containers.py @@ -0,0 +1,321 @@ +"""Container lifecycle helpers for `flow dev`.""" + +from __future__ import annotations + +import os +import shutil +from typing import Optional + +from flow.core.config import FlowContext +from flow.core.errors import FlowError + +DEFAULT_REGISTRY = "registry.tomastm.com" +DEFAULT_TAG = "latest" +CONTAINER_HOME = "/home/dev" + + +def runtime() -> str: + for name in ("docker", "podman"): + if shutil.which(name): + return name + raise FlowError("No container runtime found (docker or podman)") + + +def container_name(name: str) -> str: + return name if name.startswith("dev-") else f"dev-{name}" + + +def parse_image_ref( + image: str, + *, + default_registry: str = DEFAULT_REGISTRY, + default_tag: str = DEFAULT_TAG, +) -> tuple[str, str, str, str]: + registry = default_registry + tag = default_tag + + if image.startswith("docker/"): + registry = "docker.io" + image = f"library/{image.split('/', 1)[1]}" + elif image.startswith("tm0/"): + registry = default_registry + image = image.split("/", 1)[1] + elif "/" in image: + prefix, remainder = image.split("/", 1) + if "." in prefix or ":" in prefix or prefix == "localhost": + registry = prefix + image = remainder + + if ":" in image.split("/")[-1]: + tag = image.rsplit(":", 1)[1] + image = image.rsplit(":", 1)[0] + + repo = image + full_ref = f"{registry}/{repo}:{tag}" + label_prefix = ( + registry.rsplit(".", 1)[0].rsplit(".", 1)[-1] if "." in registry else registry + ) + label = f"{label_prefix}/{repo.split('/')[-1]}" + + return full_ref, repo, tag, label + + +class ContainerService: + """Own all container-runtime interactions.""" + + def __init__(self, ctx: FlowContext): + self.ctx = ctx + self.runner = ctx.runtime.runner + + def container_exists(self, rt: str, name: str) -> bool: + result = self.runner.run( + [rt, "container", "ls", "-a", "--format", "{{.Names}}"], + capture_output=True, + ) + return name in result.stdout.strip().splitlines() + + def container_running(self, rt: str, name: str) -> bool: + result = self.runner.run( + [rt, "container", "ls", "--format", "{{.Names}}"], + capture_output=True, + ) + return name in result.stdout.strip().splitlines() + + def run_create(self, args) -> None: + rt = runtime() + cname = container_name(args.name) + + if self.container_exists(rt, cname): + raise FlowError(f"Container already exists: {cname}") + + project_path = os.path.realpath(args.project) if args.project else None + if project_path and not os.path.isdir(project_path): + raise FlowError(f"Invalid project path: {project_path}") + + full_ref, _, _, _ = parse_image_ref( + args.image, + default_registry=self.ctx.config.container_registry, + default_tag=self.ctx.config.container_tag, + ) + + cmd = [ + rt, + "run", + "-d", + "--name", + cname, + "--label", + "dev=true", + "--label", + f"dev.name={args.name}", + "--label", + f"dev.image_ref={full_ref}", + "--network", + "host", + "--init", + ] + + if project_path: + cmd.extend(["-v", f"{project_path}:/workspace"]) + cmd.extend(["--label", f"dev.project_path={project_path}"]) + + docker_sock = "/var/run/docker.sock" + if os.path.exists(docker_sock): + cmd.extend(["-v", f"{docker_sock}:{docker_sock}"]) + + home = os.path.expanduser("~") + mounts = [ + (f"{home}/.ssh", f"{CONTAINER_HOME}/.ssh:ro", os.path.isdir), + (f"{home}/.npmrc", f"{CONTAINER_HOME}/.npmrc:ro", os.path.isfile), + (f"{home}/.npm", f"{CONTAINER_HOME}/.npm", os.path.isdir), + ] + for source, target, predicate in mounts: + if predicate(source): + cmd.extend(["-v", f"{source}:{target}"]) + + try: + import grp + + docker_gid = str(grp.getgrnam("docker").gr_gid) + cmd.extend(["--group-add", docker_gid]) + except (KeyError, ImportError): + pass + + cmd.extend([full_ref, "sleep", "infinity"]) + self.runner.run(cmd, capture_output=False, check=True) + self.ctx.console.success(f"Created and started container: {cname}") + + def run_exec(self, args) -> None: + rt = runtime() + cname = container_name(args.name) + + if not self.container_running(rt, cname): + raise FlowError(f"Container {cname} not running") + + if args.cmd: + exec_cmd = [rt, "exec"] + if os.isatty(0): + exec_cmd.extend(["-it"]) + exec_cmd.append(cname) + exec_cmd.extend(args.cmd) + result = self.runner.run(exec_cmd, capture_output=False) + raise SystemExit(result.returncode) + + for shell in (["zsh", "-l"], ["bash", "-l"], ["sh"]): + exec_cmd = [rt, "exec", "--detach-keys", "ctrl-q,ctrl-p", "-it", cname, *shell] + result = self.runner.run(exec_cmd, capture_output=False) + if result.returncode not in (126, 127): + raise SystemExit(result.returncode) + + raise FlowError(f"Unable to start an interactive shell in {cname}") + + def run_connect(self, args) -> None: + rt = runtime() + cname = container_name(args.name) + + if not self.container_exists(rt, cname): + raise FlowError(f"Container does not exist: {cname}") + + if not self.container_running(rt, cname): + self.runner.run([rt, "start", cname], capture_output=True) + + if not shutil.which("tmux"): + self.ctx.console.warn("tmux not found; falling back to direct exec") + args.cmd = [] + self.run_exec(args) + return + + result = self.runner.run( + [rt, "container", "inspect", cname, "--format", "{{ .Config.Image }}"] + ) + image_ref = result.stdout.strip() + _, _, _, image_label = parse_image_ref(image_ref) + + check = self.runner.run(["tmux", "has-session", "-t", cname], check=False) + if check.returncode != 0: + ns = os.environ.get("DF_NAMESPACE", "") + plat = os.environ.get("DF_PLATFORM", "") + self.runner.run( + [ + "tmux", + "new-session", + "-ds", + cname, + "-e", + f"DF_IMAGE={image_label}", + "-e", + f"DF_NAMESPACE={ns}", + "-e", + f"DF_PLATFORM={plat}", + f"flow dev exec {args.name}", + ], + capture_output=True, + ) + self.runner.run( + [ + "tmux", + "set-option", + "-t", + cname, + "default-command", + f"flow dev exec {args.name}", + ], + capture_output=True, + ) + + if os.environ.get("TMUX"): + os.execvp("tmux", ["tmux", "switch-client", "-t", cname]) + os.execvp("tmux", ["tmux", "attach", "-t", cname]) + + def run_list(self, _args) -> None: + rt = runtime() + result = self.runner.run( + [ + rt, + "ps", + "-a", + "--filter", + "label=dev=true", + "--format", + '{{.Label "dev.name"}}|{{.Image}}|{{.Label "dev.project_path"}}|{{.Status}}', + ] + ) + + rows = [] + for line in result.stdout.strip().splitlines(): + if not line: + continue + name, image, project, status = (line.split("|") + ["", "", "", ""])[:4] + home = os.path.expanduser("~") + if project.startswith(home): + project = "~" + project[len(home) :] + rows.append([name, image, project, status]) + + if not rows: + self.ctx.console.info("No development containers found.") + return + + self.ctx.console.table(["NAME", "IMAGE", "PROJECT", "STATUS"], rows) + + def run_stop(self, args) -> None: + rt = runtime() + cname = container_name(args.name) + + if not self.container_exists(rt, cname): + raise FlowError(f"Container {cname} does not exist") + + if args.kill: + self.ctx.console.info(f"Killing container {cname}...") + self.runner.run([rt, "kill", cname], capture_output=False, check=True) + else: + self.ctx.console.info(f"Stopping container {cname}...") + self.runner.run([rt, "stop", cname], capture_output=False, check=True) + + self._tmux_fallback(cname) + + def run_remove(self, args) -> None: + rt = runtime() + cname = container_name(args.name) + + if not self.container_exists(rt, cname): + raise FlowError(f"Container {cname} does not exist") + + if args.force: + self.ctx.console.info(f"Removing container {cname} (force)...") + self.runner.run([rt, "rm", "-f", cname], capture_output=False, check=True) + else: + self.ctx.console.info(f"Removing container {cname}...") + self.runner.run([rt, "rm", cname], capture_output=False, check=True) + + self._tmux_fallback(cname) + + def run_respawn(self, args) -> None: + if not shutil.which("tmux"): + raise FlowError("tmux is required for respawn but was not found") + + cname = container_name(args.name) + result = self.runner.run( + [ + "tmux", + "list-panes", + "-t", + cname, + "-s", + "-F", + "#{session_name}:#{window_index}.#{pane_index}", + ] + ) + for pane in result.stdout.strip().splitlines(): + if not pane: + continue + self.ctx.console.info(f"Respawning {pane}...") + self.runner.run(["tmux", "respawn-pane", "-t", pane], capture_output=False) + + def _tmux_fallback(self, cname: str) -> None: + if not os.environ.get("TMUX"): + return + result = self.runner.run(["tmux", "display-message", "-p", "#S"]) + if result.stdout.strip() != cname: + return + self.runner.run(["tmux", "new-session", "-ds", "default"], capture_output=True) + self.runner.run(["tmux", "switch-client", "-t", "default"], capture_output=True) diff --git a/src/flow/services/dotfiles.py b/src/flow/services/dotfiles.py new file mode 100644 index 0000000..4cf045c --- /dev/null +++ b/src/flow/services/dotfiles.py @@ -0,0 +1,1697 @@ +"""Dotfiles domain logic.""" + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple + +import yaml + +from flow.core.config import FlowContext +from flow.core.paths import DOTFILES_DIR, LINKED_STATE, MODULES_DIR +from flow.core.system import CommandRunner, FileSystem + +RESERVED_SHARED = "_shared" +RESERVED_ROOT = "_root" +MODULE_FILE = "_module.yaml" +LINK_BACKUP_DIR = LINKED_STATE.parent / "link-backups" +_RUNNER = CommandRunner() +_FS = FileSystem() + + +@dataclass +class LinkSpec: + source: Path + target: Path + package: str + is_directory_link: bool = False + + +@dataclass +class ModuleSpec: + package: str + source: str + ref_type: str + ref_value: str + package_dir: Path + # Relative path (under ~) where the module repo root is mounted. + # Derived from the directory containing `_module.yaml` within the package. + target_prefix: Path = Path() + + +def register(subparsers): + p = subparsers.add_parser("dotfiles", aliases=["dot"], help="Manage dotfiles") + p.add_argument("--verbose", action="store_true", help="Show detailed output") + sub = p.add_subparsers(dest="dotfiles_command") + + init = sub.add_parser("init", help="Clone dotfiles repository") + init.add_argument("--repo", help="Override repository URL") + init.set_defaults(handler=run_init) + + link = sub.add_parser("link", help="Create symlinks for dotfile packages") + link.add_argument("packages", nargs="*", help="Specific packages to link (default: all)") + link.add_argument("--profile", help="Profile to use") + link.add_argument("--copy", action="store_true", help="Copy instead of symlink") + link.add_argument("--force", action="store_true", help="Overwrite existing files") + link.add_argument("--dry-run", action="store_true", help="Show what would be done") + link.set_defaults(handler=run_link) + + unlink = sub.add_parser("unlink", help="Remove dotfile symlinks") + unlink.add_argument("packages", nargs="*", help="Specific packages to unlink (default: all)") + unlink.set_defaults(handler=run_unlink) + + undo = sub.add_parser("undo", help="Undo latest dotfiles link transaction") + undo.set_defaults(handler=run_undo) + + status = sub.add_parser("status", help="Show dotfiles link status") + status.set_defaults(handler=run_status) + + sync = sub.add_parser("sync", help="Pull latest dotfiles from remote") + sync.add_argument("--relink", action="store_true", help="Run relink after pull") + sync.add_argument("--profile", help="Profile to use when relinking") + sync.set_defaults(handler=run_sync) + + modules = sub.add_parser("modules", help="Inspect and refresh external modules") + modules_sub = modules.add_subparsers(dest="dotfiles_modules_command") + + modules_list = modules_sub.add_parser("list", help="List detected module packages") + modules_list.add_argument("packages", nargs="*", help="Filter by package name") + modules_list.add_argument("--profile", help="Limit to shared + one profile") + modules_list.set_defaults(handler=run_modules_list) + + modules_sync = modules_sub.add_parser("sync", help="Refresh module checkouts") + modules_sync.add_argument("packages", nargs="*", help="Filter by package name") + modules_sync.add_argument("--profile", help="Limit to shared + one profile") + modules_sync.set_defaults(handler=run_modules_sync) + + modules.set_defaults(handler=run_modules_list) + + repo = sub.add_parser("repo", help="Manage dotfiles repository") + repo_sub = repo.add_subparsers(dest="dotfiles_repo_command") + + repo_status = repo_sub.add_parser("status", help="Show git status for dotfiles repo") + repo_status.set_defaults(handler=run_repo_status) + + repo_pull = repo_sub.add_parser("pull", help="Pull latest changes") + repo_pull.add_argument( + "--rebase", + dest="rebase", + action="store_true", + help="Use rebase strategy (default)", + ) + repo_pull.add_argument( + "--no-rebase", + dest="rebase", + action="store_false", + help="Disable rebase strategy", + ) + repo_pull.add_argument("--relink", action="store_true", help="Run relink after pull") + repo_pull.add_argument("--profile", help="Profile to use when relinking") + repo_pull.set_defaults(rebase=True) + repo_pull.set_defaults(handler=run_repo_pull) + + repo_push = repo_sub.add_parser("push", help="Push local changes") + repo_push.set_defaults(handler=run_repo_push) + + repo.set_defaults(handler=lambda ctx, args: repo.print_help()) + + relink = sub.add_parser("relink", help="Refresh symlinks after changes") + relink.add_argument("packages", nargs="*", help="Specific packages to relink (default: all)") + relink.add_argument("--profile", help="Profile to use") + relink.set_defaults(handler=run_relink) + + clean = sub.add_parser("clean", help="Remove broken symlinks") + clean.add_argument("--dry-run", action="store_true", help="Show what would be done") + clean.set_defaults(handler=run_clean) + + edit = sub.add_parser("edit", help="Edit package or path with auto-commit") + edit.add_argument("target", help="Package name or path inside dotfiles repo") + edit.add_argument("--no-commit", action="store_true", help="Skip auto-commit") + edit.set_defaults(handler=run_edit) + + p.set_defaults(handler=lambda ctx, args: p.print_help()) + + +def _flow_config_dir(dotfiles_dir: Optional[Path] = None) -> Path: + return dotfiles_dir or DOTFILES_DIR + + +def _insert_spec( + desired: Dict[Path, LinkSpec], + *, + target: Path, + source: Path, + package: str, +) -> None: + existing = desired.get(target) + if existing is not None: + raise RuntimeError( + "Conflicting dotfile targets are not allowed: " + f"{target} from {existing.package} and {package}" + ) + + desired[target] = LinkSpec(source=source, target=target, package=package) + + +def _is_path_like_target(target: str) -> bool: + raw = Path(target) + return "/" in target or target.startswith(".") or raw.suffix != "" + + +def _module_cache_dir(spec: ModuleSpec) -> Path: + key = spec.package.replace("/", "--") + return MODULES_DIR / key + + +def _normalize_module_source(source: str, *, package_dir: Optional[Path] = None) -> str: + if source.startswith("github:"): + repo = source.split(":", 1)[1] + return f"https://github.com/{repo}.git" + + if "://" in source or source.startswith("git@"): + return source + + raw = Path(source).expanduser() + if raw.is_absolute(): + return str(raw) + + if package_dir is None: + return source + + return str((package_dir / raw).resolve()) + + +def _load_module_spec(package_dir: Path, package: str, module_file: Path) -> ModuleSpec: + if not module_file.exists(): + raise RuntimeError(f"Module file not found: {module_file}") + + try: + with open(module_file, "r", encoding="utf-8") as handle: + raw = yaml.safe_load(handle) or {} + except yaml.YAMLError as e: + raise RuntimeError(f"Invalid YAML in {module_file}: {e}") from e + + if not isinstance(raw, dict): + raise RuntimeError(f"{module_file} must contain a mapping") + + source = raw.get("source") + if not isinstance(source, str) or not source: + raise RuntimeError(f"{module_file} must define non-empty 'source'") + + ref = raw.get("ref") + if not isinstance(ref, dict): + raise RuntimeError(f"{module_file} must define 'ref' mapping") + + choices = [key for key in ("branch", "tag", "commit") if isinstance(ref.get(key), str) and ref.get(key)] + if len(choices) != 1: + raise RuntimeError(f"{module_file} 'ref' must include exactly one of: branch, tag, commit") + + ref_type = choices[0] + ref_value = str(ref[ref_type]) + + target_prefix = Path() + try: + target_prefix = module_file.parent.relative_to(package_dir) + except ValueError: + raise RuntimeError(f"{MODULE_FILE} must be inside package dir {package_dir}: {module_file}") from None + + if target_prefix == Path("."): + target_prefix = Path() + return ModuleSpec( + package=package, + # Relative sources are resolved relative to the `_module.yaml` location. + source=_normalize_module_source(source, package_dir=module_file.parent), + ref_type=ref_type, + ref_value=ref_value, + package_dir=package_dir, + target_prefix=target_prefix, + ) + + +def _run_git(dir_path: Path, *args: str, capture: bool = True) -> subprocess.CompletedProcess: + return _RUNNER.run( + ["git", "-C", str(dir_path)] + list(args), + capture_output=capture, + check=False, + ) + + +def _pull_requires_ack(stdout: str, stderr: str) -> bool: + text = f"{stdout}\n{stderr}".strip() + if not text: + return False + + lowered = text.lower() + if "already up to date" in lowered or "already up-to-date" in lowered: + return False + + return True + + +def _pull_repo_before_edit(ctx: FlowContext, repo_dir: Path, *, verbose: bool = False) -> None: + ctx.console.info(f"Pulling latest changes in {repo_dir}...") + result = _run_git(repo_dir, "pull", "--rebase", capture=True) + if result.returncode != 0: + ctx.console.warn(f"Git pull failed: {result.stderr.strip()}") + return + + if verbose: + output = result.stdout.strip() + if output: + print(output) + + if _pull_requires_ack(result.stdout, result.stderr): + ctx.console.info("Repository updated before edit. Review incoming changes first.") + try: + input("Press Enter to continue editing... ") + except (EOFError, KeyboardInterrupt): + print() + + +def _refresh_module(spec: ModuleSpec) -> None: + module_dir = _module_cache_dir(spec) + _FS.ensure_dir(module_dir.parent) + + if not module_dir.exists(): + clone = _RUNNER.run( + ["git", "clone", "--recurse-submodules", spec.source, str(module_dir)], + capture_output=True, + check=False, + ) + if clone.returncode != 0: + raise RuntimeError( + f"Failed to clone module {spec.package} from {spec.source}: {clone.stderr.strip()}" + ) + + if spec.ref_type == "branch": + fetch = _run_git(module_dir, "fetch", "origin", spec.ref_value) + if fetch.returncode != 0: + raise RuntimeError( + f"Failed to fetch module {spec.package} branch {spec.ref_value}: {fetch.stderr.strip()}" + ) + + checkout = _run_git(module_dir, "checkout", spec.ref_value) + if checkout.returncode != 0: + create = _run_git(module_dir, "checkout", "-B", spec.ref_value, f"origin/{spec.ref_value}") + if create.returncode != 0: + raise RuntimeError( + f"Failed to checkout branch {spec.ref_value} for module {spec.package}: " + f"{create.stderr.strip()}" + ) + + pull = _run_git(module_dir, "pull", "--ff-only", "origin", spec.ref_value) + if pull.returncode != 0: + raise RuntimeError( + f"Failed to update module {spec.package} branch {spec.ref_value}: {pull.stderr.strip()}" + ) + + elif spec.ref_type == "tag": + fetch = _run_git(module_dir, "fetch", "--tags", "origin") + if fetch.returncode != 0: + raise RuntimeError( + f"Failed to fetch tags for module {spec.package}: {fetch.stderr.strip()}" + ) + + checkout = _run_git(module_dir, "checkout", f"tags/{spec.ref_value}") + if checkout.returncode != 0: + raise RuntimeError( + f"Failed to checkout tag {spec.ref_value} for module {spec.package}: " + f"{checkout.stderr.strip()}" + ) + + else: + fetch = _run_git(module_dir, "fetch", "origin") + if fetch.returncode != 0: + raise RuntimeError( + f"Failed to fetch module {spec.package}: {fetch.stderr.strip()}" + ) + + checkout = _run_git(module_dir, "checkout", spec.ref_value) + if checkout.returncode != 0: + raise RuntimeError( + f"Failed to checkout commit {spec.ref_value} for module {spec.package}: " + f"{checkout.stderr.strip()}" + ) + + update = _run_git(module_dir, "submodule", "update", "--init", "--recursive") + if update.returncode != 0: + raise RuntimeError( + f"Failed to update nested submodules for module {spec.package}: {update.stderr.strip()}" + ) + + +def _iter_package_dirs( + dotfiles_dir: Path, + *, + profile: Optional[str] = None, + package_filter: Optional[Set[str]] = None, +) -> List[tuple[str, Path]]: + out: List[tuple[str, Path]] = [] + flow_dir = _flow_config_dir(dotfiles_dir) + + shared = flow_dir / RESERVED_SHARED + if shared.is_dir(): + for pkg_dir in sorted(shared.iterdir()): + if pkg_dir.is_dir() and not pkg_dir.name.startswith("."): + if package_filter and pkg_dir.name not in package_filter: + continue + out.append((f"{RESERVED_SHARED}/{pkg_dir.name}", pkg_dir)) + + profiles = [profile] if profile else _list_profiles(flow_dir) + for profile_name in profiles: + profile_dir = flow_dir / profile_name + if not profile_dir.is_dir(): + continue + for pkg_dir in sorted(profile_dir.iterdir()): + if pkg_dir.is_dir() and not pkg_dir.name.startswith("."): + if package_filter and pkg_dir.name not in package_filter: + continue + out.append((f"{profile_name}/{pkg_dir.name}", pkg_dir)) + + return out + + +def _find_module_files(package_dir: Path) -> List[Path]: + found: List[Path] = [] + for root, dirs, files in os.walk(package_dir): + # Avoid picking up nested git metadata. + dirs[:] = [entry for entry in dirs if entry != ".git"] + if MODULE_FILE in files: + found.append(Path(root) / MODULE_FILE) + return sorted(found, key=lambda item: str(item)) + + +def _find_package_module_file(package: str, package_dir: Path) -> Optional[Path]: + module_files = _find_module_files(package_dir) + if not module_files: + return None + if len(module_files) > 1: + rels = ", ".join(str(path.relative_to(package_dir)) for path in module_files) + raise RuntimeError(f"Multiple {MODULE_FILE} files found for package {package}: {rels}") + return module_files[0] + + +def _collect_module_specs( + dotfiles_dir: Path, + *, + profile: Optional[str] = None, + package_filter: Optional[Set[str]] = None, +) -> List[ModuleSpec]: + specs: List[ModuleSpec] = [] + for package, package_dir in _iter_package_dirs( + dotfiles_dir, + profile=profile, + package_filter=package_filter, + ): + module_file = _find_package_module_file(package, package_dir) + if module_file is None: + continue + specs.append(_load_module_spec(package_dir, package, module_file)) + return specs + + +def _sync_modules( + ctx: FlowContext, + *, + verbose: bool = False, + profile: Optional[str] = None, + package_filter: Optional[Set[str]] = None, +) -> None: + _ensure_flow_dir(ctx) + + for spec in _collect_module_specs( + DOTFILES_DIR, + profile=profile, + package_filter=package_filter, + ): + if verbose: + ctx.console.info( + f"Updating module {spec.package} from {spec.source} ({spec.ref_type}={spec.ref_value})" + ) + _refresh_module(spec) + + +def _module_ref_label(spec: ModuleSpec) -> str: + return f"{spec.ref_type}:{spec.ref_value}" + + +def _module_head_short(module_dir: Path) -> str: + result = _run_git(module_dir, "rev-parse", "--short", "HEAD") + if result.returncode != 0: + return "unknown" + return result.stdout.strip() or "unknown" + + +def _resolved_package_source( + ctx: FlowContext, + package: str, + package_dir: Path, + *, + verbose: bool = False, +) -> Path: + """Return the directory to treat as editable source for a package. + + If the package contains a `_module.yaml` anywhere in its tree, the module checkout + directory is returned (and must already exist from `flow dotfiles sync`). + Otherwise the local package directory is returned. + """ + + module_file = _find_package_module_file(package, package_dir) + if module_file is None: + return package_dir + + if verbose: + rel = module_file.relative_to(package_dir) + mount = rel.parent + if mount == Path("."): + mount = Path() + ctx.console.info( + f"Package {package} uses {rel}; mounting module content at {mount or '.'}" + ) + + spec = _load_module_spec(package_dir, package, module_file) + module_dir = _module_cache_dir(spec) + if not module_dir.exists(): + raise RuntimeError( + f"Module source missing for package '{package}'. Run 'flow dotfiles sync' first." + ) + + return module_dir + + +def _load_state() -> dict: + if LINKED_STATE.exists(): + with open(LINKED_STATE, "r", encoding="utf-8") as handle: + return json.load(handle) + return {"version": 2, "links": {}} + + +def _save_state(state: dict) -> None: + _FS.write_json(LINKED_STATE, state) + + +def _parse_link_specs(links: Any) -> Dict[Path, LinkSpec]: + if not isinstance(links, dict): + raise RuntimeError("Unsupported linked state format. Remove linked.json and relink dotfiles.") + + resolved: Dict[Path, LinkSpec] = {} + for package, pkg_links in links.items(): + if not isinstance(pkg_links, dict): + raise RuntimeError("Unsupported linked state format. Remove linked.json and relink dotfiles.") + + for target_str, link_info in pkg_links.items(): + if not isinstance(link_info, dict) or "source" not in link_info: + raise RuntimeError( + "Unsupported linked state format. Remove linked.json and relink dotfiles." + ) + + target = Path(target_str) + resolved[target] = LinkSpec( + source=Path(link_info["source"]), + target=target, + package=str(package), + is_directory_link=bool(link_info.get("is_directory_link", False)), + ) + + return resolved + + +def _serialize_link_specs(specs: Dict[Path, LinkSpec]) -> Dict[str, Dict[str, dict]]: + grouped: Dict[str, Dict[str, dict]] = {} + for spec in sorted(specs.values(), key=lambda s: str(s.target)): + grouped.setdefault(spec.package, {})[str(spec.target)] = { + "source": str(spec.source), + "is_directory_link": spec.is_directory_link, + } + return grouped + + +def _cleanup_link_transaction_files(transaction: Optional[dict]) -> None: + if not isinstance(transaction, dict): + return + + backup_dir = transaction.get("backup_dir") + if isinstance(backup_dir, str) and backup_dir: + _FS.remove_tree(Path(backup_dir)) + + +def _load_last_link_transaction() -> Optional[dict]: + state = _load_state() + transaction = state.get("last_transaction") + if not isinstance(transaction, dict): + return None + return transaction + + +def _save_last_link_transaction(transaction: dict) -> None: + state = _load_state() + previous = state.get("last_transaction") + if isinstance(previous, dict): + _cleanup_link_transaction_files(previous) + state["last_transaction"] = transaction + _save_state(state) + + +def _clear_last_link_transaction(*, remove_backups: bool = True) -> None: + state = _load_state() + transaction = state.get("last_transaction") + if remove_backups and isinstance(transaction, dict): + _cleanup_link_transaction_files(transaction) + state.pop("last_transaction", None) + _save_state(state) + + +def _start_link_transaction(previous_links: Dict[Path, LinkSpec]) -> dict: + now = datetime.now(timezone.utc) + tx_id = now.strftime("%Y%m%dT%H%M%S%fZ") + backup_dir = LINK_BACKUP_DIR / tx_id + return { + "id": tx_id, + "created_at": now.isoformat(), + "backup_dir": str(backup_dir), + "previous_links": _serialize_link_specs(previous_links), + "targets": [], + } + + +def _snapshot_target( + target: Path, + *, + use_sudo: bool, + backup_dir: Path, + index: int, +) -> dict: + if target.is_symlink(): + return {"kind": "symlink", "source": os.readlink(target)} + + if target.exists(): + if target.is_dir(): + raise RuntimeError(f"Cannot snapshot directory target: {target}") + + _FS.ensure_dir(backup_dir) + backup_path = backup_dir / f"{index:06d}" + if use_sudo: + _FS.copy_file(target, backup_path, sudo=True, runner=_RUNNER) + else: + _FS.copy_file(target, backup_path) + return {"kind": "file", "backup": str(backup_path)} + + return {"kind": "missing"} + + +def _restore_target_snapshot(target: Path, snapshot: dict) -> None: + if not isinstance(snapshot, dict): + raise RuntimeError(f"Unsupported transaction snapshot for {target}") + + use_sudo = not _is_in_home(target, Path.home()) + + if target.exists() or target.is_symlink(): + if target.is_dir() and not target.is_symlink(): + raise RuntimeError(f"Cannot restore {target}; a directory now exists at that path") + _remove_target(target, use_sudo=use_sudo, dry_run=False) + + kind = snapshot.get("kind") + if kind == "missing": + return + + if kind == "symlink": + source = snapshot.get("source") + if not isinstance(source, str): + raise RuntimeError(f"Unsupported transaction snapshot for {target}") + if use_sudo: + _FS.create_symlink(Path(source), target, sudo=True, runner=_RUNNER) + else: + _FS.create_symlink(Path(source), target) + return + + if kind == "file": + backup = snapshot.get("backup") + if not isinstance(backup, str): + raise RuntimeError(f"Unsupported transaction snapshot for {target}") + backup_path = Path(backup) + if not backup_path.exists(): + raise RuntimeError(f"Backup missing for {target}: {backup_path}") + if use_sudo: + _FS.copy_file(backup_path, target, sudo=True, runner=_RUNNER) + else: + _FS.copy_file(backup_path, target) + return + + raise RuntimeError(f"Unsupported transaction snapshot kind for {target}: {kind}") + + +def _load_link_specs_from_state() -> Dict[Path, LinkSpec]: + state = _load_state() + links = state.get("links", {}) + return _parse_link_specs(links) + + +def _save_link_specs_to_state(specs: Dict[Path, LinkSpec]) -> None: + state = _load_state() + state["version"] = 2 + state["links"] = _serialize_link_specs(specs) + _save_state(state) + + +def _list_profiles(flow_dir: Path) -> List[str]: + if not flow_dir.exists() or not flow_dir.is_dir(): + return [] + + profiles: List[str] = [] + for child in flow_dir.iterdir(): + if not child.is_dir(): + continue + if child.name.startswith("."): + continue + if child.name.startswith("_"): + continue + profiles.append(child.name) + return sorted(profiles) + + +def _walk_package(source_dir: Path): + for root, dirs, files in os.walk(source_dir): + # Never traverse git metadata from module-backed package sources. + dirs[:] = [entry for entry in dirs if entry != ".git"] + for fname in files: + if fname == ".git": + continue + src = Path(root) / fname + rel = src.relative_to(source_dir) + yield src, rel + + +def _profile_skip_set(ctx: FlowContext, profile: Optional[str]) -> Set[str]: + if not profile: + return set() + + profiles = ctx.manifest.get("profiles", {}) + if not isinstance(profiles, dict): + return set() + + profile_cfg = profiles.get(profile, {}) + if not isinstance(profile_cfg, dict): + return set() + + configs = profile_cfg.get("configs", {}) + if not isinstance(configs, dict): + return set() + + skip = configs.get("skip", []) + if not isinstance(skip, list): + return set() + + return {str(item) for item in skip if item} + + +def _discover_packages(dotfiles_dir: Path, profile: Optional[str] = None) -> dict: + flow_dir = _flow_config_dir(dotfiles_dir) + packages = {} + + shared = flow_dir / RESERVED_SHARED + if shared.is_dir(): + for pkg in sorted(shared.iterdir()): + if pkg.is_dir() and not pkg.name.startswith("."): + packages[pkg.name] = pkg + + if profile: + profile_dir = flow_dir / profile + if profile_dir.is_dir(): + for pkg in sorted(profile_dir.iterdir()): + if pkg.is_dir() and not pkg.name.startswith("."): + packages[pkg.name] = pkg + + return packages + + +def _find_package_dir(package_name: str, dotfiles_dir: Optional[Path] = None) -> Optional[Path]: + flow_dir = _flow_config_dir(dotfiles_dir) + + shared_dir = flow_dir / RESERVED_SHARED / package_name + if shared_dir.exists(): + return shared_dir + + for profile in _list_profiles(flow_dir): + profile_pkg = flow_dir / profile / package_name + if profile_pkg.exists(): + return profile_pkg + + return None + + +def _resolve_edit_target(target: str, dotfiles_dir: Optional[Path] = None) -> Optional[Path]: + dotfiles_dir = dotfiles_dir or DOTFILES_DIR + base_dir = dotfiles_dir.resolve() + raw = Path(target).expanduser() + if raw.is_absolute(): + if not _is_under(raw, base_dir): + return None + return raw + + is_path_like = _is_path_like_target(target) + if is_path_like: + candidate = dotfiles_dir / raw + if not _is_under(candidate, base_dir): + return None + if candidate.exists() or candidate.parent.exists(): + return candidate + return None + + package_dir = _find_package_dir(target, dotfiles_dir=dotfiles_dir) + if package_dir is not None: + return package_dir + + candidate = dotfiles_dir / raw + if candidate.exists(): + return candidate + + return None + + +def _ensure_dotfiles_dir(ctx: FlowContext): + if not DOTFILES_DIR.exists(): + ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.") + sys.exit(1) + + +def _ensure_flow_dir(ctx: FlowContext): + _ensure_dotfiles_dir(ctx) + flow_dir = _flow_config_dir() + if not flow_dir.exists() or not flow_dir.is_dir(): + ctx.console.error(f"Dotfiles repository not found at {flow_dir}") + sys.exit(1) + + +def _run_dotfiles_git(*cmd, capture: bool = True) -> subprocess.CompletedProcess: + return _RUNNER.run( + ["git", "-C", str(DOTFILES_DIR)] + list(cmd), + capture_output=capture, + ) + + +def _pull_dotfiles(ctx: FlowContext, *, rebase: bool = True) -> None: + pull_cmd = ["pull"] + if rebase: + pull_cmd.append("--rebase") + + strategy = "with rebase" if rebase else "without rebase" + ctx.console.info(f"Pulling latest dotfiles ({strategy})...") + result = _run_dotfiles_git(*pull_cmd, capture=True) + + if result.returncode != 0: + raise RuntimeError(f"Git pull failed: {result.stderr.strip()}") + + output = result.stdout.strip() + if output: + print(output) + + ctx.console.success("Dotfiles synced.") + + +def _resolve_profile(ctx: FlowContext, requested: Optional[str]) -> Optional[str]: + flow_dir = _flow_config_dir() + profiles = _list_profiles(flow_dir) + + if requested: + if requested not in profiles: + raise RuntimeError(f"Profile not found: {requested}") + return requested + + if len(profiles) == 1: + return profiles[0] + + if len(profiles) > 1: + raise RuntimeError(f"Multiple profiles available. Use --profile: {', '.join(profiles)}") + + return None + + +def _is_in_home(path: Path, home: Path) -> bool: + try: + path.relative_to(home) + return True + except ValueError: + return False + + +def _is_under(path: Path, parent: Path) -> bool: + try: + path.resolve().relative_to(parent.resolve()) + return True + except ValueError: + return False + + +def _run_sudo(cmd: List[str], *, dry_run: bool = False) -> None: + if dry_run: + print(" " + " ".join(shlex.quote(part) for part in (["sudo"] + cmd))) + return + if shutil.which("sudo") is None: + raise RuntimeError("sudo is required for root-targeted dotfiles, but it was not found in PATH") + _RUNNER.run(["sudo"] + cmd, capture_output=False, check=True) + + +def _remove_target(path: Path, *, use_sudo: bool, dry_run: bool) -> None: + if not (path.exists() or path.is_symlink()): + return + + if path.is_dir() and not path.is_symlink(): + raise RuntimeError(f"Cannot overwrite directory: {path}") + + if use_sudo: + _run_sudo(["rm", "-f", str(path)], dry_run=dry_run) + return + + if dry_run: + print(f" REMOVE: {path}") + return + _FS.remove_file(path) + + +def _same_symlink(target: Path, source: Path) -> bool: + return _FS.same_symlink(target, source) + + +def _rel_is_under(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + return True + except ValueError: + return False + + +def _add_package_home_specs( + ctx: FlowContext, + desired: Dict[Path, LinkSpec], + *, + package: str, + package_dir: Path, + home: Path, + skip: Set[str], + verbose: bool = False, +) -> None: + module_file = _find_package_module_file(package, package_dir) + module_dir: Optional[Path] = None + module_prefix = Path() + module_file_rel: Optional[Path] = None + + if module_file is not None: + module_file_rel = module_file.relative_to(package_dir) + module_prefix = module_file_rel.parent + if module_prefix == Path("."): + module_prefix = Path() + + spec = _load_module_spec(package_dir, package, module_file) + module_dir = _module_cache_dir(spec) + if not module_dir.exists(): + raise RuntimeError( + f"Module source missing for package '{package}'. Run 'flow dotfiles sync' first." + ) + + if verbose: + ctx.console.info( + f"Package {package} uses {module_file_rel}; linking module content under {module_prefix or '.'}" + ) + + # Link local package files, except module mounts and the marker file itself. + for src, rel in _walk_package(package_dir): + if module_file_rel is not None and (rel == module_file_rel or _rel_is_under(rel, module_prefix)): + continue + + if rel.parts and rel.parts[0] == RESERVED_ROOT: + if RESERVED_ROOT in skip: + continue + if len(rel.parts) < 2: + continue + target = Path("/") / Path(*rel.parts[1:]) + else: + target = home / rel + + _insert_spec( + desired, + target=target, + source=src, + package=package, + ) + + if module_dir is None: + return + + # Link module files into the directory containing `_module.yaml`. + for src, rel in _walk_package(module_dir): + mounted = module_prefix / rel + if mounted.parts and mounted.parts[0] == RESERVED_ROOT: + if RESERVED_ROOT in skip: + continue + if len(mounted.parts) < 2: + continue + target = Path("/") / Path(*mounted.parts[1:]) + else: + target = home / mounted + + _insert_spec( + desired, + target=target, + source=src, + package=package, + ) + + +def _collect_home_specs( + ctx: FlowContext, + flow_dir: Path, + home: Path, + profile: Optional[str], + skip: Set[str], + package_filter: Optional[Set[str]], + *, + verbose: bool = False, +) -> Dict[Path, LinkSpec]: + desired: Dict[Path, LinkSpec] = {} + + if RESERVED_SHARED not in skip: + shared_dir = flow_dir / RESERVED_SHARED + if shared_dir.is_dir(): + for pkg_dir in sorted(shared_dir.iterdir()): + if not pkg_dir.is_dir() or pkg_dir.name.startswith("."): + continue + if package_filter and pkg_dir.name not in package_filter: + continue + if pkg_dir.name in skip: + continue + + package_name = f"{RESERVED_SHARED}/{pkg_dir.name}" + _add_package_home_specs( + ctx, + desired, + package=package_name, + package_dir=pkg_dir, + home=home, + skip=skip, + verbose=verbose, + ) + + if profile and "_profile" not in skip: + profile_dir = flow_dir / profile + if profile_dir.is_dir(): + for pkg_dir in sorted(profile_dir.iterdir()): + if not pkg_dir.is_dir() or pkg_dir.name.startswith("."): + continue + if package_filter and pkg_dir.name not in package_filter: + continue + if pkg_dir.name in skip: + continue + + package_name = f"{profile}/{pkg_dir.name}" + _add_package_home_specs( + ctx, + desired, + package=package_name, + package_dir=pkg_dir, + home=home, + skip=skip, + verbose=verbose, + ) + + return desired + + +def _validate_conflicts( + desired: Dict[Path, LinkSpec], + current: Dict[Path, LinkSpec], +) -> tuple[List[str], List[str]]: + force_required: List[str] = [] + fatal: List[str] = [] + + # Validate removals for targets currently tracked in state. + # If a managed path was changed on disk (regular file or different symlink), + # require --force before deleting it. + for target, spec in current.items(): + if target in desired: + continue + if not (target.exists() or target.is_symlink()): + continue + if _same_symlink(target, spec.source): + continue + if target.is_dir() and not target.is_symlink(): + fatal.append(f"Conflict: {target} is a directory and cannot be overwritten") + continue + force_required.append(f"Conflict: {target} differs from managed link and would be removed") + + for target, spec in desired.items(): + if not (target.exists() or target.is_symlink()): + continue + + if _same_symlink(target, spec.source): + continue + + if target in current: + current_spec = current[target] + if _same_symlink(target, current_spec.source): + # Existing managed link can be replaced by desired link. + continue + if target.is_dir() and not target.is_symlink(): + fatal.append(f"Conflict: {target} is a directory and cannot be overwritten") + continue + force_required.append(f"Conflict: {target} differs from managed link and would be replaced") + continue + + if target.is_dir() and not target.is_symlink(): + fatal.append(f"Conflict: {target} is a directory and cannot be overwritten") + continue + + force_required.append(f"Conflict: {target} already exists and is not managed by flow") + + return force_required, fatal + + +def _apply_link_spec(spec: LinkSpec, *, copy: bool, dry_run: bool) -> bool: + use_sudo = not _is_in_home(spec.target, Path.home()) + + if copy and use_sudo: + print(f" SKIP COPY (root target): {spec.target}") + return False + + if use_sudo: + _run_sudo(["mkdir", "-p", str(spec.target.parent)], dry_run=dry_run) + _run_sudo(["ln", "-sfn", str(spec.source), str(spec.target)], dry_run=dry_run) + return True + + if dry_run: + if copy: + print(f" COPY: {spec.source} -> {spec.target}") + else: + print(f" LINK: {spec.target} -> {spec.source}") + return True + + _FS.ensure_dir(spec.target.parent) + if copy: + _FS.copy_file(spec.source, spec.target) + return True + _FS.create_symlink(spec.source, spec.target) + return True + + +def _sync_to_desired( + ctx: FlowContext, + desired: Dict[Path, LinkSpec], + *, + force: bool, + dry_run: bool, + copy: bool, +) -> None: + current = _load_link_specs_from_state() + previous = dict(current) + force_required, fatal = _validate_conflicts(desired, current) + + if fatal: + for conflict in fatal: + ctx.console.error(conflict) + raise RuntimeError("One or more targets are existing directories and cannot be overwritten") + + if force_required and not force: + for conflict in force_required: + ctx.console.error(conflict) + raise RuntimeError("Use --force to overwrite existing files") + + transaction: Optional[dict] = None + snapshots: Dict[Path, dict] = {} + if not dry_run: + transaction = _start_link_transaction(previous) + backup_dir = Path(transaction["backup_dir"]) + + def snapshot_before_change(target: Path) -> None: + if target in snapshots: + return + use_sudo = not _is_in_home(target, Path.home()) + snapshots[target] = _snapshot_target( + target, + use_sudo=use_sudo, + backup_dir=backup_dir, + index=len(snapshots) + 1, + ) + + try: + for target in sorted(current.keys(), key=str): + if target in desired: + continue + if not dry_run and transaction is not None and (target.exists() or target.is_symlink()): + snapshot_before_change(target) + use_sudo = not _is_in_home(target, Path.home()) + _remove_target(target, use_sudo=use_sudo, dry_run=dry_run) + del current[target] + + for target in sorted(desired.keys(), key=str): + spec = desired[target] + + if _same_symlink(target, spec.source): + current[target] = spec + continue + + if not dry_run and transaction is not None: + snapshot_before_change(target) + + exists = target.exists() or target.is_symlink() + if exists: + use_sudo = not _is_in_home(target, Path.home()) + _remove_target(target, use_sudo=use_sudo, dry_run=dry_run) + + applied = _apply_link_spec(spec, copy=copy, dry_run=dry_run) + if applied: + current[target] = spec + except Exception: + if not dry_run and transaction is not None: + transaction["targets"] = [ + {"target": str(target), "before": snapshots[target]} + for target in sorted(snapshots.keys(), key=str) + ] + transaction["incomplete"] = True + try: + _save_link_specs_to_state(current) + _save_last_link_transaction(transaction) + except Exception: + pass + raise + + if not dry_run: + _save_link_specs_to_state(current) + if transaction is not None: + transaction["targets"] = [ + {"target": str(target), "before": snapshots[target]} + for target in sorted(snapshots.keys(), key=str) + ] + transaction["incomplete"] = False + _save_last_link_transaction(transaction) + + +def _desired_links_for_profile( + ctx: FlowContext, + profile: Optional[str], + package_filter: Optional[Set[str]], + *, + verbose: bool = False, +) -> Dict[Path, LinkSpec]: + flow_dir = _flow_config_dir() + home = Path.home() + + skip = _profile_skip_set(ctx, profile) + return _collect_home_specs( + ctx, + flow_dir, + home, + profile, + skip, + package_filter, + verbose=verbose, + ) + + +def run_init(ctx: FlowContext, args): + repo_url = args.repo or ctx.config.dotfiles_url + if not repo_url: + ctx.console.error("No dotfiles repository URL. Set it in YAML config or pass --repo.") + sys.exit(1) + + if DOTFILES_DIR.exists(): + ctx.console.warn(f"Dotfiles directory already exists: {DOTFILES_DIR}") + return + + _FS.ensure_dir(DOTFILES_DIR.parent) + branch = ctx.config.dotfiles_branch + cmd = ["git", "clone", "-b", branch, "--recurse-submodules", repo_url, str(DOTFILES_DIR)] + ctx.console.info(f"Cloning {repo_url} (branch: {branch})...") + _RUNNER.run(cmd, capture_output=False, check=True) + + try: + _sync_modules(ctx, verbose=bool(getattr(args, "verbose", False))) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + ctx.console.success(f"Dotfiles cloned to {DOTFILES_DIR}") + + +def run_link(ctx: FlowContext, args): + _ensure_flow_dir(ctx) + + try: + profile = _resolve_profile(ctx, args.profile) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + package_filter = set(args.packages) if args.packages else None + + try: + desired = _desired_links_for_profile( + ctx, + profile, + package_filter, + verbose=bool(getattr(args, "verbose", False)), + ) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + if not desired: + ctx.console.warn("No link targets found for selected profile/filters") + return + + try: + _sync_to_desired( + ctx, + desired, + force=args.force, + dry_run=args.dry_run, + copy=args.copy, + ) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + if args.dry_run: + return + + ctx.console.success(f"Linked {len(desired)} item(s)") + + +def _package_match(package_id: str, filters: Set[str]) -> bool: + if package_id in filters: + return True + + # Allow users to pass just package basename (e.g. zsh) + base = package_id.split("/", 1)[-1] + return base in filters + + +def run_unlink(ctx: FlowContext, args): + try: + current = _load_link_specs_from_state() + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + if not current: + ctx.console.info("No linked dotfiles found.") + return + + filters = set(args.packages) if args.packages else None + removed = 0 + + for target in sorted(list(current.keys()), key=str): + spec = current[target] + if filters and not _package_match(spec.package, filters): + continue + + use_sudo = not _is_in_home(target, Path.home()) + try: + _remove_target(target, use_sudo=use_sudo, dry_run=False) + except RuntimeError as e: + ctx.console.warn(str(e)) + continue + + removed += 1 + del current[target] + + _save_link_specs_to_state(current) + _clear_last_link_transaction(remove_backups=True) + ctx.console.success(f"Removed {removed} symlink(s)") + + +def run_undo(ctx: FlowContext, args): + transaction = _load_last_link_transaction() + if transaction is None: + ctx.console.info("No dotfiles link transaction to undo.") + return + + raw_targets = transaction.get("targets") + if not isinstance(raw_targets, list): + ctx.console.error("Invalid undo state format. Remove linked.json and relink dotfiles.") + sys.exit(1) + + restore_plan: List[Tuple[Path, dict]] = [] + for entry in raw_targets: + if not isinstance(entry, dict): + ctx.console.error("Invalid undo state format. Remove linked.json and relink dotfiles.") + sys.exit(1) + + target_raw = entry.get("target") + before = entry.get("before") + if not isinstance(target_raw, str) or not isinstance(before, dict): + ctx.console.error("Invalid undo state format. Remove linked.json and relink dotfiles.") + sys.exit(1) + restore_plan.append((Path(target_raw), before)) + + try: + # Restore deeper paths first to avoid parent/child ordering issues. + for target, snapshot in sorted( + restore_plan, + key=lambda item: (len(item[0].parts), str(item[0])), + reverse=True, + ): + _restore_target_snapshot(target, snapshot) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + previous_links = transaction.get("previous_links", {}) + try: + _parse_link_specs(previous_links) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + state = _load_state() + state["version"] = 2 + state["links"] = previous_links + _save_state(state) + _clear_last_link_transaction(remove_backups=True) + ctx.console.success(f"Undid {len(restore_plan)} change(s)") + + +def run_status(ctx: FlowContext, args): + try: + current = _load_link_specs_from_state() + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + if not current: + ctx.console.info("No linked dotfiles.") + return + + grouped: Dict[str, List[LinkSpec]] = {} + for spec in current.values(): + grouped.setdefault(spec.package, []).append(spec) + + for package in sorted(grouped.keys()): + ctx.console.info(f"[{package}]") + for spec in sorted(grouped[package], key=lambda s: str(s.target)): + if spec.target.is_symlink(): + if _same_symlink(spec.target, spec.source): + print(f" OK: {spec.target} -> {spec.source}") + else: + print(f" CHANGED: {spec.target}") + elif spec.target.exists(): + print(f" NOT SYMLINK: {spec.target}") + else: + print(f" BROKEN: {spec.target} (missing)") + + +def run_sync(ctx: FlowContext, args): + _ensure_dotfiles_dir(ctx) + + try: + _pull_dotfiles(ctx, rebase=True) + _sync_modules(ctx, verbose=bool(getattr(args, "verbose", False))) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + if args.relink: + relink_args = argparse.Namespace(packages=[], profile=args.profile) + run_relink(ctx, relink_args) + + +def _validated_profile_name(profile: Optional[str]) -> Optional[str]: + if not profile: + return None + + profiles = _list_profiles(_flow_config_dir()) + if profile not in profiles: + raise RuntimeError(f"Profile not found: {profile}") + return profile + + +def _package_filter_from_args(args) -> Optional[Set[str]]: + packages = getattr(args, "packages", []) + if not packages: + return None + return {str(pkg) for pkg in packages} + + +def run_modules_list(ctx: FlowContext, args): + _ensure_flow_dir(ctx) + + try: + profile = _validated_profile_name(getattr(args, "profile", None)) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + package_filter = _package_filter_from_args(args) + + specs = _collect_module_specs( + DOTFILES_DIR, + profile=profile, + package_filter=package_filter, + ) + + if not specs: + ctx.console.info("No module packages found.") + return + + rows = [] + for spec in sorted(specs, key=lambda item: item.package): + module_dir = _module_cache_dir(spec) + if module_dir.exists(): + status = f"ready@{_module_head_short(module_dir)}" + else: + status = "missing" + rows.append([spec.package, _module_ref_label(spec), spec.source, status]) + + ctx.console.table(["PACKAGE", "REF", "SOURCE", "STATUS"], rows) + + +def run_modules_sync(ctx: FlowContext, args): + _ensure_flow_dir(ctx) + + try: + profile = _validated_profile_name(getattr(args, "profile", None)) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + package_filter = _package_filter_from_args(args) + specs = _collect_module_specs( + DOTFILES_DIR, + profile=profile, + package_filter=package_filter, + ) + + if not specs: + ctx.console.info("No module packages to sync.") + return + + try: + _sync_modules( + ctx, + verbose=bool(getattr(args, "verbose", False)), + profile=profile, + package_filter=package_filter, + ) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + ctx.console.success(f"Synced {len(specs)} module(s)") + + +def run_repo_status(ctx: FlowContext, args): + _ensure_dotfiles_dir(ctx) + + result = _run_dotfiles_git("status", "--short", "--branch", capture=True) + if result.returncode != 0: + ctx.console.error(result.stderr.strip() or "Failed to read dotfiles git status") + sys.exit(1) + + output = result.stdout.strip() + if output: + print(output) + else: + ctx.console.info("Dotfiles repository is clean.") + + +def run_repo_pull(ctx: FlowContext, args): + _ensure_dotfiles_dir(ctx) + + try: + _pull_dotfiles(ctx, rebase=args.rebase) + _sync_modules(ctx, verbose=bool(getattr(args, "verbose", False))) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + if args.relink: + relink_args = argparse.Namespace(packages=[], profile=args.profile) + run_relink(ctx, relink_args) + + +def run_repo_push(ctx: FlowContext, args): + _ensure_dotfiles_dir(ctx) + + ctx.console.info("Pushing dotfiles changes...") + result = _run_dotfiles_git("push", capture=True) + if result.returncode != 0: + ctx.console.error(f"Git push failed: {result.stderr.strip()}") + sys.exit(1) + + output = result.stdout.strip() + if output: + print(output) + ctx.console.success("Dotfiles pushed.") + + +def run_relink(ctx: FlowContext, args): + _ensure_flow_dir(ctx) + + args.copy = False + args.force = False + args.dry_run = False + ctx.console.info("Relinking with updated configuration...") + run_link(ctx, args) + + +def run_clean(ctx: FlowContext, args): + try: + current = _load_link_specs_from_state() + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + if not current: + ctx.console.info("No linked dotfiles found.") + return + + removed = 0 + for target in sorted(list(current.keys()), key=str): + if not target.is_symlink() or target.exists(): + continue + + if args.dry_run: + print(f"Would remove broken symlink: {target}") + else: + use_sudo = not _is_in_home(target, Path.home()) + _remove_target(target, use_sudo=use_sudo, dry_run=False) + del current[target] + removed += 1 + + if not args.dry_run: + _save_link_specs_to_state(current) + if removed > 0: + _clear_last_link_transaction(remove_backups=True) + + if removed > 0: + ctx.console.success(f"Cleaned {removed} broken symlink(s)") + else: + ctx.console.info("No broken symlinks found") + + +def run_edit(ctx: FlowContext, args): + _ensure_dotfiles_dir(ctx) + + target_name = args.target + verbose = bool(getattr(args, "verbose", False)) + + edit_target = None + if not _is_path_like_target(target_name): + package_dir = _find_package_dir(target_name) + if package_dir is not None: + try: + package_layer = package_dir.parent.name + package_id = f"{package_layer}/{package_dir.name}" + edit_target = _resolved_package_source( + ctx, + package_id, + package_dir, + verbose=verbose, + ) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + if edit_target is None: + edit_target = _resolve_edit_target(target_name) + + if edit_target is None: + ctx.console.error(f"No matching package or path found for: {target_name}") + sys.exit(1) + + module_mode = _is_under(edit_target, MODULES_DIR) + + if verbose and module_mode: + ctx.console.info(f"Editing module workspace: {edit_target}") + + if ctx.config.dotfiles_pull_before_edit: + pull_repo = DOTFILES_DIR + if module_mode: + pull_repo = edit_target if edit_target.is_dir() else edit_target.parent + _pull_repo_before_edit(ctx, pull_repo, verbose=verbose) + + editor = os.environ.get("EDITOR", "vim") + ctx.console.info(f"Opening {edit_target} in {editor}...") + edit_result = _RUNNER.run(shlex.split(editor) + [str(edit_target)], capture_output=False) + if edit_result.returncode != 0: + ctx.console.warn(f"Editor exited with status {edit_result.returncode}") + + if module_mode: + module_git_dir = edit_target if edit_target.is_dir() else edit_target.parent + result = _run_git(module_git_dir, "status", "--porcelain", capture=True) + + if result.stdout.strip() and not args.no_commit: + ctx.console.info("Module changes detected, committing...") + _RUNNER.run(["git", "-C", str(module_git_dir), "add", "."], capture_output=False, check=True) + _RUNNER.run( + ["git", "-C", str(module_git_dir), "commit", "-m", f"Update {target_name}"], + capture_output=False, + check=True, + ) + + try: + response = input("Push module changes to remote? [Y/n] ") + except (EOFError, KeyboardInterrupt): + response = "n" + print() + if response.lower() != "n": + _RUNNER.run(["git", "-C", str(module_git_dir), "push"], capture_output=False, check=True) + ctx.console.success("Module changes committed and pushed") + else: + ctx.console.info("Module changes committed locally (not pushed)") + elif result.stdout.strip() and args.no_commit: + ctx.console.info("Module changes detected; skipped commit (--no-commit)") + else: + ctx.console.info("No module changes to commit") + return + + result = _run_dotfiles_git("status", "--porcelain", capture=True) + + if result.stdout.strip() and not args.no_commit: + ctx.console.info("Changes detected, committing...") + _RUNNER.run(["git", "-C", str(DOTFILES_DIR), "add", "."], capture_output=False, check=True) + _RUNNER.run( + ["git", "-C", str(DOTFILES_DIR), "commit", "-m", f"Update {target_name}"], + capture_output=False, + check=True, + ) + + try: + response = input("Push changes to remote? [Y/n] ") + except (EOFError, KeyboardInterrupt): + response = "n" + print() + if response.lower() != "n": + _RUNNER.run(["git", "-C", str(DOTFILES_DIR), "push"], capture_output=False, check=True) + ctx.console.success("Changes committed and pushed") + else: + ctx.console.info("Changes committed locally (not pushed)") + elif result.stdout.strip() and args.no_commit: + ctx.console.info("Changes detected; skipped commit (--no-commit)") + else: + ctx.console.info("No changes to commit") diff --git a/src/flow/services/package_defs.py b/src/flow/services/package_defs.py new file mode 100644 index 0000000..6f62704 --- /dev/null +++ b/src/flow/services/package_defs.py @@ -0,0 +1,350 @@ +"""Shared package-manifest normalization and binary install helpers.""" + +from __future__ import annotations + +import os +import shutil +import tempfile +import urllib.request +from pathlib import Path +from typing import Any, Dict, List, Optional + +from flow.core.config import FlowContext +from flow.core.errors import FlowError +from flow.core.variables import substitute_template + +PACKAGE_TYPES = {"pkg", "binary", "cask"} + + +def linux_detect_package_manager() -> Optional[str]: + if shutil.which("apt") or shutil.which("apt-get"): + return "apt" + if shutil.which("dnf"): + return "dnf" + return None + + +def resolve_package_manager(ctx: FlowContext, profile_cfg: dict) -> str: + explicit = profile_cfg.get("package-manager") + if isinstance(explicit, str) and explicit: + return explicit + + profile_os = profile_cfg.get("os") + if profile_os == "macos": + return "brew" + if profile_os == "linux": + detected = linux_detect_package_manager() + if detected: + return detected + raise FlowError("Unable to auto-detect package manager (expected apt or dnf)") + raise FlowError("Profile 'os' must be set to 'linux' or 'macos'") + + +def get_package_catalog(ctx: FlowContext) -> Dict[str, Dict[str, Any]]: + raw = ctx.manifest.get("packages", []) + catalog: Dict[str, Dict[str, Any]] = {} + + if isinstance(raw, dict): + for name, definition in raw.items(): + if not isinstance(definition, dict): + continue + package = dict(definition) + package["name"] = str(package.get("name") or name) + package.setdefault("type", "pkg") + catalog[package["name"]] = package + return catalog + + if not isinstance(raw, list): + return catalog + + for item in raw: + if not isinstance(item, dict): + continue + name = item.get("name") + if not isinstance(name, str) or not name: + continue + package = dict(item) + package.setdefault("type", "pkg") + catalog[name] = package + + return catalog + + +def normalize_profile_package_entry(entry: Any) -> Dict[str, Any]: + if isinstance(entry, str): + if "/" in entry: + prefix, name = entry.split("/", 1) + if prefix in PACKAGE_TYPES and name: + return {"name": name, "type": prefix} + return {"name": entry} + + if isinstance(entry, dict): + name = entry.get("name") + if not isinstance(name, str) or not name: + raise FlowError("Package object entries must include a non-empty 'name'") + return dict(entry) + + raise FlowError(f"Unsupported package entry: {entry!r}") + + +def resolve_package_spec( + catalog: Dict[str, Dict[str, Any]], + profile_entry: Dict[str, Any], +) -> Dict[str, Any]: + name = profile_entry["name"] + merged = dict(catalog.get(name, {})) + merged.update(profile_entry) + merged["name"] = name + + pkg_type = merged.get("type") or "pkg" + if pkg_type not in PACKAGE_TYPES: + raise FlowError(f"Unsupported package type '{pkg_type}' for package '{name}'") + merged["type"] = pkg_type + return merged + + +def resolve_pkg_source_name(spec: Dict[str, Any], package_manager: str) -> str: + sources = spec.get("sources", {}) + if not isinstance(sources, dict): + return spec["name"] + + keys = [package_manager] + if package_manager == "apt": + keys.append("apt-get") + if package_manager == "apt-get": + keys.append("apt") + + for key in keys: + value = sources.get(key) + if isinstance(value, str) and value: + return value + return spec["name"] + + +def platform_lookup_keys(ctx: FlowContext) -> List[str]: + keys = [ctx.platform.platform] + if ctx.platform.os == "macos": + keys.append(f"darwin-{ctx.platform.arch}") + if ctx.platform.arch == "x64": + keys.append(f"{ctx.platform.os}-amd64") + if ctx.platform.os == "macos": + keys.append("darwin-amd64") + ordered: list[str] = [] + for key in keys: + if key not in ordered: + ordered.append(key) + return ordered + + +def profile_template_context( + ctx: FlowContext, + extra_env: Dict[str, str], + extra: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + env_map = dict(os.environ) + env_map.update(extra_env) + template_ctx: Dict[str, Any] = { + "env": env_map, + "os": ctx.platform.os, + "arch": ctx.platform.arch, + } + if extra: + template_ctx.update(extra) + return template_ctx + + +def render_template_value(value: Any, template_ctx: Dict[str, Any]) -> Any: + if isinstance(value, str): + return substitute_template(value, template_ctx) + if isinstance(value, list): + return [render_template_value(item, template_ctx) for item in value] + if isinstance(value, dict): + return {key: render_template_value(item, template_ctx) for key, item in value.items()} + return value + + +def resolve_binary_platform_vars(ctx: FlowContext, spec: Dict[str, Any]) -> Dict[str, str]: + platform_vars = { + "os": ctx.platform.os, + "arch": ctx.platform.arch, + } + platform_map = spec.get("platform-map", {}) + if isinstance(platform_map, dict): + for key in platform_lookup_keys(ctx): + mapping = platform_map.get(key) + if isinstance(mapping, dict): + for map_key, map_value in mapping.items(): + if isinstance(map_value, str): + platform_vars[map_key] = map_value + break + return platform_vars + + +def resolve_binary_asset(ctx: FlowContext, spec: Dict[str, Any], template_ctx: Dict[str, Any]) -> str: + assets = spec.get("assets", {}) + if isinstance(assets, dict) and assets: + for key in platform_lookup_keys(ctx): + value = assets.get(key) + if isinstance(value, str) and value: + return substitute_template(value, template_ctx) + raise FlowError( + f"No binary asset mapping for platform {ctx.platform.platform} in package '{spec['name']}'" + ) + + pattern = spec.get("asset-pattern") + if not isinstance(pattern, str) or not pattern: + raise FlowError( + f"Binary package '{spec['name']}' must define either 'assets' or 'asset-pattern'" + ) + return substitute_template(pattern, template_ctx) + + +def resolve_binary_download_url( + spec: Dict[str, Any], + asset_name: str, + template_ctx: Dict[str, Any], +) -> str: + source = spec.get("source") + if not isinstance(source, str) or not source: + raise FlowError(f"Binary package '{spec['name']}' is missing 'source'") + + version = str(spec.get("version", "")) + if source.startswith("github:"): + owner_repo = source[len("github:") :] + if not owner_repo: + raise FlowError(f"Invalid github source in package '{spec['name']}'") + if not version: + raise FlowError(f"Binary package '{spec['name']}' requires 'version'") + return f"https://github.com/{owner_repo}/releases/download/v{version}/{asset_name}" + + rendered_source = substitute_template(source, template_ctx) + if not asset_name or rendered_source.endswith(asset_name): + return rendered_source + if rendered_source.endswith("/"): + return rendered_source + asset_name + return f"{rendered_source}/{asset_name}" + + +def strip_prefix(path: Path, prefix: Path) -> Path: + try: + return path.relative_to(prefix) + except ValueError: + return path + + +def validate_declared_install_path(package_name: str, declared_path: Path) -> None: + if declared_path.is_absolute(): + raise FlowError(f"Install path for '{package_name}' must be relative: {declared_path}") + if any(part == ".." for part in declared_path.parts): + raise FlowError( + f"Install path for '{package_name}' must not include parent traversal: {declared_path}" + ) + + +def install_destination(kind: str) -> Path: + home = Path.home() + if kind == "bin": + return home / ".local" / "bin" + if kind == "share": + return home / ".local" / "share" + if kind == "man": + return home / ".local" / "share" / "man" + if kind == "lib": + return home / ".local" / "lib" + raise FlowError(f"Unsupported install section: {kind}") + + +def install_strip_prefix(kind: str) -> Path: + if kind == "bin": + return Path("bin") + if kind == "share": + return Path("share") + if kind == "man": + return Path("share") / "man" + if kind == "lib": + return Path("lib") + return Path(".") + + +class BinaryInstaller: + def __init__(self, ctx: FlowContext): + self.ctx = ctx + self.fs = ctx.runtime.fs + + def copy_install_item(self, kind: str, src: Path, declared_path: Path) -> None: + destination_root = install_destination(kind) + stripped = strip_prefix(declared_path, install_strip_prefix(kind)) + destination = destination_root / stripped + + if src.is_dir(): + self.fs.copy_tree(src, destination) + else: + self.fs.copy_file(src, destination) + if kind == "bin": + destination.chmod(destination.stat().st_mode | 0o111) + + def install(self, spec: Dict[str, Any], extra_env: Dict[str, str], *, dry_run: bool) -> None: + version = str(spec.get("version", "")) + platform_vars = resolve_binary_platform_vars(self.ctx, spec) + template_ctx = profile_template_context( + self.ctx, + extra_env, + {"name": spec["name"], "version": version, **platform_vars}, + ) + + asset_name = resolve_binary_asset(self.ctx, spec, template_ctx) + template_ctx["asset"] = asset_name + download_url = resolve_binary_download_url(spec, asset_name, template_ctx) + template_ctx["downloadUrl"] = download_url + + if dry_run: + self.ctx.console.info(f"[{spec['name']}] Would download: {download_url}") + return + + install_map = spec.get("install", {}) + if not isinstance(install_map, dict) or not install_map: + raise FlowError(f"Binary package '{spec['name']}' must define non-empty 'install'") + + with tempfile.TemporaryDirectory(prefix=f"flow-{spec['name']}-") as tmp: + tmp_dir = Path(tmp) + archive_path = tmp_dir / asset_name + extracted = tmp_dir / "extract" + + self.ctx.console.info(f"Downloading {spec['name']} from {download_url}") + with urllib.request.urlopen(download_url, timeout=60) as response: + self.fs.write_bytes(archive_path, response.read()) + + self.fs.ensure_dir(extracted) + try: + shutil.unpack_archive(str(archive_path), str(extracted)) + except (shutil.ReadError, ValueError) as exc: + raise FlowError(f"Could not extract archive for '{spec['name']}': {exc}") from exc + + extract_dir_value = substitute_template(str(spec.get("extract-dir", ".")), template_ctx) + source_root = extracted if extract_dir_value == "." else extracted / extract_dir_value + if not source_root.exists(): + raise FlowError( + f"extract-dir '{extract_dir_value}' not found for package '{spec['name']}'" + ) + source_root_resolved = source_root.resolve(strict=False) + + for kind in ("bin", "share", "man", "lib"): + items = install_map.get(kind, []) + if not isinstance(items, list): + continue + for raw_item in items: + if not isinstance(raw_item, str): + continue + rendered = substitute_template(raw_item, template_ctx) + declared_path = Path(rendered) + validate_declared_install_path(spec["name"], declared_path) + source = (source_root / declared_path).resolve(strict=False) + if not str(source).startswith(str(source_root_resolved)): + raise FlowError( + f"Install path escapes extract-dir for '{spec['name']}': {declared_path}" + ) + if not source.exists(): + raise FlowError( + f"Install path not found for '{spec['name']}': {declared_path}" + ) + self.copy_install_item(kind, source, declared_path) diff --git a/src/flow/services/packages.py b/src/flow/services/packages.py new file mode 100644 index 0000000..0b2e15c --- /dev/null +++ b/src/flow/services/packages.py @@ -0,0 +1,113 @@ +"""Package-state service built on shared package definitions.""" + +from __future__ import annotations + +from pathlib import Path + +from flow.core.config import FlowContext +from flow.core.system import JsonStateStore +from flow.services.package_defs import BinaryInstaller, get_package_catalog + + +class PackageService: + def __init__(self, ctx: FlowContext, *, installed_state: Path): + self.ctx = ctx + self.installed = JsonStateStore(installed_state, ctx.runtime.fs, dict) + self.binary_installer = BinaryInstaller(ctx) + + def load_installed(self) -> dict: + state = self.installed.load() + return state if isinstance(state, dict) else {} + + def save_installed(self, state: dict) -> None: + self.installed.save(state) + + def definitions(self): + return get_package_catalog(self.ctx) + + def install(self, args) -> None: + definitions = self.definitions() + installed = self.load_installed() + had_error = False + + for package_name in args.packages: + package_def = definitions.get(package_name) + if not package_def: + self.ctx.console.error(f"Package not found in manifest: {package_name}") + had_error = True + continue + + package_type = package_def.get("type", "pkg") + if package_type != "binary": + self.ctx.console.error( + f"'flow package install' supports binary packages only. '{package_name}' is type '{package_type}'." + ) + had_error = True + continue + + self.ctx.console.info(f"Installing {package_name}...") + try: + self.binary_installer.install(package_def, {}, dry_run=args.dry_run) + except RuntimeError as exc: + self.ctx.console.error(str(exc)) + had_error = True + continue + + if not args.dry_run: + installed[package_name] = { + "version": str(package_def.get("version", "")), + "type": package_type, + } + self.ctx.console.success(f"Installed {package_name}") + + if not args.dry_run: + self.save_installed(installed) + if had_error: + raise SystemExit(1) + + def list(self, args) -> None: + definitions = self.definitions() + installed = self.load_installed() + + rows = [] + if args.all: + if not definitions: + self.ctx.console.info("No packages defined in manifest.") + return + for name, package_def in sorted(definitions.items()): + rows.append( + [ + name, + str(package_def.get("type", "pkg")), + str(installed.get(name, {}).get("version", "-")), + str(package_def.get("version", "")) or "-", + ] + ) + else: + if not installed: + self.ctx.console.info("No packages installed.") + return + for name, info in sorted(installed.items()): + rows.append( + [ + name, + str(info.get("type", "?")), + str(info.get("version", "?")), + str(definitions.get(name, {}).get("version", "")) or "-", + ] + ) + + self.ctx.console.table(["PACKAGE", "TYPE", "INSTALLED", "AVAILABLE"], rows) + + def remove(self, args) -> None: + installed = self.load_installed() + for package_name in args.packages: + if package_name not in installed: + self.ctx.console.warn(f"Package not installed: {package_name}") + continue + del installed[package_name] + self.ctx.console.success(f"Removed {package_name} from installed packages") + self.ctx.console.warn( + "Note: installed files were not automatically deleted. Remove manually if needed." + ) + self.save_installed(installed) diff --git a/src/flow/services/projects.py b/src/flow/services/projects.py new file mode 100644 index 0000000..0f510ea --- /dev/null +++ b/src/flow/services/projects.py @@ -0,0 +1,174 @@ +"""Project sync service for `flow sync`.""" + +from __future__ import annotations + +import os +import subprocess + +from flow.core.config import FlowContext +from flow.core.errors import FlowError + + +class ProjectSyncService: + """Inspect and synchronize git repositories under the projects directory.""" + + def __init__(self, ctx: FlowContext): + self.ctx = ctx + self.runner = ctx.runtime.runner + + def git(self, repo: str, *cmd: str, capture: bool = True) -> subprocess.CompletedProcess[str]: + return self.runner.run( + ["git", "-C", repo, *cmd], + capture_output=capture, + ) + + def is_git_repo(self, repo_path: str) -> bool: + git_dir = os.path.join(repo_path, ".git") + return os.path.isdir(git_dir) or os.path.isfile(git_dir) + + def check_repo(self, repo_path: str, do_fetch: bool = True) -> tuple[str, list[str] | None]: + name = os.path.basename(repo_path) + if not self.is_git_repo(repo_path): + return name, None + + issues: list[str] = [] + + if do_fetch: + fetch_result = self.git(repo_path, "fetch", "--all", "--quiet") + if fetch_result.returncode != 0: + issues.append("git fetch failed") + + result = self.git(repo_path, "rev-parse", "--abbrev-ref", "HEAD") + branch = result.stdout.strip() if result.returncode == 0 else "HEAD" + + diff_result = self.git(repo_path, "diff", "--quiet") + cached_result = self.git(repo_path, "diff", "--cached", "--quiet") + if diff_result.returncode != 0 or cached_result.returncode != 0: + issues.append("uncommitted changes") + else: + untracked = self.git(repo_path, "ls-files", "--others", "--exclude-standard") + if untracked.stdout.strip(): + issues.append("untracked files") + + upstream_check = self.git(repo_path, "rev-parse", "--abbrev-ref", f"{branch}@{{u}}") + if upstream_check.returncode == 0: + unpushed = self.git(repo_path, "rev-list", "--oneline", f"{branch}@{{u}}..{branch}") + if unpushed.stdout.strip(): + issues.append( + f"{len(unpushed.stdout.strip().splitlines())} unpushed commit(s) on {branch}" + ) + else: + issues.append(f"no upstream for {branch}") + + branches_result = self.git( + repo_path, + "for-each-ref", + "--format=%(refname:short)", + "refs/heads", + ) + for branch_name in branches_result.stdout.strip().splitlines(): + if not branch_name or branch_name == branch: + continue + upstream = self.git(repo_path, "rev-parse", "--abbrev-ref", f"{branch_name}@{{u}}") + if upstream.returncode == 0: + ahead = self.git(repo_path, "rev-list", "--count", f"{branch_name}@{{u}}..{branch_name}") + if ahead.stdout.strip() != "0": + issues.append(f"branch {branch_name}: {ahead.stdout.strip()} ahead") + else: + issues.append(f"branch {branch_name}: no upstream") + + return name, issues + + def _projects_dir(self) -> str: + projects_dir = os.path.expanduser(self.ctx.config.projects_dir) + if not os.path.isdir(projects_dir): + raise FlowError(f"Projects directory not found: {projects_dir}") + return projects_dir + + def run_check(self, args) -> None: + projects_dir = self._projects_dir() + + rows = [] + needs_action = [] + not_git = [] + checked = 0 + + for entry in sorted(os.listdir(projects_dir)): + repo_path = os.path.join(projects_dir, entry) + if not os.path.isdir(repo_path): + continue + + name, issues = self.check_repo(repo_path, do_fetch=args.fetch) + if issues is None: + not_git.append(name) + continue + checked += 1 + if issues: + needs_action.append(name) + rows.append([name, "; ".join(issues)]) + else: + rows.append([name, "clean and synced"]) + + if checked == 0: + self.ctx.console.info("No git repositories found in projects directory.") + if not_git: + self.ctx.console.info(f"Skipped non-git directories: {', '.join(sorted(not_git))}") + return + + self.ctx.console.table(["PROJECT", "STATUS"], rows) + + if needs_action: + self.ctx.console.warn(f"Projects needing action: {', '.join(sorted(needs_action))}") + else: + self.ctx.console.success("All repositories clean and synced.") + + if not_git: + self.ctx.console.info(f"Skipped non-git directories: {', '.join(sorted(not_git))}") + + def run_fetch(self, _args) -> None: + projects_dir = self._projects_dir() + + had_error = False + fetched = 0 + for entry in sorted(os.listdir(projects_dir)): + repo_path = os.path.join(projects_dir, entry) + if not self.is_git_repo(repo_path): + continue + self.ctx.console.info(f"Fetching {entry}...") + result = self.git(repo_path, "fetch", "--all", "--quiet") + fetched += 1 + if result.returncode != 0: + self.ctx.console.error(f"Failed to fetch {entry}") + had_error = True + + if fetched == 0: + self.ctx.console.info("No git repositories found in projects directory.") + return + + if had_error: + raise SystemExit(1) + + self.ctx.console.success("All remotes fetched.") + + def run_summary(self, _args) -> None: + projects_dir = self._projects_dir() + + rows = [] + for entry in sorted(os.listdir(projects_dir)): + repo_path = os.path.join(projects_dir, entry) + if not os.path.isdir(repo_path): + continue + + name, issues = self.check_repo(repo_path, do_fetch=False) + if issues is None: + rows.append([name, "not a git repo"]) + elif issues: + rows.append([name, "; ".join(issues)]) + else: + rows.append([name, "clean"]) + + if not rows: + self.ctx.console.info("No projects found.") + return + + self.ctx.console.table(["PROJECT", "STATUS"], rows) diff --git a/src/flow/services/ssh.py b/src/flow/services/ssh.py new file mode 100644 index 0000000..16d895e --- /dev/null +++ b/src/flow/services/ssh.py @@ -0,0 +1,184 @@ +"""SSH target parsing and connection behavior for `flow enter`.""" + +from __future__ import annotations + +import getpass +import os +from typing import Optional + +from flow.core.config import FlowContext +from flow.core.errors import FlowError + +# Default host templates per platform +HOST_TEMPLATES = { + "orb": ".orb", + "utm": ".utm.local", + "core": ".core.lan", +} + + +def parse_target(target: str) -> tuple[Optional[str], Optional[str], Optional[str]]: + """Parse [user@]namespace@platform into (user, namespace, platform).""" + user = None + namespace = None + platform = None + + if "@" in target: + platform = target.rsplit("@", 1)[1] + rest = target.rsplit("@", 1)[0] + else: + rest = target + + if "@" in rest: + user = rest.rsplit("@", 1)[0] + namespace = rest.rsplit("@", 1)[1] + else: + namespace = rest + + return user, namespace, platform + + +def build_destination(user: str, host: str, preserve_host_user: bool = False) -> str: + if "@" in host: + host_user, host_name = host.rsplit("@", 1) + effective_user = host_user if preserve_host_user else (user or host_user) + return f"{effective_user}@{host_name}" + if not user: + return host + return f"{user}@{host}" + + +def terminfo_fix_command(term: Optional[str], destination: str) -> Optional[str]: + normalized_term = (term or "").strip().lower() + + if normalized_term == "xterm-ghostty": + return f"infocmp -x xterm-ghostty | ssh {destination} -- tic -x -" + + if normalized_term == "wezterm": + return ( + f"ssh {destination} -- sh -lc " + "'tempfile=$(mktemp) && curl -fsSL -o \"$tempfile\" " + "https://raw.githubusercontent.com/wezterm/wezterm/main/termwiz/data/wezterm.terminfo " + "&& tic -x -o ~/.terminfo \"$tempfile\" && rm \"$tempfile\"'" + ) + + return None + + +def handle_terminfo_warning( + ctx: FlowContext, + term: Optional[str], + destination: str, + dry_run: bool, +) -> bool: + install_cmd = terminfo_fix_command(term, destination) + if not install_cmd: + return True + + ctx.console.warn( + f"Detected TERM={term}. Remote host may be missing this terminfo entry." + ) + ctx.console.info("flow will not install or modify terminfo on the target automatically.") + ctx.console.info("If needed, run this command manually before reconnecting:") + print(f" {install_cmd}") + + if dry_run or not os.isatty(0): + return True + + response = "" + try: + response = input("Continue with SSH connection? [Y/n] ").strip().lower() + except EOFError: + return True + + if response in {"n", "no"}: + ctx.console.warn("Cancelled before opening SSH session") + return False + + return True + + +class EnterService: + """Resolve enter targets and execute the SSH handoff.""" + + def __init__(self, ctx: FlowContext): + self.ctx = ctx + + def run(self, args) -> None: + if os.environ.get("DF_NAMESPACE") and os.environ.get("DF_PLATFORM"): + ns = os.environ["DF_NAMESPACE"] + plat = os.environ["DF_PLATFORM"] + raise FlowError( + f"Not recommended inside an instance. Currently in: {ns}@{plat}" + ) + + user, namespace, platform = parse_target(args.target) + + if args.user: + user = args.user + if args.namespace: + namespace = args.namespace + if args.platform: + platform = args.platform + + user_was_explicit = bool(user) + + if not user: + user = os.environ.get("USER") or getpass.getuser() + if not namespace: + raise FlowError("Namespace is required in target") + if not platform: + raise FlowError("Platform is required in target") + + host_template = HOST_TEMPLATES.get(platform) + ssh_identity = None + + for target in self.ctx.config.targets: + if target.namespace == namespace and target.platform == platform: + host_template = target.ssh_host + ssh_identity = target.ssh_identity + break + + if not host_template: + raise FlowError(f"Unknown platform: {platform}") + + ssh_host = host_template.replace("", namespace) + destination = build_destination( + user, + ssh_host, + preserve_host_user=not user_was_explicit, + ) + + if not handle_terminfo_warning( + self.ctx, + os.environ.get("TERM"), + destination, + dry_run=args.dry_run, + ): + raise FlowError("Cancelled before opening SSH session") + + ssh_cmd = ["ssh", "-tt"] + if ssh_identity: + ssh_cmd.extend(["-i", os.path.expanduser(ssh_identity)]) + ssh_cmd.append(destination) + + if not args.no_tmux: + ssh_cmd.extend( + [ + "tmux", + "new-session", + "-As", + args.session, + "-e", + f"DF_NAMESPACE={namespace}", + "-e", + f"DF_PLATFORM={platform}", + ] + ) + + if args.dry_run: + self.ctx.console.info("Dry run command:") + print(" " + " ".join(ssh_cmd)) + return + + os.execvp("ssh", ssh_cmd) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1f58cd6..0868705 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -62,12 +62,12 @@ def test_resolve_package_manager_explicit_value(ctx): def test_resolve_package_manager_linux_auto_apt(monkeypatch, ctx): - monkeypatch.setattr("flow.commands.bootstrap.shutil.which", lambda name: "/usr/bin/apt" if name == "apt" else None) + monkeypatch.setattr("flow.services.bootstrap.shutil.which", lambda name: "/usr/bin/apt" if name == "apt" else None) assert _resolve_package_manager(ctx, {"os": "linux"}) == "apt" def test_resolve_package_manager_linux_auto_dnf(monkeypatch, ctx): - monkeypatch.setattr("flow.commands.bootstrap.shutil.which", lambda name: "/usr/bin/dnf" if name == "dnf" else None) + monkeypatch.setattr("flow.services.bootstrap.shutil.which", lambda name: "/usr/bin/dnf" if name == "dnf" else None) assert _resolve_package_manager(ctx, {"os": "linux"}) == "dnf" @@ -158,7 +158,7 @@ class _FakeResponse: def _patch_binary_download(monkeypatch, after_unpack=None): monkeypatch.setattr( - "flow.commands.bootstrap.urllib.request.urlopen", + "flow.services.bootstrap.urllib.request.urlopen", lambda *args, **kwargs: _FakeResponse(), ) @@ -168,7 +168,7 @@ def _patch_binary_download(monkeypatch, after_unpack=None): if after_unpack: after_unpack(extracted) - monkeypatch.setattr("flow.commands.bootstrap.shutil.unpack_archive", _fake_unpack) + monkeypatch.setattr("flow.services.bootstrap.shutil.unpack_archive", _fake_unpack) def test_install_binary_package_rejects_absolute_declared_path(monkeypatch, tmp_path, ctx): @@ -177,7 +177,7 @@ def test_install_binary_package_rejects_absolute_declared_path(monkeypatch, tmp_ _patch_binary_download(monkeypatch) monkeypatch.setattr( - "flow.commands.bootstrap._copy_install_item", + "flow.services.bootstrap._copy_install_item", lambda *args, **kwargs: pytest.fail("_copy_install_item should not be called"), ) @@ -199,7 +199,7 @@ def test_install_binary_package_rejects_parent_traversal_declared_path(monkeypat _patch_binary_download(monkeypatch, after_unpack=_after_unpack) monkeypatch.setattr( - "flow.commands.bootstrap._copy_install_item", + "flow.services.bootstrap._copy_install_item", lambda *args, **kwargs: pytest.fail("_copy_install_item should not be called"), ) diff --git a/tests/test_dotfiles.py b/tests/test_dotfiles.py index 1f0fd8a..8c51f0c 100644 --- a/tests/test_dotfiles.py +++ b/tests/test_dotfiles.py @@ -1,8 +1,8 @@ -"""Tests for flow.commands.dotfiles discovery and path resolution.""" +"""Tests for flow.services.dotfiles discovery and path resolution.""" import pytest -from flow.commands.dotfiles import _collect_home_specs, _discover_packages, _resolve_edit_target, _walk_package +from flow.services.dotfiles import _collect_home_specs, _discover_packages, _resolve_edit_target, _walk_package from flow.core.config import AppConfig, FlowContext from flow.core.console import ConsoleLogger from flow.core.platform import PlatformInfo diff --git a/tests/test_dotfiles_e2e_container.py b/tests/test_dotfiles_e2e_container.py index 067a30f..c2bff38 100644 --- a/tests/test_dotfiles_e2e_container.py +++ b/tests/test_dotfiles_e2e_container.py @@ -15,12 +15,12 @@ import pytest REPO_ROOT = Path(__file__).resolve().parents[1] -def _docker_available() -> bool: - if shutil.which("docker") is None: +def _runtime_available(runtime: str) -> bool: + if shutil.which(runtime) is None: return False result = subprocess.run( - ["docker", "info"], + [runtime, "info"], capture_output=True, text=True, check=False, @@ -28,16 +28,36 @@ def _docker_available() -> bool: return result.returncode == 0 -def _require_container_e2e() -> None: +def _container_runtime() -> str | None: + preferred = os.environ.get("FLOW_E2E_CONTAINER_RUNTIME") + candidates = [preferred] if preferred else ["podman", "docker"] + + for runtime in candidates: + if not runtime: + continue + if _runtime_available(runtime): + return runtime + + return None + + +def _require_container_e2e() -> str: if os.environ.get("FLOW_RUN_E2E_CONTAINER") != "1": pytest.skip("Set FLOW_RUN_E2E_CONTAINER=1 to run container e2e tests") - if not _docker_available(): - pytest.skip("Docker is required for container e2e tests") + runtime = _container_runtime() + if runtime is None: + pytest.skip("Podman or Docker is required for container e2e tests") + return runtime @pytest.fixture(scope="module") -def e2e_image(tmp_path_factory): - _require_container_e2e() +def e2e_runtime(): + return _require_container_e2e() + + +@pytest.fixture(scope="module") +def e2e_image(tmp_path_factory, e2e_runtime): + runtime = e2e_runtime context_dir = tmp_path_factory.mktemp("flow-e2e-docker-context") dockerfile = context_dir / "Dockerfile" @@ -53,7 +73,7 @@ def e2e_image(tmp_path_factory): tag = f"flow-e2e-{uuid.uuid4().hex[:10]}" subprocess.run( - ["docker", "build", "-t", tag, str(context_dir)], + [runtime, "build", "-t", tag, str(context_dir)], check=True, capture_output=True, text=True, @@ -62,13 +82,13 @@ def e2e_image(tmp_path_factory): try: yield tag finally: - subprocess.run(["docker", "rmi", "-f", tag], capture_output=True, text=True, check=False) + subprocess.run([runtime, "rmi", "-f", tag], capture_output=True, text=True, check=False) -def _run_in_container(image_tag: str, script: str) -> subprocess.CompletedProcess: +def _run_in_container(runtime: str, image_tag: str, script: str) -> subprocess.CompletedProcess: return subprocess.run( [ - "docker", + runtime, "run", "--rm", "-v", @@ -89,7 +109,7 @@ def _assert_ok(run: subprocess.CompletedProcess) -> None: raise AssertionError(f"Container e2e failed:\nSTDOUT:\n{run.stdout}\nSTDERR:\n{run.stderr}") -def test_e2e_link_and_undo_with_root_targets(e2e_image): +def test_e2e_link_and_undo_with_root_targets(e2e_runtime, e2e_image): script = r""" set -euo pipefail export HOME=/home/flow @@ -116,10 +136,10 @@ test ! -L "$HOME/.zshrc" grep -q '^# before$' "$HOME/.zshrc" test ! -e /tmp/flow-e2e-root-target """ - _assert_ok(_run_in_container(e2e_image, script)) + _assert_ok(_run_in_container(e2e_runtime, e2e_image, script)) -def test_e2e_dry_run_force_is_read_only_in_both_flag_orders(e2e_image): +def test_e2e_dry_run_force_is_read_only_in_both_flag_orders(e2e_runtime, e2e_image): script = r""" set -euo pipefail export HOME=/home/flow @@ -150,10 +170,10 @@ assert "last_transaction" not in data, data PY fi """ - _assert_ok(_run_in_container(e2e_image, script)) + _assert_ok(_run_in_container(e2e_runtime, e2e_image, script)) -def test_e2e_unmanaged_conflict_without_force_is_non_destructive(e2e_image): +def test_e2e_unmanaged_conflict_without_force_is_non_destructive(e2e_runtime, e2e_image): script = r""" set -euo pipefail export HOME=/home/flow @@ -177,10 +197,10 @@ test -f "$HOME/.zshrc" test ! -L "$HOME/.zshrc" grep -q '^# user-file$' "$HOME/.zshrc" """ - _assert_ok(_run_in_container(e2e_image, script)) + _assert_ok(_run_in_container(e2e_runtime, e2e_image, script)) -def test_e2e_managed_drift_requires_force(e2e_image): +def test_e2e_managed_drift_requires_force(e2e_runtime, e2e_image): script = r""" set -euo pipefail export HOME=/home/flow @@ -208,10 +228,10 @@ test -f "$HOME/.zshrc" test ! -L "$HOME/.zshrc" grep -q '^# drifted-manual$' "$HOME/.zshrc" """ - _assert_ok(_run_in_container(e2e_image, script)) + _assert_ok(_run_in_container(e2e_runtime, e2e_image, script)) -def test_e2e_directory_conflict_is_atomic_even_with_force(e2e_image): +def test_e2e_directory_conflict_is_atomic_even_with_force(e2e_runtime, e2e_image): script = r""" set -euo pipefail export HOME=/home/flow @@ -236,10 +256,10 @@ test "$rc" -ne 0 test -d "$HOME/.zshrc" test ! -e "$HOME/.gitconfig" """ - _assert_ok(_run_in_container(e2e_image, script)) + _assert_ok(_run_in_container(e2e_runtime, e2e_image, script)) -def test_e2e_undo_after_failed_followup_link_restores_last_transaction(e2e_image): +def test_e2e_undo_after_failed_followup_link_restores_last_transaction(e2e_runtime, e2e_image): script = r""" set -euo pipefail export HOME=/home/flow @@ -273,4 +293,4 @@ test -f "$HOME/.a" test ! -L "$HOME/.a" grep -q '^# pre-a$' "$HOME/.a" """ - _assert_ok(_run_in_container(e2e_image, script)) + _assert_ok(_run_in_container(e2e_runtime, e2e_image, script)) diff --git a/tests/test_dotfiles_folding.py b/tests/test_dotfiles_folding.py index 42262cc..60c0f20 100644 --- a/tests/test_dotfiles_folding.py +++ b/tests/test_dotfiles_folding.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest -from flow.commands.dotfiles import ( +from flow.services.dotfiles import ( LinkSpec, _collect_home_specs, _list_profiles, @@ -16,6 +16,7 @@ from flow.commands.dotfiles import ( _pull_requires_ack, _resolved_package_source, _run_sudo, + run_relink, run_undo, _save_link_specs_to_state, _sync_to_desired, @@ -95,7 +96,7 @@ def test_collect_home_specs_skip_root_marker(tmp_path): def test_state_round_trip(tmp_path, monkeypatch): state_file = tmp_path / "linked.json" - monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file) + monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file) specs = { Path("/home/user/.gitconfig"): LinkSpec( @@ -113,7 +114,7 @@ def test_state_round_trip(tmp_path, monkeypatch): def test_state_old_format_rejected(tmp_path, monkeypatch): state_file = tmp_path / "linked.json" - monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file) + monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file) state_file.write_text( json.dumps( { @@ -131,24 +132,24 @@ def test_state_old_format_rejected(tmp_path, monkeypatch): def test_module_source_requires_sync(tmp_path): - package_dir = tmp_path / "_shared" / "nvim" - package_dir.mkdir(parents=True) - (package_dir / "_module.yaml").write_text( + package_root = tmp_path / "_shared" / "nvim" + module_mount = package_root / ".config" / "nvim" + module_mount.mkdir(parents=True) + (module_mount / "_module.yaml").write_text( "source: github:dummy/example\n" "ref:\n" " branch: main\n" ) with pytest.raises(RuntimeError, match="Run 'flow dotfiles sync' first"): - _resolved_package_source(_ctx(), "_shared/nvim", package_dir) + _resolved_package_source(_ctx(), "_shared/nvim", package_root) def test_sync_modules_populates_cache_and_resolves_source(tmp_path, monkeypatch): module_src = tmp_path / "module-src" module_src.mkdir() subprocess.run(["git", "init", "-b", "main", str(module_src)], check=True) - (module_src / ".config" / "nvim").mkdir(parents=True) - (module_src / ".config" / "nvim" / "init.lua").write_text("-- module") + (module_src / "init.lua").write_text("-- module") subprocess.run(["git", "-C", str(module_src), "add", "."], check=True) subprocess.run( [ @@ -167,30 +168,30 @@ def test_sync_modules_populates_cache_and_resolves_source(tmp_path, monkeypatch) ) dotfiles = tmp_path / "dotfiles" - package_dir = dotfiles / "_shared" / "nvim" - package_dir.mkdir(parents=True) - (package_dir / "_module.yaml").write_text( + package_root = dotfiles / "_shared" / "nvim" + module_mount = package_root / ".config" / "nvim" + module_mount.mkdir(parents=True) + (module_mount / "_module.yaml").write_text( f"source: {module_src}\n" "ref:\n" " branch: main\n" ) - (package_dir / "notes.txt").write_text("ignore me") + (package_root / "notes.txt").write_text("ignore me") - monkeypatch.setattr("flow.commands.dotfiles.DOTFILES_DIR", dotfiles) - monkeypatch.setattr("flow.commands.dotfiles.MODULES_DIR", tmp_path / "modules") + monkeypatch.setattr("flow.services.dotfiles.DOTFILES_DIR", dotfiles) + monkeypatch.setattr("flow.services.dotfiles.MODULES_DIR", tmp_path / "modules") _sync_modules(_ctx(), verbose=False) - resolved = _resolved_package_source(_ctx(), "_shared/nvim", package_dir) + resolved = _resolved_package_source(_ctx(), "_shared/nvim", package_root) - assert (resolved / ".config" / "nvim" / "init.lua").exists() + assert (resolved / "init.lua").exists() def test_module_backed_link_specs_exclude_git_internals(tmp_path, monkeypatch): module_src = tmp_path / "module-src" module_src.mkdir() subprocess.run(["git", "init", "-b", "main", str(module_src)], check=True) - (module_src / ".config" / "nvim").mkdir(parents=True) - (module_src / ".config" / "nvim" / "init.lua").write_text("-- module") + (module_src / "init.lua").write_text("-- module") subprocess.run(["git", "-C", str(module_src), "add", "."], check=True) subprocess.run( [ @@ -209,16 +210,17 @@ def test_module_backed_link_specs_exclude_git_internals(tmp_path, monkeypatch): ) dotfiles = tmp_path / "dotfiles" - package_dir = dotfiles / "_shared" / "nvim" - package_dir.mkdir(parents=True) - (package_dir / "_module.yaml").write_text( + package_root = dotfiles / "_shared" / "nvim" + module_mount = package_root / ".config" / "nvim" + module_mount.mkdir(parents=True) + (module_mount / "_module.yaml").write_text( f"source: {module_src}\n" "ref:\n" " branch: main\n" ) - monkeypatch.setattr("flow.commands.dotfiles.DOTFILES_DIR", dotfiles) - monkeypatch.setattr("flow.commands.dotfiles.MODULES_DIR", tmp_path / "modules") + monkeypatch.setattr("flow.services.dotfiles.DOTFILES_DIR", dotfiles) + monkeypatch.setattr("flow.services.dotfiles.MODULES_DIR", tmp_path / "modules") _sync_modules(_ctx(), verbose=False) @@ -234,8 +236,7 @@ def test_sync_modules_resolves_relative_source_independent_of_cwd(tmp_path, monk module_src = tmp_path / "module-src" module_src.mkdir() subprocess.run(["git", "init", "-b", "main", str(module_src)], check=True) - (module_src / ".config" / "nvim").mkdir(parents=True) - (module_src / ".config" / "nvim" / "init.lua").write_text("-- module") + (module_src / "init.lua").write_text("-- module") subprocess.run(["git", "-C", str(module_src), "add", "."], check=True) subprocess.run( [ @@ -254,10 +255,11 @@ def test_sync_modules_resolves_relative_source_independent_of_cwd(tmp_path, monk ) dotfiles = tmp_path / "dotfiles" - package_dir = dotfiles / "_shared" / "nvim" - package_dir.mkdir(parents=True) - relative_source = Path("../../../module-src") - (package_dir / "_module.yaml").write_text( + package_root = dotfiles / "_shared" / "nvim" + module_mount = package_root / ".config" / "nvim" + module_mount.mkdir(parents=True) + relative_source = Path("../../../../../module-src") + (module_mount / "_module.yaml").write_text( f"source: {relative_source}\n" "ref:\n" " branch: main\n" @@ -266,13 +268,61 @@ def test_sync_modules_resolves_relative_source_independent_of_cwd(tmp_path, monk unrelated_cwd = tmp_path / "unrelated-cwd" unrelated_cwd.mkdir() monkeypatch.chdir(unrelated_cwd) - monkeypatch.setattr("flow.commands.dotfiles.DOTFILES_DIR", dotfiles) - monkeypatch.setattr("flow.commands.dotfiles.MODULES_DIR", tmp_path / "modules") + monkeypatch.setattr("flow.services.dotfiles.DOTFILES_DIR", dotfiles) + monkeypatch.setattr("flow.services.dotfiles.MODULES_DIR", tmp_path / "modules") _sync_modules(_ctx(), verbose=False) - resolved = _resolved_package_source(_ctx(), "_shared/nvim", package_dir) + resolved = _resolved_package_source(_ctx(), "_shared/nvim", package_root) - assert (resolved / ".config" / "nvim" / "init.lua").exists() + assert (resolved / "init.lua").exists() + + +def test_module_mount_inherits_directory_path(tmp_path, monkeypatch): + module_src = tmp_path / "module-src" + module_src.mkdir() + subprocess.run(["git", "init", "-b", "main", str(module_src)], check=True) + (module_src / "init.lua").write_text("-- module") + (module_src / "lua").mkdir() + (module_src / "lua" / "config.lua").write_text("-- module") + subprocess.run(["git", "-C", str(module_src), "add", "."], check=True) + subprocess.run( + [ + "git", + "-C", + str(module_src), + "-c", + "user.name=Flow Test", + "-c", + "user.email=flow-test@example.com", + "commit", + "-m", + "init module", + ], + check=True, + ) + + dotfiles = tmp_path / "dotfiles" + package_root = dotfiles / "_shared" / "nvim" + module_mount = package_root / ".config" / "nvim" + module_mount.mkdir(parents=True) + (module_mount / "_module.yaml").write_text( + f"source: {module_src}\n" + "ref:\n" + " branch: main\n" + ) + + monkeypatch.setattr("flow.services.dotfiles.DOTFILES_DIR", dotfiles) + monkeypatch.setattr("flow.services.dotfiles.MODULES_DIR", tmp_path / "modules") + _sync_modules(_ctx(), verbose=False) + + home = tmp_path / "home" + home.mkdir() + specs = _collect_home_specs(_ctx(), dotfiles, home, None, set(), None) + + assert home / ".config" / "nvim" / "init.lua" in specs + assert home / ".config" / "nvim" / "lua" / "config.lua" in specs + assert home / "init.lua" not in specs + assert home / "lua" / "config.lua" not in specs def test_pull_requires_ack_only_on_real_updates(): @@ -280,10 +330,29 @@ def test_pull_requires_ack_only_on_real_updates(): assert _pull_requires_ack("Updating 123..456\n", "") is True +def test_run_relink_uses_transactional_link_path(monkeypatch): + calls = [] + + monkeypatch.setattr("flow.services.dotfiles._ensure_flow_dir", lambda _ctx: None) + monkeypatch.setattr( + "flow.services.dotfiles.run_unlink", + lambda _ctx, _args: (_ for _ in ()).throw(AssertionError("run_unlink must not be used")), + ) + + def _fake_run_link(_ctx, args): + calls.append((args.packages, args.profile, args.copy, args.force, args.dry_run)) + + monkeypatch.setattr("flow.services.dotfiles.run_link", _fake_run_link) + + run_relink(_ctx(), Namespace(packages=["git"], profile="work")) + + assert calls == [(["git"], "work", False, False, False)] + + def test_sync_to_desired_dry_run_force_is_read_only(tmp_path, monkeypatch): state_file = tmp_path / "linked.json" - monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file) - monkeypatch.setattr("flow.commands.dotfiles._is_in_home", lambda _path, _home: True) + monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file) + monkeypatch.setattr("flow.services.dotfiles._is_in_home", lambda _path, _home: True) source = tmp_path / "source" / ".zshrc" source.parent.mkdir(parents=True) @@ -317,8 +386,8 @@ def test_sync_to_desired_dry_run_force_is_read_only(tmp_path, monkeypatch): def test_sync_to_desired_force_fails_before_any_writes_on_directory_conflict(tmp_path, monkeypatch): state_file = tmp_path / "linked.json" - monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file) - monkeypatch.setattr("flow.commands.dotfiles._is_in_home", lambda _path, _home: True) + monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file) + monkeypatch.setattr("flow.services.dotfiles._is_in_home", lambda _path, _home: True) source_root = tmp_path / "source" source_root.mkdir() @@ -354,9 +423,9 @@ def test_sync_to_desired_force_fails_before_any_writes_on_directory_conflict(tmp def test_undo_restores_previous_file_and_link_state(tmp_path, monkeypatch): state_file = tmp_path / "linked.json" - monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file) - monkeypatch.setattr("flow.commands.dotfiles.LINK_BACKUP_DIR", tmp_path / "link-backups") - monkeypatch.setattr("flow.commands.dotfiles._is_in_home", lambda _path, _home: True) + monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file) + monkeypatch.setattr("flow.services.dotfiles.LINK_BACKUP_DIR", tmp_path / "link-backups") + monkeypatch.setattr("flow.services.dotfiles._is_in_home", lambda _path, _home: True) source = tmp_path / "source" / ".zshrc" source.parent.mkdir(parents=True) @@ -403,9 +472,9 @@ def test_undo_restores_previous_file_and_link_state(tmp_path, monkeypatch): def test_sync_to_desired_persists_incomplete_transaction_on_failure(tmp_path, monkeypatch): state_file = tmp_path / "linked.json" - monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file) - monkeypatch.setattr("flow.commands.dotfiles.LINK_BACKUP_DIR", tmp_path / "link-backups") - monkeypatch.setattr("flow.commands.dotfiles._is_in_home", lambda _path, _home: True) + monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file) + monkeypatch.setattr("flow.services.dotfiles.LINK_BACKUP_DIR", tmp_path / "link-backups") + monkeypatch.setattr("flow.services.dotfiles._is_in_home", lambda _path, _home: True) source = tmp_path / "source" source.mkdir() @@ -435,7 +504,7 @@ def test_sync_to_desired_persists_incomplete_transaction_on_failure(tmp_path, mo spec.target.symlink_to(spec.source) return True - monkeypatch.setattr("flow.commands.dotfiles._apply_link_spec", _failing_apply) + monkeypatch.setattr("flow.services.dotfiles._apply_link_spec", _failing_apply) with pytest.raises(RuntimeError, match="simulated failure"): _sync_to_desired( @@ -463,8 +532,8 @@ def test_sync_to_desired_persists_incomplete_transaction_on_failure(tmp_path, mo def test_sync_to_desired_requires_force_to_remove_modified_managed_target(tmp_path, monkeypatch): state_file = tmp_path / "linked.json" - monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file) - monkeypatch.setattr("flow.commands.dotfiles._is_in_home", lambda _path, _home: True) + monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file) + monkeypatch.setattr("flow.services.dotfiles._is_in_home", lambda _path, _home: True) source = tmp_path / "source" / ".old" source.parent.mkdir(parents=True) @@ -501,8 +570,8 @@ def test_sync_to_desired_requires_force_to_remove_modified_managed_target(tmp_pa def test_sync_to_desired_requires_force_to_replace_modified_managed_target(tmp_path, monkeypatch): state_file = tmp_path / "linked.json" - monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file) - monkeypatch.setattr("flow.commands.dotfiles._is_in_home", lambda _path, _home: True) + monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file) + monkeypatch.setattr("flow.services.dotfiles._is_in_home", lambda _path, _home: True) old_source = tmp_path / "source" / ".old" new_source = tmp_path / "source" / ".new" @@ -548,6 +617,6 @@ def test_sync_to_desired_requires_force_to_replace_modified_managed_target(tmp_p def test_run_sudo_errors_when_binary_missing(monkeypatch): - monkeypatch.setattr("flow.commands.dotfiles.shutil.which", lambda _name: None) + monkeypatch.setattr("flow.services.dotfiles.shutil.which", lambda _name: None) with pytest.raises(RuntimeError, match="sudo is required"): _run_sudo(["true"], dry_run=False)