This commit is contained in:
2026-02-13 09:57:38 +02:00
parent e35f9651c1
commit 1217337fbb
20 changed files with 867 additions and 65 deletions

160
AGENTS.md Normal file
View File

@@ -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.

View File

@@ -60,10 +60,11 @@ work@ec2 = work.internal ~/.ssh/id_work
## Manifest format ## 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 - `profiles` for bootstrap profiles
- `binaries` for package definitions - `binaries` for package definitions
- `package-map` for cross-package-manager name mapping
`environments` is no longer supported. `environments` is no longer supported.
@@ -78,7 +79,7 @@ profiles:
locale: en_US.UTF-8 locale: en_US.UTF-8
requires: [HOSTNAME] requires: [HOSTNAME]
packages: packages:
standard: [git, tmux, zsh] standard: [git, tmux, zsh, fd]
binary: [neovim] binary: [neovim]
ssh_keygen: ssh_keygen:
- type: ed25519 - type: ed25519
@@ -86,6 +87,12 @@ profiles:
runcmd: runcmd:
- mkdir -p ~/projects - mkdir -p ~/projects
package-map:
fd:
apt: fd-find
dnf: fd-find
brew: fd
binaries: binaries:
neovim: neovim:
source: github:neovim/neovim source: github:neovim/neovim
@@ -134,6 +141,9 @@ flow dotfiles link
flow dotfiles status flow dotfiles status
flow dotfiles relink flow dotfiles relink
flow dotfiles clean --dry-run flow dotfiles clean --dry-run
flow dotfiles repo status
flow dotfiles repo pull --relink
flow dotfiles repo push
``` ```
### Bootstrap ### Bootstrap
@@ -141,10 +151,15 @@ flow dotfiles clean --dry-run
```bash ```bash
flow bootstrap list flow bootstrap list
flow bootstrap show linux-vm 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 --var HOSTNAME=devbox
flow bootstrap run --profile linux-vm --dry-run 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 ### Packages
```bash ```bash

78
example/README.md Normal file
View File

@@ -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.

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env sh
echo "Hello from flow example dotfiles"

View File

@@ -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

View File

@@ -0,0 +1,2 @@
export FLOW_ENV=example
export FLOW_EDITOR=vim

View File

@@ -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

View File

@@ -0,0 +1,9 @@
[user]
name = Example User
email = example@example.com
[init]
defaultBranch = main
[pull]
rebase = true

View File

@@ -0,0 +1,6 @@
vim.opt.number = true
vim.opt.relativenumber = true
vim.opt.expandtab = true
vim.opt.shiftwidth = 2
vim.g.mapleader = " "

View File

@@ -0,0 +1,3 @@
set -g mouse on
set -g history-limit 100000
setw -g mode-keys vi

View File

@@ -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

View File

@@ -0,0 +1,6 @@
[user]
name = Example Work User
email = work@example.com
[url "git@github.com:work/"]
insteadOf = https://github.com/work/

View File

@@ -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

View File

