From d0f8315cf1374352af1be8e7aea9f8608a070a46 Mon Sep 17 00:00:00 2001 From: Tomas Mirchev Date: Mon, 16 Mar 2026 08:06:25 +0200 Subject: [PATCH] refactor --- src/flow/cli.py | 69 +-- src/flow/commands/completion.py | 577 +++++++++++++++++++---- src/flow/commands/dev.py | 63 ++- src/flow/commands/dotfiles.py | 113 ++++- src/flow/commands/packages.py | 9 +- src/flow/commands/projects.py | 40 +- src/flow/commands/remote.py | 29 +- src/flow/commands/setup.py | 34 +- src/flow/core/config.py | 348 ++++++++++---- src/flow/domain/bootstrap/models.py | 2 + src/flow/domain/bootstrap/planning.py | 47 +- src/flow/domain/containers/models.py | 15 +- src/flow/domain/containers/resolution.py | 119 ++--- src/flow/domain/packages/catalog.py | 33 +- src/flow/domain/packages/models.py | 11 +- src/flow/domain/packages/planning.py | 22 +- src/flow/domain/packages/resolution.py | 113 ++++- src/flow/domain/remote/models.py | 3 + src/flow/domain/remote/resolution.py | 125 +++-- src/flow/services/bootstrap.py | 29 +- src/flow/services/containers.py | 239 +++++++--- src/flow/services/dotfiles.py | 259 ++++++++-- src/flow/services/packages.py | 280 ++++++++--- src/flow/services/remote.py | 42 +- tests/test_cli.py | 44 +- tests/test_core_config.py | 56 +++ tests/test_domain_bootstrap_planning.py | 24 + tests/test_domain_containers.py | 35 +- tests/test_domain_packages.py | 68 ++- tests/test_domain_remote.py | 41 +- tests/test_service_bootstrap.py | 60 ++- tests/test_service_containers.py | 29 +- tests/test_service_dotfiles.py | 70 ++- tests/test_service_packages.py | 67 +++ tests/test_service_remote.py | 2 + 35 files changed, 2493 insertions(+), 624 deletions(-) diff --git a/src/flow/cli.py b/src/flow/cli.py index 2f7ef01..4a0141b 100644 --- a/src/flow/cli.py +++ b/src/flow/cli.py @@ -8,11 +8,11 @@ import sys from typing import Optional from flow import __version__ -from flow.core.config import AppConfig, FlowContext, load_config, load_manifest +from flow.core.config import FlowContext, load_config, load_manifest from flow.core.console import Console from flow.core.errors import FlowError from flow.core import paths -from flow.core.platform import PlatformInfo, detect_context, detect_platform +from flow.core.platform import detect_context, detect_platform from flow.core.runtime import SystemRuntime @@ -33,35 +33,25 @@ def main(argv: Optional[list[str]] = None) -> None: parser.print_help() return - # Build context - console = Console(quiet=getattr(args, "quiet", False), color=None) - platform_info = detect_platform() - context = detect_context() - - # Block remote commands inside VMs - cmd_name = getattr(args, "command", "") - if context == "vm" and cmd_name in ("remote", "dev"): - console.error(f"Command '{cmd_name}' is not available inside a VM.") - sys.exit(1) - - paths.ensure_dirs() - config = load_config(paths.CONFIG_DIR) - # Also try loading config from dotfiles - if paths.DOTFILES_FLOW_CONFIG.is_dir(): - dotfiles_config = load_config(paths.DOTFILES_FLOW_CONFIG) - config = _merge_config(config, dotfiles_config) - - manifest = load_manifest(paths.DOTFILES_FLOW_CONFIG) - - ctx = FlowContext( - config=config, - manifest=manifest, - platform=platform_info, - console=console, - runtime=SystemRuntime(), - ) - try: + console = Console(quiet=getattr(args, "quiet", False), color=None) + platform_info = detect_platform() + context = detect_context() + cmd_name = getattr(args, "command", "") + + if context == "vm" and cmd_name == "remote": + raise FlowError("Command 'remote' is not available inside a VM.") + if context == "container" and cmd_name in {"remote", "dev", "projects"}: + raise FlowError(f"Command '{cmd_name}' is not available inside a container.") + + paths.ensure_dirs() + ctx = FlowContext( + config=load_config(), + manifest=load_manifest(), + platform=platform_info, + console=console, + runtime=SystemRuntime(), + ) args.handler(ctx, args) except FlowError as e: console.error(str(e)) @@ -71,25 +61,6 @@ def main(argv: Optional[list[str]] = None) -> None: sys.exit(130) -def _merge_config(base: AppConfig, overlay: AppConfig) -> AppConfig: - """Merge two configs: overlay's explicitly-set fields override base.""" - - def _pick(field: str) -> str: - if field in overlay._explicit: - return getattr(overlay, field) - return getattr(base, field) - - return AppConfig( - dotfiles_url=_pick("dotfiles_url"), - dotfiles_branch=_pick("dotfiles_branch"), - projects_dir=_pick("projects_dir"), - container_registry=_pick("container_registry"), - container_tag=_pick("container_tag"), - tmux_session=_pick("tmux_session"), - targets=overlay.targets if "targets" in overlay._explicit else base.targets, - ) - - def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="flow", diff --git a/src/flow/commands/completion.py b/src/flow/commands/completion.py index bfda612..e5eef36 100644 --- a/src/flow/commands/completion.py +++ b/src/flow/commands/completion.py @@ -1,99 +1,506 @@ -"""Zsh completion for flow CLI.""" +"""Shell completion support.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +from pathlib import Path +from typing import Sequence + +from flow.core.config import load_config, load_manifest +from flow.core import paths +from flow.domain.remote.resolution import HOST_TEMPLATES + +ZSH_RC_START = "# >>> flow completion >>>" +ZSH_RC_END = "# <<< flow completion <<<" + +TOP_LEVEL_COMMANDS = [ + "enter", + "remote", + "dev", + "dotfiles", + "setup", + "packages", + "projects", + "completion", +] -COMMANDS = { - "dotfiles": { - "subcommands": ["link", "unlink", "status", "sync"], - "flags": { - "link": ["--profile", "--dry-run", "--skip"], - "unlink": ["--dry-run"], - "status": [], - "sync": [], - }, - }, - "packages": { - "subcommands": ["install", "remove", "list"], - "flags": { - "install": ["--profile", "--dry-run"], - "remove": ["--dry-run"], - "list": [], - }, - }, - "setup": { - "subcommands": ["run", "show", "list"], - "flags": { - "run": ["--dry-run"], - "show": [], - "list": [], - }, - }, - "remote": { - "subcommands": ["enter", "list"], - "flags": { - "enter": ["--dry-run"], - "list": [], - }, - }, - "dev": { - "subcommands": ["create", "enter", "stop", "remove", "list"], - "flags": { - "create": ["--namespace", "--dry-run"], - "enter": ["--shell"], - "stop": [], - "remove": [], - "list": [], - }, - }, - "projects": { - "subcommands": ["check"], - "flags": { - "check": ["--fetch"], - }, - }, -} +def register(subparsers): + parser = subparsers.add_parser("completion", help="Shell completion helpers") + sub = parser.add_subparsers(dest="completion_action") + + zsh = sub.add_parser("zsh", help="Print the zsh completion script") + zsh.set_defaults(handler=_run_zsh_script) + + install = sub.add_parser("install-zsh", help="Install zsh completion") + install.add_argument("--dir", default="~/.zsh/completions") + install.add_argument("--rc", default="~/.zshrc") + install.add_argument("--no-rc", action="store_true") + install.set_defaults(handler=_run_install_zsh) + + hidden = sub.add_parser("_zsh_complete", help=argparse.SUPPRESS) + hidden.add_argument("--cword", type=int, required=True, help=argparse.SUPPRESS) + hidden.add_argument("words", nargs="*", help=argparse.SUPPRESS) + hidden.set_defaults(handler=_run_zsh_complete) + + parser.set_defaults(handler=_run_zsh_script) -def complete(comp_words: list[str], comp_cword: int) -> list[str]: - """Return completions for the current word.""" - if comp_cword <= 1: - # Complete top-level commands - prefix = comp_words[1] if len(comp_words) > 1 else "" - return [c for c in COMMANDS if c.startswith(prefix)] +def complete(words: Sequence[str], cword: int) -> list[str]: + before, current = _split_words(words, cword) - command = comp_words[1] if len(comp_words) > 1 else "" - if command not in COMMANDS: - return [] + if not before: + return _filter(TOP_LEVEL_COMMANDS + ["-h", "--help", "--version"], current) - cmd_def = COMMANDS[command] + command = _canonical_command(before[0]) - if comp_cword == 2: - # Complete subcommands - prefix = comp_words[2] if len(comp_words) > 2 else "" - return [s for s in cmd_def["subcommands"] if s.startswith(prefix)] - - if comp_cword >= 3: - # Complete flags for the subcommand - subcommand = comp_words[2] if len(comp_words) > 2 else "" - flags = cmd_def["flags"].get(subcommand, []) - prefix = comp_words[comp_cword] if comp_cword < len(comp_words) else "" - return [f for f in flags if f.startswith(prefix)] + if command in {"enter", "remote"}: + return _complete_remote(before, current) + if command == "dev": + return _complete_dev(before, current) + if command == "dotfiles": + return _complete_dotfiles(before, current) + if command == "bootstrap": + return _complete_bootstrap(before, current) + if command == "packages": + return _complete_packages(before, current) + if command == "projects": + return _complete_projects(before, current) + if command == "completion": + return _complete_completion(before, current) return [] -def register(subparsers): - """Register completion subcommand.""" - p = subparsers.add_parser("completion", help="Shell completion") - p.add_argument("--shell", default="zsh", choices=["zsh"]) - p.set_defaults(handler=_handle) +def _split_words(words: Sequence[str], cword: int) -> tuple[list[str], str]: + tokens = list(words) + if not tokens: + return [], "" + index = max(1, cword) + current = tokens[index] if index < len(tokens) else "" + before = tokens[1:index] + return before, current -def _handle(ctx, args): - """Output completion script.""" - import os - comp_words = os.environ.get("COMP_WORDS", "").split() - comp_cword = int(os.environ.get("COMP_CWORD", "0")) +def _canonical_command(command: str) -> str: + aliases = { + "dot": "dotfiles", + "bootstrap": "bootstrap", + "setup": "bootstrap", + "provision": "bootstrap", + "package": "packages", + "pkg": "packages", + "project": "projects", + "sync": "projects", + } + return aliases.get(command, command) - completions = complete(comp_words, comp_cword) - for c in completions: - print(c) + +def _filter(candidates: Sequence[str], prefix: str) -> list[str]: + unique = sorted(set(candidates)) + if not prefix: + return unique + return [candidate for candidate in unique if candidate.startswith(prefix)] + + +def _config(): + return load_config() + + +def _manifest(): + return load_manifest() + + +def _list_targets() -> list[str]: + cfg = _config() + return sorted({f"{target.namespace}@{target.platform}" for target in cfg.targets}) + + +def _list_namespaces() -> list[str]: + cfg = _config() + return sorted({target.namespace for target in cfg.targets}) + + +def _list_platforms() -> list[str]: + cfg = _config() + return sorted(set(HOST_TEMPLATES) | {target.platform for target in cfg.targets}) + + +def _list_bootstrap_profiles() -> list[str]: + manifest = _manifest() + return sorted(manifest.get("profiles", {}).keys()) + + +def _list_manifest_packages() -> list[str]: + manifest = _manifest() + packages = manifest.get("packages", []) + names: set[str] = set() + if isinstance(packages, list): + for package in packages: + if isinstance(package, dict) and isinstance(package.get("name"), str): + names.add(package["name"]) + return sorted(names) + if isinstance(packages, dict): + for name, package in packages.items(): + if isinstance(package, dict) and isinstance(package.get("name"), str): + names.add(package["name"]) + elif isinstance(name, str): + names.add(name) + return sorted(names) + + +def _list_installed_packages() -> list[str]: + if not paths.INSTALLED_STATE.exists(): + return [] + with open(paths.INSTALLED_STATE, encoding="utf-8") as handle: + state = json.load(handle) + packages = state.get("packages", {}) if isinstance(state, dict) else {} + return sorted(packages.keys()) if isinstance(packages, dict) else [] + + +def _list_dotfiles_profiles() -> list[str]: + if not paths.DOTFILES_DIR.is_dir(): + return [] + return sorted( + entry.name + for entry in paths.DOTFILES_DIR.iterdir() + if entry.is_dir() and not entry.name.startswith(".") and entry.name != "_shared" + ) + + +def _list_dotfiles_packages(profile: str | None = None) -> list[str]: + if not paths.DOTFILES_DIR.is_dir(): + return [] + + names: set[str] = set() + shared = paths.DOTFILES_DIR / "_shared" + if shared.is_dir(): + names.update( + entry.name for entry in shared.iterdir() + if entry.is_dir() and not entry.name.startswith(".") + ) + + layers = [] + if profile: + layers.append(paths.DOTFILES_DIR / profile) + else: + layers.extend( + entry + for entry in paths.DOTFILES_DIR.iterdir() + if entry.is_dir() and not entry.name.startswith(".") and entry.name != "_shared" + ) + + for layer in layers: + if not layer.is_dir(): + continue + names.update( + entry.name for entry in layer.iterdir() + if entry.is_dir() and not entry.name.startswith(".") + ) + + return sorted(names) + + +def _list_container_names() -> list[str]: + runtime = None + for candidate in ("docker", "podman"): + if shutil.which(candidate): + runtime = candidate + break + if runtime is None: + return [] + + result = subprocess.run( + [ + runtime, + "ps", + "-a", + "--filter", + "label=dev=true", + "--format", + '{{.Label "dev.name"}}', + ], + capture_output=True, + text=True, + timeout=1, + check=False, + ) + if result.returncode != 0: + return [] + return sorted({line.strip() for line in result.stdout.splitlines() if line.strip()}) + + +def _profile_from_before(before: Sequence[str]) -> str | None: + for i, token in enumerate(before): + if token == "--profile" and i + 1 < len(before): + return before[i + 1] + return None + + +def _complete_remote(before: Sequence[str], current: str) -> list[str]: + if before[0] == "remote": + if len(before) == 1: + return _filter(["enter", "list"], current) + if before[1] == "enter": + enter_tokens = ["enter", *before[2:]] + else: + return [] + else: + enter_tokens = before + + if enter_tokens and enter_tokens[-1] in {"-p", "--platform"}: + return _filter(_list_platforms(), current) + if enter_tokens and enter_tokens[-1] in {"-n", "--namespace"}: + return _filter(_list_namespaces(), current) + if current.startswith("-"): + return _filter( + [ + "-u", "--user", + "-n", "--namespace", + "-p", "--platform", + "-s", "--session", + "--no-tmux", + "-d", "--dry-run", + ], + current, + ) + return _filter(_list_targets(), current) + + +def _complete_dev(before: Sequence[str], current: str) -> list[str]: + if len(before) <= 1: + return _filter( + ["create", "attach", "connect", "exec", "enter", "list", "stop", "remove", "rm", "respawn"], + current, + ) + + subcommand = before[1] + if subcommand in {"attach", "connect", "exec", "enter", "stop", "remove", "rm", "respawn"}: + if current.startswith("-"): + options = { + "stop": ["--kill"], + "remove": ["-f", "--force"], + "rm": ["-f", "--force"], + } + return _filter(options.get(subcommand, []), current) + non_option = [token for token in before[2:] if not token.startswith("-")] + if not non_option: + return _filter(_list_container_names(), current) + return [] + + if subcommand == "create": + if current.startswith("-"): + return _filter(["-i", "--image", "-p", "--project", "--dry-run"], current) + return [] + + return [] + + +def _complete_dotfiles(before: Sequence[str], current: str) -> list[str]: + if len(before) <= 1: + return _filter( + ["init", "link", "relink", "unlink", "undo", "status", "clean", "sync", "modules", "repo", "repos", "edit"], + current, + ) + + subcommand = before[1] + if subcommand == "init": + return _filter(["--repo"], current) if current.startswith("-") else [] + + if subcommand in {"link", "relink"}: + if before and before[-1] == "--profile": + return _filter(_list_dotfiles_profiles(), current) + if current.startswith("-"): + return _filter(["--profile", "--dry-run", "--skip"], current) + return _filter(_list_dotfiles_packages(_profile_from_before(before)), current) + + if subcommand == "unlink": + if current.startswith("-"): + return _filter(["--dry-run"], current) + return _filter(_list_dotfiles_packages(), current) + + if subcommand == "sync": + if before and before[-1] == "--profile": + return _filter(_list_dotfiles_profiles(), current) + if current.startswith("-"): + return _filter(["--relink", "--profile"], current) + return [] + + if subcommand == "clean": + return _filter(["--dry-run"], current) if current.startswith("-") else [] + + if subcommand == "edit": + return _filter(_list_dotfiles_packages(), current) if not current.startswith("-") else [] + + if subcommand in {"repo", "repos"}: + if len(before) <= 2: + return _filter(["status", "pull", "push"], current) + repo_subcommand = before[2] + if repo_subcommand == "pull": + if before and before[-1] == "--profile": + return _filter(_list_dotfiles_profiles(), current) + if current.startswith("-"): + return _filter(["--rebase", "--no-rebase", "--relink", "--profile"], current) + return [] + + if subcommand == "modules": + if len(before) <= 2: + return _filter(["list", "sync"], current) + if before and before[-1] == "--profile": + return _filter(_list_dotfiles_profiles(), current) + if current.startswith("-"): + return _filter(["--profile"], current) + return _filter(_list_dotfiles_packages(_profile_from_before(before)), current) + + return [] + + +def _complete_bootstrap(before: Sequence[str], current: str) -> list[str]: + if len(before) <= 1: + return _filter(["run", "show", "list"], current) + + subcommand = before[1] + if subcommand == "run": + if before and before[-1] == "--profile": + return _filter(_list_bootstrap_profiles(), current) + if current.startswith("-"): + return _filter(["--profile", "--dry-run", "--var"], current) + return _filter(_list_bootstrap_profiles(), current) + + if subcommand == "show": + return _filter(_list_bootstrap_profiles(), current) if not current.startswith("-") else [] + + return [] + + +def _complete_packages(before: Sequence[str], current: str) -> list[str]: + if len(before) <= 1: + return _filter(["install", "remove", "list"], current) + + subcommand = before[1] + if subcommand == "install": + if before and before[-1] == "--profile": + return _filter(_list_bootstrap_profiles(), current) + if current.startswith("-"): + return _filter(["--profile", "--dry-run"], current) + return _filter(_list_manifest_packages(), current) + + if subcommand == "remove": + if current.startswith("-"): + return [] + return _filter(_list_installed_packages(), current) + + if subcommand == "list": + return _filter(["--all"], current) if current.startswith("-") else [] + + return [] + + +def _complete_projects(before: Sequence[str], current: str) -> list[str]: + if len(before) <= 1: + return _filter(["check", "fetch", "summary"], current) + if before[1] == "check" and current.startswith("-"): + return _filter(["--fetch", "--no-fetch"], current) + return [] + + +def _complete_completion(before: Sequence[str], current: str) -> list[str]: + if len(before) <= 1: + return _filter(["zsh", "install-zsh"], current) + if before[1] == "install-zsh" and current.startswith("-"): + return _filter(["--dir", "--rc", "--no-rc"], current) + return [] + + +def _run_zsh_complete(_ctx, args): + for item in complete(args.words, args.cword): + print(item) + + +def _zsh_script_text() -> str: + return r'''#compdef flow + +_flow() { + local -a suggestions + suggestions=("${(@f)$(flow completion _zsh_complete --cword "$CURRENT" -- "${words[@]}" 2>/dev/null)}") + + if (( ${#suggestions[@]} > 0 )); then + compadd -Q -- "${suggestions[@]}" + return 0 + fi + + if [[ "$words[CURRENT]" == */* || "$words[CURRENT]" == ./* || "$words[CURRENT]" == ~* ]]; then + _files + return 0 + fi + + return 1 +} + +compdef _flow flow +''' + + +def _run_zsh_script(_ctx, _args): + print(_zsh_script_text()) + + +def _run_install_zsh(_ctx, args): + completions_dir = Path(args.dir).expanduser() + completions_dir.mkdir(parents=True, exist_ok=True) + + completion_file = completions_dir / "_flow" + completion_file.write_text(_zsh_script_text(), encoding="utf-8") + print(f"Installed completion script: {completion_file}") + + if args.no_rc: + print("Skipped rc file update (--no-rc)") + return + + rc_path = Path(args.rc).expanduser() + changed = _ensure_rc_snippet(rc_path, completions_dir) + if changed: + print(f"Updated shell rc: {rc_path}") + else: + print(f"Shell rc already configured: {rc_path}") + + +def _ensure_rc_snippet(rc_path: Path, completions_dir: Path) -> bool: + snippet = _zsh_rc_snippet(completions_dir) + content = rc_path.read_text(encoding="utf-8") if rc_path.exists() else "" + + if ZSH_RC_START in content and ZSH_RC_END in content: + start = content.find(ZSH_RC_START) + end = content.find(ZSH_RC_END, start) + len(ZSH_RC_END) + updated = content[:start] + snippet.rstrip("\n") + content[end:] + if updated == content: + return False + rc_path.write_text(updated, encoding="utf-8") + return True + + separator = "" if content.endswith("\n") or not content else "\n" + rc_path.parent.mkdir(parents=True, exist_ok=True) + rc_path.write_text(content + separator + snippet, encoding="utf-8") + return True + + +def _zsh_rc_snippet(completions_dir: Path) -> str: + return ( + f"{ZSH_RC_START}\n" + f"fpath=({_zsh_dir_for_rc(completions_dir)} $fpath)\n" + "autoload -Uz compinit && compinit\n" + f"{ZSH_RC_END}\n" + ) + + +def _zsh_dir_for_rc(path: Path) -> str: + home = Path.home().resolve() + resolved = path.expanduser().resolve() + try: + rel = resolved.relative_to(home) + return f"~/{rel}" if str(rel) != "." else "~" + except ValueError: + return str(resolved) diff --git a/src/flow/commands/dev.py b/src/flow/commands/dev.py index 7bfdfc3..4d78e26 100644 --- a/src/flow/commands/dev.py +++ b/src/flow/commands/dev.py @@ -1,5 +1,7 @@ """Dev container commands.""" +from __future__ import annotations + from flow.core.config import FlowContext from flow.services.containers import ContainerService @@ -8,26 +10,41 @@ def register(subparsers): p = subparsers.add_parser("dev", help="Manage development containers") sub = p.add_subparsers(dest="dev_action") - create = sub.add_parser("create", help="Create a container") - create.add_argument("image", help="Container image") - create.add_argument("--namespace", "-n", default="default") + 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="Project path to mount at /workspace") create.add_argument("--dry-run", action="store_true") create.set_defaults(handler=_create) - enter = sub.add_parser("enter", help="Enter a running container") + attach = sub.add_parser("attach", aliases=["connect"], help="Attach to the container tmux session") + attach.add_argument("name", help="Container name") + attach.set_defaults(handler=_attach) + + exec_cmd = sub.add_parser("exec", help="Execute a command in a container") + exec_cmd.add_argument("name", help="Container name") + exec_cmd.add_argument("cmd", nargs="*", help="Command to run") + exec_cmd.set_defaults(handler=_exec) + + enter = sub.add_parser("enter", help="Open an interactive shell in a container") enter.add_argument("name", help="Container name") - enter.add_argument("--shell", default="/bin/bash") enter.set_defaults(handler=_enter) stop = sub.add_parser("stop", help="Stop a 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=_stop) - rm = sub.add_parser("remove", help="Remove a container") + rm = sub.add_parser("remove", aliases=["rm"], help="Remove a container") rm.add_argument("name", help="Container name") + rm.add_argument("-f", "--force", action="store_true", help="Force removal") rm.set_defaults(handler=_remove) - ls = sub.add_parser("list", help="List flow containers") + respawn = sub.add_parser("respawn", help="Respawn tmux panes for a session") + respawn.add_argument("name", help="Container name") + respawn.set_defaults(handler=_respawn) + + ls = sub.add_parser("list", help="List development containers") ls.set_defaults(handler=_list) p.set_defaults(handler=_default) @@ -38,25 +55,37 @@ def _default(ctx: FlowContext, args): def _create(ctx: FlowContext, args): - svc = ContainerService(ctx) - svc.create(args.image, args.namespace, dry_run=args.dry_run) + ContainerService(ctx).create( + args.name, + args.image, + project_path=args.project, + dry_run=args.dry_run, + ) + + +def _attach(ctx: FlowContext, args): + ContainerService(ctx).connect(args.name) + + +def _exec(ctx: FlowContext, args): + ContainerService(ctx).exec(args.name, args.cmd or None) def _enter(ctx: FlowContext, args): - svc = ContainerService(ctx) - svc.enter(args.name, shell=args.shell) + ContainerService(ctx).exec(args.name) def _stop(ctx: FlowContext, args): - svc = ContainerService(ctx) - svc.stop(args.name) + ContainerService(ctx).stop(args.name, kill=args.kill) def _remove(ctx: FlowContext, args): - svc = ContainerService(ctx) - svc.remove(args.name) + ContainerService(ctx).remove(args.name, force=args.force) + + +def _respawn(ctx: FlowContext, args): + ContainerService(ctx).respawn(args.name) def _list(ctx: FlowContext, args): - svc = ContainerService(ctx) - svc.list() + ContainerService(ctx).list() diff --git a/src/flow/commands/dotfiles.py b/src/flow/commands/dotfiles.py index 3eb62b4..ef09f31 100644 --- a/src/flow/commands/dotfiles.py +++ b/src/flow/commands/dotfiles.py @@ -1,30 +1,85 @@ """Dotfiles commands.""" +from __future__ import annotations + from flow.core.config import FlowContext from flow.services.dotfiles import DotfilesService def register(subparsers): - p = subparsers.add_parser("dotfiles", help="Manage dotfile symlinks") + p = subparsers.add_parser("dotfiles", aliases=["dot"], help="Manage dotfile symlinks") sub = p.add_subparsers(dest="dotfiles_action") + init = sub.add_parser("init", help="Clone the dotfiles repository") + init.add_argument("--repo", help="Override the configured repository URL") + init.set_defaults(handler=_init) + link = sub.add_parser("link", help="Link dotfiles to home") link.add_argument("--profile", help="Profile to include") link.add_argument("--dry-run", "-n", action="store_true") link.add_argument("--skip", nargs="*", default=[]) link.set_defaults(handler=_link) + relink = sub.add_parser("relink", help="Refresh managed symlinks") + relink.add_argument("--profile", help="Profile to include") + relink.set_defaults(handler=_relink) + unlink = sub.add_parser("unlink", help="Remove managed symlinks") unlink.add_argument("packages", nargs="*", help="Packages to unlink (all if empty)") unlink.add_argument("--dry-run", "-n", action="store_true") unlink.set_defaults(handler=_unlink) + undo = sub.add_parser("undo", help="Restore the previous linked state") + undo.set_defaults(handler=_undo) + status = sub.add_parser("status", help="Show link status") status.set_defaults(handler=_status) + clean = sub.add_parser("clean", help="Remove broken symlinks") + clean.add_argument("--dry-run", action="store_true") + clean.set_defaults(handler=_clean) + sync = sub.add_parser("sync", help="Pull dotfiles and sync modules") + sync.add_argument("--relink", action="store_true") + sync.add_argument("--profile", help="Profile to relink after syncing") sync.set_defaults(handler=_sync) + modules = sub.add_parser("modules", help="Inspect and refresh external modules") + modules_sub = modules.add_subparsers(dest="dotfiles_modules_action") + + modules_list = modules_sub.add_parser("list", help="List module packages") + modules_list.add_argument("--profile", help="Limit to shared + one profile") + modules_list.set_defaults(handler=_modules_list) + + modules_sync = modules_sub.add_parser("sync", help="Refresh module repositories") + modules_sync.add_argument("--profile", help="Limit to shared + one profile") + modules_sync.set_defaults(handler=_modules_sync) + + modules.set_defaults(handler=_modules_list) + + repo = sub.add_parser("repo", aliases=["repos"], help="Manage the dotfiles repository") + repo_sub = repo.add_subparsers(dest="dotfiles_repo_action") + + repo_status = repo_sub.add_parser("status", help="Show git status") + repo_status.set_defaults(handler=_repo_status) + + repo_pull = repo_sub.add_parser("pull", help="Pull latest changes") + repo_pull.add_argument("--rebase", dest="rebase", action="store_true") + repo_pull.add_argument("--no-rebase", dest="rebase", action="store_false") + repo_pull.add_argument("--relink", action="store_true") + repo_pull.add_argument("--profile", help="Profile to relink after pulling") + repo_pull.set_defaults(rebase=True) + repo_pull.set_defaults(handler=_repo_pull) + + repo_push = repo_sub.add_parser("push", help="Push local changes") + repo_push.set_defaults(handler=_repo_push) + + repo.set_defaults(handler=_repo_status) + + edit = sub.add_parser("edit", help="Show the package directory") + edit.add_argument("package", help="Package name") + edit.set_defaults(handler=_edit) + p.set_defaults(handler=_default) @@ -32,28 +87,68 @@ def _default(ctx: FlowContext, args): _status(ctx, args) +def _init(ctx: FlowContext, args): + DotfilesService(ctx).init(repo_url=args.repo) + + def _link(ctx: FlowContext, args): - svc = DotfilesService(ctx) - svc.link( + DotfilesService(ctx).link( profile=args.profile, dry_run=args.dry_run, skip=set(args.skip) if args.skip else None, ) +def _relink(ctx: FlowContext, args): + DotfilesService(ctx).relink(profile=args.profile) + + def _unlink(ctx: FlowContext, args): - svc = DotfilesService(ctx) - svc.unlink( + DotfilesService(ctx).unlink( packages=args.packages if args.packages else None, dry_run=args.dry_run, ) +def _undo(ctx: FlowContext, args): + DotfilesService(ctx).undo() + + def _status(ctx: FlowContext, args): - svc = DotfilesService(ctx) - svc.status() + DotfilesService(ctx).status() + + +def _clean(ctx: FlowContext, args): + DotfilesService(ctx).clean(dry_run=args.dry_run) def _sync(ctx: FlowContext, args): - svc = DotfilesService(ctx) - svc.sync() + DotfilesService(ctx).sync(profile=args.profile, relink=args.relink) + + +def _modules_list(ctx: FlowContext, args): + DotfilesService(ctx).list_modules(profile=getattr(args, "profile", None)) + + +def _modules_sync(ctx: FlowContext, args): + DotfilesService(ctx).sync_modules(profile=args.profile) + + +def _repo_status(ctx: FlowContext, args): + DotfilesService(ctx).repo_status() + + +def _repo_pull(ctx: FlowContext, args): + DotfilesService(ctx).repo_pull( + profile=args.profile, + relink=args.relink, + rebase=args.rebase, + ) + + +def _repo_push(ctx: FlowContext, args): + DotfilesService(ctx).repo_push() + + +def _edit(ctx: FlowContext, args): + DotfilesService(ctx).edit(args.package) diff --git a/src/flow/commands/packages.py b/src/flow/commands/packages.py index c90411f..2d87407 100644 --- a/src/flow/commands/packages.py +++ b/src/flow/commands/packages.py @@ -5,7 +5,7 @@ from flow.services.packages import PackageService def register(subparsers): - p = subparsers.add_parser("packages", help="Manage packages") + p = subparsers.add_parser("packages", aliases=["package", "pkg"], help="Manage packages") sub = p.add_subparsers(dest="packages_action") install = sub.add_parser("install", help="Install packages") @@ -20,6 +20,7 @@ def register(subparsers): remove.set_defaults(handler=_remove) ls = sub.add_parser("list", help="List installed packages") + ls.add_argument("--all", action="store_true", help="List all known packages") ls.set_defaults(handler=_list) p.set_defaults(handler=_default) @@ -31,11 +32,11 @@ def _default(ctx: FlowContext, args): def _install(ctx: FlowContext, args): svc = PackageService(ctx) - svc.install( + packages = svc.resolve_install_packages( package_names=args.packages if args.packages else None, profile=args.profile, - dry_run=args.dry_run, ) + svc.install(packages, dry_run=args.dry_run) def _remove(ctx: FlowContext, args): @@ -45,4 +46,4 @@ def _remove(ctx: FlowContext, args): def _list(ctx: FlowContext, args): svc = PackageService(ctx) - svc.list_packages() + svc.list_packages(show_all=args.all) diff --git a/src/flow/commands/projects.py b/src/flow/commands/projects.py index 454036d..dfc0bbe 100644 --- a/src/flow/commands/projects.py +++ b/src/flow/commands/projects.py @@ -5,14 +5,8 @@ from flow.services.projects import ProjectService def register(subparsers): - p = subparsers.add_parser("projects", help="Manage git projects") - sub = p.add_subparsers(dest="projects_action") - - check = sub.add_parser("check", help="Check project status") - check.add_argument("--fetch", "-f", action="store_true", help="Fetch remotes first") - check.set_defaults(handler=_check) - - p.set_defaults(handler=_default) + _register_projects_parser(subparsers, "projects", default_fetch=False, aliases=["project"]) + _register_projects_parser(subparsers, "sync", default_fetch=True) def _default(ctx: FlowContext, args): @@ -22,3 +16,33 @@ def _default(ctx: FlowContext, args): def _check(ctx: FlowContext, args): svc = ProjectService(ctx) svc.check(fetch=getattr(args, "fetch", False)) + + +def _fetch(ctx: FlowContext, args): + svc = ProjectService(ctx) + svc.fetch() + + +def _summary(ctx: FlowContext, args): + svc = ProjectService(ctx) + svc.summary() + + +def _register_projects_parser(subparsers, name: str, *, default_fetch: bool, aliases=None): + parser = subparsers.add_parser(name, aliases=aliases or [], help="Manage git projects") + sub = parser.add_subparsers(dest=f"{name}_action") + + check = sub.add_parser("check", help="Check project status") + check.add_argument("--fetch", dest="fetch", action="store_true", help="Fetch remotes first") + if default_fetch: + check.add_argument("--no-fetch", dest="fetch", action="store_false", help="Skip fetching remotes") + check.set_defaults(fetch=default_fetch) + check.set_defaults(handler=_check) + + fetch = sub.add_parser("fetch", help="Fetch all project remotes") + fetch.set_defaults(handler=_fetch) + + summary = sub.add_parser("summary", help="Show a summary without fetching") + summary.set_defaults(handler=_summary) + + parser.set_defaults(handler=_default) diff --git a/src/flow/commands/remote.py b/src/flow/commands/remote.py index 6eb43aa..409bc2d 100644 --- a/src/flow/commands/remote.py +++ b/src/flow/commands/remote.py @@ -1,5 +1,7 @@ """Remote commands.""" +from __future__ import annotations + from flow.core.config import FlowContext from flow.services.remote import RemoteService @@ -9,8 +11,7 @@ def register(subparsers): sub = p.add_subparsers(dest="remote_action") enter = sub.add_parser("enter", help="SSH into a target") - enter.add_argument("target", help="Target (namespace@platform)") - enter.add_argument("--dry-run", "-n", action="store_true") + _add_enter_args(enter) enter.set_defaults(handler=_enter) ls = sub.add_parser("list", help="List configured targets") @@ -18,6 +19,10 @@ def register(subparsers): p.set_defaults(handler=_default) + alias = subparsers.add_parser("enter", help="SSH into a target") + _add_enter_args(alias) + alias.set_defaults(handler=_enter) + def _default(ctx: FlowContext, args): _list(ctx, args) @@ -25,9 +30,27 @@ def _default(ctx: FlowContext, args): def _enter(ctx: FlowContext, args): svc = RemoteService(ctx) - svc.enter(args.target, dry_run=args.dry_run) + svc.enter( + args.target, + user=args.user, + namespace=args.namespace, + platform=args.platform, + session=args.session, + no_tmux=args.no_tmux, + dry_run=args.dry_run, + ) def _list(ctx: FlowContext, args): svc = RemoteService(ctx) svc.list() + + +def _add_enter_args(parser) -> None: + parser.add_argument("target", help="Target ([user@]namespace@platform)") + parser.add_argument("-u", "--user", help="SSH user override") + parser.add_argument("-n", "--namespace", help="Namespace override") + parser.add_argument("-p", "--platform", help="Platform override") + parser.add_argument("-s", "--session", help="tmux session name") + parser.add_argument("--no-tmux", action="store_true", help="Open plain SSH without tmux") + parser.add_argument("--dry-run", "-d", action="store_true") diff --git a/src/flow/commands/setup.py b/src/flow/commands/setup.py index bdcd44c..60789e3 100644 --- a/src/flow/commands/setup.py +++ b/src/flow/commands/setup.py @@ -1,16 +1,25 @@ """Setup/bootstrap commands.""" +from __future__ import annotations + from flow.core.config import FlowContext +from flow.core.errors import FlowError from flow.services.bootstrap import BootstrapService def register(subparsers): - p = subparsers.add_parser("setup", help="Bootstrap a system profile") + p = subparsers.add_parser( + "setup", + aliases=["bootstrap", "provision"], + help="Bootstrap a system profile", + ) sub = p.add_subparsers(dest="setup_action") run = sub.add_parser("run", help="Run bootstrap for a profile") - run.add_argument("profile", help="Profile name") + run.add_argument("profile", nargs="?", help="Profile name") + run.add_argument("--profile", dest="profile_option", help="Profile name") run.add_argument("--dry-run", "-n", action="store_true") + run.add_argument("--var", action="append", default=[], help="Set variable KEY=VALUE") run.set_defaults(handler=_run) show = sub.add_parser("show", help="Show bootstrap plan") @@ -29,7 +38,8 @@ def _default(ctx: FlowContext, args): def _run(ctx: FlowContext, args): svc = BootstrapService(ctx) - svc.run(args.profile, dry_run=args.dry_run) + env = _parse_vars(args.var) + svc.run(_profile_arg(args), dry_run=args.dry_run, env=env) def _show(ctx: FlowContext, args): @@ -40,3 +50,21 @@ def _show(ctx: FlowContext, args): def _list(ctx: FlowContext, args): svc = BootstrapService(ctx) svc.list_profiles() + + +def _profile_arg(args) -> str | None: + if args.profile and args.profile_option and args.profile != args.profile_option: + raise FlowError("Specify the profile only once.") + return args.profile or args.profile_option + + +def _parse_vars(items: list[str]) -> dict[str, str]: + values: dict[str, str] = {} + for item in items: + if "=" not in item: + raise FlowError(f"Invalid --var value '{item}'. Expected KEY=VALUE.") + key, value = item.split("=", 1) + if not key: + raise FlowError(f"Invalid --var value '{item}'. KEY cannot be empty.") + values[key] = value + return values diff --git a/src/flow/core/config.py b/src/flow/core/config.py index 095314c..1094f70 100644 --- a/src/flow/core/config.py +++ b/src/flow/core/config.py @@ -8,7 +8,9 @@ from typing import Any, Optional import yaml +from flow.core import paths from flow.core.console import Console +from flow.core.errors import ConfigError from flow.core.platform import PlatformInfo from flow.core.runtime import SystemRuntime @@ -25,13 +27,12 @@ class TargetConfig: class AppConfig: dotfiles_url: str = "" dotfiles_branch: str = "main" + dotfiles_pull_before_edit: bool = True projects_dir: str = "~/projects" container_registry: str = "registry.tomastm.com" container_tag: str = "latest" tmux_session: str = "default" targets: list[TargetConfig] = field(default_factory=list) - # Tracks which fields were explicitly set in config (not defaults) - _explicit: set[str] = field(default_factory=set, repr=False, compare=False) @dataclass @@ -48,122 +49,281 @@ def _load_yaml_file(path: Path) -> dict[str, Any]: with open(path, "r", encoding="utf-8") as handle: data = yaml.safe_load(handle) except yaml.YAMLError as e: - raise RuntimeError(f"Invalid YAML in {path}: {e}") from e + raise ConfigError(f"Invalid YAML in {path}: {e}") from e if data is None: return {} if not isinstance(data, dict): - raise RuntimeError(f"YAML file must contain a mapping at root: {path}") + raise ConfigError(f"YAML file must contain a mapping at root: {path}") return data -def _parse_targets(raw: Any) -> list[TargetConfig]: - from flow.core.errors import ConfigError +def _merge_yaml_values(base: Any, overlay: Any) -> Any: + if isinstance(base, dict) and isinstance(overlay, dict): + merged = dict(base) + for key, value in overlay.items(): + if key in merged: + merged[key] = _merge_yaml_values(merged[key], value) + else: + merged[key] = value + return merged - if not isinstance(raw, dict): + if isinstance(base, list) and isinstance(overlay, list): + return [*base, *overlay] + + return overlay + + +def _list_yaml_files(directory: Path) -> list[Path]: + if not directory.exists() or not directory.is_dir(): return [] - targets: list[TargetConfig] = [] - for key, value in raw.items(): - if "@" not in key: - raise ConfigError(f"Invalid target key '{key}': expected 'namespace@platform'") + return sorted( + ( + child for child in directory.iterdir() + if child.is_file() and child.suffix.lower() in {".yaml", ".yml"} + ), + key=lambda child: child.name, + ) + + +def _load_yaml_source(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + + if path.is_file(): + return _load_yaml_file(path) + + merged: dict[str, Any] = {} + for file_path in _list_yaml_files(path): + merged = _merge_yaml_values(merged, _load_yaml_file(file_path)) + return merged + + +def _load_yaml_documents(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + if path.is_file(): + return [_load_yaml_file(path)] + return [_load_yaml_file(file_path) for file_path in _list_yaml_files(path)] + + +def _load_yaml_sources(*source_paths: Path) -> dict[str, Any]: + merged: dict[str, Any] = {} + for path in source_paths: + merged = _merge_yaml_values(merged, _load_yaml_source(path)) + return merged + + +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off"}: + return False + raise ConfigError(f"Expected boolean value, got {value!r}") + + +def _parse_target_shorthand(key: str, value: str) -> TargetConfig: + parts = value.split() + if not parts: + raise ConfigError(f"Target '{key}' must define a host") + + if "@" in key: namespace, platform = key.split("@", 1) if not namespace or not platform: - raise ConfigError(f"Invalid target key '{key}': both namespace and platform required") + raise ConfigError(f"Invalid target key '{key}'") + return TargetConfig( + namespace=namespace, + platform=platform, + host=parts[0], + identity=parts[1] if len(parts) > 1 else None, + ) - if isinstance(value, str): - targets.append(TargetConfig( - namespace=namespace, - platform=platform, - host=value, - )) - elif isinstance(value, dict): - host = value.get("host") - if not host: - raise ConfigError(f"Target '{key}': 'host' is required") - identity = value.get("identity") - targets.append(TargetConfig( - namespace=namespace, - platform=platform, - host=str(host), - identity=str(identity) if identity is not None else None, - )) - else: - raise ConfigError(f"Target '{key}': value must be a string or mapping") + if len(parts) < 2: + raise ConfigError( + f"Invalid target value for '{key}': expected 'platform host [identity]'" + ) + + return TargetConfig( + namespace=key, + platform=parts[0], + host=parts[1], + identity=parts[2] if len(parts) > 2 else None, + ) + + +def _parse_targets(raw: Any) -> list[TargetConfig]: + targets: list[TargetConfig] = [] + + if raw is None: + return targets + + if isinstance(raw, dict): + for key, value in raw.items(): + if isinstance(value, str): + targets.append(_parse_target_shorthand(key, value)) + continue + + if not isinstance(value, dict): + raise ConfigError(f"Target '{key}': value must be a string or mapping") + + if "@" in key: + namespace, platform = key.split("@", 1) + else: + namespace = key + platform = value.get("platform") + + host = value.get("host", value.get("ssh-host", value.get("ssh_host"))) + if not namespace or not platform or not host: + raise ConfigError( + f"Target '{key}' must define namespace, platform, and host" + ) + identity = value.get("identity", value.get("ssh-identity", value.get("ssh_identity"))) + targets.append( + TargetConfig( + namespace=str(namespace), + platform=str(platform), + host=str(host), + identity=str(identity) if identity is not None else None, + ) + ) + return targets + + if isinstance(raw, list): + for item in raw: + if not isinstance(item, dict): + raise ConfigError("Target list entries must be mappings") + namespace = item.get("namespace") + platform = item.get("platform") + host = item.get("host", item.get("ssh-host", item.get("ssh_host"))) + if not namespace or not platform or not host: + raise ConfigError( + "Target list entries must define namespace, platform, and host" + ) + identity = item.get("identity", item.get("ssh-identity", item.get("ssh_identity"))) + targets.append( + TargetConfig( + namespace=str(namespace), + platform=str(platform), + host=str(host), + identity=str(identity) if identity is not None else None, + ) + ) + return targets + + raise ConfigError("Targets must be a mapping or list of mappings") return targets -def load_config(config_dir: Path) -> AppConfig: - """Load config.yaml from the given directory into AppConfig.""" - config_file = config_dir / "config.yaml" - if not config_file.exists(): - return AppConfig() +def load_config( + config_dir: Optional[Path] = None, + overlay_dir: Optional[Path] = None, +) -> AppConfig: + """Load config into AppConfig.""" + if config_dir is None and overlay_dir is None: + source_paths = (paths.CONFIG_DIR, paths.DOTFILES_FLOW_CONFIG) + elif overlay_dir is None: + source_paths = (config_dir,) + elif config_dir is None: + source_paths = (paths.CONFIG_DIR, overlay_dir) + else: + source_paths = (config_dir, overlay_dir) - data = _load_yaml_file(config_file) - - cfg = AppConfig() - explicit: set[str] = set() + loaded_sources = [ + source + for path in source_paths + for source in _load_yaml_documents(path) + ] + data: dict[str, Any] = {} + for source in loaded_sources: + data = _merge_yaml_values(data, source) repository = data.get("repository") - if isinstance(repository, dict): - url = repository.get("url") - if url is not None: - cfg.dotfiles_url = str(url) - explicit.add("dotfiles_url") - branch = repository.get("branch") - if branch is not None: - cfg.dotfiles_branch = str(branch) - explicit.add("dotfiles_branch") - paths_section = data.get("paths") - if isinstance(paths_section, dict): - projects = paths_section.get("projects") - if projects is not None: - cfg.projects_dir = str(projects) - explicit.add("projects_dir") - defaults = data.get("defaults") - if isinstance(defaults, dict): - registry = defaults.get("container-registry") - if registry is not None: - cfg.container_registry = str(registry) - explicit.add("container_registry") - tag = defaults.get("container-tag") - if tag is not None: - cfg.container_tag = str(tag) - explicit.add("container_tag") - tmux = defaults.get("tmux-session") - if tmux is not None: - cfg.tmux_session = str(tmux) - explicit.add("tmux_session") - raw_targets = data.get("targets") - if raw_targets is not None: - cfg.targets = _parse_targets(raw_targets) - explicit.add("targets") - - cfg._explicit = explicit - return cfg - - -def load_manifest(manifest_dir: Path) -> dict[str, Any]: - """Load manifest.yaml or merge all *.yaml files from the directory.""" - if not manifest_dir.exists(): - return {} - - manifest_file = manifest_dir / "manifest.yaml" - if manifest_file.exists(): - return _load_yaml_file(manifest_file) - - merged: dict[str, Any] = {} - yaml_files = sorted( - (f for f in manifest_dir.iterdir() if f.is_file() and f.suffix in {".yaml", ".yml"}), - key=lambda p: p.name, + return AppConfig( + dotfiles_url=( + str(repository["url"]) + if isinstance(repository, dict) and "url" in repository + else str(data["dotfiles_url"]) if "dotfiles_url" in data + else "" + ), + dotfiles_branch=( + str(repository["branch"]) + if isinstance(repository, dict) and "branch" in repository + else str(data["dotfiles_branch"]) if "dotfiles_branch" in data + else "main" + ), + dotfiles_pull_before_edit=( + _as_bool(repository["pull-before-edit"]) + if isinstance(repository, dict) and "pull-before-edit" in repository + else _as_bool(repository["pull_before_edit"]) + if isinstance(repository, dict) and "pull_before_edit" in repository + else _as_bool(data["dotfiles_pull_before_edit"]) + if "dotfiles_pull_before_edit" in data + else True + ), + projects_dir=( + str(paths_section["projects"]) + if isinstance(paths_section, dict) and "projects" in paths_section + else str(paths_section["projects_dir"]) + if isinstance(paths_section, dict) and "projects_dir" in paths_section + else str(data["projects_dir"]) if "projects_dir" in data + else "~/projects" + ), + container_registry=( + str(defaults["container-registry"]) + if isinstance(defaults, dict) and "container-registry" in defaults + else str(defaults["container_registry"]) + if isinstance(defaults, dict) and "container_registry" in defaults + else str(data["container_registry"]) if "container_registry" in data + else "registry.tomastm.com" + ), + container_tag=( + str(defaults["container-tag"]) + if isinstance(defaults, dict) and "container-tag" in defaults + else str(defaults["container_tag"]) + if isinstance(defaults, dict) and "container_tag" in defaults + else str(data["container_tag"]) if "container_tag" in data + else "latest" + ), + tmux_session=( + str(defaults["tmux-session"]) + if isinstance(defaults, dict) and "tmux-session" in defaults + else str(defaults["tmux_session"]) + if isinstance(defaults, dict) and "tmux_session" in defaults + else str(data["tmux_session"]) if "tmux_session" in data + else "default" + ), + targets=[ + target + for source in loaded_sources + if "targets" in source + for target in _parse_targets(source["targets"]) + ] if any("targets" in source for source in loaded_sources) else [], ) - for path in yaml_files: - merged.update(_load_yaml_file(path)) - return merged + +def load_manifest( + config_dir: Optional[Path] = None, + overlay_dir: Optional[Path] = None, +) -> dict[str, Any]: + """Load merged manifest YAML.""" + if config_dir is None and overlay_dir is None: + source_paths = (paths.CONFIG_DIR, paths.DOTFILES_FLOW_CONFIG) + elif overlay_dir is None: + source_paths = (config_dir,) + elif config_dir is None: + source_paths = (paths.CONFIG_DIR, overlay_dir) + else: + source_paths = (config_dir, overlay_dir) + + return _load_yaml_sources(*source_paths) diff --git a/src/flow/domain/bootstrap/models.py b/src/flow/domain/bootstrap/models.py index 51ba086..36b54da 100644 --- a/src/flow/domain/bootstrap/models.py +++ b/src/flow/domain/bootstrap/models.py @@ -17,6 +17,8 @@ class Profile: runcmd: tuple[str, ...] packages: tuple[Any, ...] # Raw entries, resolved later env_required: tuple[str, ...] + dotfiles_profile: Optional[str] = None + post_link: Optional[str] = None @dataclass(frozen=True) diff --git a/src/flow/domain/bootstrap/planning.py b/src/flow/domain/bootstrap/planning.py index d4d6213..729ebeb 100644 --- a/src/flow/domain/bootstrap/planning.py +++ b/src/flow/domain/bootstrap/planning.py @@ -1,7 +1,7 @@ """Bootstrap planning -- builds ordered action list.""" import os -from typing import Any, Optional +from typing import Any, Mapping, Optional from flow.core.errors import ConfigError from flow.domain.bootstrap.models import BootstrapAction, BootstrapPlan, Profile @@ -17,8 +17,35 @@ from flow.domain.packages.models import PackageDef from flow.domain.packages.resolution import resolve_spec +def _normalize_ssh_keys(raw: Any) -> tuple[dict[str, str], ...]: + if not raw: + return () + if not isinstance(raw, list): + raise ConfigError("Profile SSH key definitions must be a list") + + keys: list[dict[str, str]] = [] + for entry in raw: + if not isinstance(entry, dict): + raise ConfigError("SSH key entries must be mappings") + normalized = dict(entry) + if "path" not in normalized: + filename = normalized.get("filename") + key_type = normalized.get("type", "ed25519") + normalized["path"] = f"~/.ssh/{filename or f'id_{key_type}'}" + keys.append(normalized) + return tuple(keys) + + def parse_profile(name: str, raw: dict[str, Any]) -> Profile: """Parse a profile definition from manifest.""" + ssh_keys = raw.get("ssh-keys") or raw.get("ssh_keys") + if ssh_keys is None: + ssh_keys = raw.get("ssh-keygen") or raw.get("ssh_keygen") + + env_required = raw.get("env-required") or raw.get("env_required") + if env_required is None: + env_required = raw.get("requires") + return Profile( name=name, os=raw.get("os", "linux"), @@ -26,22 +53,27 @@ def parse_profile(name: str, raw: dict[str, Any]) -> Profile: hostname=raw.get("hostname"), locale=raw.get("locale"), shell=raw.get("shell"), - ssh_keys=tuple(raw.get("ssh-keys") or raw.get("ssh_keys") or []), + ssh_keys=_normalize_ssh_keys(ssh_keys), runcmd=tuple(raw.get("runcmd") or []), packages=tuple(raw.get("packages") or []), - env_required=tuple(raw.get("env-required") or raw.get("env_required") or []), + env_required=tuple(env_required or []), + dotfiles_profile=raw.get("dotfiles-profile") or raw.get("dotfiles_profile"), + post_link=raw.get("post-link") or raw.get("post_link"), ) def plan_bootstrap( profile: Profile, manifest: dict[str, Any], + *, + env: Optional[Mapping[str, str]] = None, ) -> BootstrapPlan: """Build a complete bootstrap plan from a profile.""" actions: list[BootstrapAction] = [] + environment = env or os.environ # Phase 1: Validate required env vars - missing = [v for v in profile.env_required if not os.environ.get(v)] + missing = [v for v in profile.env_required if not environment.get(v)] if missing: raise ConfigError( f"Missing required environment variables for profile '{profile.name}': " @@ -109,6 +141,13 @@ def plan_bootstrap( commands=(), # Executed by DotfilesService )) + if profile.post_link: + actions.append(BootstrapAction( + phase="post-link", + description="Run post-link commands", + commands=(profile.post_link,), + )) + return BootstrapPlan( profile=profile.name, actions=tuple(actions), diff --git a/src/flow/domain/containers/models.py b/src/flow/domain/containers/models.py index 543b6e0..cd36c43 100644 --- a/src/flow/domain/containers/models.py +++ b/src/flow/domain/containers/models.py @@ -9,12 +9,17 @@ from typing import Optional class ImageRef: """A container image reference.""" registry: str - name: str + repo: str tag: str + label: str @property def full(self) -> str: - return f"{self.registry}/{self.name}:{self.tag}" + return f"{self.registry}/{self.repo}:{self.tag}" + + @property + def name(self) -> str: + return self.repo @dataclass(frozen=True) @@ -35,6 +40,6 @@ class ContainerSpec: name: str image: ImageRef mounts: tuple[Mount, ...] - env: dict[str, str] = field(default_factory=dict) - extra_flags: tuple[str, ...] = () - command: Optional[str] = None + project_path: Optional[Path] = None + labels: dict[str, str] = field(default_factory=dict) + network: str = "host" diff --git a/src/flow/domain/containers/resolution.py b/src/flow/domain/containers/resolution.py index 621f1ad..b526f6f 100644 --- a/src/flow/domain/containers/resolution.py +++ b/src/flow/domain/containers/resolution.py @@ -3,7 +3,6 @@ from pathlib import Path from typing import Optional -from flow.core.errors import FlowError from flow.domain.containers.models import ContainerSpec, ImageRef, Mount @@ -12,89 +11,97 @@ def parse_image_ref( default_registry: str = "registry.tomastm.com", default_tag: str = "latest", ) -> ImageRef: - """Parse image string into ImageRef.""" - # Handle full registry/name:tag - if ":" in image: - base, tag = image.rsplit(":", 1) - else: - base = image - tag = default_tag + """Parse an image string into registry, repo, tag, and display label.""" + registry = default_registry + tag = default_tag + repo = image - if "/" in base: - # Has registry - parts = base.split("/", 1) - registry = parts[0] - name = parts[1] - else: - registry = default_registry - name = base + if image.startswith("docker/"): + registry = "docker.io" + repo = f"library/{image.split('/', 1)[1]}" + elif image.startswith("tm0/"): + repo = image.split("/", 1)[1] + elif "/" in image: + prefix, remainder = image.split("/", 1) + if "." in prefix or ":" in prefix or prefix == "localhost": + registry = prefix + repo = remainder - return ImageRef(registry=registry, name=name, tag=tag) + if ":" in repo.split("/")[-1]: + repo, tag = repo.rsplit(":", 1) + + label_prefix = ( + registry.rsplit(".", 1)[0].rsplit(".", 1)[-1] if "." in registry else registry + ) + label = f"{label_prefix}/{repo.split('/')[-1]}" + + return ImageRef(registry=registry, repo=repo, tag=tag, label=label) -def container_name(namespace: str, image_name: str) -> str: - """Compute container name from namespace and image.""" - return f"flow-{namespace}-{image_name}" +def container_name(name: str) -> str: + """Normalize the flow container name.""" + return name if name.startswith("dev-") else f"dev-{name}" def resolve_mounts( home: Path, - projects_dir: str, + *, + project_path: Optional[str] = None, dotfiles_dir: Optional[Path] = None, - extra_mounts: Optional[list[dict]] = None, ) -> list[Mount]: """Resolve standard container mounts.""" mounts: list[Mount] = [] - # Projects dir - projects = Path(projects_dir).expanduser() - if projects.exists(): - mounts.append(Mount(source=projects, target="/home/user/projects")) + if project_path: + project = Path(project_path).expanduser().resolve() + mounts.append(Mount(source=project, target="/workspace")) - # SSH agent - ssh_auth = Path.home() / ".ssh" - if ssh_auth.exists(): - mounts.append(Mount(source=ssh_auth, target="/home/user/.ssh", readonly=True)) + standard_mounts = [ + (home / ".ssh", "/home/dev/.ssh", True), + (home / ".npmrc", "/home/dev/.npmrc", True), + (home / ".npm", "/home/dev/.npm", False), + ] + for source, target, readonly in standard_mounts: + if source.exists(): + mounts.append(Mount(source=source, target=target, readonly=readonly)) + + docker_sock = Path("/var/run/docker.sock") + if docker_sock.exists(): + mounts.append(Mount(source=docker_sock, target="/var/run/docker.sock")) - # Dotfiles if dotfiles_dir and dotfiles_dir.exists(): - mounts.append(Mount(source=dotfiles_dir, target="/home/user/.local/share/flow/dotfiles", readonly=True)) - - # Extra mounts from config - if extra_mounts: - for m in extra_mounts: - source = Path(str(m.get("source", ""))).expanduser() - target = str(m.get("target", "")) - if source and target: - mounts.append(Mount( - source=source, - target=target, - readonly=bool(m.get("readonly", False)), - )) + mounts.append( + Mount( + source=dotfiles_dir, + target="/home/dev/.local/share/flow/dotfiles", + readonly=True, + ) + ) return mounts def build_container_spec( - namespace: str, + name: str, image_ref: ImageRef, mounts: list[Mount], - env: Optional[dict[str, str]] = None, - command: Optional[str] = None, + *, + project_path: Optional[str] = None, ) -> ContainerSpec: """Build a complete container run specification.""" - name = container_name(namespace, image_ref.name) - container_env = { - "DF_NAMESPACE": namespace, - "DF_PLATFORM": "container", + labels = { + "dev": "true", + "dev.name": name, + "dev.image_ref": image_ref.full, } - if env: - container_env.update(env) + if project_path: + labels["dev.project_path"] = str(Path(project_path).expanduser().resolve()) return ContainerSpec( - name=name, + name=container_name(name), image=image_ref, mounts=tuple(mounts), - env=container_env, - command=command, + project_path=Path(project_path).expanduser().resolve() if project_path else None, + labels=labels, + network="host", ) diff --git a/src/flow/domain/packages/catalog.py b/src/flow/domain/packages/catalog.py index c19c4ee..0ac1427 100644 --- a/src/flow/domain/packages/catalog.py +++ b/src/flow/domain/packages/catalog.py @@ -59,7 +59,7 @@ def _parse_package_entry(entry: dict[str, Any]) -> PackageDef: extract_dir=entry.get("extract-dir") or entry.get("extract_dir"), install=entry.get("install") or {}, post_install=entry.get("post-install") or entry.get("post_install"), - allow_sudo=bool(entry.get("allow-sudo", False)), + allow_sudo=bool(entry.get("allow-sudo", entry.get("allow_sudo", False))), ) @@ -69,8 +69,30 @@ def normalize_profile_entry(entry: Any) -> ProfilePackageRef: # Could be "binary/neovim" or just "neovim" if "/" in entry: pkg_type, name = entry.split("/", 1) - return ProfilePackageRef(name=name, type=pkg_type, source=None, version=None, asset_pattern=None) - return ProfilePackageRef(name=entry, type=None, source=None, version=None, asset_pattern=None) + return ProfilePackageRef( + name=name, + type=pkg_type, + source=None, + version=None, + asset_pattern=None, + platform_map=None, + extract_dir=None, + install=None, + post_install=None, + allow_sudo=None, + ) + return ProfilePackageRef( + name=entry, + type=None, + source=None, + version=None, + asset_pattern=None, + platform_map=None, + extract_dir=None, + install=None, + post_install=None, + allow_sudo=None, + ) if isinstance(entry, dict): name = entry.get("name", "") @@ -80,6 +102,11 @@ def normalize_profile_entry(entry: Any) -> ProfilePackageRef: source=entry.get("source"), version=entry.get("version"), asset_pattern=entry.get("asset-pattern") or entry.get("asset_pattern"), + platform_map=entry.get("platform-map") or entry.get("platform_map"), + extract_dir=entry.get("extract-dir") or entry.get("extract_dir"), + install=entry.get("install"), + post_install=entry.get("post-install") or entry.get("post_install"), + allow_sudo=entry.get("allow-sudo", entry.get("allow_sudo")), ) raise ConfigError(f"Invalid profile package entry: {entry}") diff --git a/src/flow/domain/packages/models.py b/src/flow/domain/packages/models.py index e718219..2a85196 100644 --- a/src/flow/domain/packages/models.py +++ b/src/flow/domain/packages/models.py @@ -9,12 +9,12 @@ from typing import Any, Optional class PackageDef: """A package definition from the manifest.""" name: str - type: str # "pkg" | "binary" | "appimage" | "script" + type: str # "pkg" | "binary" | "appimage" | "cask" sources: dict[str, str] # pm_name -> package_name source: Optional[str] # direct URL or github shorthand version: Optional[str] asset_pattern: Optional[str] - platform_map: dict[str, str] # platform -> asset suffix + platform_map: dict[str, Any] # platform -> asset suffix or template context overrides extract_dir: Optional[str] install: dict[str, Any] # install config overrides post_install: Optional[str] @@ -29,13 +29,18 @@ class ProfilePackageRef: source: Optional[str] version: Optional[str] asset_pattern: Optional[str] + platform_map: Optional[dict[str, Any]] = None + extract_dir: Optional[str] = None + install: Optional[dict[str, Any]] = None + post_install: Optional[str] = None + allow_sudo: Optional[bool] = None @dataclass(frozen=True) class PkgInstallOp: """A single package install operation.""" package: PackageDef - method: str # "pm" | "binary" | "appimage" | "script" + method: str # "pm" | "binary" | "appimage" | "cask" source_name: str # resolved pm package name or URL download_url: Optional[str] diff --git a/src/flow/domain/packages/planning.py b/src/flow/domain/packages/planning.py index 6a2adbc..a82a27d 100644 --- a/src/flow/domain/packages/planning.py +++ b/src/flow/domain/packages/planning.py @@ -48,26 +48,32 @@ def plan_install( package=pkg, method="pm", source_name=source_name, download_url=None, )) + elif pkg.type == "cask": + if pm != "brew": + raise FlowError(f"Cask package '{pkg.name}' requires Homebrew") + source_name = resolve_source_name(pkg, pm) + install_ops.append(PkgInstallOp( + package=pkg, + method="cask", + source_name=source_name, + download_url=None, + )) elif pkg.type in ("binary", "appimage"): asset = resolve_binary_asset(pkg, platform_str) - url = resolve_download_url(pkg, asset) + url = resolve_download_url(pkg, asset, platform_str) install_ops.append(PkgInstallOp( package=pkg, method=pkg.type, source_name=asset, download_url=url, )) - elif pkg.type == "script": - install_ops.append(PkgInstallOp( - package=pkg, method="script", - source_name=pkg.source or pkg.name, - download_url=pkg.source, - )) + else: + raise FlowError(f"Unsupported package type: {pkg.type}") pm_cmd = pm_install_command(pm, pm_packages) if pm and pm_packages else None return PackagePlan( install_ops=tuple(install_ops), remove_ops=(), - pm_update_needed=bool(pm_packages), + pm_update_needed=bool(pm_packages or any(op.method == "cask" for op in install_ops)), pm_command=pm_cmd, ) diff --git a/src/flow/domain/packages/resolution.py b/src/flow/domain/packages/resolution.py index f2df694..39bbd6e 100644 --- a/src/flow/domain/packages/resolution.py +++ b/src/flow/domain/packages/resolution.py @@ -1,8 +1,9 @@ """Package resolution: resolving what to install and how.""" import shutil -from typing import Optional +from typing import Any, Optional +from flow.core.template import substitute_template from flow.core.errors import FlowError from flow.domain.packages.models import PackageDef, ProfilePackageRef @@ -22,11 +23,11 @@ def resolve_spec( source=ref.source, version=ref.version, asset_pattern=ref.asset_pattern, - platform_map={}, - extract_dir=None, - install={}, - post_install=None, - allow_sudo=False, + platform_map=ref.platform_map or {}, + extract_dir=ref.extract_dir, + install=ref.install or {}, + post_install=ref.post_install, + allow_sudo=bool(ref.allow_sudo), ) # Merge: profile overrides catalog @@ -37,11 +38,11 @@ def resolve_spec( source=ref.source or base.source, version=ref.version or base.version, asset_pattern=ref.asset_pattern or base.asset_pattern, - platform_map=base.platform_map, - extract_dir=base.extract_dir, - install=base.install, - post_install=base.post_install, - allow_sudo=base.allow_sudo, + platform_map=ref.platform_map or base.platform_map, + extract_dir=ref.extract_dir or base.extract_dir, + install=ref.install or base.install, + post_install=ref.post_install or base.post_install, + allow_sudo=ref.allow_sudo if ref.allow_sudo is not None else base.allow_sudo, ) @@ -52,35 +53,93 @@ def resolve_source_name(pkg: PackageDef, pm: Optional[str]) -> str: return pkg.name +def binary_template_context(pkg: PackageDef, platform_str: str) -> dict[str, str]: + os_name, arch = platform_str.split("-", 1) + context = {"os": os_name, "arch": arch} + + for key in platform_lookup_keys(platform_str): + if key not in pkg.platform_map: + continue + mapping = pkg.platform_map[key] + if isinstance(mapping, dict): + for map_key, map_value in mapping.items(): + if isinstance(map_value, str): + context[map_key] = map_value + break + + return context + + +def _render_template_value(template: str, context: dict[str, str]) -> str: + rendered = substitute_template(template, context) + for key, value in context.items(): + rendered = rendered.replace(f"{{{key}}}", value) + return rendered + + def resolve_binary_asset(pkg: PackageDef, platform_str: str) -> str: """Resolve the binary asset filename for a platform.""" - if platform_str in pkg.platform_map: - return pkg.platform_map[platform_str] + for key in platform_lookup_keys(platform_str): + if key not in pkg.platform_map: + continue + mapping = pkg.platform_map[key] + if isinstance(mapping, str): + return mapping + if isinstance(mapping, dict): + break + if pkg.asset_pattern: - os_name, arch = platform_str.split("-", 1) - return pkg.asset_pattern.replace("{os}", os_name).replace("{arch}", arch) + return _render_template_value(pkg.asset_pattern, binary_template_context(pkg, platform_str)) raise FlowError(f"No asset mapping for {pkg.name} on {platform_str}") -def resolve_download_url(pkg: PackageDef, asset: str) -> str: +def resolve_extract_dir(pkg: PackageDef, platform_str: str) -> Optional[str]: + if not pkg.extract_dir: + return None + return _render_template_value(pkg.extract_dir, binary_template_context(pkg, platform_str)) + + +def resolve_download_url( + pkg: PackageDef, + asset: str, + platform_str: Optional[str] = None, +) -> str: """Build download URL from source + asset.""" source = pkg.source if not source: raise FlowError(f"No source URL for {pkg.name}") - if source.startswith("github:"): - repo = source.split(":", 1)[1] + context = binary_template_context(pkg, platform_str) if platform_str is not None else {} + rendered_source = _render_template_value(source, context) + + if rendered_source.startswith("github:"): + repo = rendered_source.split(":", 1)[1] version = pkg.version or "latest" if version == "latest": return f"https://github.com/{repo}/releases/latest/download/{asset}" - return f"https://github.com/{repo}/releases/download/{version}/{asset}" + release = version if version.startswith("v") else f"v{version}" + return f"https://github.com/{repo}/releases/download/{release}/{asset}" - if source.startswith(("http://", "https://")): - if source.endswith("/"): - return f"{source}{asset}" - return source + if rendered_source.startswith(("http://", "https://")): + if rendered_source.endswith(asset): + return rendered_source + if rendered_source.endswith("/"): + return f"{rendered_source}{asset}" + return f"{rendered_source}/{asset}" - return source + return rendered_source + + +def platform_lookup_keys(platform_str: str) -> list[str]: + os_name, arch = platform_str.split("-", 1) + keys = [platform_str] + if os_name == "macos": + keys.append(f"darwin-{arch}") + if arch == "x64": + keys.append(f"{os_name}-amd64") + if os_name == "macos": + keys.append("darwin-amd64") + return keys def detect_package_manager() -> Optional[str]: @@ -117,3 +176,9 @@ def pm_install_command(pm: str, packages: list[str]) -> str: if pm not in commands: raise FlowError(f"Unsupported package manager: {pm}") return commands[pm] + + +def pm_cask_install_command(pm: str, packages: list[str]) -> str: + if pm != "brew": + raise FlowError(f"Package manager '{pm}' does not support casks") + return f"brew install --cask {' '.join(packages)}" diff --git a/src/flow/domain/remote/models.py b/src/flow/domain/remote/models.py index aa06833..0560812 100644 --- a/src/flow/domain/remote/models.py +++ b/src/flow/domain/remote/models.py @@ -11,6 +11,7 @@ class Target: platform: str host: str identity: Optional[str] = None + user: str = "" @property def label(self) -> str: @@ -21,4 +22,6 @@ class Target: class SSHCommand: """A constructed SSH command.""" argv: tuple[str, ...] + destination: str + tmux_session: Optional[str] env: dict[str, str] = field(default_factory=dict) diff --git a/src/flow/domain/remote/resolution.py b/src/flow/domain/remote/resolution.py index c8132e2..50a4903 100644 --- a/src/flow/domain/remote/resolution.py +++ b/src/flow/domain/remote/resolution.py @@ -7,78 +7,147 @@ from flow.core.errors import FlowError from flow.domain.remote.models import SSHCommand, Target -def parse_target(spec: str) -> tuple[str, str]: - """Parse 'namespace@platform' into (namespace, platform).""" - if "@" not in spec: - raise FlowError(f"Invalid target format: {spec!r}. Expected 'namespace@platform'") - namespace, platform = spec.split("@", 1) +HOST_TEMPLATES = { + "orb": ".orb", + "utm": ".utm.local", + "core": ".core.lan", +} + + +def parse_target(spec: str) -> tuple[Optional[str], str, str]: + """Parse [user@]namespace@platform.""" + parts = spec.split("@") + if len(parts) == 2: + namespace, platform = parts + user = None + elif len(parts) == 3: + user, namespace, platform = parts + else: + raise FlowError( + f"Invalid target format: {spec!r}. Expected '[user@]namespace@platform'" + ) if not namespace or not platform: - raise FlowError(f"Invalid target format: {spec!r}. Both namespace and platform required") - return namespace, platform + raise FlowError( + f"Invalid target format: {spec!r}. Both namespace and platform required" + ) + return user, namespace, platform def resolve_target( spec: str, targets: list[TargetConfig], + *, + default_user: str = "", + user: Optional[str] = None, + namespace: Optional[str] = None, + platform: Optional[str] = None, ) -> Target: """Resolve a target spec against configured targets.""" - namespace, platform = parse_target(spec) + parsed_user, parsed_namespace, parsed_platform = parse_target(spec) + resolved_user = user or parsed_user or default_user + resolved_namespace = namespace or parsed_namespace + resolved_platform = platform or parsed_platform for t in targets: - if t.namespace == namespace and t.platform == platform: + if t.namespace == resolved_namespace and t.platform == resolved_platform: return Target( + user=resolved_user, namespace=t.namespace, platform=t.platform, host=t.host, identity=t.identity, ) - raise FlowError(f"Unknown target: {spec}. Check flow config targets section.") + if resolved_platform not in HOST_TEMPLATES: + raise FlowError(f"Unknown target: {spec}. Check flow config targets section.") + + return Target( + user=resolved_user, + namespace=resolved_namespace, + platform=resolved_platform, + host=HOST_TEMPLATES[resolved_platform].replace("", resolved_namespace), + identity=None, + ) def build_ssh_command( target: Target, *, - extra_args: Optional[list[str]] = None, - remote_command: Optional[str] = None, + tmux_session: str = "default", + no_tmux: bool = False, ) -> SSHCommand: """Build SSH command for a target.""" argv: list[str] = ["ssh"] + if not no_tmux: + argv.append("-tt") + if target.identity: argv.extend(["-i", target.identity]) - # Standard SSH options argv.extend(["-o", "StrictHostKeyChecking=accept-new"]) - - if extra_args: - argv.extend(extra_args) - - argv.append(target.host) - - if remote_command: - argv.append(remote_command) + destination = _build_destination(target.user, target.host) + argv.append(destination) env = { "DF_NAMESPACE": target.namespace, "DF_PLATFORM": target.platform, } - return SSHCommand(argv=tuple(argv), env=env) + if not no_tmux: + argv.extend([ + "tmux", + "new-session", + "-As", + tmux_session, + "-e", + f"DF_NAMESPACE={target.namespace}", + "-e", + f"DF_PLATFORM={target.platform}", + ]) + + return SSHCommand( + argv=tuple(argv), + destination=destination, + tmux_session=None if no_tmux else tmux_session, + env=env, + ) -def terminfo_fix_command(term: str = "xterm-256color") -> list[str]: - """Commands to fix terminfo on remote host.""" - return [ - f"infocmp -x {term} > /tmp/{term}.terminfo", - f"ssh TARGET tic -x /tmp/{term}.terminfo", - ] +def _build_destination(user: str, host: str) -> str: + if "@" in host: + return host + if not user: + return host + return f"{user}@{host}" + + +def terminfo_fix_command( + term: Optional[str] = "xterm-256color", + destination: str = "TARGET", +) -> 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\"'" + ) + + fallback_term = normalized_term or "xterm-256color" + return f"infocmp -x {fallback_term} | ssh {destination} -- tic -x -" def list_targets(targets: list[TargetConfig]) -> list[Target]: """Convert config targets to domain targets.""" return [ Target( + user="", namespace=t.namespace, platform=t.platform, host=t.host, diff --git a/src/flow/services/bootstrap.py b/src/flow/services/bootstrap.py index 0743a53..8da3faf 100644 --- a/src/flow/services/bootstrap.py +++ b/src/flow/services/bootstrap.py @@ -3,7 +3,8 @@ from __future__ import annotations -from typing import Any, Optional +import os +from typing import Optional from flow.core.config import FlowContext from flow.core.errors import FlowError @@ -16,17 +17,32 @@ class BootstrapService: def run( self, - profile_name: str, + profile_name: Optional[str], *, dry_run: bool = False, + env: Optional[dict[str, str]] = None, ) -> None: """Run bootstrap for a profile.""" profiles = self.ctx.manifest.get("profiles", {}) + if profile_name is None: + if len(profiles) == 1: + profile_name = next(iter(profiles)) + else: + raise FlowError( + "Multiple profiles available. Specify one with --profile." + ) if profile_name not in profiles: raise FlowError(f"Unknown profile: {profile_name}") profile = parse_profile(profile_name, profiles[profile_name]) - plan = plan_bootstrap(profile, self.ctx.manifest) + if profile.os != self.ctx.platform.os: + raise FlowError( + f"Profile '{profile_name}' targets '{profile.os}', current OS is '{self.ctx.platform.os}'" + ) + runtime_env = dict(os.environ) + if env: + runtime_env.update(env) + plan = plan_bootstrap(profile, self.ctx.manifest, env=runtime_env) self.ctx.console.info(f"Bootstrap profile: {profile_name}") self.ctx.console.print_plan(plan.actions, verb="bootstrap") @@ -41,16 +57,15 @@ class BootstrapService: # Delegate to PackageService from flow.services.packages import PackageService pkg_svc = PackageService(self.ctx) - pkg_names = [p.name for p in plan.packages_to_install] - if pkg_names: - pkg_svc.install(pkg_names) + if plan.packages_to_install: + pkg_svc.install(list(plan.packages_to_install)) continue if action.phase == "dotfiles": # Delegate to DotfilesService from flow.services.dotfiles import DotfilesService dot_svc = DotfilesService(self.ctx) - dot_svc.link(profile=profile_name) + dot_svc.link(profile=profile.dotfiles_profile or profile_name) continue # Execute shell commands diff --git a/src/flow/services/containers.py b/src/flow/services/containers.py index 9983666..f31c145 100644 --- a/src/flow/services/containers.py +++ b/src/flow/services/containers.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Optional +import shutil from flow.core.config import FlowContext from flow.core.errors import FlowError @@ -16,102 +16,227 @@ from flow.domain.containers.resolution import ( ) +def runtime() -> str: + for name in ("docker", "podman"): + if shutil.which(name): + return name + raise FlowError("No container runtime found (docker or podman)") + + class ContainerService: def __init__(self, ctx: FlowContext): self.ctx = ctx + self.runner = ctx.runtime.runner def create( self, + name: str, image: str, - namespace: str = "default", *, + project_path: str | None = None, dry_run: bool = False, - extra_env: Optional[dict[str, str]] = None, ) -> None: - """Create and start a container.""" - image_ref = parse_image_ref( - image, - default_registry=self.ctx.config.container_registry, - default_tag=self.ctx.config.container_tag, - ) - - mounts = resolve_mounts( - paths.HOME, - self.ctx.config.projects_dir, - dotfiles_dir=paths.DOTFILES_DIR, - ) - + """Create and start a development container.""" + rt = runtime() spec = build_container_spec( - namespace, image_ref, mounts, - env=extra_env, + name, + parse_image_ref( + image, + default_registry=self.ctx.config.container_registry, + default_tag=self.ctx.config.container_tag, + ), + resolve_mounts( + paths.HOME, + project_path=project_path, + dotfiles_dir=paths.DOTFILES_DIR, + ), + project_path=project_path, ) + if self._container_exists(rt, spec.name): + raise FlowError(f"Container already exists: {spec.name}") + self.ctx.console.info(f"Creating container: {spec.name}") self.ctx.console.info(f" Image: {spec.image.full}") - self.ctx.console.info(f" Mounts: {len(spec.mounts)}") if dry_run: return - # Build docker run command - argv = ["docker", "run", "-d", "--name", spec.name] + cmd = [ + rt, + "run", + "-d", + "--name", + spec.name, + "--network", + spec.network, + "--init", + ] + for key, value in spec.labels.items(): + cmd.extend(["--label", f"{key}={value}"]) + for mount in spec.mounts: + cmd.extend(["-v", f"{mount.source}:{mount.target}{':ro' if mount.readonly else ''}"]) + cmd.extend([spec.image.full, "sleep", "infinity"]) - for m in spec.mounts: - argv.extend(["-v", f"{m.source}:{m.target}{':ro' if m.readonly else ''}"]) + self.runner.run(cmd, capture_output=False, check=True) + self.ctx.console.success(f"Created and started container: {spec.name}") - for k, v in spec.env.items(): - argv.extend(["-e", f"{k}={v}"]) + def exec(self, name: str, command: list[str] | None = None) -> None: + """Run a command or interactive shell inside a container.""" + rt = runtime() + cname = container_name(name) + if not self._container_running(rt, cname): + raise FlowError(f"Container {cname} not running") - argv.append(spec.image.full) + if command: + argv = [rt, "exec"] + if os.isatty(0): + argv.extend(["-it"]) + argv.append(cname) + argv.extend(command) + result = self.runner.run(argv, capture_output=False) + raise SystemExit(result.returncode) - if spec.command: - argv.extend(spec.command.split()) + for shell in (["zsh", "-l"], ["bash", "-l"], ["sh"]): + argv = [rt, "exec", "--detach-keys", "ctrl-q,ctrl-p", "-it", cname, *shell] + result = self.runner.run(argv, capture_output=False) + if result.returncode not in (126, 127): + raise SystemExit(result.returncode) - self.ctx.runtime.runner.run(argv, check=True, capture_output=False) - self.ctx.console.success(f"Container {spec.name} created.") + raise FlowError(f"Unable to start an interactive shell in {cname}") - def enter( - self, - name: str, - *, - shell: str = "/bin/bash", - ) -> None: - """Exec into a running container.""" - self.ctx.console.info(f"Entering container: {name}") - self.ctx.runtime.runner.run( - ["docker", "exec", "-it", name, shell], - capture_output=False, + def connect(self, name: str) -> None: + """Attach to the container tmux session.""" + rt = runtime() + cname = container_name(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, check=True) + + if not shutil.which("tmux"): + self.ctx.console.warn("tmux not found; falling back to direct exec") + self.exec(name) + return + + inspect = self.runner.run( + [rt, "container", "inspect", cname, "--format", "{{ .Config.Image }}"], check=True, ) + image_ref = parse_image_ref(inspect.stdout.strip()) - def stop(self, name: str) -> None: + has_session = self.runner.run(["tmux", "has-session", "-t", cname], check=False) + if has_session.returncode != 0: + self.runner.run( + [ + "tmux", + "new-session", + "-ds", + cname, + "-e", + f"DF_IMAGE={image_ref.label}", + f"flow dev exec {name}", + ], + capture_output=True, + check=True, + ) + self.runner.run( + ["tmux", "set-option", "-t", cname, "default-command", f"flow dev exec {name}"], + capture_output=True, + check=True, + ) + + if os.environ.get("TMUX"): + os.execvp("tmux", ["tmux", "switch-client", "-t", cname]) + os.execvp("tmux", ["tmux", "attach", "-t", cname]) + + def stop(self, name: str, *, kill: bool = False) -> None: """Stop a running container.""" - self.ctx.runtime.runner.run( - ["docker", "stop", name], check=True, - ) - self.ctx.console.success(f"Container {name} stopped.") + rt = runtime() + cname = container_name(name) + if not self._container_exists(rt, cname): + raise FlowError(f"Container {cname} does not exist") + argv = [rt, "kill" if kill else "stop", cname] + self.runner.run(argv, capture_output=False, check=True) + self.ctx.console.success(f"Container {cname} stopped.") - def remove(self, name: str) -> None: + def remove(self, name: str, *, force: bool = False) -> None: """Remove a container.""" - self.ctx.runtime.runner.run( - ["docker", "rm", "-f", name], check=True, + rt = runtime() + cname = container_name(name) + if not self._container_exists(rt, cname): + raise FlowError(f"Container {cname} does not exist") + argv = [rt, "rm"] + if force: + argv.append("-f") + argv.append(cname) + self.runner.run(argv, capture_output=False, check=True) + self.ctx.console.success(f"Container {cname} removed.") + + def respawn(self, name: str) -> None: + """Respawn all tmux panes for a session.""" + if not shutil.which("tmux"): + raise FlowError("tmux is required for respawn but was not found") + + cname = container_name(name) + panes = self.runner.run( + [ + "tmux", + "list-panes", + "-t", + cname, + "-s", + "-F", + "#{session_name}:#{window_index}.#{pane_index}", + ], + check=True, ) - self.ctx.console.success(f"Container {name} removed.") + for pane in panes.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, check=True) def list(self) -> None: """List flow-managed containers.""" - result = self.ctx.runtime.runner.run( - ["docker", "ps", "-a", "--filter", "name=flow-", "--format", - "{{.Names}}\t{{.Image}}\t{{.Status}}"], + rt = runtime() + result = self.runner.run( + [ + rt, + "ps", + "-a", + "--filter", + "label=dev=true", + "--format", + '{{.Label "dev.name"}}\t{{.Image}}\t{{.Label "dev.project_path"}}\t{{.Status}}', + ], + check=True, ) if not result.stdout.strip(): self.ctx.console.info("No flow containers found.") return rows = [] + home = str(paths.HOME) for line in result.stdout.strip().splitlines(): - parts = line.split("\t") - if len(parts) >= 3: - rows.append(parts[:3]) + name, image, project, status = (line.split("\t") + ["", "", "", ""])[:4] + if project.startswith(home): + project = "~" + project[len(home):] + rows.append([name, image, project or "-", status]) - self.ctx.console.table(["NAME", "IMAGE", "STATUS"], rows) + self.ctx.console.table(["NAME", "IMAGE", "PROJECT", "STATUS"], rows) + + 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() diff --git a/src/flow/services/dotfiles.py b/src/flow/services/dotfiles.py index 60095b7..42ea489 100644 --- a/src/flow/services/dotfiles.py +++ b/src/flow/services/dotfiles.py @@ -76,26 +76,8 @@ class DotfilesService: if dry_run: return - # Execute - new_state = LinkedState(links=dict(current.links)) - for op in plan.operations: - if op.type == "create_link": - assert op.source is not None - self.ctx.runtime.fs.create_symlink( - op.source, op.target, - sudo=op.needs_sudo, - runner=self.ctx.runtime.runner if op.needs_sudo else None, - ) - # Find the matching LinkTarget - lt = next(t for t in targets if t.target == op.target) - new_state.links[op.target] = lt - elif op.type == "remove_link": - self.ctx.runtime.fs.remove_file( - op.target, - sudo=op.needs_sudo, - runner=self.ctx.runtime.runner if op.needs_sudo else None, - ) - new_state.links.pop(op.target, None) + self._save_backup(current) + new_state = self._apply_plan(plan, targets, current) self._save_state(new_state) self.ctx.console.success( @@ -127,6 +109,7 @@ class DotfilesService: if dry_run: return + self._save_backup(current) new_state = LinkedState(links=dict(current.links)) for op in plan.operations: self.ctx.runtime.fs.remove_file( @@ -167,29 +150,172 @@ class DotfilesService: self.ctx.console.info(f"Package directory: {pkg_dir}") - def sync(self) -> None: + def init(self, repo_url: Optional[str] = None) -> None: + """Clone the dotfiles repository.""" + remote = repo_url or self.ctx.config.dotfiles_url + if not remote: + raise FlowError("No dotfiles URL configured") + if self.dotfiles_dir.exists(): + self.ctx.console.warn(f"Dotfiles directory already exists: {self.dotfiles_dir}") + return + + self.ctx.console.info( + f"Cloning {remote} (branch: {self.ctx.config.dotfiles_branch})..." + ) + self.ctx.runtime.git.run( + self.dotfiles_dir.parent, + "clone", + "-b", + self.ctx.config.dotfiles_branch, + "--recurse-submodules", + remote, + str(self.dotfiles_dir), + check=True, + ) + self.sync_modules() + self.ctx.console.success(f"Dotfiles cloned to {self.dotfiles_dir}") + + def sync(self, *, profile: Optional[str] = None, relink: bool = False) -> None: """Pull latest dotfiles and sync modules.""" if not self.dotfiles_dir.is_dir(): - if not self.ctx.config.dotfiles_url: - raise FlowError("No dotfiles URL configured") - self.ctx.console.info(f"Cloning dotfiles from {self.ctx.config.dotfiles_url}") - self.ctx.runtime.git.run( - self.dotfiles_dir.parent, - "clone", self.ctx.config.dotfiles_url, str(self.dotfiles_dir), - check=True, - ) + self.init() else: self.ctx.console.info("Pulling latest dotfiles...") self.ctx.runtime.git.run( self.dotfiles_dir, "pull", "--ff-only", check=True, ) - # Sync modules - packages = self._discover_packages(profile=None) + self.sync_modules(profile=profile) + if relink: + self.relink(profile=profile) + + def list_modules(self, *, profile: Optional[str] = None) -> None: + """List detected module packages.""" + packages = self._discover_packages( + profile=profile, + include_all_layers=profile is None, + ) + module_packages = [pkg for pkg in packages if pkg.module is not None] + if not module_packages: + self.ctx.console.info("No module packages found.") + return + + rows = [] + for pkg in module_packages: + assert pkg.module is not None + status = "ready" if pkg.module.cache_dir.exists() else "missing" + rows.append([ + pkg.package_id, + f"{pkg.module.ref_type}:{pkg.module.ref_value}", + pkg.module.source, + status, + ]) + self.ctx.console.table(["PACKAGE", "REF", "SOURCE", "STATUS"], rows) + + def sync_modules(self, *, profile: Optional[str] = None) -> None: + """Clone or update module repositories.""" + packages = self._discover_packages( + profile=profile, + include_all_layers=profile is None, + ) for pkg in packages: if pkg.module: self._sync_module(pkg) + def repo_status(self) -> None: + """Show git status for the dotfiles repository.""" + if not self.dotfiles_dir.is_dir(): + raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}") + result = self.ctx.runtime.git.run( + self.dotfiles_dir, + "status", + "--short", + "--branch", + check=True, + ) + output = result.stdout.strip() + if output: + print(output) + return + self.ctx.console.info("Dotfiles repository is clean.") + + def repo_pull( + self, + *, + profile: Optional[str] = None, + relink: bool = False, + rebase: bool = True, + ) -> None: + """Pull the dotfiles repository and refresh modules.""" + if not self.dotfiles_dir.is_dir(): + raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}") + argv = ["pull"] + argv.append("--rebase" if rebase else "--ff-only") + self.ctx.runtime.git.run(self.dotfiles_dir, *argv, check=True) + self.sync_modules(profile=profile) + if relink: + self.relink(profile=profile) + + def repo_push(self) -> None: + """Push the dotfiles repository.""" + if not self.dotfiles_dir.is_dir(): + raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}") + self.ctx.runtime.git.run(self.dotfiles_dir, "push", check=True) + self.ctx.console.success("Dotfiles pushed.") + + def relink(self, *, profile: Optional[str] = None) -> None: + """Refresh symlinks for the selected profile.""" + self.link(profile=profile) + + def clean(self, *, dry_run: bool = False) -> None: + """Remove broken symlinks from managed state.""" + current = self._load_state() + broken = [ + target for target in sorted(current.links) + if target.is_symlink() and not target.exists() + ] + if not broken: + self.ctx.console.info("No broken symlinks found.") + return + + if dry_run: + for target in broken: + self.ctx.console.info(f"Would remove broken symlink: {target}") + return + + self._save_backup(current) + for target in broken: + link = current.links[target] + self.ctx.runtime.fs.remove_file( + target, + sudo=link.needs_sudo, + runner=self.ctx.runtime.runner if link.needs_sudo else None, + missing_ok=True, + ) + current.links.pop(target, None) + self._save_state(current) + self.ctx.console.success(f"Cleaned {len(broken)} broken symlink(s).") + + def undo(self) -> None: + """Restore the previous linked state.""" + previous = self._load_backup() + if previous is None: + self.ctx.console.info("No dotfiles link transaction to undo.") + return + + current = self._load_state() + desired = list(previous.links.values()) + plan = plan_link(desired, current, self._filesystem_check) + if not plan.operations: + self.ctx.console.info("Nothing to undo.") + return + + self.ctx.console.print_plan(plan.operations, verb="undo") + self._save_backup(current) + restored = self._apply_plan(plan, desired, current) + self._save_state(restored) + self.ctx.console.success("Dotfiles state restored.") + def _sync_module(self, pkg: Package) -> None: """Clone or update a module.""" module = pkg.module @@ -226,7 +352,12 @@ class DotfilesService: ref = f"tags/{ref}" self.ctx.runtime.git.run(cache_dir, "checkout", ref, check=True) - def _discover_packages(self, profile: Optional[str]) -> list[Package]: + def _discover_packages( + self, + profile: Optional[str], + *, + include_all_layers: bool = False, + ) -> list[Package]: """Walk dotfiles dir and build Package objects.""" packages: list[Package] = [] @@ -234,7 +365,17 @@ class DotfilesService: return packages layers = ["_shared"] - if profile: + if include_all_layers: + layers.extend( + sorted( + layer.name + for layer in self.dotfiles_dir.iterdir() + if layer.is_dir() + and not layer.name.startswith(".") + and layer.name != "_shared" + ) + ) + elif profile: layers.append(profile) for layer in layers: @@ -327,8 +468,58 @@ class DotfilesService: data = self.ctx.runtime.fs.read_json(paths.LINKED_STATE, default={}) if data is None: data = {} - return LinkedState.from_dict(data) + state = LinkedState.from_dict(data) + reconciled = LinkedState( + links={ + target: link + for target, link in state.links.items() + if self.ctx.runtime.fs.same_symlink(target, link.source) + } + ) + if reconciled.links != state.links: + self._save_state(reconciled) + return reconciled def _save_state(self, state: LinkedState) -> None: """Save linked state to disk.""" self.ctx.runtime.fs.write_json(paths.LINKED_STATE, state.as_dict()) + + def _save_backup(self, state: LinkedState) -> None: + self.ctx.runtime.fs.write_json(self._backup_path(), state.as_dict()) + + def _load_backup(self) -> Optional[LinkedState]: + data = self.ctx.runtime.fs.read_json(self._backup_path(), default=None) + if data is None: + return None + return LinkedState.from_dict(data) + + def _backup_path(self) -> Path: + return paths.LINKED_STATE.with_name("linked.previous.json") + + def _apply_plan( + self, + plan, + targets: list[LinkTarget], + current: LinkedState, + ) -> LinkedState: + new_state = LinkedState(links=dict(current.links)) + for op in plan.operations: + if op.type == "create_link": + assert op.source is not None + self.ctx.runtime.fs.create_symlink( + op.source, + op.target, + sudo=op.needs_sudo, + runner=self.ctx.runtime.runner if op.needs_sudo else None, + ) + link_target = next(target for target in targets if target.target == op.target) + new_state.links[op.target] = link_target + elif op.type == "remove_link": + self.ctx.runtime.fs.remove_file( + op.target, + sudo=op.needs_sudo, + runner=self.ctx.runtime.runner if op.needs_sudo else None, + missing_ok=True, + ) + new_state.links.pop(op.target, None) + return new_state diff --git a/src/flow/services/packages.py b/src/flow/services/packages.py index 8eddbf4..43394e6 100644 --- a/src/flow/services/packages.py +++ b/src/flow/services/packages.py @@ -1,13 +1,17 @@ -# src/flow/services/packages.py """PackageService -- orchestrates package installation.""" from __future__ import annotations +import os +import shutil +import tempfile +import urllib.request from pathlib import Path -from typing import Any, Optional +from typing import Optional from flow.core.config import FlowContext from flow.core.errors import FlowError +from flow.core.template import substitute_template from flow.core import paths from flow.domain.packages.catalog import normalize_profile_entry, parse_catalog from flow.domain.packages.models import ( @@ -18,10 +22,13 @@ from flow.domain.packages.models import ( ) from flow.domain.packages.planning import plan_install, plan_remove from flow.domain.packages.resolution import ( + binary_template_context, detect_package_manager, + pm_cask_install_command, pm_install_command, pm_update_command, resolve_binary_asset, + resolve_extract_dir, resolve_download_url, resolve_source_name, resolve_spec, @@ -32,36 +39,44 @@ class PackageService: def __init__(self, ctx: FlowContext): self.ctx = ctx - def install( + def resolve_install_packages( self, - package_names: Optional[list[str]] = None, *, + package_names: Optional[list[str]] = None, profile: Optional[str] = None, - dry_run: bool = False, - ) -> None: - """Install packages from profile or by name.""" + ) -> list[PackageDef]: + """Resolve package definitions from names or a profile.""" catalog = parse_catalog(self.ctx.manifest) - installed = self._load_state() - pm = detect_package_manager() - - # Resolve packages to install packages: list[PackageDef] = [] if package_names: for name in package_names: ref = normalize_profile_entry(name) - pkg = resolve_spec(ref, catalog) - packages.append(pkg) - elif profile: + packages.append(resolve_spec(ref, catalog)) + return packages + + if profile: profiles = self.ctx.manifest.get("profiles", {}) if profile not in profiles: raise FlowError(f"Unknown profile: {profile}") profile_data = profiles[profile] for entry in profile_data.get("packages", []): ref = normalize_profile_entry(entry) - pkg = resolve_spec(ref, catalog) - packages.append(pkg) - else: - raise FlowError("Specify package names or --profile") + packages.append(resolve_spec(ref, catalog)) + return packages + + raise FlowError("Specify package names or --profile") + + def install( + self, + packages: Optional[list[PackageDef]] = None, + *, + dry_run: bool = False, + ) -> None: + """Install the resolved package definitions.""" + if not packages: + raise FlowError("Specify packages to install") + installed = self._load_state() + pm = detect_package_manager() plan = plan_install(packages, installed, self.ctx.platform.platform, pm) @@ -83,9 +98,7 @@ class PackageService: pm_update_command(pm), check=True, ) - pm_names = [ - op.source_name for op in plan.install_ops if op.method == "pm" - ] + pm_names = [op.source_name for op in plan.install_ops if op.method == "pm"] if pm_names and pm: cmd = pm_install_command(pm, pm_names) self.ctx.console.info(f"Installing: {', '.join(pm_names)}") @@ -97,13 +110,29 @@ class PackageService: version=op.package.version or "system", type="pkg", ) + self._run_post_install(op.package) + + cask_names = [op.source_name for op in plan.install_ops if op.method == "cask"] + if cask_names and pm: + cmd = pm_cask_install_command(pm, cask_names) + self.ctx.console.info(f"Installing casks: {', '.join(cask_names)}") + self.ctx.runtime.runner.run_shell(cmd, check=True) + for op in plan.install_ops: + if op.method == "cask": + installed.packages[op.package.name] = InstalledPackage( + name=op.package.name, + version=op.package.version or "system", + type="cask", + ) + self._run_post_install(op.package) - # Execute binary packages for op in plan.install_ops: if op.method == "binary" and op.download_url: self._install_binary(op.package, op.download_url, op.source_name, installed) + self._run_post_install(op.package) elif op.method == "appimage" and op.download_url: self._install_appimage(op.package, op.download_url, installed) + self._run_post_install(op.package) self._save_state(installed) self.ctx.console.success(f"Installed {len(plan.install_ops)} package(s).") @@ -112,52 +141,64 @@ class PackageService: self, pkg: PackageDef, url: str, asset: str, state: InstalledState, ) -> None: """Download and install a binary package.""" - self.ctx.console.info(f"Downloading {pkg.name}...") - tmp_dir = paths.DATA_DIR / "tmp" - self.ctx.runtime.fs.ensure_dir(tmp_dir) - archive = tmp_dir / asset + install_map = pkg.install + if not install_map: + raise FlowError(f"Binary package '{pkg.name}' must define install paths") - self.ctx.runtime.runner.run( - ["curl", "-fSL", "-o", str(archive), url], check=True, - ) + context = self._binary_context(pkg) + with tempfile.TemporaryDirectory(prefix=f"flow-{pkg.name}-") as tmp: + tmp_dir = Path(tmp) + archive = tmp_dir / asset + extracted = tmp_dir / "extract" - bin_dir = Path.home() / ".local" / "bin" - self.ctx.runtime.fs.ensure_dir(bin_dir) + self.ctx.console.info(f"Downloading {pkg.name}...") + with urllib.request.urlopen(url, timeout=60) as response: + self.ctx.runtime.fs.write_bytes(archive, response.read()) - installed_files: list[Path] = [] - if asset.endswith((".tar.gz", ".tar.xz", ".tar.bz2", ".tgz")): - extract_dir = tmp_dir / f"{pkg.name}-extract" - self.ctx.runtime.fs.ensure_dir(extract_dir) - self.ctx.runtime.runner.run( - ["tar", "-xf", str(archive), "-C", str(extract_dir)], check=True, - ) - # Find and install binaries - install_cfg = pkg.install or {} - binary_name = install_cfg.get("binary", pkg.name) - search_root = extract_dir / pkg.extract_dir if pkg.extract_dir else extract_dir + self.ctx.runtime.fs.ensure_dir(extracted) + try: + shutil.unpack_archive(str(archive), str(extracted)) + except (shutil.ReadError, ValueError) as e: + raise FlowError(f"Could not extract archive for '{pkg.name}': {e}") from e - for candidate in search_root.rglob(binary_name): - if candidate.is_file(): - target = bin_dir / binary_name - self.ctx.runtime.fs.copy_file(candidate, target) - target.chmod(0o755) - installed_files.append(target) - break - else: - # Single binary - target = bin_dir / pkg.name - self.ctx.runtime.fs.copy_file(archive, target) - target.chmod(0o755) - installed_files.append(target) + extract_dir = resolve_extract_dir(pkg, self.ctx.platform.platform) + source_root = extracted if extract_dir is None else extracted / extract_dir + if not source_root.exists(): + raise FlowError(f"extract-dir '{extract_dir}' not found for package '{pkg.name}'") - # Cleanup - self.ctx.runtime.fs.remove_tree(tmp_dir) + source_root_resolved = source_root.resolve(strict=False) + installed_paths: list[Path] = [] + for section in ("bin", "share", "man", "lib"): + if section not in install_map: + continue + items = install_map[section] + if not isinstance(items, list): + raise FlowError( + f"Install section '{section}' for '{pkg.name}' must be a list" + ) + for item in items: + if not isinstance(item, str): + raise FlowError( + f"Install paths for '{pkg.name}' must be strings" + ) + installed_paths.append( + self._copy_install_item( + pkg.name, + source_root, + source_root_resolved, + section, + substitute_template(item, context), + ) + ) + + if not installed_paths: + raise FlowError(f"Binary package '{pkg.name}' installed no files") state.packages[pkg.name] = InstalledPackage( name=pkg.name, version=pkg.version or "latest", type="binary", - files=installed_files, + files=installed_paths, ) def _install_appimage( @@ -204,15 +245,35 @@ class PackageService: for op in plan.remove_ops: for f in op.files: - self.ctx.runtime.fs.remove_file(f, missing_ok=True) + if f.is_dir(): + self.ctx.runtime.fs.remove_tree(f) + else: + self.ctx.runtime.fs.remove_file(f, missing_ok=True) installed.packages.pop(op.name, None) self._save_state(installed) self.ctx.console.success(f"Removed {len(plan.remove_ops)} package(s).") - def list_packages(self) -> None: + def list_packages(self, *, show_all: bool = False) -> None: """List installed packages.""" + catalog = parse_catalog(self.ctx.manifest) installed = self._load_state() + if show_all: + if not catalog: + self.ctx.console.info("No packages defined in manifest.") + return + rows = [] + for name, package in sorted(catalog.items()): + installed_pkg = installed.packages.get(name) + rows.append([ + name, + package.type, + installed_pkg.version if installed_pkg else "-", + package.version or "-", + ]) + self.ctx.console.table(["NAME", "TYPE", "INSTALLED", "AVAILABLE"], rows) + return + if not installed.packages: self.ctx.console.info("No packages installed by flow.") return @@ -231,3 +292,102 @@ class PackageService: def _save_state(self, state: InstalledState) -> None: self.ctx.runtime.fs.write_json(paths.INSTALLED_STATE, state.as_dict()) + + def _binary_context(self, pkg: PackageDef) -> dict[str, str]: + return { + "env": dict(os.environ), + "name": pkg.name, + "version": pkg.version or "", + **binary_template_context(pkg, self.ctx.platform.platform), + } + + def _copy_install_item( + self, + package_name: str, + source_root: Path, + source_root_resolved: Path, + section: str, + raw_path: str, + ) -> Path: + declared_path = Path(raw_path) + self._validate_install_path(package_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 '{package_name}': {declared_path}" + ) + if not source.exists(): + raise FlowError( + f"Install path not found for '{package_name}': {declared_path}" + ) + + destination_root = self._install_destination(section) + stripped_path = self._strip_prefix(declared_path, self._install_strip_prefix(section)) + destination = destination_root / stripped_path + + if source.is_dir(): + self.ctx.runtime.fs.copy_tree(source, destination) + else: + self.ctx.runtime.fs.copy_file(source, destination) + if section == "bin": + destination.chmod(destination.stat().st_mode | 0o111) + + return destination + + def _run_post_install(self, pkg: PackageDef) -> None: + if not pkg.post_install: + return + + script = substitute_template(pkg.post_install, self._binary_context(pkg)) + if not pkg.allow_sudo and self._script_uses_sudo(script): + raise FlowError( + f"Package '{pkg.name}' post-install uses sudo but allow-sudo is false" + ) + self.ctx.runtime.runner.run_shell(script, check=True) + + def _install_destination(self, section: str) -> Path: + home = Path.home() + destinations = { + "bin": home / ".local" / "bin", + "share": home / ".local" / "share", + "man": home / ".local" / "share" / "man", + "lib": home / ".local" / "lib", + } + if section not in destinations: + raise FlowError(f"Unsupported install section: {section}") + return destinations[section] + + def _install_strip_prefix(self, section: str) -> Path: + prefixes = { + "bin": Path("bin"), + "share": Path("share"), + "man": Path("share") / "man", + "lib": Path("lib"), + } + if section not in prefixes: + raise FlowError(f"Unsupported install section: {section}") + return prefixes[section] + + def _strip_prefix(self, path: Path, prefix: Path) -> Path: + try: + return path.relative_to(prefix) + except ValueError: + return path + + def _validate_install_path(self, 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 _script_uses_sudo(self, script: str) -> bool: + for line in script.splitlines(): + stripped = line.strip() + if stripped.startswith("sudo "): + return True + return False diff --git a/src/flow/services/remote.py b/src/flow/services/remote.py index 85abb89..1795cca 100644 --- a/src/flow/services/remote.py +++ b/src/flow/services/remote.py @@ -2,6 +2,7 @@ from __future__ import annotations +import getpass import os from typing import Optional @@ -23,11 +24,27 @@ class RemoteService: self, target_spec: str, *, + user: Optional[str] = None, + namespace: Optional[str] = None, + platform: Optional[str] = None, + session: Optional[str] = None, + no_tmux: bool = False, dry_run: bool = False, ) -> None: """SSH into a target.""" - target = resolve_target(target_spec, self.ctx.config.targets) - cmd = build_ssh_command(target) + target = resolve_target( + target_spec, + self.ctx.config.targets, + default_user=os.environ.get("USER") or getpass.getuser(), + user=user, + namespace=namespace, + platform=platform, + ) + cmd = build_ssh_command( + target, + tmux_session=session or self.ctx.config.tmux_session, + no_tmux=no_tmux, + ) self.ctx.console.info(f"Connecting to {target.label} ({target.host})") @@ -35,13 +52,8 @@ class RemoteService: self.ctx.console.info(f"Would run: {' '.join(cmd.argv)}") return - # Set env vars for the SSH session - env = dict(os.environ) - env.update(cmd.env) - self.ctx.runtime.runner.run( cmd.argv, - env=env, capture_output=False, check=True, ) @@ -61,7 +73,15 @@ class RemoteService: def fix_terminfo(self, target_spec: str) -> None: """Show terminfo fix commands.""" - cmds = terminfo_fix_command() - self.ctx.console.info("Run these commands to fix terminfo:") - for cmd in cmds: - self.ctx.console.info(f" {cmd}") + target = resolve_target( + target_spec, + self.ctx.config.targets, + default_user=os.environ.get("USER") or getpass.getuser(), + ) + destination = f"{target.user}@{target.host}" if target.user else target.host + cmd = terminfo_fix_command(os.environ.get("TERM"), destination) + if cmd is None: + self.ctx.console.info("No terminfo workaround needed for the current TERM.") + return + self.ctx.console.info("Run this command to fix terminfo:") + self.ctx.console.info(f" {cmd}") diff --git a/tests/test_cli.py b/tests/test_cli.py index b9d14bb..03d6ad5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,8 @@ """Tests for CLI.""" +import os import subprocess import sys -from unittest.mock import patch - -import pytest def test_version_flag(): @@ -46,3 +44,43 @@ def test_packages_help(): ) assert result.returncode == 0 assert "install" in result.stdout + + +def test_invalid_config_is_reported_without_traceback(tmp_path): + config_root = tmp_path / "config" + (config_root / "flow").mkdir(parents=True) + (config_root / "flow" / "config.yaml").write_text(":\n bad\n") + + env = dict(os.environ) + env["XDG_CONFIG_HOME"] = str(config_root) + result = subprocess.run( + [sys.executable, "-m", "flow", "completion"], + capture_output=True, + text=True, + env=env, + ) + assert result.returncode == 1 + assert "Invalid YAML" in result.stderr + assert "Traceback" not in result.stderr + + +def test_local_manifest_is_loaded(tmp_path): + config_root = tmp_path / "config" + flow_dir = config_root / "flow" + flow_dir.mkdir(parents=True) + (flow_dir / "manifest.yaml").write_text( + "profiles:\n" + " demo:\n" + " os: linux\n" + ) + + env = dict(os.environ) + env["XDG_CONFIG_HOME"] = str(config_root) + result = subprocess.run( + [sys.executable, "-m", "flow", "setup", "list"], + capture_output=True, + text=True, + env=env, + ) + assert result.returncode == 0 + assert "demo" in result.stdout diff --git a/tests/test_core_config.py b/tests/test_core_config.py index c663723..f9b8114 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -75,3 +75,59 @@ def test_load_manifest_merges_files(tmp_path): data = load_manifest(tmp_path) assert "packages" in data assert "profiles" in data + + +def test_load_config_merges_local_and_overlay(tmp_path): + local = tmp_path / "local" + overlay = tmp_path / "overlay" + local.mkdir() + overlay.mkdir() + (local / "config.yaml").write_text( + "repository:\n" + " url: git@github.com:user/dots.git\n" + "targets:\n" + " personal@orb: personal.orb\n" + ) + (overlay / "config.yaml").write_text( + "repository:\n" + " branch: dev\n" + "defaults:\n" + " tmux-session: main\n" + ) + + cfg = load_config(local, overlay) + assert cfg.dotfiles_url == "git@github.com:user/dots.git" + assert cfg.dotfiles_branch == "dev" + assert cfg.tmux_session == "main" + assert cfg.targets[0].host == "personal.orb" + + +def test_load_config_parses_legacy_targets(tmp_path): + (tmp_path / "01-targets.yaml").write_text( + "targets:\n" + " personal: orb personal.orb ~/.ssh/id_personal\n" + ) + (tmp_path / "02-targets.yaml").write_text( + "targets:\n" + " - namespace: work\n" + " platform: ec2\n" + " host: work.ec2.internal\n" + " identity: ~/.ssh/id_work\n" + ) + cfg = load_config(tmp_path) + assert len(cfg.targets) == 2 + assert cfg.targets[0].platform == "orb" + assert cfg.targets[0].identity == "~/.ssh/id_personal" + assert cfg.targets[1].namespace == "work" + + +def test_load_manifest_merges_local_and_overlay(tmp_path): + local = tmp_path / "local" + overlay = tmp_path / "overlay" + local.mkdir() + overlay.mkdir() + (local / "manifest.yaml").write_text("profiles:\n local:\n os: linux\n") + (overlay / "packages.yaml").write_text("packages:\n - name: fd\n type: pkg\n") + data = load_manifest(local, overlay) + assert "profiles" in data + assert "packages" in data diff --git a/tests/test_domain_bootstrap_planning.py b/tests/test_domain_bootstrap_planning.py index 7b30359..d934d9b 100644 --- a/tests/test_domain_bootstrap_planning.py +++ b/tests/test_domain_bootstrap_planning.py @@ -33,6 +33,20 @@ class TestParseProfile: profile = parse_profile("test", raw) assert len(profile.ssh_keys) == 1 + def test_ssh_keygen_alias(self): + raw = {"ssh-keygen": [{"filename": "id_work", "type": "ed25519"}]} + profile = parse_profile("test", raw) + assert profile.ssh_keys[0]["path"] == "~/.ssh/id_work" + + def test_requires_alias(self): + profile = parse_profile("test", {"requires": ["USER_EMAIL"]}) + assert profile.env_required == ("USER_EMAIL",) + + def test_post_link_and_dotfiles_profile(self): + profile = parse_profile("test", {"dotfiles-profile": "linux-work", "post-link": "echo done"}) + assert profile.dotfiles_profile == "linux-work" + assert profile.post_link == "echo done" + class TestPlanBootstrap: def test_basic_plan(self): @@ -73,6 +87,16 @@ class TestPlanBootstrap: runcmd_actions = [a for a in plan.actions if "custom command" in a.description.lower()] assert len(runcmd_actions) == 1 + def test_post_link_produces_action(self): + profile = Profile( + name="test", os="linux", arch=None, + hostname=None, locale=None, shell=None, + ssh_keys=[], runcmd=[], packages=[], env_required=[], + post_link="echo done", + ) + plan = plan_bootstrap(profile, {}) + assert any(action.phase == "post-link" for action in plan.actions) + def test_ssh_keys_action(self): profile = Profile( name="test", os="linux", arch=None, diff --git a/tests/test_domain_containers.py b/tests/test_domain_containers.py index 155b973..cb29b42 100644 --- a/tests/test_domain_containers.py +++ b/tests/test_domain_containers.py @@ -15,7 +15,7 @@ class TestParseImageRef: def test_simple_name(self): ref = parse_image_ref("devbox") assert ref.registry == "registry.tomastm.com" - assert ref.name == "devbox" + assert ref.repo == "devbox" assert ref.tag == "latest" def test_with_tag(self): @@ -25,7 +25,7 @@ class TestParseImageRef: def test_full_ref(self): ref = parse_image_ref("ghcr.io/user/image:main") assert ref.registry == "ghcr.io" - assert ref.name == "user/image" + assert ref.repo == "user/image" assert ref.tag == "main" def test_full_image_string(self): @@ -35,38 +35,35 @@ class TestParseImageRef: class TestContainerName: def test_basic(self): - assert container_name("personal", "devbox") == "flow-personal-devbox" + assert container_name("devbox") == "dev-devbox" class TestResolveMounts: def test_projects_mount(self, tmp_path): projects = tmp_path / "projects" projects.mkdir() - mounts = resolve_mounts(tmp_path, str(projects)) - project_mounts = [m for m in mounts if m.target == "/home/user/projects"] + mounts = resolve_mounts(tmp_path, project_path=str(projects)) + project_mounts = [m for m in mounts if m.target == "/workspace"] assert len(project_mounts) == 1 - def test_extra_mounts(self, tmp_path): - mounts = resolve_mounts( - tmp_path, str(tmp_path), - extra_mounts=[{"source": str(tmp_path), "target": "/data"}], - ) - extra = [m for m in mounts if m.target == "/data"] - assert len(extra) == 1 + def test_dotfiles_mount(self, tmp_path): + dotfiles = tmp_path / "dotfiles" + dotfiles.mkdir() + mounts = resolve_mounts(tmp_path, dotfiles_dir=dotfiles) + assert any(m.target.endswith("/flow/dotfiles") for m in mounts) class TestBuildContainerSpec: def test_basic(self): - image = ImageRef(registry="reg", name="img", tag="v1") - spec = build_container_spec("personal", image, []) - assert spec.name == "flow-personal-img" - assert spec.env["DF_NAMESPACE"] == "personal" - assert spec.env["DF_PLATFORM"] == "container" + image = ImageRef(registry="reg", repo="img", tag="v1", label="reg/img") + spec = build_container_spec("api", image, []) + assert spec.name == "dev-api" + assert spec.labels["dev.name"] == "api" def test_with_mounts(self): - image = ImageRef(registry="reg", name="img", tag="v1") + image = ImageRef(registry="reg", repo="img", tag="v1", label="reg/img") mounts = [Mount(source=Path("/a"), target="/b")] - spec = build_container_spec("ns", image, mounts) + spec = build_container_spec("api", image, mounts) assert len(spec.mounts) == 1 diff --git a/tests/test_domain_packages.py b/tests/test_domain_packages.py index 3ad05fd..ea4d0f2 100644 --- a/tests/test_domain_packages.py +++ b/tests/test_domain_packages.py @@ -4,16 +4,20 @@ import pytest from flow.core.errors import ConfigError, FlowError from flow.domain.packages.catalog import normalize_profile_entry, parse_catalog +from flow.domain.packages.planning import plan_install from flow.domain.packages.resolution import ( + binary_template_context, detect_package_manager, + pm_cask_install_command, pm_install_command, pm_update_command, resolve_binary_asset, resolve_download_url, + resolve_extract_dir, resolve_source_name, resolve_spec, ) -from flow.domain.packages.models import PackageDef, ProfilePackageRef +from flow.domain.packages.models import InstalledState, PackageDef, ProfilePackageRef class TestParseCatalog: @@ -77,6 +81,26 @@ class TestResolveSpec: assert result.name == "unknown" assert result.type == "binary" + def test_profile_object_overrides_catalog(self): + catalog = {"docker": PackageDef( + name="docker", type="pkg", sources={"apt": "docker-ce"}, + source=None, version=None, asset_pattern=None, + platform_map={}, extract_dir=None, install={}, + post_install=None, allow_sudo=False, + )} + ref = ProfilePackageRef( + name="docker", + type=None, + source=None, + version=None, + asset_pattern=None, + post_install="sudo groupadd docker || true", + allow_sudo=True, + ) + result = resolve_spec(ref, catalog) + assert result.post_install == "sudo groupadd docker || true" + assert result.allow_sudo is True + class TestResolveSourceName: def test_with_pm_mapping(self): @@ -125,6 +149,19 @@ class TestResolveBinaryAsset: assert "x64" in result assert "linux" in result + def test_double_brace_pattern_uses_platform_map_context(self): + pkg = PackageDef( + name="nvim", type="binary", sources={}, + source="github:neovim/neovim", + version="0.10.4", + asset_pattern="nvim-{{os}}-{{arch}}.tar.gz", + platform_map={"linux-x64": {"os": "linux", "arch": "x86_64"}}, + extract_dir="nvim-{{os}}64", install={}, + post_install=None, allow_sudo=False, + ) + assert resolve_binary_asset(pkg, "linux-x64") == "nvim-linux-x86_64.tar.gz" + assert resolve_extract_dir(pkg, "linux-x64") == "nvim-linux64" + class TestResolveDownloadUrl: def test_github_shorthand_with_version(self): @@ -140,6 +177,18 @@ class TestResolveDownloadUrl: assert "github.com/neovim/neovim" in url assert "v0.10.4" in url + def test_github_shorthand_prefixes_v(self): + pkg = PackageDef( + name="nvim", type="binary", sources={}, + source="github:neovim/neovim", + version="0.10.4", + asset_pattern=None, platform_map={}, + extract_dir=None, install={}, + post_install=None, allow_sudo=False, + ) + url = resolve_download_url(pkg, "nvim.tar.gz", "linux-x64") + assert "/download/v0.10.4/" in url + def test_github_latest(self): pkg = PackageDef( name="nvim", type="binary", sources={}, @@ -181,7 +230,24 @@ class TestPmCommands: cmd = pm_install_command("apt", ["fd-find"]) assert "apt-get install" in cmd + def test_brew_cask_install(self): + cmd = pm_cask_install_command("brew", ["wezterm"]) + assert "--cask" in cmd + assert "wezterm" in cmd + def test_detect_package_manager_returns_something(self): # Just verify it doesn't error result = detect_package_manager() assert result is None or result in ("apt", "dnf", "brew") + + +class TestPlanning: + def test_cask_package_is_planned(self): + pkg = PackageDef( + name="wezterm", type="cask", sources={"brew": "wezterm"}, + source=None, version=None, asset_pattern=None, + platform_map={}, extract_dir=None, install={}, + post_install=None, allow_sudo=False, + ) + plan = plan_install([pkg], InstalledState(), "macos-arm64", "brew") + assert plan.install_ops[0].method == "cask" diff --git a/tests/test_domain_remote.py b/tests/test_domain_remote.py index 5a3cc8c..f9a30f9 100644 --- a/tests/test_domain_remote.py +++ b/tests/test_domain_remote.py @@ -16,7 +16,14 @@ from flow.domain.remote.resolution import ( class TestParseTarget: def test_valid_spec(self): - ns, plat = parse_target("personal@orb") + user, ns, plat = parse_target("personal@orb") + assert user is None + assert ns == "personal" + assert plat == "orb" + + def test_valid_spec_with_user(self): + user, ns, plat = parse_target("alice@personal@orb") + assert user == "alice" assert ns == "personal" assert plat == "orb" @@ -32,33 +39,40 @@ class TestParseTarget: class TestResolveTarget: def test_found(self): targets = [TargetConfig(namespace="personal", platform="orb", host="personal.orb")] - result = resolve_target("personal@orb", targets) + result = resolve_target("personal@orb", targets, default_user="tomas") assert result.host == "personal.orb" assert result.label == "personal@orb" + assert result.user == "tomas" def test_not_found(self): with pytest.raises(FlowError, match="Unknown target"): - resolve_target("missing@host", []) + resolve_target("missing@host", [], default_user="tomas") + + def test_falls_back_to_host_template(self): + result = resolve_target("personal@orb", [], default_user="tomas") + assert result.host == "personal.orb" class TestBuildSSHCommand: def test_basic(self): - target = Target(namespace="personal", platform="orb", host="personal.orb") + target = Target(namespace="personal", platform="orb", host="personal.orb", user="tomas") cmd = build_ssh_command(target) assert "ssh" in cmd.argv - assert "personal.orb" in cmd.argv + assert cmd.destination == "tomas@personal.orb" assert cmd.env["DF_NAMESPACE"] == "personal" + assert "tmux" in cmd.argv def test_with_identity(self): - target = Target(namespace="work", platform="ec2", host="work.ec2", identity="~/.ssh/id_work") + target = Target(namespace="work", platform="ec2", host="work.ec2", identity="~/.ssh/id_work", user="tomas") cmd = build_ssh_command(target) assert "-i" in cmd.argv assert "~/.ssh/id_work" in cmd.argv - def test_with_remote_command(self): - target = Target(namespace="p", platform="o", host="h") - cmd = build_ssh_command(target, remote_command="ls -la") - assert cmd.argv[-1] == "ls -la" + def test_without_tmux(self): + target = Target(namespace="p", platform="o", host="h", user="tomas") + cmd = build_ssh_command(target, no_tmux=True) + assert "tmux" not in cmd.argv + assert cmd.destination == "tomas@h" class TestListTargets: @@ -73,7 +87,6 @@ class TestListTargets: class TestTerminfoFix: - def test_returns_commands(self): - cmds = terminfo_fix_command() - assert len(cmds) == 2 - assert "infocmp" in cmds[0] + def test_returns_command(self): + cmd = terminfo_fix_command() + assert "infocmp" in cmd diff --git a/tests/test_service_bootstrap.py b/tests/test_service_bootstrap.py index e7c46fe..9549afb 100644 --- a/tests/test_service_bootstrap.py +++ b/tests/test_service_bootstrap.py @@ -1,7 +1,5 @@ """Tests for BootstrapService.""" -from pathlib import Path - import pytest from flow.core.config import AppConfig, FlowContext @@ -65,3 +63,61 @@ class TestBootstrapService: svc = BootstrapService(ctx) svc.list_profiles() assert "No profiles" in capsys.readouterr().out + + def test_run_preserves_profile_package_overrides(self, monkeypatch): + captured = {} + + class StubPackageService: + def __init__(self, ctx): + pass + + def install(self, packages, *, dry_run=False): + captured["packages"] = packages + + monkeypatch.setattr("flow.services.packages.PackageService", StubPackageService) + monkeypatch.setattr("flow.services.dotfiles.DotfilesService.link", lambda self, profile=None: None) + + manifest = { + "profiles": { + "linux-auto": { + "os": "linux", + "packages": [{ + "name": "docker", + "allow-sudo": True, + "post-install": "sudo groupadd docker || true", + }], + }, + }, + "packages": [{"name": "docker", "type": "pkg", "sources": {"apt": "docker-ce"}}], + } + ctx = _make_ctx(manifest) + BootstrapService(ctx).run("linux-auto") + + assert captured["packages"][0].allow_sudo is True + assert captured["packages"][0].post_install == "sudo groupadd docker || true" + + def test_run_uses_dotfiles_profile_override(self, monkeypatch): + captured = {} + + monkeypatch.setattr("flow.services.packages.PackageService.install", lambda self, packages, dry_run=False: None) + + class StubDotfilesService: + def __init__(self, ctx): + pass + + def link(self, profile=None): + captured["profile"] = profile + + monkeypatch.setattr("flow.services.dotfiles.DotfilesService", StubDotfilesService) + + manifest = { + "profiles": { + "linux-auto": { + "os": "linux", + "dotfiles-profile": "linux-work", + }, + }, + } + ctx = _make_ctx(manifest) + BootstrapService(ctx).run("linux-auto") + assert captured["profile"] == "linux-work" diff --git a/tests/test_service_containers.py b/tests/test_service_containers.py index 4234cf8..2ec34d3 100644 --- a/tests/test_service_containers.py +++ b/tests/test_service_containers.py @@ -1,13 +1,11 @@ """Tests for ContainerService.""" import subprocess -from pathlib import Path -from unittest.mock import MagicMock, patch from flow.core.config import AppConfig, FlowContext from flow.core.console import Console from flow.core.platform import PlatformInfo -from flow.core.runtime import CommandRunner, FileSystem, SystemRuntime +from flow.core.runtime import CommandRunner, SystemRuntime from flow.core import paths from flow.services.containers import ContainerService @@ -19,6 +17,11 @@ class FakeRunner(CommandRunner): def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None): self.calls.append(("run", list(argv))) + command = list(argv) + if command[:4] == ["docker", "container", "ls", "-a"]: + return subprocess.CompletedProcess(argv, 0, stdout="dev-api\n", stderr="") + if command[:3] == ["docker", "container", "ls"]: + return subprocess.CompletedProcess(argv, 0, stdout="dev-api\n", stderr="") return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") def run_shell(self, command, *, cwd=None, env=None, capture_output=True, check=False, timeout=None): @@ -43,31 +46,35 @@ class TestContainerService: def test_create_dry_run(self, tmp_path, capsys, monkeypatch): monkeypatch.setattr(paths, "HOME", tmp_path) monkeypatch.setattr(paths, "DOTFILES_DIR", tmp_path / "dotfiles") + monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker") ctx = _make_ctx(tmp_path) svc = ContainerService(ctx) - svc.create("devbox", "personal", dry_run=True) + svc.create("api", "tm0/node", dry_run=True) output = capsys.readouterr().out - assert "devbox" in output + assert "dev-api" in output - def test_list_no_docker(self, tmp_path, capsys): + def test_list_no_containers(self, tmp_path, capsys, monkeypatch): runner = FakeRunner() + monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker") + runner.run = lambda argv, **kwargs: subprocess.CompletedProcess(argv, 0, stdout="", stderr="") ctx = _make_ctx(tmp_path, runner=runner) svc = ContainerService(ctx) svc.list() - # FakeRunner returns empty stdout -> "No flow containers" output = capsys.readouterr().out assert "No flow containers" in output - def test_stop_calls_docker(self, tmp_path): + def test_stop_calls_docker(self, tmp_path, monkeypatch): runner = FakeRunner() + monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker") ctx = _make_ctx(tmp_path, runner=runner) svc = ContainerService(ctx) - svc.stop("flow-personal-devbox") + svc.stop("api") assert any("docker" in str(c) and "stop" in str(c) for c in runner.calls) - def test_remove_calls_docker(self, tmp_path): + def test_remove_calls_docker(self, tmp_path, monkeypatch): runner = FakeRunner() + monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker") ctx = _make_ctx(tmp_path, runner=runner) svc = ContainerService(ctx) - svc.remove("flow-personal-devbox") + svc.remove("api") assert any("docker" in str(c) and "rm" in str(c) for c in runner.calls) diff --git a/tests/test_service_dotfiles.py b/tests/test_service_dotfiles.py index 27bd3f4..fdd8038 100644 --- a/tests/test_service_dotfiles.py +++ b/tests/test_service_dotfiles.py @@ -1,18 +1,28 @@ """Tests for DotfilesService.""" +import subprocess from pathlib import Path -from unittest.mock import MagicMock import yaml from flow.core.config import AppConfig, FlowContext from flow.core.console import Console from flow.core.platform import PlatformInfo -from flow.core.runtime import SystemRuntime +from flow.core.runtime import CommandRunner, SystemRuntime from flow.core import paths from flow.services.dotfiles import DotfilesService +class FakeRunner(CommandRunner): + def __init__(self): + self.calls: list[list[str]] = [] + + def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None): + command = [str(part) for part in argv] + self.calls.append(command) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + def _make_ctx(tmp_path, console=None): """Build a FlowContext for testing.""" return FlowContext( @@ -170,3 +180,59 @@ class TestDotfilesServiceLink: svc.status() output = capsys.readouterr().out assert "zsh" in output + + def test_relink_does_not_remove_unmanaged_file(self, tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + + dotfiles = _setup_dotfiles(tmp_path, { + "zsh": {".zshrc": "# zsh"}, + }) + + monkeypatch.setattr(paths, "HOME", home) + monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) + monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") + monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") + + ctx = _make_ctx(tmp_path) + svc = DotfilesService(ctx) + svc.link() + + target = home / ".zshrc" + target.unlink() + target.write_text("user managed file") + + svc.link() + assert target.read_text() == "user managed file" + assert not target.is_symlink() + + def test_sync_modules_includes_profile_layers(self, tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + dotfiles = tmp_path / "dotfiles" + profile_pkg = dotfiles / "linux-work" / "nvim" / ".config" / "nvim" + profile_pkg.mkdir(parents=True) + (profile_pkg / "_module.yaml").write_text(yaml.dump({ + "source": "github:test/nvim-config", + "ref": {"branch": "main"}, + })) + + monkeypatch.setattr(paths, "HOME", home) + monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) + monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") + monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") + + runtime = SystemRuntime() + runner = FakeRunner() + runtime.runner = runner + runtime.git.runner = runner + ctx = FlowContext( + config=AppConfig(), + manifest={}, + platform=PlatformInfo(), + console=Console(color=False), + runtime=runtime, + ) + + DotfilesService(ctx).sync_modules() + assert any("linux-work--nvim" in " ".join(call) for call in runner.calls) diff --git a/tests/test_service_packages.py b/tests/test_service_packages.py index da72afc..b88e665 100644 --- a/tests/test_service_packages.py +++ b/tests/test_service_packages.py @@ -1,5 +1,7 @@ """Tests for PackageService.""" +import io +import tarfile from pathlib import Path import pytest @@ -63,3 +65,68 @@ class TestPackageService: svc = PackageService(ctx) with pytest.raises(FlowError, match="Specify"): svc.install() + + def test_list_all_known_packages(self, tmp_path, monkeypatch, capsys): + monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json") + manifest = {"packages": [{"name": "fd", "type": "pkg"}]} + ctx = _make_ctx(tmp_path, manifest) + svc = PackageService(ctx) + svc.list_packages(show_all=True) + assert "fd" in capsys.readouterr().out + + def test_install_binary_honors_declared_install_map(self, tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setattr(paths, "DATA_DIR", tmp_path / "data") + monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json") + + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode="w:gz") as tar: + files = { + "nvim-linux64/bin/nvim": b"#!/bin/sh\n", + "nvim-linux64/share/nvim/runtime.txt": b"runtime\n", + "nvim-linux64/share/man/man1/nvim.1": b"manpage\n", + } + for name, content in files.items(): + info = tarfile.TarInfo(name=name) + info.size = len(content) + tar.addfile(info, io.BytesIO(content)) + archive_bytes = archive.getvalue() + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return archive_bytes + + monkeypatch.setattr("flow.services.packages.urllib.request.urlopen", lambda *args, **kwargs: FakeResponse()) + + manifest = { + "packages": [{ + "name": "neovim", + "type": "binary", + "source": "github:neovim/neovim", + "version": "0.10.4", + "asset-pattern": "nvim-{{os}}-{{arch}}.tar.gz", + "platform-map": {"linux-x64": {"os": "linux", "arch": "x64"}}, + "extract-dir": "nvim-{{os}}64", + "install": { + "bin": ["bin/nvim"], + "share": ["share/nvim"], + "man": ["share/man/man1/nvim.1"], + }, + }], + } + ctx = _make_ctx(tmp_path, manifest) + svc = PackageService(ctx) + packages = svc.resolve_install_packages(package_names=["neovim"]) + svc.install(packages) + + assert (home / ".local" / "bin" / "nvim").exists() + assert (home / ".local" / "share" / "nvim" / "runtime.txt").exists() + assert (home / ".local" / "share" / "man" / "man1" / "nvim.1").exists() diff --git a/tests/test_service_remote.py b/tests/test_service_remote.py index 77a7e77..a673a0b 100644 --- a/tests/test_service_remote.py +++ b/tests/test_service_remote.py @@ -29,6 +29,8 @@ class TestRemoteService: output = capsys.readouterr().out assert "personal@orb" in output assert "ssh" in output + assert "tmux" in output + assert "DF_NAMESPACE=personal" in output def test_enter_unknown_target(self): ctx = _make_ctx()