update
This commit is contained in:
160
AGENTS.md
Normal file
160
AGENTS.md
Normal 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.
|
||||
19
README.md
19
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
|
||||
|
||||
78
example/README.md
Normal file
78
example/README.md
Normal 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.
|
||||
3
example/dotfiles-repo/common/bin/.local/bin/flow-hello
Normal file
3
example/dotfiles-repo/common/bin/.local/bin/flow-hello
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
echo "Hello from flow example dotfiles"
|
||||
15
example/dotfiles-repo/common/flow/.config/flow/config
Normal file
15
example/dotfiles-repo/common/flow/.config/flow/config
Normal 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
|
||||
2
example/dotfiles-repo/common/flow/.config/flow/env.sh
Normal file
2
example/dotfiles-repo/common/flow/.config/flow/env.sh
Normal file
@@ -0,0 +1,2 @@
|
||||
export FLOW_ENV=example
|
||||
export FLOW_EDITOR=vim
|
||||
96
example/dotfiles-repo/common/flow/.config/flow/manifest.yaml
Normal file
96
example/dotfiles-repo/common/flow/.config/flow/manifest.yaml
Normal 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
|
||||
9
example/dotfiles-repo/common/git/.gitconfig
Normal file
9
example/dotfiles-repo/common/git/.gitconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
[user]
|
||||
name = Example User
|
||||
email = example@example.com
|
||||
|
||||
[init]
|
||||
defaultBranch = main
|
||||
|
||||
[pull]
|
||||
rebase = true
|
||||
6
example/dotfiles-repo/common/nvim/.config/nvim/init.lua
Normal file
6
example/dotfiles-repo/common/nvim/.config/nvim/init.lua
Normal file
@@ -0,0 +1,6 @@
|
||||
vim.opt.number = true
|
||||
vim.opt.relativenumber = true
|
||||
vim.opt.expandtab = true
|
||||
vim.opt.shiftwidth = 2
|
||||
|
||||
vim.g.mapleader = " "
|
||||
3
example/dotfiles-repo/common/tmux/.tmux.conf
Normal file
3
example/dotfiles-repo/common/tmux/.tmux.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
set -g mouse on
|
||||
set -g history-limit 100000
|
||||
setw -g mode-keys vi
|
||||
8
example/dotfiles-repo/common/zsh/.zshrc
Normal file
8
example/dotfiles-repo/common/zsh/.zshrc
Normal 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
|
||||
6
example/dotfiles-repo/profiles/work/git/.gitconfig
Normal file
6
example/dotfiles-repo/profiles/work/git/.gitconfig
Normal file
@@ -0,0 +1,6 @@
|
||||
[user]
|
||||
name = Example Work User
|
||||
email = work@example.com
|
||||
|
||||
[url "git@github.com:work/"]
|
||||
insteadOf = https://github.com/work/
|
||||
7
example/dotfiles-repo/profiles/work/zsh/.zshrc
Normal file
7
example/dotfiles-repo/profiles/work/zsh/.zshrc
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user