@@ -6,7 +6,7 @@ import shlex
import shutil import shutil
import sys import sys
from pathlib import Path 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.action import Action, ActionExecutor
from flow.core.config import FlowContext, load_manifest from flow.core.config import FlowContext, load_manifest
@@ -38,6 +38,16 @@ def register(subparsers):
show.add_argument("profile", help="Profile name") show.add_argument("profile", help="Profile name")
show.set_defaults(handler=run_show) 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()) p.set_defaults(handler=lambda ctx, args: p.print_help())
@@ -68,6 +78,111 @@ def _parse_variables(var_args: list) -> dict:
return variables 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]: def _plan_actions(ctx: FlowContext, profile_name: str, env_config: dict, variables: dict) -> List[Action]:
"""Plan all actions from a profile configuration.""" """Plan all actions from a profile configuration."""
actions = [] actions = []
@@ -114,7 +229,7 @@ def _plan_actions(ctx: FlowContext, profile_name: str, env_config: dict, variabl
# Packages # Packages
if "packages" in env_config: if "packages" in env_config:
packages_config = env_config["packages"] packages_config = env_config["packages"]
pm = env_config.get("package-manager", "apt-get") pm = _resolve_package_manager(ctx, env_config)
# Package manager update # Package manager update
actions.append(Action( actions.append(Action(
@@ -127,10 +242,8 @@ def _plan_actions(ctx: FlowContext, profile_name: str, env_config: dict, variabl
# Standard packages # Standard packages
standard = [] standard = []
for pkg in packages_config.get("standard", []) + packages_config.get("package", []): for pkg in packages_config.get("standard", []) + packages_config.get("package", []):
if isinstance(pkg, str): pkg_name = _package_name_from_spec(pkg)
standard.append(pkg) standard.append(_resolve_package_name(ctx, pkg_name, pm, warn_missing=True))
else:
standard.append(pkg["name"])
if standard: if standard:
actions.append(Action( actions.append(Action(
@@ -143,10 +256,8 @@ def _plan_actions(ctx: FlowContext, profile_name: str, env_config: dict, variabl
# Cask packages (macOS) # Cask packages (macOS)
cask = [] cask = []
for pkg in packages_config.get("cask", []): for pkg in packages_config.get("cask", []):
if isinstance(pkg, str): pkg_name = _package_name_from_spec(pkg)
cask.append(pkg) cask.append(_resolve_package_name(ctx, pkg_name, pm, warn_missing=True))
else:
cask.append(pkg["name"])
if cask: if cask:
actions.append(Action( actions.append(Action(
@@ -160,7 +271,7 @@ def _plan_actions(ctx: FlowContext, profile_name: str, env_config: dict, variabl
# Binary packages # Binary packages
binaries_manifest = ctx.manifest.get("binaries", {}) binaries_manifest = ctx.manifest.get("binaries", {})
for pkg in packages_config.get("binary", []): 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, {}) binary_def = binaries_manifest.get(pkg_name, {})
actions.append(Action( actions.append(Action(
type="install-binary", type="install-binary",
@@ -242,6 +353,7 @@ def _register_handlers(executor: ActionExecutor, ctx: FlowContext, variables: di
commands = { commands = {
"apt-get": "sudo apt-get update -qq", "apt-get": "sudo apt-get update -qq",
"apt": "sudo apt update -qq", "apt": "sudo apt update -qq",
"dnf": "sudo dnf makecache -q",
"brew": "brew update", "brew": "brew update",
} }
cmd = commands.get(pm, f"sudo {pm} 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"): if pm in ("apt-get", "apt"):
cmd = f"sudo {pm} install -y {pkg_str}" 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": elif pm == "brew" and pkg_type == "cask":
cmd = f"brew install --cask {pkg_str}" cmd = f"brew install --cask {pkg_str}"
elif pm == "brew": elif pm == "brew":
@@ -422,3 +536,53 @@ def run_show(ctx: FlowContext, args):
executor = ActionExecutor(ctx.console) executor = ActionExecutor(ctx.console)
executor.execute(actions, dry_run=True) 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)

View File

@@ -277,7 +277,7 @@ def _complete_dev(before: Sequence[str], current: str) -> List[str]:
def _complete_dotfiles(before: Sequence[str], current: str) -> List[str]: def _complete_dotfiles(before: Sequence[str], current: str) -> List[str]:
if len(before) <= 1: if len(before) <= 1:
return _filter( return _filter(
["init", "link", "unlink", "status", "sync", "relink", "clean", "edit"], ["init", "link", "unlink", "status", "sync", "relink", "clean", "edit", "repo"],
current, current,
) )
@@ -286,6 +286,21 @@ def _complete_dotfiles(before: Sequence[str], current: str) -> List[str]:
if sub == "init": if sub == "init":
return _filter(["--repo", "-h", "--help"], current) if current.startswith("-") else [] 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 sub in {"link", "relink"}:
if before and before[-1] == "--profile": if before and before[-1] == "--profile":
return _filter(_list_dotfiles_profiles(), current) return _filter(_list_dotfiles_profiles(), current)
@@ -312,12 +327,17 @@ def _complete_dotfiles(before: Sequence[str], current: str) -> List[str]:
if sub == "clean": if sub == "clean":
return _filter(["--dry-run", "-h", "--help"], current) if current.startswith("-") else [] 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 [] return []
def _complete_bootstrap(before: Sequence[str], current: str) -> List[str]: def _complete_bootstrap(before: Sequence[str], current: str) -> List[str]:
if len(before) <= 1: if len(before) <= 1:
return _filter(["run", "list", "show"], current) return _filter(["run", "list", "show", "packages"], current)
sub = before[1] sub = before[1]
@@ -336,6 +356,13 @@ def _complete_bootstrap(before: Sequence[str], current: str) -> List[str]:
return _filter(_list_bootstrap_profiles(), current) return _filter(_list_bootstrap_profiles(), current)
return [] 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 [] return []

View File

@@ -1,5 +1,6 @@
"""flow dotfiles — dotfile management with GNU Stow-style symlinking.""" """flow dotfiles — dotfile management with GNU Stow-style symlinking."""
import argparse
import json import json
import os import os
import shlex import shlex
@@ -43,8 +44,30 @@ def register(subparsers):
# sync # sync
sync = sub.add_parser("sync", help="Pull latest dotfiles from remote") 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) 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
relink = sub.add_parser("relink", help="Refresh symlinks after changes") relink = sub.add_parser("relink", help="Refresh symlinks after changes")
relink.add_argument("packages", nargs="*", help="Specific packages to relink (default: all)") relink.add_argument("packages", nargs="*", help="Specific packages to relink (default: all)")
@@ -57,8 +80,8 @@ def register(subparsers):
clean.set_defaults(handler=run_clean) clean.set_defaults(handler=run_clean)
# edit # edit
edit = sub.add_parser("edit", help="Edit package config with auto-commit") edit = sub.add_parser("edit", help="Edit package or path with auto-commit")
edit.add_argument("package", help="Package name to edit") 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.add_argument("--no-commit", action="store_true", help="Skip auto-commit")
edit.set_defaults(handler=run_edit) edit.set_defaults(handler=run_edit)
@@ -113,6 +136,79 @@ def _walk_package(source_dir: Path, home: Path):
yield src, dst 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): def run_init(ctx: FlowContext, args):
repo_url = args.repo or ctx.config.dotfiles_url repo_url = args.repo or ctx.config.dotfiles_url
if not repo_url: if not repo_url:
@@ -132,9 +228,7 @@ def run_init(ctx: FlowContext, args):
def run_link(ctx: FlowContext, args): def run_link(ctx: FlowContext, args):
if not DOTFILES_DIR.exists(): _ensure_dotfiles_dir(ctx)
ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.")
sys.exit(1)
home = Path.home() home = Path.home()
packages = _discover_packages(DOTFILES_DIR, args.profile) packages = _discover_packages(DOTFILES_DIR, args.profile)
@@ -296,29 +390,66 @@ def run_status(ctx: FlowContext, args):
def run_sync(ctx: FlowContext, args): def run_sync(ctx: FlowContext, args):
if not DOTFILES_DIR.exists(): _ensure_dotfiles_dir(ctx)
ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.")
try:
_pull_dotfiles(ctx, rebase=True)
except RuntimeError as e:
ctx.console.error(str(e))
sys.exit(1) sys.exit(1)
ctx.console.info("Pulling latest dotfiles...") if args.relink:
result = subprocess.run( relink_args = argparse.Namespace(packages=[], profile=args.profile)
["git", "-C", str(DOTFILES_DIR), "pull", "--rebase"], run_relink(ctx, relink_args)
capture_output=True, text=True,
)
if result.returncode == 0: def run_repo_status(ctx: FlowContext, args):
if result.stdout.strip(): _ensure_dotfiles_dir(ctx)
print(result.stdout.strip())
ctx.console.success("Dotfiles synced.") result = _run_dotfiles_git("status", "--short", "--branch", capture=True)
else: if result.returncode != 0:
ctx.console.error(f"Git pull failed: {result.stderr.strip()}") ctx.console.error(result.stderr.strip() or "Failed to read dotfiles git status")
sys.exit(1) 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): def run_relink(ctx: FlowContext, args):
"""Refresh symlinks after changes (unlink + link).""" """Refresh symlinks after changes (unlink + link)."""
if not DOTFILES_DIR.exists(): _ensure_dotfiles_dir(ctx)
ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.")
sys.exit(1)
# First unlink # First unlink
ctx.console.info("Unlinking current symlinks...") ctx.console.info("Unlinking current symlinks...")
@@ -366,53 +497,36 @@ def run_clean(ctx: FlowContext, args):
def run_edit(ctx: FlowContext, args): def run_edit(ctx: FlowContext, args):
"""Edit package config with auto-commit workflow.""" """Edit package config with auto-commit workflow."""
if not DOTFILES_DIR.exists(): _ensure_dotfiles_dir(ctx)
ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.")
sys.exit(1)
package_name = args.package target_name = args.target
edit_target = _resolve_edit_target(target_name)
# Find package directory if edit_target is None:
common_dir = DOTFILES_DIR / "common" / package_name ctx.console.error(f"No matching package or path found for: {target_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}")
sys.exit(1) sys.exit(1)
# Git pull before editing # Git pull before editing
ctx.console.info("Pulling latest changes...") ctx.console.info("Pulling latest changes...")
result = subprocess.run( result = _run_dotfiles_git("pull", "--rebase", capture=True)
["git", "-C", str(DOTFILES_DIR), "pull", "--rebase"],
capture_output=True, text=True,
)
if result.returncode != 0: if result.returncode != 0:
ctx.console.warn(f"Git pull failed: {result.stderr.strip()}") ctx.console.warn(f"Git pull failed: {result.stderr.strip()}")
# Open editor # Open editor
editor = os.environ.get("EDITOR", "vim") editor = os.environ.get("EDITOR", "vim")
ctx.console.info(f"Opening {package_dir} in {editor}...") ctx.console.info(f"Opening {edit_target} in {editor}...")
edit_result = subprocess.run(shlex.split(editor) + [str(package_dir)]) edit_result = subprocess.run(shlex.split(editor) + [str(edit_target)])
if edit_result.returncode != 0: if edit_result.returncode != 0:
ctx.console.warn(f"Editor exited with status {edit_result.returncode}") ctx.console.warn(f"Editor exited with status {edit_result.returncode}")
# Check for changes # Check for changes
result = subprocess.run( result = _run_dotfiles_git("status", "--porcelain", capture=True)
["git", "-C", str(DOTFILES_DIR), "status", "--porcelain"],
capture_output=True, text=True,
)
if result.stdout.strip() and not args.no_commit: if result.stdout.strip() and not args.no_commit:
# Auto-commit changes # Auto-commit changes
ctx.console.info("Changes detected, committing...") ctx.console.info("Changes detected, committing...")
subprocess.run(["git", "-C", str(DOTFILES_DIR), "add", "."], check=True) subprocess.run(["git", "-C", str(DOTFILES_DIR), "add", "."], check=True)
subprocess.run( 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, check=True,
) )

View File

@@ -2,7 +2,12 @@
import pytest 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.config import AppConfig, FlowContext
from flow.core.console import ConsoleLogger from flow.core.console import ConsoleLogger
from flow.core.platform import PlatformInfo from flow.core.platform import PlatformInfo
@@ -60,6 +65,22 @@ def test_plan_packages(ctx):
assert "install-binary" in types 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): def test_plan_ssh_keygen(ctx):
env_config = { env_config = {
"ssh_keygen": [ "ssh_keygen": [
@@ -127,3 +148,41 @@ def test_get_profiles_rejects_environments(ctx):
ctx.manifest = {"environments": {"legacy": {"os": "linux"}}} ctx.manifest = {"environments": {"legacy": {"os": "linux"}}}
with pytest.raises(RuntimeError, match="no longer supported"): with pytest.raises(RuntimeError, match="no longer supported"):
_get_profiles(ctx) _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

View File

@@ -55,6 +55,7 @@ def test_dotfiles_help():
assert "unlink" in result.stdout assert "unlink" in result.stdout
assert "status" in result.stdout assert "status" in result.stdout
assert "sync" in result.stdout assert "sync" in result.stdout
assert "repo" in result.stdout
def test_bootstrap_help(): def test_bootstrap_help():
@@ -66,6 +67,7 @@ def test_bootstrap_help():
assert "run" in result.stdout assert "run" in result.stdout
assert "list" in result.stdout assert "list" in result.stdout
assert "show" in result.stdout assert "show" in result.stdout
assert "packages" in result.stdout
def test_package_help(): def test_package_help():

View File

@@ -15,6 +15,15 @@ def test_complete_bootstrap_profiles(monkeypatch):
assert out == ["linux-vm"] 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): def test_complete_package_install(monkeypatch):
monkeypatch.setattr(completion, "_list_manifest_packages", lambda: ["neovim", "fzf"]) monkeypatch.setattr(completion, "_list_manifest_packages", lambda: ["neovim", "fzf"])
out = completion.complete(["flow", "package", "install", "n"], 4) out = completion.complete(["flow", "package", "install", "n"], 4)
@@ -33,6 +42,11 @@ def test_complete_dotfiles_profile_value(monkeypatch):
assert out == ["work"] 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): def test_complete_enter_targets(monkeypatch):
monkeypatch.setattr(completion, "_list_targets", lambda: ["personal@orb", "work@ec2"]) monkeypatch.setattr(completion, "_list_targets", lambda: ["personal@orb", "work@ec2"])
out = completion.complete(["flow", "enter", "p"], 3) out = completion.complete(["flow", "enter", "p"], 3)

View File

@@ -5,7 +5,7 @@ from pathlib import Path
import pytest 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.config import AppConfig, FlowContext
from flow.core.console import ConsoleLogger from flow.core.console import ConsoleLogger
from flow.core.platform import PlatformInfo from flow.core.platform import PlatformInfo
@@ -64,3 +64,17 @@ def test_walk_package(dotfiles_tree):
targets = {str(t) for _, t in pairs} targets = {str(t) for _, t in pairs}
assert str(home / ".zshrc") in targets assert str(home / ".zshrc") in targets
assert str(home / ".zshenv") 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