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
|
## 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
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 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)
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user