From 1217337fbb8545854c5dd48468553b23e1f3a9c3 Mon Sep 17 00:00:00 2001 From: Tomas Mirchev Date: Fri, 13 Feb 2026 09:57:38 +0200 Subject: [PATCH] update --- AGENTS.md | 160 +++++++++++++ README.md | 19 +- example/README.md | 78 +++++++ .../common/bin/.local/bin/flow-hello | 3 + .../common/flow/.config/flow/config | 15 ++ .../common/flow/.config/flow/env.sh | 2 + .../common/flow/.config/flow/manifest.yaml | 96 ++++++++ example/dotfiles-repo/common/git/.gitconfig | 9 + .../common/nvim/.config/nvim/init.lua | 6 + example/dotfiles-repo/common/tmux/.tmux.conf | 3 + example/dotfiles-repo/common/zsh/.zshrc | 8 + .../profiles/work/git/.gitconfig | 6 + .../dotfiles-repo/profiles/work/zsh/.zshrc | 7 + src/flow/commands/bootstrap.py | 186 +++++++++++++++- src/flow/commands/completion.py | 31 ++- src/flow/commands/dotfiles.py | 210 ++++++++++++++---- tests/test_bootstrap.py | 61 ++++- tests/test_cli.py | 2 + tests/test_completion.py | 14 ++ tests/test_dotfiles.py | 16 +- 20 files changed, 867 insertions(+), 65 deletions(-) create mode 100644 AGENTS.md create mode 100644 example/README.md create mode 100644 example/dotfiles-repo/common/bin/.local/bin/flow-hello create mode 100644 example/dotfiles-repo/common/flow/.config/flow/config create mode 100644 example/dotfiles-repo/common/flow/.config/flow/env.sh create mode 100644 example/dotfiles-repo/common/flow/.config/flow/manifest.yaml create mode 100644 example/dotfiles-repo/common/git/.gitconfig create mode 100644 example/dotfiles-repo/common/nvim/.config/nvim/init.lua create mode 100644 example/dotfiles-repo/common/tmux/.tmux.conf create mode 100644 example/dotfiles-repo/common/zsh/.zshrc create mode 100644 example/dotfiles-repo/profiles/work/git/.gitconfig create mode 100644 example/dotfiles-repo/profiles/work/zsh/.zshrc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..adb8e8f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,160 @@ +# AGENTS.md + +Guidance for coding agents working in this repository. + +## Project at a glance +- Language: Python +- Package layout: `src/flow/` +- Tests: `tests/` with `pytest` +- Build backend: Hatchling (`pyproject.toml`) +- CLI entrypoint: `flow.cli:main` (`python3 -m flow`) +- Binary packaging: PyInstaller (`Makefile`) + +## Build, test, and validation commands + +### Setup +```bash +python3 -m venv .venv +.venv/bin/pip install -e ".[dev]" +``` + +### Run CLI locally +```bash +python3 -m flow --help +python3 -m flow --version +``` + +### Run all tests +```bash +python3 -m pytest +``` + +### Run a single test file +```bash +python3 -m pytest tests/test_cli.py +``` + +### Run a single test function +```bash +python3 -m pytest tests/test_cli.py::test_version +``` + +### Run a single test class +```bash +python3 -m pytest tests/test_commands.py::TestParseImageRef +``` + +### Run tests by keyword expression +```bash +python3 -m pytest -k "terminfo" +``` + +### Build standalone binary +```bash +make build +``` + +### Install local binary +```bash +make install-local +``` + +### Smoke-check built binary +```bash +make check-binary +``` + +### Clean build artifacts +```bash +make clean +``` + +### Lint/format/type-check status +- No dedicated lint/format/type-check commands are currently configured. +- There is no Ruff/Black/isort/mypy config in `pyproject.toml`. +- Agents must follow existing style and keep diffs readable. + +## Code style conventions + +### Imports +- Group imports as stdlib, third-party, then local (`flow.*`). +- Use explicit imports; avoid wildcard imports. +- Keep imports at module top unless a local import prevents a cycle or unnecessary load. + +### Formatting +- Use 4 spaces for indentation. +- Keep lines readable; wrap long calls/literals across lines. +- Keep trailing commas in multiline calls/lists where helpful for cleaner diffs. +- Match local quoting style within each file (mostly double quotes). + +### Typing +- Codebase uses gradual typing, not strict typing. +- Add type hints for new helpers, dataclasses, and public functions. +- Follow local module style (`Optional`, `Dict`, `List` vs builtin generics). + +### Naming +- `snake_case` for functions, variables, module-level helpers. +- `PascalCase` for classes/dataclasses. +- `UPPER_SNAKE_CASE` for constants. +- Prefix private helpers with `_` when not part of module API. + +### Command module patterns +- Each command module exposes `register(subparsers)`. +- Subcommands bind handlers via `set_defaults(handler=...)`. +- Runtime handler signature is typically `(ctx: FlowContext, args)`. +- Keep parse/plan/execute steps separated when logic grows. + +### Error handling +- Use concise user-facing messages via `ctx.console.error(...)`. +- Use `sys.exit(1)` for expected CLI failures. +- Keep `KeyboardInterrupt` behavior as exit `130`. +- Raise `RuntimeError` in lower-level helpers for domain failures. +- Avoid noisy tracebacks for normal user mistakes. + +### Subprocess safety +- Prefer `subprocess.run([...], check=True)` when possible. +- For shell strings, quote dynamic values (`shlex.quote`). +- Avoid passing unchecked external input into shell commands. +- Use `capture_output=True, text=True` when parsing command output. + +### Filesystem and paths +- Prefer `pathlib.Path` for path-heavy logic. +- Expand `~` intentionally when required. +- Ensure parent dirs exist before write/link operations. + +### Tests +- Add/update tests for behavior changes. +- Keep tests deterministic and host-independent when possible. +- Favor focused tests for parser/planner helper functions. + +## Architecture notes +- `src/flow/cli.py`: parser wiring, context creation, top-level exception handling. +- `src/flow/commands/`: user-facing command implementations. +- `src/flow/core/`: shared primitives (`config`, `console`, `process`, `stow`, etc.). +- `FlowContext` holds config, manifest, platform info, and console logger. + +## Behavior constraints to preserve +- Self-hosted config/manifest priority: dotfiles path first, local fallback. +- Manifest uses `profiles`; legacy `environments` is intentionally rejected. +- Dotfiles link state supports v2 format only (`linked.json`). + +## Cursor/Copilot rule files +No repository-level rule files were found at the time of writing: +- `.cursorrules` not found +- `.cursor/rules/` not found +- `.github/copilot-instructions.md` not found + +If these files are later added, treat them as authoritative and update this guide. + +## Workspace hygiene +- Do not commit generated outputs: `build/`, `dist/`, `*.spec`. +- Do not commit Python bytecode/cache files: `__pycache__/`, `*.pyc`, `*.pyo`. +- Keep changes scoped; avoid unrelated refactors. +- Update tests/docs when user-facing behavior changes. + +## Agent checklist before finishing +- Run targeted tests for touched behavior. +- Run full suite: `python3 -m pytest`. +- If packaging/build paths changed, run `make build`. +- Verify no generated artifacts are staged. +- Ensure errors are concise and actionable. diff --git a/README.md b/README.md index 84df593..ffbfa01 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,11 @@ work@ec2 = work.internal ~/.ssh/id_work ## Manifest format -The manifest is YAML with two top-level sections used by the current code: +The manifest is YAML with these top-level sections used by the current code: - `profiles` for bootstrap profiles - `binaries` for package definitions +- `package-map` for cross-package-manager name mapping `environments` is no longer supported. @@ -78,7 +79,7 @@ profiles: locale: en_US.UTF-8 requires: [HOSTNAME] packages: - standard: [git, tmux, zsh] + standard: [git, tmux, zsh, fd] binary: [neovim] ssh_keygen: - type: ed25519 @@ -86,6 +87,12 @@ profiles: runcmd: - mkdir -p ~/projects +package-map: + fd: + apt: fd-find + dnf: fd-find + brew: fd + binaries: neovim: source: github:neovim/neovim @@ -134,6 +141,9 @@ flow dotfiles link flow dotfiles status flow dotfiles relink flow dotfiles clean --dry-run +flow dotfiles repo status +flow dotfiles repo pull --relink +flow dotfiles repo push ``` ### Bootstrap @@ -141,10 +151,15 @@ flow dotfiles clean --dry-run ```bash flow bootstrap list flow bootstrap show linux-vm +flow bootstrap packages --profile linux-vm +flow bootstrap packages --profile linux-vm --resolved flow bootstrap run --profile linux-vm --var HOSTNAME=devbox flow bootstrap run --profile linux-vm --dry-run ``` +`flow bootstrap` auto-detects the package manager (`brew`, `apt`, `dnf`) when +`package-manager` is not set in a profile. + ### Packages ```bash diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..ab054b7 --- /dev/null +++ b/example/README.md @@ -0,0 +1,78 @@ +# Example working scenario + +This folder contains a complete, practical dotfiles + bootstrap setup that exercises most `flow` +features. + +## What this example shows + +- Dotfiles repository layout with `common/` packages and `profiles/work/` overrides +- Self-hosted `flow` config + manifest in `common/flow/.config/flow/` +- Bootstrap profiles for Linux (auto PM detection), Ubuntu (`apt`), Fedora (`dnf`), and macOS + (`brew`) +- Bootstrap actions: `requires`, `hostname`, `locale`, `shell`, package install, binary install, + `ssh_keygen`, `configs`, and `runcmd` +- Package name mapping via `package-map` (`apt`/`dnf`/`brew`) +- Dotfiles repo workflows: `status`, `pull`, `push`, `sync --relink`, and `edit` + +## Layout + +- `dotfiles-repo/common/flow/.config/flow/config` example `flow` config +- `dotfiles-repo/common/flow/.config/flow/manifest.yaml` profiles + package map + binaries +- `dotfiles-repo/common/zsh/.zshrc`, `common/git/.gitconfig`, `common/tmux/.tmux.conf` +- `dotfiles-repo/common/nvim/.config/nvim/init.lua` +- `dotfiles-repo/common/bin/.local/bin/flow-hello` +- `dotfiles-repo/profiles/work/git/.gitconfig` and `profiles/work/zsh/.zshrc` overrides + +## Quick start + +Use the absolute path to this local example repo: + +```bash +EXAMPLE_REPO="/ABSOLUTE/PATH/TO/flow-cli/example/dotfiles-repo" +``` + +Initialize and link dotfiles: + +```bash +flow dotfiles init --repo "$EXAMPLE_REPO" +flow dotfiles link +flow dotfiles status +``` + +Check repo commands: + +```bash +flow dotfiles repo status +flow dotfiles repo pull --relink +flow dotfiles repo push +``` + +Edit package or file/path targets: + +```bash +flow dotfiles edit zsh --no-commit +flow dotfiles edit common/flow/.config/flow/manifest.yaml --no-commit +``` + +Inspect bootstrap profiles and package resolution: + +```bash +flow bootstrap list +flow bootstrap packages --resolved +flow bootstrap packages --profile fedora-dev --resolved +flow bootstrap show linux-auto +``` + +Run bootstrap in dry-run mode: + +```bash +flow bootstrap run --profile linux-auto --var TARGET_HOSTNAME=devbox --var USER_EMAIL=you@example.com --dry-run +flow bootstrap run --profile work-linux --var WORK_EMAIL=you@company.com --dry-run +``` + +## Manifest notes + +- `linux-auto` omits `package-manager` to demonstrate auto-detection. +- `ubuntu-dev` uses legacy `packages.package` key to show compatibility. +- `package-map` rewrites logical names like `fd` and `python-dev` per package manager. +- If mapping is missing for the selected manager, `flow` uses the original package name and warns. diff --git a/example/dotfiles-repo/common/bin/.local/bin/flow-hello b/example/dotfiles-repo/common/bin/.local/bin/flow-hello new file mode 100644 index 0000000..7b8564b --- /dev/null +++ b/example/dotfiles-repo/common/bin/.local/bin/flow-hello @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Hello from flow example dotfiles" diff --git a/example/dotfiles-repo/common/flow/.config/flow/config b/example/dotfiles-repo/common/flow/.config/flow/config new file mode 100644 index 0000000..1cc41f6 --- /dev/null +++ b/example/dotfiles-repo/common/flow/.config/flow/config @@ -0,0 +1,15 @@ +[repository] +dotfiles_url = /ABSOLUTE/PATH/TO/flow-cli/example/dotfiles-repo +dotfiles_branch = main + +[paths] +projects_dir = ~/projects + +[defaults] +container_registry = registry.example.com +container_tag = latest +tmux_session = default + +[targets] +personal = orb personal.orb +work@ec2 = work.internal ~/.ssh/id_work diff --git a/example/dotfiles-repo/common/flow/.config/flow/env.sh b/example/dotfiles-repo/common/flow/.config/flow/env.sh new file mode 100644 index 0000000..0921405 --- /dev/null +++ b/example/dotfiles-repo/common/flow/.config/flow/env.sh @@ -0,0 +1,2 @@ +export FLOW_ENV=example +export FLOW_EDITOR=vim diff --git a/example/dotfiles-repo/common/flow/.config/flow/manifest.yaml b/example/dotfiles-repo/common/flow/.config/flow/manifest.yaml new file mode 100644 index 0000000..b5c9a52 --- /dev/null +++ b/example/dotfiles-repo/common/flow/.config/flow/manifest.yaml @@ -0,0 +1,96 @@ +profiles: + linux-auto: + os: linux + requires: [TARGET_HOSTNAME, USER_EMAIL] + hostname: "$TARGET_HOSTNAME" + locale: en_US.UTF-8 + shell: zsh + packages: + standard: [git, tmux, zsh, fd, ripgrep, python-dev] + binary: [neovim, lazygit] + ssh_keygen: + - type: ed25519 + filename: id_ed25519 + comment: "$USER_EMAIL" + configs: [flow, zsh, git, tmux, nvim, bin] + runcmd: + - mkdir -p ~/projects + - git config --global user.email "$USER_EMAIL" + + ubuntu-dev: + os: linux + package-manager: apt + packages: + package: [git, tmux, zsh, fd, ripgrep, python-dev] + binary: [neovim] + configs: [flow, zsh, git, tmux] + + fedora-dev: + os: linux + package-manager: dnf + packages: + standard: [git, tmux, zsh, fd, ripgrep, python-dev] + binary: [neovim] + configs: [flow, zsh, git, tmux] + + macos-dev: + os: macos + package-manager: brew + packages: + standard: [git, tmux, zsh, fd, ripgrep] + cask: [wezterm] + binary: [neovim] + configs: [flow, zsh, git, nvim] + + work-linux: + os: linux + package-manager: apt + requires: [WORK_EMAIL] + packages: + standard: [git, tmux, zsh] + configs: [git, zsh] + runcmd: + - git config --global user.email "$WORK_EMAIL" + +package-map: + fd: + apt: fd-find + dnf: fd-find + brew: fd + python-dev: + apt: python3-dev + dnf: python3-devel + brew: python + ripgrep: + apt: ripgrep + dnf: ripgrep + brew: ripgrep + +binaries: + neovim: + source: github:neovim/neovim + version: "0.10.4" + asset-pattern: "nvim-{{os}}-{{arch}}.tar.gz" + platform-map: + linux-amd64: { os: linux, arch: x86_64 } + linux-arm64: { os: linux, arch: arm64 } + macos-arm64: { os: macos, arch: arm64 } + install-script: | + curl -fL "{{downloadUrl}}" -o /tmp/nvim.tar.gz + tar -xzf /tmp/nvim.tar.gz -C /tmp + mkdir -p ~/.local/bin + cp /tmp/nvim-*/bin/nvim ~/.local/bin/nvim + + lazygit: + source: github:jesseduffield/lazygit + version: "0.44.1" + asset-pattern: "lazygit_{{version}}_{{os}}_{{arch}}.tar.gz" + platform-map: + linux-amd64: { os: Linux, arch: x86_64 } + linux-arm64: { os: Linux, arch: arm64 } + macos-arm64: { os: Darwin, arch: arm64 } + install-script: | + curl -fL "{{downloadUrl}}" -o /tmp/lazygit.tar.gz + tar -xzf /tmp/lazygit.tar.gz -C /tmp + mkdir -p ~/.local/bin + cp /tmp/lazygit ~/.local/bin/lazygit diff --git a/example/dotfiles-repo/common/git/.gitconfig b/example/dotfiles-repo/common/git/.gitconfig new file mode 100644 index 0000000..d42a31c --- /dev/null +++ b/example/dotfiles-repo/common/git/.gitconfig @@ -0,0 +1,9 @@ +[user] + name = Example User + email = example@example.com + +[init] + defaultBranch = main + +[pull] + rebase = true diff --git a/example/dotfiles-repo/common/nvim/.config/nvim/init.lua b/example/dotfiles-repo/common/nvim/.config/nvim/init.lua new file mode 100644 index 0000000..4555de8 --- /dev/null +++ b/example/dotfiles-repo/common/nvim/.config/nvim/init.lua @@ -0,0 +1,6 @@ +vim.opt.number = true +vim.opt.relativenumber = true +vim.opt.expandtab = true +vim.opt.shiftwidth = 2 + +vim.g.mapleader = " " diff --git a/example/dotfiles-repo/common/tmux/.tmux.conf b/example/dotfiles-repo/common/tmux/.tmux.conf new file mode 100644 index 0000000..fee94c9 --- /dev/null +++ b/example/dotfiles-repo/common/tmux/.tmux.conf @@ -0,0 +1,3 @@ +set -g mouse on +set -g history-limit 100000 +setw -g mode-keys vi diff --git a/example/dotfiles-repo/common/zsh/.zshrc b/example/dotfiles-repo/common/zsh/.zshrc new file mode 100644 index 0000000..9cf61b0 --- /dev/null +++ b/example/dotfiles-repo/common/zsh/.zshrc @@ -0,0 +1,8 @@ +export EDITOR=vim +export PATH="$HOME/.local/bin:$PATH" + +alias ll='ls -lah' + +if [ -f "$HOME/.config/flow/env.sh" ]; then + . "$HOME/.config/flow/env.sh" +fi diff --git a/example/dotfiles-repo/profiles/work/git/.gitconfig b/example/dotfiles-repo/profiles/work/git/.gitconfig new file mode 100644 index 0000000..f16d56d --- /dev/null +++ b/example/dotfiles-repo/profiles/work/git/.gitconfig @@ -0,0 +1,6 @@ +[user] + name = Example Work User + email = work@example.com + +[url "git@github.com:work/"] + insteadOf = https://github.com/work/ diff --git a/example/dotfiles-repo/profiles/work/zsh/.zshrc b/example/dotfiles-repo/profiles/work/zsh/.zshrc new file mode 100644 index 0000000..300915f --- /dev/null +++ b/example/dotfiles-repo/profiles/work/zsh/.zshrc @@ -0,0 +1,7 @@ +export EDITOR=vim +export PATH="$HOME/.local/bin:$PATH" + +alias ll='ls -lah' +alias gs='git status -sb' + +export WORK_MODE=1 diff --git a/src/flow/commands/bootstrap.py b/src/flow/commands/bootstrap.py index 0deb527..b8675a0 100644 --- a/src/flow/commands/bootstrap.py +++ b/src/flow/commands/bootstrap.py @@ -6,7 +6,7 @@ import shlex import shutil import sys from pathlib import Path -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from flow.core.action import Action, ActionExecutor from flow.core.config import FlowContext, load_manifest @@ -38,6 +38,16 @@ def register(subparsers): show.add_argument("profile", help="Profile name") show.set_defaults(handler=run_show) + # packages + packages = sub.add_parser("packages", help="List packages defined in profiles") + packages.add_argument("--profile", help="Profile name (default: all profiles)") + packages.add_argument( + "--resolved", + action="store_true", + help="Show resolved package names for detected package manager", + ) + packages.set_defaults(handler=run_packages) + p.set_defaults(handler=lambda ctx, args: p.print_help()) @@ -68,6 +78,111 @@ def _parse_variables(var_args: list) -> dict: return variables +def _parse_os_release(text: str) -> Dict[str, str]: + data: Dict[str, str] = {} + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + data[key] = value.strip().strip('"').strip("'") + return data + + +def _linux_package_manager_from_os_release(os_release: Dict[str, str]) -> Optional[str]: + tokens = set() + for field in (os_release.get("ID", ""), os_release.get("ID_LIKE", "")): + for token in field.replace(",", " ").split(): + tokens.add(token.lower()) + + if tokens & {"debian", "ubuntu", "linuxmint", "pop"}: + return "apt" + if tokens & {"fedora", "rhel", "centos", "rocky", "almalinux"}: + return "dnf" + return None + + +def _auto_detect_package_manager(ctx: FlowContext, os_release_text: Optional[str] = None) -> str: + if ctx.platform.os == "macos": + return "brew" + + if ctx.platform.os == "linux": + if os_release_text is None: + try: + os_release_text = Path("/etc/os-release").read_text() + except OSError: + os_release_text = "" + + if os_release_text: + parsed = _parse_os_release(os_release_text) + detected = _linux_package_manager_from_os_release(parsed) + if detected: + return detected + + for candidate in ("apt", "apt-get", "dnf", "brew"): + if shutil.which(candidate): + return candidate + + return "apt-get" + + +def _resolve_package_manager( + ctx: FlowContext, + env_config: dict, + *, + os_release_text: Optional[str] = None, +) -> str: + explicit = env_config.get("package-manager") + if explicit: + return explicit + return _auto_detect_package_manager(ctx, os_release_text=os_release_text) + + +def _resolve_package_name( + ctx: FlowContext, + package_name: str, + package_manager: str, + *, + warn_missing: bool = True, +) -> str: + package_map = ctx.manifest.get("package-map", {}) + if not isinstance(package_map, dict): + return package_name + + mapping = package_map.get(package_name) + if mapping is None: + return package_name + + if isinstance(mapping, str): + return mapping + + if not isinstance(mapping, dict): + return package_name + + lookup_order = [package_manager] + if package_manager == "apt-get": + lookup_order.append("apt") + elif package_manager == "apt": + lookup_order.append("apt-get") + + for key in lookup_order: + mapped = mapping.get(key) + if isinstance(mapped, str) and mapped: + return mapped + + if warn_missing: + ctx.console.warn( + f"No package-map entry for '{package_name}' on '{package_manager}', using original name" + ) + return package_name + + +def _package_name_from_spec(spec: Any) -> str: + if isinstance(spec, str): + return spec + return spec["name"] + + def _plan_actions(ctx: FlowContext, profile_name: str, env_config: dict, variables: dict) -> List[Action]: """Plan all actions from a profile configuration.""" actions = [] @@ -114,7 +229,7 @@ def _plan_actions(ctx: FlowContext, profile_name: str, env_config: dict, variabl # Packages if "packages" in env_config: packages_config = env_config["packages"] - pm = env_config.get("package-manager", "apt-get") + pm = _resolve_package_manager(ctx, env_config) # Package manager update actions.append(Action( @@ -127,10 +242,8 @@ def _plan_actions(ctx: FlowContext, profile_name: str, env_config: dict, variabl # Standard packages standard = [] for pkg in packages_config.get("standard", []) + packages_config.get("package", []): - if isinstance(pkg, str): - standard.append(pkg) - else: - standard.append(pkg["name"]) + pkg_name = _package_name_from_spec(pkg) + standard.append(_resolve_package_name(ctx, pkg_name, pm, warn_missing=True)) if standard: actions.append(Action( @@ -143,10 +256,8 @@ def _plan_actions(ctx: FlowContext, profile_name: str, env_config: dict, variabl # Cask packages (macOS) cask = [] for pkg in packages_config.get("cask", []): - if isinstance(pkg, str): - cask.append(pkg) - else: - cask.append(pkg["name"]) + pkg_name = _package_name_from_spec(pkg) + cask.append(_resolve_package_name(ctx, pkg_name, pm, warn_missing=True)) if cask: actions.append(Action( @@ -160,7 +271,7 @@ def _plan_actions(ctx: FlowContext, profile_name: str, env_config: dict, variabl # Binary packages binaries_manifest = ctx.manifest.get("binaries", {}) for pkg in packages_config.get("binary", []): - pkg_name = pkg if isinstance(pkg, str) else pkg["name"] + pkg_name = _package_name_from_spec(pkg) binary_def = binaries_manifest.get(pkg_name, {}) actions.append(Action( type="install-binary", @@ -242,6 +353,7 @@ def _register_handlers(executor: ActionExecutor, ctx: FlowContext, variables: di commands = { "apt-get": "sudo apt-get update -qq", "apt": "sudo apt update -qq", + "dnf": "sudo dnf makecache -q", "brew": "brew update", } cmd = commands.get(pm, f"sudo {pm} update") @@ -255,6 +367,8 @@ def _register_handlers(executor: ActionExecutor, ctx: FlowContext, variables: di if pm in ("apt-get", "apt"): cmd = f"sudo {pm} install -y {pkg_str}" + elif pm == "dnf": + cmd = f"sudo dnf install -y {pkg_str}" elif pm == "brew" and pkg_type == "cask": cmd = f"brew install --cask {pkg_str}" elif pm == "brew": @@ -422,3 +536,53 @@ def run_show(ctx: FlowContext, args): executor = ActionExecutor(ctx.console) executor.execute(actions, dry_run=True) + + +def run_packages(ctx: FlowContext, args): + profiles = _get_profiles(ctx) + if not profiles: + ctx.console.info("No profiles defined in manifest.") + return + + if args.profile: + if args.profile not in profiles: + ctx.console.error( + f"Profile not found: {args.profile}. Available: {', '.join(profiles.keys())}" + ) + sys.exit(1) + selected_profiles = [(args.profile, profiles[args.profile])] + else: + selected_profiles = sorted(profiles.items()) + + rows = [] + for profile_name, profile_cfg in selected_profiles: + packages_cfg = profile_cfg.get("packages", {}) + pm = _resolve_package_manager(ctx, profile_cfg) + + for section in ("standard", "package", "cask", "binary"): + for spec in packages_cfg.get(section, []): + package_name = _package_name_from_spec(spec) + + if section == "binary": + resolved_name = package_name + else: + resolved_name = _resolve_package_name( + ctx, + package_name, + pm, + warn_missing=False, + ) + + if args.resolved: + rows.append([profile_name, pm, section, package_name, resolved_name]) + else: + rows.append([profile_name, section, package_name]) + + if not rows: + ctx.console.info("No packages defined in selected profile(s).") + return + + if args.resolved: + ctx.console.table(["PROFILE", "PM", "TYPE", "PACKAGE", "RESOLVED"], rows) + else: + ctx.console.table(["PROFILE", "TYPE", "PACKAGE"], rows) diff --git a/src/flow/commands/completion.py b/src/flow/commands/completion.py index cfd28f6..8a2f83b 100644 --- a/src/flow/commands/completion.py +++ b/src/flow/commands/completion.py @@ -277,7 +277,7 @@ def _complete_dev(before: Sequence[str], current: str) -> List[str]: def _complete_dotfiles(before: Sequence[str], current: str) -> List[str]: if len(before) <= 1: return _filter( - ["init", "link", "unlink", "status", "sync", "relink", "clean", "edit"], + ["init", "link", "unlink", "status", "sync", "relink", "clean", "edit", "repo"], current, ) @@ -286,6 +286,21 @@ def _complete_dotfiles(before: Sequence[str], current: str) -> List[str]: if sub == "init": return _filter(["--repo", "-h", "--help"], current) if current.startswith("-") else [] + if sub == "repo": + if len(before) <= 2: + return _filter(["status", "pull", "push"], current) + + repo_sub = before[2] + if repo_sub == "pull": + if before and before[-1] == "--profile": + return _filter(_list_dotfiles_profiles(), current) + if current.startswith("-"): + return _filter(["--rebase", "--no-rebase", "--relink", "--profile", "-h", "--help"], current) + elif current.startswith("-"): + return _filter(["-h", "--help"], current) + + return [] + if sub in {"link", "relink"}: if before and before[-1] == "--profile": return _filter(_list_dotfiles_profiles(), current) @@ -312,12 +327,17 @@ def _complete_dotfiles(before: Sequence[str], current: str) -> List[str]: if sub == "clean": return _filter(["--dry-run", "-h", "--help"], current) if current.startswith("-") else [] + if sub == "sync": + if before and before[-1] == "--profile": + return _filter(_list_dotfiles_profiles(), current) + return _filter(["--relink", "--profile", "-h", "--help"], current) if current.startswith("-") else [] + return [] def _complete_bootstrap(before: Sequence[str], current: str) -> List[str]: if len(before) <= 1: - return _filter(["run", "list", "show"], current) + return _filter(["run", "list", "show", "packages"], current) sub = before[1] @@ -336,6 +356,13 @@ def _complete_bootstrap(before: Sequence[str], current: str) -> List[str]: return _filter(_list_bootstrap_profiles(), current) return [] + if sub == "packages": + if before and before[-1] == "--profile": + return _filter(_list_bootstrap_profiles(), current) + if current.startswith("-"): + return _filter(["--profile", "--resolved", "-h", "--help"], current) + return [] + return [] diff --git a/src/flow/commands/dotfiles.py b/src/flow/commands/dotfiles.py index 55958d7..1a69e56 100644 --- a/src/flow/commands/dotfiles.py +++ b/src/flow/commands/dotfiles.py @@ -1,5 +1,6 @@ """flow dotfiles — dotfile management with GNU Stow-style symlinking.""" +import argparse import json import os import shlex @@ -43,8 +44,30 @@ def register(subparsers): # sync sync = sub.add_parser("sync", help="Pull latest dotfiles from remote") + sync.add_argument("--relink", action="store_true", help="Run relink after pull") + sync.add_argument("--profile", help="Profile to use when relinking") sync.set_defaults(handler=run_sync) + # repo + repo = sub.add_parser("repo", help="Manage dotfiles repository") + repo_sub = repo.add_subparsers(dest="dotfiles_repo_command") + + repo_status = repo_sub.add_parser("status", help="Show git status for dotfiles repo") + repo_status.set_defaults(handler=run_repo_status) + + repo_pull = repo_sub.add_parser("pull", help="Pull latest changes") + repo_pull.add_argument("--rebase", dest="rebase", action="store_true", help="Use rebase strategy (default)") + repo_pull.add_argument("--no-rebase", dest="rebase", action="store_false", help="Disable rebase strategy") + repo_pull.add_argument("--relink", action="store_true", help="Run relink after pull") + repo_pull.add_argument("--profile", help="Profile to use when relinking") + repo_pull.set_defaults(rebase=True) + repo_pull.set_defaults(handler=run_repo_pull) + + repo_push = repo_sub.add_parser("push", help="Push local changes") + repo_push.set_defaults(handler=run_repo_push) + + repo.set_defaults(handler=lambda ctx, args: repo.print_help()) + # relink relink = sub.add_parser("relink", help="Refresh symlinks after changes") relink.add_argument("packages", nargs="*", help="Specific packages to relink (default: all)") @@ -57,8 +80,8 @@ def register(subparsers): clean.set_defaults(handler=run_clean) # edit - edit = sub.add_parser("edit", help="Edit package config with auto-commit") - edit.add_argument("package", help="Package name to edit") + edit = sub.add_parser("edit", help="Edit package or path with auto-commit") + edit.add_argument("target", help="Package name or path inside dotfiles repo") edit.add_argument("--no-commit", action="store_true", help="Skip auto-commit") edit.set_defaults(handler=run_edit) @@ -113,6 +136,79 @@ def _walk_package(source_dir: Path, home: Path): yield src, dst +def _ensure_dotfiles_dir(ctx: FlowContext): + if not DOTFILES_DIR.exists(): + ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.") + sys.exit(1) + + +def _run_dotfiles_git(*cmd, capture: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", "-C", str(DOTFILES_DIR)] + list(cmd), + capture_output=capture, + text=True, + ) + + +def _pull_dotfiles(ctx: FlowContext, *, rebase: bool = True) -> None: + pull_cmd = ["pull"] + if rebase: + pull_cmd.append("--rebase") + + strategy = "with rebase" if rebase else "without rebase" + ctx.console.info(f"Pulling latest dotfiles ({strategy})...") + result = _run_dotfiles_git(*pull_cmd, capture=True) + + if result.returncode != 0: + raise RuntimeError(f"Git pull failed: {result.stderr.strip()}") + + output = result.stdout.strip() + if output: + print(output) + + ctx.console.success("Dotfiles synced.") + + +def _find_package_dir(package_name: str, dotfiles_dir: Path = DOTFILES_DIR) -> Optional[Path]: + common_dir = dotfiles_dir / "common" / package_name + if common_dir.exists(): + return common_dir + + profile_dirs = list((dotfiles_dir / "profiles").glob(f"*/{package_name}")) + if profile_dirs: + return profile_dirs[0] + + return None + + +def _resolve_edit_target(target: str, dotfiles_dir: Path = DOTFILES_DIR) -> Optional[Path]: + base_dir = dotfiles_dir.resolve() + raw = Path(target).expanduser() + if raw.is_absolute(): + try: + raw.resolve().relative_to(base_dir) + except ValueError: + return None + return raw + + is_path_like = "/" in target or target.startswith(".") or raw.suffix != "" + if is_path_like: + candidate = dotfiles_dir / raw + if candidate.exists() or candidate.parent.exists(): + return candidate + return None + + package_dir = _find_package_dir(target, dotfiles_dir=dotfiles_dir) + if package_dir is not None: + return package_dir + + candidate = dotfiles_dir / raw + if candidate.exists(): + return candidate + + return None + + def run_init(ctx: FlowContext, args): repo_url = args.repo or ctx.config.dotfiles_url if not repo_url: @@ -132,9 +228,7 @@ def run_init(ctx: FlowContext, args): def run_link(ctx: FlowContext, args): - if not DOTFILES_DIR.exists(): - ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.") - sys.exit(1) + _ensure_dotfiles_dir(ctx) home = Path.home() packages = _discover_packages(DOTFILES_DIR, args.profile) @@ -296,29 +390,66 @@ def run_status(ctx: FlowContext, args): def run_sync(ctx: FlowContext, args): - if not DOTFILES_DIR.exists(): - ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.") + _ensure_dotfiles_dir(ctx) + + try: + _pull_dotfiles(ctx, rebase=True) + except RuntimeError as e: + ctx.console.error(str(e)) sys.exit(1) - ctx.console.info("Pulling latest dotfiles...") - result = subprocess.run( - ["git", "-C", str(DOTFILES_DIR), "pull", "--rebase"], - capture_output=True, text=True, - ) - if result.returncode == 0: - if result.stdout.strip(): - print(result.stdout.strip()) - ctx.console.success("Dotfiles synced.") - else: - ctx.console.error(f"Git pull failed: {result.stderr.strip()}") + if args.relink: + relink_args = argparse.Namespace(packages=[], profile=args.profile) + run_relink(ctx, relink_args) + + +def run_repo_status(ctx: FlowContext, args): + _ensure_dotfiles_dir(ctx) + + result = _run_dotfiles_git("status", "--short", "--branch", capture=True) + if result.returncode != 0: + ctx.console.error(result.stderr.strip() or "Failed to read dotfiles git status") sys.exit(1) + output = result.stdout.strip() + if output: + print(output) + else: + ctx.console.info("Dotfiles repository is clean.") + + +def run_repo_pull(ctx: FlowContext, args): + _ensure_dotfiles_dir(ctx) + + try: + _pull_dotfiles(ctx, rebase=args.rebase) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + + if args.relink: + relink_args = argparse.Namespace(packages=[], profile=args.profile) + run_relink(ctx, relink_args) + + +def run_repo_push(ctx: FlowContext, args): + _ensure_dotfiles_dir(ctx) + + ctx.console.info("Pushing dotfiles changes...") + result = _run_dotfiles_git("push", capture=True) + if result.returncode != 0: + ctx.console.error(f"Git push failed: {result.stderr.strip()}") + sys.exit(1) + + output = result.stdout.strip() + if output: + print(output) + ctx.console.success("Dotfiles pushed.") + def run_relink(ctx: FlowContext, args): """Refresh symlinks after changes (unlink + link).""" - if not DOTFILES_DIR.exists(): - ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.") - sys.exit(1) + _ensure_dotfiles_dir(ctx) # First unlink ctx.console.info("Unlinking current symlinks...") @@ -366,53 +497,36 @@ def run_clean(ctx: FlowContext, args): def run_edit(ctx: FlowContext, args): """Edit package config with auto-commit workflow.""" - if not DOTFILES_DIR.exists(): - ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.") - sys.exit(1) + _ensure_dotfiles_dir(ctx) - package_name = args.package - - # Find package directory - common_dir = DOTFILES_DIR / "common" / package_name - profile_dirs = list((DOTFILES_DIR / "profiles").glob(f"*/{package_name}")) - - package_dir = None - if common_dir.exists(): - package_dir = common_dir - elif profile_dirs: - package_dir = profile_dirs[0] - else: - ctx.console.error(f"Package not found: {package_name}") + target_name = args.target + edit_target = _resolve_edit_target(target_name) + if edit_target is None: + ctx.console.error(f"No matching package or path found for: {target_name}") sys.exit(1) # Git pull before editing ctx.console.info("Pulling latest changes...") - result = subprocess.run( - ["git", "-C", str(DOTFILES_DIR), "pull", "--rebase"], - capture_output=True, text=True, - ) + result = _run_dotfiles_git("pull", "--rebase", capture=True) if result.returncode != 0: ctx.console.warn(f"Git pull failed: {result.stderr.strip()}") # Open editor editor = os.environ.get("EDITOR", "vim") - ctx.console.info(f"Opening {package_dir} in {editor}...") - edit_result = subprocess.run(shlex.split(editor) + [str(package_dir)]) + ctx.console.info(f"Opening {edit_target} in {editor}...") + edit_result = subprocess.run(shlex.split(editor) + [str(edit_target)]) if edit_result.returncode != 0: ctx.console.warn(f"Editor exited with status {edit_result.returncode}") # Check for changes - result = subprocess.run( - ["git", "-C", str(DOTFILES_DIR), "status", "--porcelain"], - capture_output=True, text=True, - ) + result = _run_dotfiles_git("status", "--porcelain", capture=True) if result.stdout.strip() and not args.no_commit: # Auto-commit changes ctx.console.info("Changes detected, committing...") subprocess.run(["git", "-C", str(DOTFILES_DIR), "add", "."], check=True) subprocess.run( - ["git", "-C", str(DOTFILES_DIR), "commit", "-m", f"Update {package_name} config"], + ["git", "-C", str(DOTFILES_DIR), "commit", "-m", f"Update {target_name}"], check=True, ) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index dbfa285..d2aea0f 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -2,7 +2,12 @@ import pytest -from flow.commands.bootstrap import _get_profiles, _plan_actions +from flow.commands.bootstrap import ( + _get_profiles, + _plan_actions, + _resolve_package_manager, + _resolve_package_name, +) from flow.core.config import AppConfig, FlowContext from flow.core.console import ConsoleLogger from flow.core.platform import PlatformInfo @@ -60,6 +65,22 @@ def test_plan_packages(ctx): assert "install-binary" in types +def test_plan_packages_uses_package_map(ctx): + ctx.manifest["package-map"] = { + "fd": {"apt": "fd-find"}, + } + env_config = { + "package-manager": "apt", + "packages": { + "standard": ["fd"], + }, + } + + actions = _plan_actions(ctx, "test", env_config, {}) + install = [a for a in actions if a.type == "install-packages"][0] + assert install.data["packages"] == ["fd-find"] + + def test_plan_ssh_keygen(ctx): env_config = { "ssh_keygen": [ @@ -127,3 +148,41 @@ def test_get_profiles_rejects_environments(ctx): ctx.manifest = {"environments": {"legacy": {"os": "linux"}}} with pytest.raises(RuntimeError, match="no longer supported"): _get_profiles(ctx) + + +def test_resolve_package_manager_explicit_value(ctx): + assert _resolve_package_manager(ctx, {"package-manager": "dnf"}) == "dnf" + + +def test_resolve_package_manager_linux_ubuntu(ctx): + os_release = "ID=ubuntu\nID_LIKE=debian" + assert _resolve_package_manager(ctx, {}, os_release_text=os_release) == "apt" + + +def test_resolve_package_manager_linux_fedora(ctx): + os_release = "ID=fedora\nID_LIKE=rhel" + assert _resolve_package_manager(ctx, {}, os_release_text=os_release) == "dnf" + + +def test_resolve_package_name_with_package_map(ctx): + ctx.manifest["package-map"] = { + "fd": { + "apt": "fd-find", + "dnf": "fd-find", + "brew": "fd", + } + } + assert _resolve_package_name(ctx, "fd", "apt") == "fd-find" + assert _resolve_package_name(ctx, "fd", "dnf") == "fd-find" + assert _resolve_package_name(ctx, "fd", "brew") == "fd" + + +def test_resolve_package_name_falls_back_with_warning(ctx): + warnings = [] + ctx.console.warn = warnings.append + ctx.manifest["package-map"] = {"python3-dev": {"apt": "python3-dev"}} + + resolved = _resolve_package_name(ctx, "python3-dev", "dnf", warn_missing=True) + + assert resolved == "python3-dev" + assert warnings diff --git a/tests/test_cli.py b/tests/test_cli.py index b92be51..b3c7850 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -55,6 +55,7 @@ def test_dotfiles_help(): assert "unlink" in result.stdout assert "status" in result.stdout assert "sync" in result.stdout + assert "repo" in result.stdout def test_bootstrap_help(): @@ -66,6 +67,7 @@ def test_bootstrap_help(): assert "run" in result.stdout assert "list" in result.stdout assert "show" in result.stdout + assert "packages" in result.stdout def test_package_help(): diff --git a/tests/test_completion.py b/tests/test_completion.py index 7745a06..2392a29 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -15,6 +15,15 @@ def test_complete_bootstrap_profiles(monkeypatch): assert out == ["linux-vm"] +def test_complete_bootstrap_packages_options(monkeypatch): + monkeypatch.setattr(completion, "_list_bootstrap_profiles", lambda: ["linux-vm", "macos-host"]) + out = completion.complete(["flow", "bootstrap", "packages", "--p"], 4) + assert out == ["--profile"] + + out = completion.complete(["flow", "bootstrap", "packages", "--profile", "m"], 5) + assert out == ["macos-host"] + + def test_complete_package_install(monkeypatch): monkeypatch.setattr(completion, "_list_manifest_packages", lambda: ["neovim", "fzf"]) out = completion.complete(["flow", "package", "install", "n"], 4) @@ -33,6 +42,11 @@ def test_complete_dotfiles_profile_value(monkeypatch): assert out == ["work"] +def test_complete_dotfiles_repo_subcommands(): + out = completion.complete(["flow", "dotfiles", "repo", "p"], 4) + assert out == ["pull", "push"] + + def test_complete_enter_targets(monkeypatch): monkeypatch.setattr(completion, "_list_targets", lambda: ["personal@orb", "work@ec2"]) out = completion.complete(["flow", "enter", "p"], 3) diff --git a/tests/test_dotfiles.py b/tests/test_dotfiles.py index 3455774..cb2040a 100644 --- a/tests/test_dotfiles.py +++ b/tests/test_dotfiles.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from flow.commands.dotfiles import _discover_packages, _walk_package +from flow.commands.dotfiles import _discover_packages, _resolve_edit_target, _walk_package from flow.core.config import AppConfig, FlowContext from flow.core.console import ConsoleLogger from flow.core.platform import PlatformInfo @@ -64,3 +64,17 @@ def test_walk_package(dotfiles_tree): targets = {str(t) for _, t in pairs} assert str(home / ".zshrc") in targets assert str(home / ".zshenv") in targets + + +def test_resolve_edit_target_package(dotfiles_tree): + target = _resolve_edit_target("zsh", dotfiles_dir=dotfiles_tree) + assert target == dotfiles_tree / "common" / "zsh" + + +def test_resolve_edit_target_repo_path(dotfiles_tree): + target = _resolve_edit_target("common/zsh/.zshrc", dotfiles_dir=dotfiles_tree) + assert target == dotfiles_tree / "common" / "zsh" / ".zshrc" + + +def test_resolve_edit_target_missing_returns_none(dotfiles_tree): + assert _resolve_edit_target("does-not-exist", dotfiles_dir=dotfiles_tree) is None