diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 53fc41b..0eb2947 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,11 +17,16 @@ jobs: with: python-version: "3.13" + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + - name: Install dependencies run: make deps - name: Run unit tests - run: .venv/bin/python -m pytest tests/ -v --ignore=tests/e2e + run: uv run pytest tests/ -v --ignore=tests/e2e e2e: runs-on: ubuntu-latest @@ -33,6 +38,11 @@ jobs: with: python-version: "3.13" + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + - name: Install dependencies run: make deps @@ -42,4 +52,4 @@ jobs: - name: Run Docker-backed e2e tests env: FLOW_RUN_E2E: "1" - run: .venv/bin/python -m pytest tests/e2e/ -v + run: uv run pytest tests/e2e/ -v diff --git a/.gitignore b/.gitignore index f269d7c..c8d051f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,21 @@ __pycache__/ *.pyc *.pyo +*.egg +*.whl +*.tar.gz dist/ build/ *.spec *.egg-info/ .pytest_cache/ +.coverage +coverage.xml +htmlcov/ +.ruff_cache/ +.mypy_cache/ +.tox/ +.nox/ .worktrees/ .claude/ .venv/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index d28aff4..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,42 +0,0 @@ -# Development Standards - -## General - -- **Fix the root cause, not the symptom.** Do not add defaults, fallbacks, conditional wrappers, or - runtime workarounds to cope with a broken upstream flow. If a value is missing, fix the source - that provides it. If a dependency isn't ready, fix the ordering. - -- **No speculative abstraction.** Do not add overrides, toggles, or configurable escape hatches for - scenarios that do not exist yet. If a new requirement appears, add the abstraction then. - -- **Fail loudly at the boundary.** When a value reaches an unexpected state, throw or surface the - error immediately. A loud failure with a clear trace is more useful than a silent fallback that - hides the bug and lets it propagate further. - -- **Direct access over defensive wrapping.** Access values directly. Do not add null-coalescing - chains, fallback guards, or default-dict patterns for values that are always expected to be - present. - -- **One canonical form.** Keep a single representation for each concept -- one source of truth for - constants, one naming convention per domain, no conversion layers between equivalent forms. - -- **Explicit over implicit.** Write values and behaviour directly rather than generating or - inferring them when the intent is small and stable. Clarity at the call site matters more than - cleverness at the definition. - -- **Reuse existing mechanisms.** Do not introduce local duplicates of something already shared - without a clear reason. Prefer extending what exists. - -- **No empty files or placeholders.** Every file committed should serve a purpose. - -- **Exhaustive over defensive.** When branching on a finite set of known values, handle every case - explicitly. The compiler should enforce completeness -- not runtime guards or catch-all defaults. - -- **Infer intent from context.** Resolve ambiguity against existing conventions and adjacent code - first. If uncertainty remains, ask before changing behaviour. - -- **Refactors are collaborative.** Discuss direction first, implement once agreed. Prefer small - directed phases over broad rewrites. - -- **During implementation, audit the upstream flow.** Report any bug, mismatch, or unexpected - result found -- even if it is outside the immediate scope. diff --git a/Makefile b/Makefile index bdff51c..19ea22e 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,4 @@ -PYTHON ?= python3 -VENV_DIR ?= .venv -VENV_BIN := $(VENV_DIR)/bin -VENV_PYTHON := $(VENV_BIN)/python +UV ?= uv SRC_DIR := $(CURDIR)/src ENTRYPOINT := src/flow/__main__.py DIST_DIR := dist @@ -10,64 +7,50 @@ SPEC_FILE := flow.spec BINARY := $(DIST_DIR)/flow INSTALL_DIR ?= $(HOME)/.local/bin -.PHONY: deps build install install-local check-binary clean help test test-e2e +.PHONY: help deps test test-e2e check package build binary install install-local check-binary clean distclean help: @printf "Targets:\n" - @printf " make deps Create .venv and install build+dev dependencies\n" - @printf " make build Build standalone binary at dist/flow\n" - @printf " make install Build and install to ~/.local/bin/flow\n" - @printf " make install-local Install binary to ~/.local/bin/flow\n" - @printf " make check-binary Run dist/flow --help\n" + @printf " make deps Sync locked dev/build dependencies into .venv\n" @printf " make test Run unit tests\n" - @printf " make test-e2e Run end-to-end container tests (FLOW_RUN_E2E=1)\n" - @printf " make clean Remove build artifacts\n" + @printf " make test-e2e Run Docker/Podman e2e tests (FLOW_RUN_E2E=1)\n" + @printf " make check Run unit tests and CLI smoke check\n" + @printf " make package Build wheel and sdist into dist/\n" + @printf " make build Build standalone binary at dist/flow\n" + @printf " make install Build and install binary to ~/.local/bin/flow\n" + @printf " make check-binary Run dist/flow --help\n" + @printf " make clean Remove generated build/test artifacts\n" + @printf " make distclean Also remove local virtualenvs\n" deps: - @set -eu; \ - if [ ! -x "$(VENV_PYTHON)" ]; then \ - if ! $(PYTHON) -m venv "$(VENV_DIR)" >/dev/null 2>&1; then \ - if command -v apt-get >/dev/null 2>&1; then \ - echo "venv support missing; installing python3-venv via apt-get (sudo required)"; \ - sudo apt-get update; \ - sudo apt-get install -y python3-venv; \ - elif command -v dnf >/dev/null 2>&1; then \ - echo "venv support missing; installing python3 via dnf (sudo required)"; \ - sudo dnf install -y python3; \ - elif command -v brew >/dev/null 2>&1; then \ - echo "venv support missing; installing python via Homebrew"; \ - brew install python; \ - else \ - echo "Unable to create virtualenv automatically. Install python venv support and rerun make deps."; \ - exit 1; \ - fi; \ - $(PYTHON) -m venv "$(VENV_DIR)"; \ - fi; \ - fi; \ - . "$(VENV_BIN)/activate"; \ - python -m pip install --upgrade pip; \ - python -m pip install -e ".[build,dev]" + $(UV) sync --locked --extra build --extra dev -build: deps - @set -eu; \ - . "$(VENV_BIN)/activate"; \ - python -m PyInstaller --noconfirm --clean --onefile --name flow --paths "$(SRC_DIR)" "$(ENTRYPOINT)" +test: + $(UV) run pytest tests/ -q --ignore=tests/e2e -install-local: build +test-e2e: + FLOW_RUN_E2E=1 $(UV) run pytest tests/e2e/ -v + +check: test + $(UV) run python -m flow --help >/dev/null + $(UV) run python -m flow --version >/dev/null + +package: deps + $(UV) build + +build binary: deps + $(UV) run pyinstaller --noconfirm --clean --onefile --name flow --paths "$(SRC_DIR)" "$(ENTRYPOINT)" + +install-local install: build mkdir -p "$(INSTALL_DIR)" install -m 755 "$(BINARY)" "$(INSTALL_DIR)/flow" @printf "Installed flow to $(INSTALL_DIR)/flow\n" -install: deps build install-local - check-binary: - "./$(BINARY)" --help - -test: - $(VENV_PYTHON) -m pytest tests/ -q - -test-e2e: - FLOW_RUN_E2E=1 $(VENV_PYTHON) -m pytest tests/e2e/ -v + "$(BINARY)" --help clean: - rm -rf "$(BUILD_DIR)" "$(DIST_DIR)" "$(SPEC_FILE)" + rm -rf "$(BUILD_DIR)" "$(DIST_DIR)" "$(SPEC_FILE)" .coverage coverage.xml htmlcov .pytest_cache .ruff_cache .mypy_cache + +distclean: clean + rm -rf .venv venv diff --git a/README.md b/README.md index be12f5f..0a655cd 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,58 @@ # flow -CLI for managing development environments: dotfiles, packages, dev containers, remote targets, and system bootstrap. +Action-centered CLI for managing a development machine: dotfiles, packages, +setup profiles, dev containers, remote targets, project git status, and shell +completion. -## Quick start +## Quick Start + +From the repository: ```bash -make build && make install-local # installs to ~/.local/bin/flow -flow setup run linux-work # bootstrap a machine -flow dotfiles link # symlink dotfiles -flow dev create api -i tm0/node # spin up a dev container -flow dev attach api # attach via tmux +uv sync --locked --extra dev --extra build +uv run flow --help +uv run flow --version ``` -## Commands +Initialize a dotfiles repo, link a profile, and preview setup: + +```bash +flow dotfiles init --repo git@github.com:you/dotfiles.git +flow dotfiles link --profile linux-work --dry-run +flow setup show linux-work +flow setup run linux-work --dry-run +``` + +Install the CLI as a local uv tool: + +```bash +uv tool install --force . +flow --help +``` + +Build a standalone binary: + +```bash +make build +./dist/flow --help +make install # installs dist/flow to ~/.local/bin/flow +``` + +`build/`, `dist/`, and `flow.spec` are generated by packaging/binary builds and +are ignored. `.venv/` is the uv-managed local environment. `venv/` is legacy +local clutter and should not be used. + +## Command Surface ```bash # Dotfiles +flow dotfiles init [--repo URL] flow dotfiles link [--profile NAME] [--dry-run] [--skip PKG...] flow dotfiles unlink [PACKAGES...] [--dry-run] flow dotfiles status [PACKAGES...] -flow dotfiles edit PACKAGE [--no-commit] # pull -> $EDITOR -> commit+push +flow dotfiles edit PACKAGE [--no-commit] -# Dotfiles repos (unified: dotfiles repo + module repos) +# Dotfiles and module repos flow dotfiles repos list flow dotfiles repos status [--repo NAME] flow dotfiles repos pull [--repo NAME] [--dry-run] @@ -32,41 +63,43 @@ flow packages install [NAMES...] [--profile NAME] [--dry-run] flow packages remove NAMES... [--dry-run] flow packages list [--all] -# Bootstrap -flow setup run [PROFILE|--profile NAME] [--dry-run] [--var KEY=VALUE] -flow setup show PROFILE # preview profile steps +# Setup/bootstrap flow setup list +flow setup show PROFILE +flow setup run [PROFILE|--profile NAME] [--dry-run] [--var KEY=VALUE] # Remote targets -flow remote enter TARGET [--dry-run] # ssh + tmux into a remote target flow remote list +flow remote enter TARGET [--user USER] [--namespace NAME] [--platform NAME] [--session NAME] [--no-tmux] [--dry-run] +flow enter TARGET -# Dev containers (docker or podman) -flow dev create NAME -i IMAGE [-p PROJECT] [--dry-run] -flow dev attach NAME # tmux session into container -flow dev exec NAME [-- CMD...] # run a command in container -flow dev enter NAME # interactive shell +# Dev containers +flow dev create NAME --image IMAGE [--project PATH] [--dry-run] +flow dev attach NAME +flow dev exec NAME [CMD...] +flow dev enter NAME flow dev stop NAME [--kill] -flow dev remove NAME [-f] -flow dev respawn NAME # restart all tmux panes +flow dev remove NAME [--force] +flow dev respawn NAME flow dev list # Projects -flow projects check [--fetch] # scan ~/projects for dirty repos -flow projects fetch # fetch all project remotes -flow projects summary # quick overview +flow projects check [--fetch] +flow projects fetch +flow projects summary +flow sync # Shell completion -flow completion zsh # print zsh completion script -flow completion install-zsh # install to ~/.zsh/completions +flow completion zsh +flow completion install-zsh [--dir DIR] [--rc FILE] [--no-rc] # Global flags flow --version -flow --quiet # suppress info output -flow --no-color # disable colored output +flow --quiet +flow --no-color ``` -### Aliases +Aliases: - `dotfiles` -> `dot` - `dotfiles repos` -> `dotfiles repo` @@ -79,75 +112,40 @@ flow --no-color # disable colored output ## Configuration -Loaded from `~/.config/flow/config.yaml`, merged with dotfiles repo overlay at `~/.local/share/flow/dotfiles/_shared/flow/.config/flow/`. +Flow reads XDG paths: + +- config: `~/.config/flow` +- data: `~/.local/share/flow` +- state: `~/.local/state/flow` + +The main config lives at `~/.config/flow/config.yaml`. A dotfiles repo may also +provide an overlay at `_shared/flow/.config/flow/`; after `dotfiles init`, that +overlay is merged into config and manifest loading. ```yaml repository: url: git@github.com:you/dotfiles.git branch: main + pull-before-edit: true paths: projects: ~/projects defaults: - container-runtime: auto # auto | docker | podman | podman-rootful + container-runtime: auto # auto | docker | podman | podman-rootful container-registry: registry.example.com container-tag: latest - tmux-session: main + tmux-session: default targets: personal@orb: personal.orb work@ec2: - host: work.ec2.internal + host: work.internal identity: ~/.ssh/id_work ``` -### Container runtime - -`container-runtime` controls which engine `flow dev` uses: - -- `auto` -- detect docker or podman from PATH (default) -- `docker` -- force docker -- `podman` -- force podman, prefer rootless socket -- `podman-rootful` -- force podman, prefer rootful socket (`/run/podman/podman.sock`) - -When using podman, `flow dev create` automatically adds `--security-opt label=disable` for engine socket access. The socket is always mounted to `/var/run/docker.sock` inside the container (Podman's Docker-compatible API). - -## Dotfiles layout - -``` -_shared/ # Linked for all profiles - zsh/ - .zshrc - nvim/ - .config/nvim/ - _module.yaml # External git module - .local/bin/ - nvim-wrapper - dns/ - _root/ # Absolute path marker (needs sudo) - etc/hosts - -linux-work/ # Profile-specific layer - i3/ - .config/i3/config -``` - -### External modules - -`_module.yaml` inside a package mounts an external git repo at that location: - -```yaml -source: github:org/nvim-config -ref: - branch: main -``` - -Modules are regular git clones managed by flow (not git submodules). They appear alongside the dotfiles repo in `flow dotfiles repos list` and are pulled/pushed with the same commands. - -## Manifest - -Packages and profiles defined in YAML files under the flow config directory. +Packages and setup profiles are YAML manifest data loaded from the same config +directories: ```yaml packages: @@ -163,45 +161,95 @@ packages: version: "0.10.4" platform-map: linux-x64: nvim-linux-x86_64.tar.gz + install: + bin: [bin/nvim] profiles: linux-work: os: linux - hostname: dev-box - locale: en_US.UTF-8 shell: zsh packages: - fd - binary/neovim - ssh-keys: - - path: ~/.ssh/id_ed25519 - type: ed25519 runcmd: - - echo "setup complete" + - mkdir -p ~/projects ``` +See `example/README.md` for a complete runnable dotfiles/setup fixture. + +## Dotfiles Layout + +```text +_shared/ + zsh/ + .zshrc + nvim/ + .config/nvim/ + _module.yaml + system/ + _root/ + etc/hostname + +linux-work/ + i3/ + .config/i3/config +``` + +`_shared/` applies to every profile. Profile directories add or override package +sets. `_root/` marks absolute paths and plans sudo-backed link actions. + +External module repos are declared with `_module.yaml`: + +```yaml +source: github:org/nvim-config +ref: + branch: main +``` + +Modules are regular git clones managed by flow, not git submodules. The +dotfiles repo and all module repos are operated through `flow dotfiles repos`. + ## Architecture -Flow uses an action-centered runtime: +Flow uses a single execution boundary: -- **cli** parses Typer command arguments and calls app use-cases. -- **app** resolves config/state, builds `ActionPlan` objects for executor-managed work, and keeps only explicit interactive boundaries outside the executor. -- **domain** modules keep planning and resolution logic pure with frozen dataclasses. -- **actions** are the execution boundary: `DomainAction` records domain intent, expansion converts it to `PrimitiveAction`, and `ActionExecutor` handles dry-run rendering, audit logging, rollback, and dispatch. -- **adapters** provide runtime primitives through `SystemRuntime`: `CommandRunner`, `FileSystem`, `GitClient`, `TmuxClient`, `ContainerRuntime`, download, and archive adapters. +```text +cli -> app -> domain -> actions -> adapters +``` -Action audit records are appended to `actions.jsonl` under the relevant flow state directory. +- `src/flow/cli.py`: Typer command definitions and argument parsing only. +- `src/flow/app/`: use-cases that load config/state and build action plans. +- `src/flow/domain/`: pure dataclasses, parsers, resolvers, and planners. +- `src/flow/actions/`: `ActionPlan`, domain-to-primitive expansion, execution, + dry-run rendering, JSONL audit logs, and rollback. +- `src/flow/adapters/`: filesystem, process, git, package-manager, download, + archive, container, and tmux adapters. -## Security +Use-cases do not directly mutate files or run shell commands for non-interactive +work. They produce actions and pass them to `ActionExecutor`. Interactive +handoffs such as attaching to tmux remain explicit boundaries. -- Rejects root invocation -- `_root/` dotfile paths require sudo for linking -- Package post-install hooks run without sudo by default +Action audit records are append-only JSONL files under the relevant state +directory, normally `~/.local/state/flow/actions.jsonl`. ## Development ```bash -make deps # create .venv + install deps -.venv/bin/python -m pytest tests/ -v --ignore=tests/e2e -FLOW_RUN_E2E=1 .venv/bin/python -m pytest tests/e2e/ -v # requires docker or podman +make deps # uv sync --locked --extra build --extra dev +make test # unit tests, excluding e2e +make test-e2e # requires Docker or healthy Podman +make check # tests plus CLI smoke checks +make package # wheel and sdist in dist/ +make build # PyInstaller binary in dist/flow +make clean # remove build/test artifacts +make distclean # also remove .venv/ and venv/ +``` + +Direct commands are equivalent: + +```bash +uv run pytest tests/ -q --ignore=tests/e2e +FLOW_RUN_E2E=1 uv run pytest tests/e2e/ -v +uv build +uv run pyinstaller --noconfirm --clean --onefile --name flow --paths src src/flow/__main__.py ``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..2edb701 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,124 @@ +# Flow Architecture + +This document describes the current action-runtime architecture. + +## Runtime Shape + +Flow is organized around a canonical action plan: + +```text +cli -> app -> domain -> actions -> adapters +``` + +The important boundary is `ActionPlan -> ActionExecutor -> ActionResult`. + +- `cli`: Typer command declarations and argument parsing in `src/flow/cli.py`. +- `app`: command use-cases in `src/flow/app/`. These load config/state, compose + domain planners, and submit action plans. +- `domain`: pure models, parsers, resolvers, and planners in `src/flow/domain/`. +- `actions`: canonical action models, expansion, execution, dry-run rendering, + JSONL audit logging, and rollback in `src/flow/actions/`. +- `adapters`: filesystem, process, git, package-manager, download, archive, + container, and tmux implementations in `src/flow/adapters/`. + +The filesystem is not the core abstraction. It is one adapter used by the +executor. The core abstraction is the action plan. + +## Actions + +`DomainAction` captures user intent: + +- `kind`: `dotfiles`, `package`, `project`, `repo`, `container`, `remote`, + `setup`, or `completion` +- `action`: `link`, `unlink`, `install`, `remove`, `update`, `pull`, `push`, + `create`, `stop`, and related verbs +- `payload`: typed-by-domain data or already expanded primitive actions +- `rollback_policy`: `rollbackable`, `barrier`, or `none` + +`PrimitiveAction` is the executor-owned operation set: + +- filesystem: create/remove symlink, write, write JSON, copy, copy tree, remove, + chmod +- git: clone, pull, push, fetch, checkout, status +- process: argv command or explicit user shell hook +- packages/assets: download and archive extraction +- containers: run, start, stop, kill, remove, exec +- tmux: new session, set option, respawn pane + +Domain actions are expanded before execution. Dry-run output and audit logging +come from the same primitive list that real execution uses. + +## Rollback + +Rollback is explicit and best effort. + +Supported file/state primitives record reverse actions before mutation. On +failure, the executor rolls back in reverse order until it reaches a barrier. +External boundaries such as package-manager commands, user shell hooks, git +pushes, container lifecycle operations, and interactive command handoffs are not +guaranteed to roll back. + +## Command Surface + +Implemented commands: + +```text +flow dotfiles init|link|unlink|status|edit +flow dotfiles repos list|status|pull|push +flow packages install|remove|list +flow setup run|show|list +flow remote enter|list +flow enter +flow dev create|attach|connect|exec|enter|stop|remove|rm|respawn|list +flow projects check|fetch|summary +flow sync +flow completion zsh|install-zsh +``` + +Global flags: + +```text +flow --version +flow --quiet +flow --no-color +``` + +Mutating non-interactive commands expose `--dry-run` where a preview is useful: +dotfiles link/unlink, dotfiles repo pull/push, package install/remove, setup +run, remote enter, and dev create. + +## Dotfiles And Modules + +The dotfiles repo and `_module.yaml` repos share one abstraction: managed repos. +`flow dotfiles repos` operates on both. Modules are regular git clones cached +under the flow data directory; they are not git submodules. + +Link planning treats conflicts and missing modules as pre-execution failures. +State is written through actions, and broken tracked symlinks are repaired by +the same link reconciliation path. + +## Packages + +Package manifests are parsed into typed dataclasses. Package-manager commands +are built as argv lists by the package adapter. Binary and AppImage installs +expand into download, archive, copy/chmod, cleanup, post-install barrier, and +state-write primitives. + +`packages remove` is strict: it refuses unknown packages, removes tracked files, +and writes updated state through the executor. + +## Tests And Guards + +The test suite covers: + +- action executor dry-run, audit logs, rollback, barriers, domain expansion, and + primitive dispatch +- dotfiles discovery, module handling, conflict behavior, rollback, and repo + operations +- package manifest parsing, package-manager argv, binary installs, archive + safety, strict removal, and post-install barriers +- Typer CLI invocation, help output, completion, and error reporting +- Docker/Podman e2e for the example dotfiles repo when `FLOW_RUN_E2E=1` + +Static guard tests reject direct mutating filesystem and command APIs outside +`actions`, `adapters`, and tests. diff --git a/docs/code-review.md b/docs/code-review.md deleted file mode 100644 index 065da26..0000000 --- a/docs/code-review.md +++ /dev/null @@ -1,271 +0,0 @@ -# Flow CLI -- Code Review - -> Reviewed 2026-03-15 against commit `24d682a` (main). Source: ~6,900 LOC across 21 modules. Tests: -> ~2,600 LOC, 167 pass / 6 skip. - ---- - -## 1. Reason to Exist - -Flow solves a real problem: managing a personal dev environment across machines. It unifies dotfiles -linking, machine bootstrapping, container management, SSH entry, and binary package installs into -one CLI. The alternative is a pile of shell scripts or Ansible for what is essentially -personal-workstation setup. Flow is lighter, more opinionated, and self-hosted (config lives in your -dotfiles repo). This is a reasonable project to maintain. - ---- - -## 2. Features Supported - -| Area | Implemented | Notes | -| ------------ | --------------------------------------------------------------------------------------------- | ----------------------------------------- | -| `enter` | SSH handoff with tmux, terminfo warnings, target config | Works | -| `dev` | Container create/exec/connect/list/stop/remove/respawn | Docker & Podman | -| `dotfiles` | init/link/unlink/undo/status/sync/relink/clean/edit, modules, repo ops | Most complex subsystem | -| `bootstrap` | Profile-driven provisioning: packages, shell, locale, hostname, ssh-keygen, runcmd, post-link | Works | -| `package` | Binary package install/list/remove from manifest | Real install; "remove" only forgets state | -| `sync` | Git health check across `~/projects` | Works | -| `completion` | Dynamic zsh completion with context-aware candidates | Thorough | - ---- - -## 3. Mismatches Between Docs/README and Source Code - -### 3.1 Architecture doc describes layers that don't fully exist yet - -`docs/architecture.md` describes a clean adapter/service/runtime split: - -> `flow.commands.*` — argparse registration and compatibility wrappers only `flow.services.*` — -> domain behavior - -In practice: - -- **`commands/sync.py`** still contains ~170 lines of its own git logic (`_git`, `_check_repo`, - `run_check`, `run_fetch`, `run_summary`) that duplicate `services/projects.py` almost - line-for-line. The service exists but the command module **never imports or uses it**. -- **`commands/package.py`** does its own JSON state loading and binary install calls instead of - delegating to `services/packages.py`. The service class `PackageService` exists but is **never - used anywhere**. -- **`commands/dotfiles.py`** is a 193-line shim that re-exports ~20 private functions from - `services/dotfiles.py`, each wrapped in `_sync_service_module()`. The command module is not - "argparse registration only" -- it's a compatibility layer that monkeypatches module-level - constants into the service at runtime. - -**Verdict:** The architecture doc describes the target state, not the actual state. Two service -modules (`projects`, `packages`) are dead code. - -### 3.2 README says "`environments` is not supported" but code still checks for it - -`services/bootstrap.py:63` raises `RuntimeError` if `environments` key is found. This is actually -correct defensive code, but the README makes it sound like the key is simply ignored. Minor. - -### 3.3 README documents `flow dev create api -i tm0/node -p ~/projects/api` - -The code works, but the container is always `sleep infinity` + exec-in. README doesn't mention this -is the execution model (not a standard container entry point). - -### 3.4 `docs/flows.md` lists "Keep But Treat As Convenience Aliases" section - -These aliases aren't flagged in code or docs as aliases. `dotfiles sync` is described as -"`repo pull` + `modules sync`", but in code it's `_pull_dotfiles` + `_sync_modules` -- not actually -calling the repo/modules commands. Functionally equivalent but not aliased. - ---- - -## 4. Code Quality Assessment - -### 4.1 Bugs - -#### Duplicate method definition in `system.py` - -`FileSystem.write_bytes` is defined **twice** (lines 259-261 and 263-265). The second definition -silently shadows the first. They're identical, so no behavioral bug, but this is a clear copy-paste -error. - -#### `commands/container.py` has unused legacy functions - -`_container_exists`, `_container_running`, and `_tmux_fallback` are defined at module level (lines -61-122) but never called -- the `ContainerService` class has its own versions. Dead code. - -#### `commands/bootstrap.py` monkeypatches the service module - -```python -def _sync_service_module() -> None: - _service.shutil = shutil - _service.urllib = urllib - _service._copy_install_item = _copy_install_item -``` - -This replaces `shutil` and `urllib` on the service module object at runtime. The service already -imports these -- this patching has no real effect (the service's own imports win). -`_copy_install_item` patch is circular: it reassigns the service function to a wrapper that calls -the original. This is effectively a no-op. - -#### `dotfiles.py` command module monkeypatches module-level constants - -```python -def _sync_service_module() -> None: - _service.DOTFILES_DIR = DOTFILES_DIR - _service.MODULES_DIR = MODULES_DIR - _service.LINKED_STATE = LINKED_STATE - ... -``` - -The service already imports these from `flow.core.paths`. This override only matters if the command -module's own constants diverge from the service's -- but they're imported from the same source. The -whole pattern is a code smell from an incomplete refactoring. - -### 4.2 Massive Code Duplication - -This is the single biggest quality issue. The bootstrap service duplicates nearly every function -from `package_defs.py`: - -| `services/package_defs.py` | `services/bootstrap.py` | -| ------------------------------------- | ------------------------------------ | -| `linux_detect_package_manager()` | `_linux_detect_package_manager()` | -| `resolve_package_manager()` | `_resolve_package_manager()` | -| `get_package_catalog()` | `_get_package_catalog()` | -| `normalize_profile_package_entry()` | `_normalize_profile_package_entry()` | -| `resolve_package_spec()` | `_resolve_package_spec()` | -| `resolve_pkg_source_name()` | `_resolve_pkg_source_name()` | -| `platform_lookup_keys()` | `_platform_lookup_keys()` | -| `resolve_binary_platform_vars()` | `_resolve_binary_platform_vars()` | -| `resolve_binary_asset()` | `_resolve_binary_asset()` | -| `resolve_binary_download_url()` | `_resolve_binary_download_url()` | -| `validate_declared_install_path()` | `_validate_declared_install_path()` | -| `install_destination()` | `_install_destination()` | -| `install_strip_prefix()` | `_install_strip_prefix()` | -| `strip_prefix()` | `_strip_prefix()` | -| `BinaryInstaller.install()` | `_install_binary_package()` | -| `BinaryInstaller.copy_install_item()` | `_copy_install_item()` | -| `profile_template_context()` | `_profile_template_context()` | -| `render_template_value()` | `_render_template_value()` | - -That's **18 duplicate function pairs** with near-identical implementations. `package_defs.py` was -clearly extracted as the canonical version, but `bootstrap.py` was never updated to import from it. -Similarly, `services/projects.py` duplicates `commands/sync.py`. - -Total duplicate code: roughly 500-600 lines. - -### 4.3 Module-Level Singletons - -`services/dotfiles.py` and `services/bootstrap.py` use module-level singletons: - -```python -_RUNNER = CommandRunner() -_FS = FileSystem() -``` - -These are then patched by the command modules. This makes the service modules hard to test in -isolation and creates hidden mutable global state. The `FlowContext` already carries a -`SystemRuntime` with `runner`, `fs`, and `git` -- but the service code ignores it and uses the -module-level singletons instead. - -### 4.4 Tests Reach Into Private APIs - -Tests import private (underscore-prefixed) functions directly: - -```python -from flow.commands.bootstrap import ( - _ensure_required_variables, - _get_profiles, - _install_binary_package, - ... -) -``` - -This couples tests to internal implementation details. Combined with the command-module shimming -pattern, it means the test suite is testing the wrong layer (command shims instead of service logic -directly). - -### 4.5 Inconsistent Error Handling - -Two exception hierarchies coexist: - -- `FlowError(RuntimeError)` -- defined in `core/errors.py`, used by `system.py` and services -- Plain `RuntimeError` -- used throughout `bootstrap.py`, `dotfiles.py`, and command modules - -The CLI entry point catches `RuntimeError` as the generic error type, so both work. But `FlowError` -was clearly intended to be the project-wide error type and is underused. The `bootstrap.py` service -(1001 lines) never imports or uses `FlowError`. - -### 4.6 `stow.py` is Unused - -`core/stow.py` (358 lines) implements a GNU Stow-style tree folding/unfolding algorithm with -`LinkTree`, `TreeFolder`, `LinkOperation`. It has 310 lines of tests. But **nothing in the -application imports or uses it**. The dotfiles service has its own `LinkSpec`-based approach. This -is dead code (or a planned feature that never landed). - -### 4.7 `action.py` is Unused - -`core/action.py` (120 lines) defines `Action` and `ActionExecutor` for plan-then-execute workflows. -It has 115 lines of tests. But **no command or service uses it**. The bootstrap command does its own -sequential execution. Dead code. - -### 4.8 `process.py` is a Thin Wrapper With Odd Restrictions - -```python -def run_command(command, console, *, check=True, shell=True, capture=False): - if not shell: - raise RuntimeError("run_command only supports shell commands") -``` - -It rejects `shell=False` even though the parameter exists in the signature. This wrapper creates a -new `CommandRunner()` on every call instead of using the one from context. - -### 4.9 Minor Issues - -- **No type checking enforcement.** `pyproject.toml` has no mypy/pyright configuration. Type - annotations exist but are never validated. -- **`ConsoleLogger` hardcodes ANSI codes.** No TTY detection, no `--no-color` flag. Piping output to - a file produces escape sequences. -- **`flow package remove` only forgets state.** README and `docs/flows.md` both acknowledge this, - but the CLI help text says "Remove installed packages" without qualification. Users will expect - files to be deleted. -- **SSH keygen quoting.** `bootstrap.py:747` passes `shlex.quote` results into a string that is - later shell-executed. Double quoting could be an issue with values containing spaces, though - unlikely in practice for key filenames. - ---- - -## 5. Summary - -### What's Good - -- **Clear domain model.** The manifest format is well-designed: profiles, packages, dotfiles layout, - modules, templates. It's genuinely useful. -- **Transactional dotfiles linking.** The undo system with snapshots and backup directories is - well-thought-out and more robust than most dotfile managers. -- **Defensive input validation.** Config parsing handles multiple formats (dict keys, lists, string - shorthand), missing values, and type mismatches gracefully. -- **Test coverage exists.** 167 tests pass. The dotfiles folding and e2e container tests show real - investment in correctness. -- **Clean CLI surface.** Argparse registration is consistent, aliases work, help text is clear. -- **Zsh completion is thorough.** Context-aware dynamic completion is a nice touch. - -### What Needs Work - -| Priority | Issue | Effort | -| -------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -| High | ~600 lines of duplicated code between bootstrap and package_defs | Delete duplicates, import from package_defs | -| High | Two dead modules (stow.py, action.py) + dead service modules (projects.py, packages.py) = ~900 lines of unused code | Delete or wire up | -| High | Command modules bypass their own service layer | Finish the refactoring described in architecture.md | -| Medium | Module-level singletons vs FlowContext.runtime | Use runtime from context consistently | -| Medium | `_sync_service_module()` monkeypatching pattern | Remove once services use their own imports | -| Medium | Inconsistent error types (RuntimeError vs FlowError) | Standardize on FlowError | -| Low | No color/TTY detection | Add `--no-color` or detect `isatty` | -| Low | `package remove` semantics | Either implement real uninstall or rename to `forget` | -| Low | Duplicate `write_bytes` method | Delete the duplicate | - -### Overall Assessment - -This is a **functional but mid-refactoring codebase**. The core functionality works -- dotfiles -linking, bootstrapping, container management, and SSH entry all do what they claim. The manifest -model and transactional undo are genuinely well-designed. - -The main debt is architectural: a service-layer extraction was started but left incomplete, -resulting in large amounts of duplicated code, dead modules, and a monkeypatching compatibility -layer. The tests pass but are coupled to the wrong abstraction layer. - -For a personal "vibe-coded" tool, this is solid. For production or team use, the duplication and -dead code need cleanup before adding more features. diff --git a/docs/refactor-plan.md b/docs/refactor-plan.md deleted file mode 100644 index 9132e1c..0000000 --- a/docs/refactor-plan.md +++ /dev/null @@ -1,180 +0,0 @@ -# Flow CLI Refactor Status - -> Based on code review (2026-03-22), architecture discussion, and the current implementation. -> Spec: `docs/superpowers/specs/2026-03-16-flow-architecture-redesign.md` - -## Current State - -The action-runtime rewrite is implemented. `cli.py` is a thin Typer adapter, `flow.app` owns -application orchestration, domain modules keep pure planning and resolution logic, and -executor-managed mutations are represented as action plans before they reach runtime adapters. - -The old structural problems from the original codebase (duplicated flows, monkeypatching, dead -modules, singleton-style runtime access) have been removed from the active command paths. The -remaining refactor work is deferred cleanup, not a blocker for the action-centered architecture. - ---- - -## Command Surface - -This is the implemented command surface. - -``` -flow remote enter # Host only. SSH+tmux into VM. -flow remote list # List configured targets. - -flow dev create -i # VM only. Create+start container. -flow dev attach # Attach to container tmux session. -flow dev exec [cmd...] # Run command in container. -flow dev enter # Interactive shell in container. -flow dev list # List dev containers. -flow dev stop # Stop container. -flow dev rm # Remove container. -flow dev respawn # Respawn tmux panes. - -flow dotfiles init --repo # Clone dotfiles repo + all module repos. -flow dotfiles link [--profile p] # Reconcile symlinks (creates, fixes broken, removes stale). -flow dotfiles unlink [packages...] # Remove managed symlinks. -flow dotfiles status [packages...] # Show packages, link health, module info. -flow dotfiles edit # Pull -> $EDITOR -> commit+push. - -flow dotfiles repos list # List ALL managed repos (dotfiles + modules). -flow dotfiles repos status [--repo=x] # Git status for one or all repos. -flow dotfiles repos pull [--repo=x] [--dry-run] -flow dotfiles repos push [--repo=x] [--dry-run] - -flow setup run [profile|--profile p] [--dry-run] [--var KEY=VALUE] -flow setup list # List profiles. -flow setup show # Show profile plan. - -flow packages install [name...] [--profile p] [--dry-run] -flow packages list [--all] # List packages. -flow packages remove [--dry-run] - -flow projects check [--fetch] # VM only. Git health across ~/projects. -flow projects fetch # Fetch all project remotes. -flow projects summary # Quick status overview. -``` - -### Aliases - -- `dotfiles` -> `dot` -- `packages` -> `package`, `pkg` -- `projects` -> `project`; `flow sync` -> `flow projects check --fetch` -- `setup` -> `bootstrap`, `provision` -- `remote enter` -> `enter` -- `dev attach` -> `dev connect` -- `dev rm` -> `dev remove` -- `dotfiles repos` -> `dotfiles repo` - -### Global flags - -- `--version` -- `--quiet` / `-q` -- `--no-color` - -### Commands Removed During Refactor - -| Removed | Reason | -|---------|--------| -| `dotfiles sync` | Redundant: `repos pull` + `link` | -| `dotfiles relink` | Redundant: `link` is idempotent | -| `dotfiles undo` | Redundant: `unlink` is the inverse of `link` | -| `dotfiles clean` | Folded into `link` (reconciliation handles broken symlinks) | -| `dotfiles modules list` | Replaced by `dotfiles repos list` | -| `dotfiles modules sync` | Replaced by `dotfiles repos pull` | - -## Action-Centered Architecture - -The runtime boundary is `flow.actions`. - -- `ActionPlan` is the unit of execution. It can contain high-level `DomainAction` entries and direct - `PrimitiveAction` entries. -- `DomainAction` records intent from a domain such as dotfiles, packages, repos, remote targets, - containers, completion, or setup. -- `expand_actions()` converts domain actions into primitive actions. Some domains supply already - expanded primitive plans when the service has concrete runtime arguments. -- `PrimitiveAction` is the canonical executor input for filesystem, process, git, download, - archive, container, and tmux operations. -- `ActionExecutor` owns dry-run output, append-only JSONL audit logging, rollback stack management, - rollback barriers, and dispatch into `SystemRuntime`. - -App use-cases construct plans and pass them to the executor for action-backed commands. Direct -runtime calls are limited to explicit interactive boundaries such as attaching to tmux or entering a -container shell. Domain modules stay free of I/O where the current implementation has pure -resolution/planning functions. - -Rollback is best-effort and explicit. Actions default to `rollbackable`; external boundaries such -as shell commands, remote sessions, and non-reversible git/container operations use `barrier` or -`none` policies. - -## Key Design Decision: Modules Are Repos - -The `_module.yaml` files define external git repos that provide content for dotfiles packages. -These module repos are **not** git submodules -- they are regular git clones managed by flow. - -The dotfiles repo itself is also a git clone managed by flow. So **all managed git repos** (the -dotfiles repo + every module repo) share the same abstraction and the same commands. - -`dotfiles repos` is the single entry point for all repo operations. There is no separate -`dotfiles modules` subcommand group. The `--repo=` flag filters to a specific repo when -needed (dotfiles repo is named `dotfiles`, module repos are named by their package, e.g. `nvim`). - ---- - -## Completed Work - -### Unified Repos Abstraction - -`RepoInfo` with `module_ref` is the canonical repo model. `_discover_repos()` finds the dotfiles -repo and module repos, and `repos list/status/pull/push` iterate that single collection with an -optional `--repo` filter. `dotfiles init` uses the same pull-or-clone flow. - -### Command Trimming - -Removed redundant dotfiles commands: - -- `dotfiles sync`: use `dotfiles repos pull` plus `dotfiles link` -- `dotfiles relink`: `dotfiles link` is idempotent -- `dotfiles undo`: use `dotfiles unlink` -- `dotfiles clean`: broken symlink repair is part of link planning -- `dotfiles modules list/sync`: use `dotfiles repos list/pull` - -### Feature Completion - -- `dotfiles edit`: pull -> `$EDITOR`/`$VISUAL` -> scoped `git add` -> commit+push, with - `--no-commit` to skip auto-commit/push. -- `dotfiles status`: module info, link health, and package filtering. -- `dotfiles repos list`: all managed repos with name, type, local path, and clone status. -- `--no-color`: global flag added to `cli.py`. -- `--dry-run`: supported by dotfiles link/unlink, repos pull/push, packages install, setup run, - remote enter, and dev create. - -### Improvements Over Spec - -These are correct deviations -- the implementation improved on the spec: - -- `adapters/containers.py` + `adapters/tmux.py` extracted as adapters (spec had them inline) -- `core/config_parse.py` + `core/yaml.py` extracted for config parsing -- `SystemRuntime` extended with containers, tmux, download, and archive runtime fields -- `flow.actions` extracted as the canonical execution layer instead of leaving mutation dispatch in - individual app use-cases - ---- - -## CI - -The GitHub Actions workflow is split into two jobs: - -- `unit`: installs dependencies and runs `pytest tests/ -v --ignore=tests/e2e` -- `e2e`: verifies Docker is available, sets `FLOW_RUN_E2E=1`, and runs `pytest tests/e2e/ -v` - ---- - -## Optional Future Work - -These are optional refinements, not blockers for the action-centered rewrite. - -### Global `--dry-run` - -If per-command `--dry-run` becomes a maintenance burden, promote to a global flag in `cli.py`. diff --git a/docs/superpowers/plans/2026-03-16-flow-rewrite.md b/docs/superpowers/plans/2026-03-16-flow-rewrite.md deleted file mode 100644 index 9a4bf20..0000000 --- a/docs/superpowers/plans/2026-03-16-flow-rewrite.md +++ /dev/null @@ -1,2533 +0,0 @@ -# Flow CLI Rewrite Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Full rewrite of the flow CLI with correct domain abstractions, plan-then-execute pattern, and working module path resolution. - -**Architecture:** Four-layer hybrid (core -> domain -> services -> commands). Domain layer is pure functions + frozen dataclasses. Services orchestrate I/O. Commands are trivial dispatchers. See `docs/superpowers/specs/2026-03-16-flow-architecture-redesign.md` for the full spec. - -**Tech Stack:** Python 3.9+, PyYAML, argparse, pytest, PyInstaller - -**Spec:** `docs/superpowers/specs/2026-03-16-flow-architecture-redesign.md` -**Standards:** `CLAUDE.md` - ---- - -## Phasing Strategy - -Build bottom-up. Each phase produces tested, working code before the next begins. Old code under `src/flow/` is untouched until the final phase when it's deleted. - -The new code is written in-place (same `src/flow/` tree) because this is a full rewrite with no backward compatibility. Old modules are deleted as their replacements land. - -| Phase | What | Depends on | -|-------|------|------------| -| 1 | Core layer (errors, template, paths, platform, console, runtime, config) | Nothing | -| 2 | Dotfiles domain (models, modules, resolution, planning) | Core | -| 3 | Packages domain (models, catalog, resolution, planning) | Core | -| 4 | Bootstrap domain (models, modules, planning) | Core, packages domain | -| 5 | Remote + containers + projects domains | Core | -| 6 | All services | Core, all domains | -| 7 | Commands + CLI + completion | Core, all services | -| 8 | Delete old code, final integration | Everything | - ---- - -## Chunk 1: Core Layer - -### Task 1: Errors - -**Files:** -- Create: `src/flow/core/errors.py` -- Create: `tests/test_errors.py` - -- [ ] **Step 1: Write the error hierarchy** - -```python -# src/flow/core/errors.py -"""Project-wide error types.""" - - -class FlowError(Exception): - """Base for all user-facing errors.""" - - -class ConfigError(FlowError): - """Invalid config or manifest YAML.""" - - -class PlanConflict(FlowError): - """Conflicts detected during planning.""" - - def __init__(self, message: str, conflicts: list[str]): - super().__init__(message) - self.conflicts = conflicts - - -class ExecutionError(FlowError): - """A plan step failed during execution.""" -``` - -- [ ] **Step 2: Write tests** - -```python -# tests/test_errors.py -"""Tests for flow.core.errors.""" - -from flow.core.errors import ConfigError, ExecutionError, FlowError, PlanConflict - - -def test_flow_error_is_exception(): - assert issubclass(FlowError, Exception) - - -def test_config_error_is_flow_error(): - assert issubclass(ConfigError, FlowError) - - -def test_plan_conflict_carries_conflicts(): - err = PlanConflict("2 conflicts", ["a exists", "b exists"]) - assert str(err) == "2 conflicts" - assert err.conflicts == ["a exists", "b exists"] - - -def test_execution_error_is_flow_error(): - assert issubclass(ExecutionError, FlowError) -``` - -- [ ] **Step 3: Run tests** - -Run: `python -m pytest tests/test_errors.py -v` -Expected: 4 passed - -- [ ] **Step 4: Commit** - -```bash -git add src/flow/core/errors.py tests/test_errors.py -git commit -m "feat: add FlowError hierarchy" -``` - ---- - -### Task 2: Template (pure string substitution) - -**Files:** -- Create: `src/flow/core/template.py` -- Create: `tests/test_template.py` - -- [ ] **Step 1: Write tests** - -```python -# tests/test_template.py -"""Tests for flow.core.template.""" - -import os - -from flow.core.template import substitute, substitute_template - - -class TestSubstitute: - def test_replaces_dollar_var(self): - assert substitute("hello $NAME", {"NAME": "world"}) == "hello world" - - def test_replaces_braced_var(self): - assert substitute("hello ${NAME}", {"NAME": "world"}) == "hello world" - - def test_falls_back_to_env(self, monkeypatch): - monkeypatch.setenv("FOO", "bar") - assert substitute("$FOO", {}) == "bar" - - def test_preserves_unknown_vars(self): - assert substitute("$UNKNOWN", {}) == "$UNKNOWN" - - def test_non_string_passthrough(self): - assert substitute(42, {}) == 42 - - -class TestSubstituteTemplate: - def test_replaces_double_braces(self): - assert substitute_template("nvim-{{os}}", {"os": "linux"}) == "nvim-linux" - - def test_env_dot_notation(self, monkeypatch): - monkeypatch.setenv("USER", "tomas") - result = substitute_template("{{ env.USER }}", {"env": dict(os.environ)}) - assert result == "tomas" - - def test_nested_dict_lookup(self): - ctx = {"platform": {"arch": "arm64"}} - assert substitute_template("{{ platform.arch }}", ctx) == "arm64" - - def test_preserves_unknown_templates(self): - assert substitute_template("{{ unknown }}", {}) == "{{ unknown }}" - - def test_non_string_passthrough(self): - assert substitute_template(42, {}) == 42 - - def test_whitespace_in_braces(self): - assert substitute_template("{{ os }}", {"os": "linux"}) == "linux" -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `python -m pytest tests/test_template.py -v` -Expected: FAIL (module not found) - -- [ ] **Step 3: Implement template.py** - -```python -# src/flow/core/template.py -"""Variable and template substitution -- pure functions, no I/O.""" - -import os -import re -from typing import Any, Dict - - -def substitute(text: Any, variables: Dict[str, str]) -> Any: - """Replace $VAR and ${VAR} with values from variables dict or env.""" - if not isinstance(text, str): - return text - - pattern = re.compile(r"\$(\w+)|\$\{([^}]+)\}") - - def _replace(match: re.Match[str]) -> str: - key = match.group(1) or match.group(2) or "" - if key in variables: - return str(variables[key]) - if key in os.environ: - return os.environ[key] - return match.group(0) - - return pattern.sub(_replace, text) - - -def _resolve_template_value(expr: str, context: Dict[str, Any]) -> Any: - if expr.startswith("env."): - env_key = expr.split(".", 1)[1] - env_ctx = context.get("env", {}) - if isinstance(env_ctx, dict) and env_key in env_ctx: - return env_ctx[env_key] - return os.environ.get(env_key) - - if expr in context: - return context[expr] - - current: Any = context - for part in expr.split("."): - if not isinstance(current, dict) or part not in current: - return None - current = current[part] - - return current - - -def substitute_template(text: Any, context: Dict[str, Any]) -> Any: - """Replace {{expr}} placeholders with values from context dict.""" - if not isinstance(text, str): - return text - - def _replace(match: re.Match[str]) -> str: - key = match.group(1).strip() - value = _resolve_template_value(key, context) - if value is None: - return match.group(0) - return str(value) - - return re.sub(r"\{\{\s*([^{}]+?)\s*\}\}", _replace, text) -``` - -- [ ] **Step 4: Run tests** - -Run: `python -m pytest tests/test_template.py -v` -Expected: All passed - -- [ ] **Step 5: Commit** - -```bash -git add src/flow/core/template.py tests/test_template.py -git commit -m "feat: add template substitution (pure functions)" -``` - ---- - -### Task 3: Paths (XDG constants) - -**Files:** -- Create: `src/flow/core/paths.py` -- Create: `tests/test_core_paths.py` - -- [ ] **Step 1: Write paths.py** - -```python -# src/flow/core/paths.py -"""XDG-compliant path constants for flow.""" - -import os -from pathlib import Path - - -def _xdg(env_var: str, fallback: str) -> Path: - return Path(os.environ.get(env_var, fallback)) - - -HOME = Path.home() - -CONFIG_DIR = _xdg("XDG_CONFIG_HOME", str(HOME / ".config")) / "flow" -DATA_DIR = _xdg("XDG_DATA_HOME", str(HOME / ".local" / "share")) / "flow" -STATE_DIR = _xdg("XDG_STATE_HOME", str(HOME / ".local" / "state")) / "flow" - -DOTFILES_DIR = DATA_DIR / "dotfiles" -MODULES_DIR = DATA_DIR / "modules" -PACKAGES_DIR = DATA_DIR / "packages" - -LINKED_STATE = STATE_DIR / "linked.json" -INSTALLED_STATE = STATE_DIR / "installed.json" - -# Self-hosted flow config path (from dotfiles repo) -DOTFILES_FLOW_CONFIG = DOTFILES_DIR / "_shared" / "flow" / ".config" / "flow" - - -def ensure_dirs() -> None: - """Create all required directories.""" - for d in (CONFIG_DIR, DATA_DIR, STATE_DIR, MODULES_DIR, PACKAGES_DIR): - d.mkdir(parents=True, exist_ok=True) -``` - -- [ ] **Step 2: Write tests** - -```python -# tests/test_core_paths.py -"""Tests for flow.core.paths.""" - -from pathlib import Path - -from flow.core import paths - - -def test_config_dir_ends_with_flow(): - assert paths.CONFIG_DIR.name == "flow" - - -def test_data_dir_ends_with_flow(): - assert paths.DATA_DIR.name == "flow" - - -def test_modules_dir_under_data(): - assert paths.MODULES_DIR.parent == paths.DATA_DIR - - -def test_linked_state_under_state(): - assert paths.LINKED_STATE.parent == paths.STATE_DIR - - -def test_dotfiles_flow_config_path(): - expected_suffix = Path("_shared") / "flow" / ".config" / "flow" - assert str(paths.DOTFILES_FLOW_CONFIG).endswith(str(expected_suffix)) - - -def test_ensure_dirs_creates_directories(tmp_path, monkeypatch): - monkeypatch.setattr(paths, "CONFIG_DIR", tmp_path / "config" / "flow") - monkeypatch.setattr(paths, "DATA_DIR", tmp_path / "data" / "flow") - monkeypatch.setattr(paths, "STATE_DIR", tmp_path / "state" / "flow") - monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "data" / "flow" / "modules") - monkeypatch.setattr(paths, "PACKAGES_DIR", tmp_path / "data" / "flow" / "packages") - - paths.ensure_dirs() - - assert (tmp_path / "config" / "flow").is_dir() - assert (tmp_path / "data" / "flow" / "modules").is_dir() - assert (tmp_path / "state" / "flow").is_dir() -``` - -- [ ] **Step 3: Run tests** - -Run: `python -m pytest tests/test_core_paths.py -v` -Expected: All passed - -- [ ] **Step 4: Commit** - -```bash -git add src/flow/core/paths.py tests/test_core_paths.py -git commit -m "feat: add XDG path constants" -``` - ---- - -### Task 4: Platform detection - -**Files:** -- Create: `src/flow/core/platform.py` -- Create: `tests/test_core_platform.py` - -- [ ] **Step 1: Write tests** - -```python -# tests/test_core_platform.py -"""Tests for flow.core.platform.""" - -import os - -import pytest - -from flow.core.platform import PlatformInfo, detect_context, detect_platform - - -def test_platform_info_computes_platform_string(): - p = PlatformInfo(os="linux", arch="x64") - assert p.platform == "linux-x64" - - -def test_detect_platform_returns_valid_info(): - info = detect_platform() - assert info.os in ("linux", "macos") - assert info.arch in ("x64", "arm64") - assert info.platform == f"{info.os}-{info.arch}" - - -def test_detect_platform_raises_flow_error_on_unsupported(monkeypatch): - from flow.core.errors import FlowError - monkeypatch.setattr("platform.system", lambda: "FreeBSD") - with pytest.raises(FlowError, match="Unsupported operating system"): - detect_platform() - - -def test_detect_context_host(monkeypatch): - monkeypatch.delenv("DF_NAMESPACE", raising=False) - monkeypatch.delenv("DF_PLATFORM", raising=False) - assert detect_context() == "host" - - -def test_detect_context_vm(monkeypatch): - monkeypatch.setenv("DF_NAMESPACE", "personal") - monkeypatch.setenv("DF_PLATFORM", "orb") - assert detect_context() == "vm" -``` - -- [ ] **Step 2: Implement platform.py** - -```python -# src/flow/core/platform.py -"""OS/arch detection and execution context.""" - -import os -import platform as _platform -from dataclasses import dataclass - -from flow.core.errors import FlowError - - -@dataclass(frozen=True) -class PlatformInfo: - os: str = "linux" - arch: str = "x64" - - @property - def platform(self) -> str: - return f"{self.os}-{self.arch}" - - -_OS_MAP = {"Darwin": "macos", "Linux": "linux"} -_ARCH_MAP = {"x86_64": "x64", "amd64": "x64", "aarch64": "arm64", "arm64": "arm64"} - - -def detect_platform() -> PlatformInfo: - raw_os = _platform.system() - os_name = _OS_MAP.get(raw_os) - if os_name is None: - raise FlowError(f"Unsupported operating system: {raw_os}") - - raw_arch = _platform.machine().lower() - arch = _ARCH_MAP.get(raw_arch) - if arch is None: - raise FlowError(f"Unsupported architecture: {raw_arch}") - - return PlatformInfo(os=os_name, arch=arch) - - -def detect_context() -> str: - """Detect execution context: 'host', 'vm', or 'container'.""" - if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"): - return "container" - if os.environ.get("DF_NAMESPACE") and os.environ.get("DF_PLATFORM"): - return "vm" - return "host" -``` - -- [ ] **Step 3: Run tests** - -Run: `python -m pytest tests/test_core_platform.py -v` -Expected: All passed - -- [ ] **Step 4: Commit** - -```bash -git add src/flow/core/platform.py tests/test_core_platform.py -git commit -m "feat: add platform detection and context awareness" -``` - ---- - -### Task 5: Console output - -**Files:** -- Create: `src/flow/core/console.py` -- Create: `tests/test_core_console.py` - -- [ ] **Step 1: Write tests** - -```python -# tests/test_core_console.py -"""Tests for flow.core.console.""" - -from flow.core.console import Console - - -def test_info_prints_message(capsys): - c = Console(color=False) - c.info("hello") - assert "hello" in capsys.readouterr().out - - -def test_quiet_suppresses_info(capsys): - c = Console(quiet=True, color=False) - c.info("hidden") - assert capsys.readouterr().out == "" - - -def test_quiet_does_not_suppress_error(capsys): - c = Console(quiet=True, color=False) - c.error("visible") - captured = capsys.readouterr() - assert "visible" in captured.err or "visible" in captured.out - - -def test_table_prints_headers_and_rows(capsys): - c = Console(color=False) - c.table(["NAME", "STATUS"], [["foo", "ok"], ["bar", "fail"]]) - output = capsys.readouterr().out - assert "NAME" in output - assert "foo" in output - assert "bar" in output - - -def test_no_color_strips_ansi(capsys): - c = Console(color=False) - c.info("test") - output = capsys.readouterr().out - assert "\033[" not in output -``` - -- [ ] **Step 2: Implement console.py** - -```python -# src/flow/core/console.py -"""Console output formatting with TTY detection and color control.""" - -import os -import sys -from typing import Any, Optional - - -class Console: - def __init__(self, *, quiet: bool = False, color: Optional[bool] = None): - self.quiet = quiet - if color is None: - self._color = os.isatty(sys.stdout.fileno()) if hasattr(sys.stdout, "fileno") else False - else: - self._color = color - - def _style(self, code: str, text: str) -> str: - if not self._color: - return text - return f"{code}{text}\033[0m" - - def info(self, msg: str) -> None: - if self.quiet: - return - tag = self._style("\033[36m", "[INFO]") - print(f"{tag} {msg}") - - def warn(self, msg: str) -> None: - tag = self._style("\033[33m", "[WARN]") - print(f"{tag} {msg}") - - def error(self, msg: str) -> None: - tag = self._style("\033[31m", "[ERROR]") - print(f"{tag} {msg}", file=sys.stderr) - - def success(self, msg: str) -> None: - tag = self._style("\033[32m", "[OK]") - print(f"{tag} {msg}") - - def table(self, headers: list[str], rows: list[list[str]]) -> None: - if not rows: - return - widths = [len(h) for h in headers] - for row in rows: - for i, cell in enumerate(row): - if i < len(widths): - widths[i] = max(widths[i], len(str(cell))) - - header_line = " ".join(f"{h:<{widths[i]}}" for i, h in enumerate(headers)) - if self._color: - print(f"\033[1m{header_line}\033[0m") - else: - print(header_line) - print(" ".join("-" * w for w in widths)) - for row in rows: - print(" ".join(f"{str(cell):<{widths[i]}}" for i, cell in enumerate(row))) - - def print_plan(self, operations: list[Any], *, verb: str = "execute") -> None: - if not operations: - self.info(f"Nothing to {verb}.") - return - self.info(f"Plan ({len(operations)} operation(s)):") - for op in operations: - print(f" {op}") -``` - -- [ ] **Step 3: Run tests** - -Run: `python -m pytest tests/test_core_console.py -v` -Expected: All passed - -- [ ] **Step 4: Commit** - -```bash -git add src/flow/core/console.py tests/test_core_console.py -git commit -m "feat: add Console with color/quiet/TTY support" -``` - ---- - -### Task 6: Runtime primitives (CommandRunner, FileSystem, GitClient, SystemRuntime) - -**Files:** -- Create: `src/flow/core/runtime.py` -- Create: `tests/test_core_runtime.py` - -- [ ] **Step 1: Write tests for FileSystem** - -```python -# tests/test_core_runtime.py -"""Tests for flow.core.runtime.""" - -from pathlib import Path - -from flow.core.runtime import CommandRunner, FileSystem, GitClient, SystemRuntime - - -class TestFileSystem: - def test_ensure_dir_creates_nested(self, tmp_path): - fs = FileSystem() - target = tmp_path / "a" / "b" / "c" - fs.ensure_dir(target) - assert target.is_dir() - - def test_write_and_read_text(self, tmp_path): - fs = FileSystem() - path = tmp_path / "test.txt" - fs.write_text(path, "hello") - assert fs.read_text(path) == "hello" - - def test_read_text_default(self, tmp_path): - fs = FileSystem() - path = tmp_path / "missing.txt" - assert fs.read_text(path, default="fallback") == "fallback" - - def test_write_and_read_json(self, tmp_path): - fs = FileSystem() - path = tmp_path / "data.json" - fs.write_json(path, {"key": "value"}) - assert fs.read_json(path) == {"key": "value"} - - def test_create_symlink(self, tmp_path): - fs = FileSystem() - source = tmp_path / "source" - source.write_text("content") - target = tmp_path / "link" - fs.create_symlink(source, target) - assert target.is_symlink() - assert target.resolve() == source.resolve() - - def test_same_symlink_true(self, tmp_path): - fs = FileSystem() - source = tmp_path / "source" - source.write_text("content") - target = tmp_path / "link" - target.symlink_to(source) - assert fs.same_symlink(target, source) is True - - def test_same_symlink_false(self, tmp_path): - fs = FileSystem() - source = tmp_path / "source" - source.write_text("content") - other = tmp_path / "other" - other.write_text("other") - target = tmp_path / "link" - target.symlink_to(other) - assert fs.same_symlink(target, source) is False - - def test_remove_file(self, tmp_path): - fs = FileSystem() - path = tmp_path / "file" - path.write_text("x") - fs.remove_file(path) - assert not path.exists() - - def test_remove_file_missing_ok(self, tmp_path): - fs = FileSystem() - fs.remove_file(tmp_path / "missing", missing_ok=True) # no error - - def test_copy_file(self, tmp_path): - fs = FileSystem() - src = tmp_path / "src" - src.write_text("data") - dst = tmp_path / "sub" / "dst" - fs.copy_file(src, dst) - assert dst.read_text() == "data" - - -class TestCommandRunner: - def test_run_echo(self): - runner = CommandRunner() - result = runner.run(["echo", "hello"], capture_output=True) - assert result.stdout.strip() == "hello" - - def test_require_binary_finds_echo(self): - runner = CommandRunner() - path = runner.require_binary("echo") - assert path is not None - - -class TestSystemRuntime: - def test_creates_git_client(self): - rt = SystemRuntime() - assert isinstance(rt.git, GitClient) - assert rt.git.runner is rt.runner -``` - -- [ ] **Step 2: Implement runtime.py** - -Implement `CommandRunner`, `FileSystem`, `GitClient`, and `SystemRuntime` per the spec in Section 3.3. Port the working implementations from the current `src/flow/core/system.py` but: -- Remove the duplicate `write_bytes` method -- Use `FlowError` instead of `RuntimeError` for error wrapping -- Keep the same API surface - -```python -# src/flow/core/runtime.py -"""Runtime primitives for process, git, state, and filesystem access.""" - -from __future__ import annotations - -import json -import os -import shlex -import shutil -import subprocess -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Iterable, Mapping, Optional, Sequence - -from flow.core.console import Console -from flow.core.errors import FlowError - - -class CommandRunner: - """Subprocess wrapper with consistent defaults.""" - - def run( - self, - argv: Sequence[str] | Iterable[str], - *, - cwd: Optional[Path] = None, - env: Optional[Mapping[str, str]] = None, - capture_output: bool = True, - check: bool = False, - timeout: Optional[float] = None, - ) -> subprocess.CompletedProcess[str]: - parts = [str(a) for a in argv] - completed = subprocess.run( - parts, - cwd=str(cwd) if cwd else None, - env=dict(env) if env else None, - capture_output=capture_output, - text=True, - check=False, - timeout=timeout, - ) - if check and completed.returncode != 0: - msg = completed.stderr.strip() or completed.stdout.strip() - if not msg: - msg = f"Command failed with exit code {completed.returncode}" - raise FlowError(msg) - return completed - - def run_shell( - self, - command: str, - *, - cwd: Optional[Path] = None, - env: Optional[Mapping[str, str]] = None, - capture_output: bool = True, - check: bool = False, - timeout: Optional[float] = None, - ) -> subprocess.CompletedProcess[str]: - completed = subprocess.run( - command, - shell=True, - cwd=str(cwd) if cwd else None, - env=dict(env) if env else None, - capture_output=capture_output, - text=True, - check=False, - timeout=timeout, - ) - if check and completed.returncode != 0: - msg = completed.stderr.strip() or completed.stdout.strip() - if not msg: - msg = f"Command failed with exit code {completed.returncode}" - raise FlowError(msg) - return completed - - def stream_shell( - self, - command: str, - console: Console, - *, - check: bool = True, - ) -> subprocess.CompletedProcess[str]: - process = subprocess.Popen( - command, - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - ) - lines: list[str] = [] - assert process.stdout is not None - try: - for line in process.stdout: - stripped = line.rstrip() - if stripped: - lines.append(stripped) - finally: - process.stdout.close() - process.wait() - - if check and process.returncode != 0: - raise FlowError(f"Command failed (exit {process.returncode}): {command}") - - return subprocess.CompletedProcess(command, process.returncode, stdout="\n".join(lines), stderr="") - - def require_binary(self, name: str) -> str: - path = shutil.which(name) - if path is None: - raise FlowError(f"Required executable not found: {name}") - return path - - -class FileSystem: - """Filesystem wrapper for all mutating operations.""" - - def ensure_dir(self, path: Path, *, sudo: bool = False, runner: Optional[CommandRunner] = None, mode: Optional[int] = None) -> None: - if sudo: - if runner is None: - raise FlowError("Runner required for sudo operations") - runner.require_binary("sudo") - argv: list[str] = ["sudo", "mkdir", "-p"] - if mode is not None: - argv.extend(["-m", f"{mode:o}"]) - argv.append(str(path)) - runner.run(argv, check=True) - return - path.mkdir(parents=True, exist_ok=True) - if mode is not None: - path.chmod(mode) - - def remove_file(self, path: Path, *, sudo: bool = False, runner: Optional[CommandRunner] = None, missing_ok: bool = True) -> None: - if sudo: - if runner is None: - raise FlowError("Runner required for sudo operations") - argv = ["sudo", "rm"] - if missing_ok: - argv.append("-f") - argv.append(str(path)) - runner.run(argv, check=True) - return - try: - path.unlink() - except FileNotFoundError: - if not missing_ok: - raise - - def remove_tree(self, path: Path) -> None: - shutil.rmtree(path, ignore_errors=True) - - def copy_file(self, source: Path, target: Path, *, sudo: bool = False, runner: Optional[CommandRunner] = None) -> None: - if sudo: - if runner is None: - raise FlowError("Runner required for sudo operations") - self.ensure_dir(target.parent, sudo=True, runner=runner) - runner.run(["sudo", "cp", "-a", str(source), str(target)], check=True) - return - self.ensure_dir(target.parent) - shutil.copy2(source, target) - - def copy_tree(self, source: Path, target: Path) -> None: - self.ensure_dir(target.parent) - shutil.copytree(source, target, dirs_exist_ok=True) - - def create_symlink(self, source: Path, target: Path, *, sudo: bool = False, runner: Optional[CommandRunner] = None) -> None: - if sudo: - if runner is None: - raise FlowError("Runner required for sudo operations") - self.ensure_dir(target.parent, sudo=True, runner=runner) - runner.run(["sudo", "ln", "-sfn", str(source), str(target)], check=True) - return - self.ensure_dir(target.parent) - target.symlink_to(source) - - def same_symlink(self, target: Path, source: Path) -> bool: - if not target.is_symlink(): - return False - return target.resolve(strict=False) == source.resolve(strict=False) - - def read_text(self, path: Path, *, default: Optional[str] = None) -> str: - try: - return path.read_text(encoding="utf-8") - except FileNotFoundError: - if default is None: - raise - return default - - def write_text(self, path: Path, content: str) -> None: - self.ensure_dir(path.parent) - path.write_text(content, encoding="utf-8") - - def write_bytes(self, path: Path, content: bytes) -> None: - self.ensure_dir(path.parent) - path.write_bytes(content) - - def read_json(self, path: Path, *, default: Any = None) -> Any: - try: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - except FileNotFoundError: - return default - - def write_json(self, path: Path, data: Any) -> None: - self.ensure_dir(path.parent) - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - - -class GitClient: - """Git adapter scoped to a repository root.""" - - def __init__(self, runner: CommandRunner): - self.runner = runner - - def run(self, repo_dir: Path, *args: str, capture_output: bool = True, check: bool = False) -> subprocess.CompletedProcess[str]: - return self.runner.run( - ["git", "-C", str(repo_dir), *args], - capture_output=capture_output, - check=check, - ) - - -@dataclass -class SystemRuntime: - """Shared runtime dependencies.""" - runner: CommandRunner = field(default_factory=CommandRunner) - fs: FileSystem = field(default_factory=FileSystem) - git: GitClient = field(init=False) - - def __post_init__(self) -> None: - self.git = GitClient(self.runner) -``` - -- [ ] **Step 3: Run tests** - -Run: `python -m pytest tests/test_core_runtime.py -v` -Expected: All passed - -- [ ] **Step 4: Commit** - -```bash -git add src/flow/core/runtime.py tests/test_core_runtime.py -git commit -m "feat: add runtime primitives (CommandRunner, FileSystem, GitClient)" -``` - ---- - -### Task 7: Config loading and FlowContext - -**Files:** -- Create: `src/flow/core/config.py` -- Create: `tests/test_core_config.py` - -- [ ] **Step 1: Write tests** - -```python -# tests/test_core_config.py -"""Tests for flow.core.config.""" - -from pathlib import Path - -import pytest - -from flow.core.config import AppConfig, load_config, load_manifest - - -def test_load_config_missing_path(tmp_path): - cfg = load_config(tmp_path / "nonexistent") - assert isinstance(cfg, AppConfig) - assert cfg.dotfiles_url == "" - assert cfg.container_registry == "registry.tomastm.com" - - -def test_load_config_from_yaml(tmp_path): - (tmp_path / "config.yaml").write_text( - "repository:\n" - " url: git@github.com:user/dots.git\n" - " branch: dev\n" - "paths:\n" - " projects: ~/code\n" - "defaults:\n" - " container-registry: my.registry.com\n" - " tmux-session: main\n" - ) - cfg = load_config(tmp_path) - assert cfg.dotfiles_url == "git@github.com:user/dots.git" - assert cfg.dotfiles_branch == "dev" - assert cfg.projects_dir == "~/code" - assert cfg.container_registry == "my.registry.com" - assert cfg.tmux_session == "main" - - -def test_load_config_parses_targets_shorthand(tmp_path): - (tmp_path / "config.yaml").write_text( - "targets:\n" - " personal@orb: personal.orb\n" - ) - cfg = load_config(tmp_path) - assert len(cfg.targets) == 1 - assert cfg.targets[0].namespace == "personal" - assert cfg.targets[0].platform == "orb" - assert cfg.targets[0].host == "personal.orb" - - -def test_load_config_parses_targets_dict(tmp_path): - (tmp_path / "config.yaml").write_text( - "targets:\n" - " work@ec2:\n" - " host: work.ec2.internal\n" - " identity: ~/.ssh/id_work\n" - ) - cfg = load_config(tmp_path) - assert len(cfg.targets) == 1 - assert cfg.targets[0].host == "work.ec2.internal" - assert cfg.targets[0].identity == "~/.ssh/id_work" - - -def test_load_manifest_returns_dict(tmp_path): - (tmp_path / "manifest.yaml").write_text( - "packages:\n" - " - name: fd\n" - " type: pkg\n" - ) - data = load_manifest(tmp_path) - assert isinstance(data, dict) - assert "packages" in data - - -def test_load_manifest_merges_files(tmp_path): - (tmp_path / "01-packages.yaml").write_text("packages:\n - name: fd\n type: pkg\n") - (tmp_path / "02-profiles.yaml").write_text("profiles:\n work:\n os: linux\n") - data = load_manifest(tmp_path) - assert "packages" in data - assert "profiles" in data -``` - -- [ ] **Step 2: Implement config.py** - -Port config loading from current `src/flow/core/config.py` with these changes: -- Use `TargetConfig` dataclass (new) matching spec Section 7.1 -- Use `FlowContext` dataclass matching spec Section 3.4 -- Support both shorthand string and dict target formats -- Remove all `_get_value` multi-key fallback logic (one canonical form per CLAUDE.md) - -The config parser normalizes target entries: -- `"personal@orb: personal.orb"` -> `TargetConfig(namespace="personal", platform="orb", host="personal.orb")` -- Dict form: `TargetConfig(namespace=..., platform=..., host=..., identity=...)` - -Key types: - -```python -@dataclass(frozen=True) -class TargetConfig: - namespace: str - platform: str - host: str - identity: str | None = None - -@dataclass -class AppConfig: - dotfiles_url: str = "" - dotfiles_branch: str = "main" - projects_dir: str = "~/projects" - container_registry: str = "registry.tomastm.com" - container_tag: str = "latest" - tmux_session: str = "default" - targets: list[TargetConfig] = field(default_factory=list) - -@dataclass -class FlowContext: - config: AppConfig - manifest: dict[str, Any] - platform: PlatformInfo - console: Console - runtime: SystemRuntime = field(default_factory=SystemRuntime) -``` - -- [ ] **Step 3: Run tests** - -Run: `python -m pytest tests/test_core_config.py -v` -Expected: All passed - -- [ ] **Step 4: Commit** - -```bash -git add src/flow/core/config.py tests/test_core_config.py -git commit -m "feat: add config loading and FlowContext" -``` - ---- - -## Chunk 2: Dotfiles Domain - -### Task 8: Dotfiles models - -**Files:** -- Create: `src/flow/domain/__init__.py` (empty) -- Create: `src/flow/domain/dotfiles/__init__.py` (empty) -- Create: `src/flow/domain/dotfiles/models.py` -- Create: `tests/test_domain_dotfiles_models.py` - -- [ ] **Step 1: Write models** - -```python -# src/flow/domain/dotfiles/models.py -"""Dotfiles domain models -- all frozen dataclasses.""" - -from dataclasses import dataclass, field -from pathlib import Path -from typing import Optional - - -@dataclass(frozen=True) -class ModuleRef: - """An external git repo providing content for a package subtree.""" - source: str - ref_type: str # "branch" | "tag" | "commit" - ref_value: str - mount_path: Path # Relative path within package to _module.yaml parent - cache_dir: Path # Where the repo is cloned - module_files: tuple[tuple[Path, Path], ...] # (abs_source, rel_to_cache_root) - - -@dataclass(frozen=True) -class Package: - """A dotfiles package: a named set of files mapping to home-relative targets.""" - name: str # e.g. "zsh", "nvim" - layer: str # "_shared" or profile name - package_id: str # "layer/name" - source_dir: Path # Absolute path in dotfiles repo - module: Optional[ModuleRef] - local_files: tuple[tuple[Path, Path], ...] # (abs_source, rel_to_package_root) - - -@dataclass(frozen=True) -class LinkTarget: - """A single file that should be linked into the filesystem.""" - source: Path - target: Path - package: str # package_id - from_module: bool - needs_sudo: bool - - -@dataclass(frozen=True) -class LinkOp: - """A single operation in a link plan.""" - type: str # "create_link" | "remove_link" | "create_dir" - target: Path - source: Optional[Path] - package: str - needs_sudo: bool - - def __str__(self) -> str: - if self.type == "create_link": - sudo = " (sudo)" if self.needs_sudo else "" - return f"LINK: {self.target} -> {self.source}{sudo}" - if self.type == "remove_link": - return f"REMOVE: {self.target}" - if self.type == "create_dir": - return f"MKDIR: {self.target}" - return f"{self.type}: {self.target}" - - -@dataclass(frozen=True) -class PlanSummary: - added: int - removed: int - unchanged: int - from_modules: int - - -@dataclass(frozen=True) -class LinkPlan: - """Complete reconciliation plan.""" - operations: list[LinkOp] - conflicts: list[str] - summary: PlanSummary - - -@dataclass -class LinkedState: - """Persisted link state.""" - links: dict[Path, LinkTarget] = field(default_factory=dict) - - def as_dict(self) -> dict: - grouped: dict[str, dict[str, dict]] = {} - for target, lt in sorted(self.links.items(), key=lambda x: str(x[0])): - pkg_links = grouped.setdefault(lt.package, {}) - pkg_links[str(target)] = { - "source": str(lt.source), - "from_module": lt.from_module, - "needs_sudo": lt.needs_sudo, - } - return {"version": 2, "links": grouped} - - @classmethod - def from_dict(cls, data: dict) -> "LinkedState": - version = data.get("version") - if version is not None and version != 2: - from flow.core.errors import ConfigError - raise ConfigError( - f"Unsupported linked.json version {version}. " - "Delete ~/.local/state/flow/linked.json and relink." - ) - links: dict[Path, LinkTarget] = {} - raw_links = data.get("links", {}) - for package, pkg_links in raw_links.items(): - for target_str, info in pkg_links.items(): - links[Path(target_str)] = LinkTarget( - source=Path(info["source"]), - target=Path(target_str), - package=str(package), - from_module=bool(info.get("from_module", False)), - needs_sudo=bool(info.get("needs_sudo", False)), - ) - return cls(links=links) - - -@dataclass(frozen=True) -class RepoInfo: - """A managed git repo (dotfiles or module).""" - name: str - path: Path - source: str - is_module: bool -``` - -- [ ] **Step 2: Write tests** - -```python -# tests/test_domain_dotfiles_models.py -"""Tests for dotfiles domain models.""" - -from pathlib import Path - -from flow.domain.dotfiles.models import ( - LinkOp, - LinkPlan, - LinkTarget, - LinkedState, - ModuleRef, - Package, - PlanSummary, -) - - -def test_link_op_str_create(): - op = LinkOp(type="create_link", target=Path("/home/x/.zshrc"), - source=Path("/dots/zsh/.zshrc"), package="_shared/zsh", needs_sudo=False) - assert "LINK:" in str(op) - assert ".zshrc" in str(op) - - -def test_link_op_str_sudo(): - op = LinkOp(type="create_link", target=Path("/etc/hosts"), - source=Path("/dots/dns/hosts"), package="_shared/dns", needs_sudo=True) - assert "(sudo)" in str(op) - - -def test_linked_state_roundtrip(): - lt = LinkTarget(source=Path("/a"), target=Path("/b"), package="p", from_module=False, needs_sudo=False) - state = LinkedState(links={Path("/b"): lt}) - data = state.as_dict() - restored = LinkedState.from_dict(data) - assert Path("/b") in restored.links - assert restored.links[Path("/b")].source == Path("/a") - assert restored.links[Path("/b")].package == "p" - - -def test_linked_state_empty(): - state = LinkedState.from_dict({}) - assert state.links == {} - - -def test_package_has_id(): - pkg = Package(name="zsh", layer="_shared", package_id="_shared/zsh", - source_dir=Path("/dots/_shared/zsh"), module=None, local_files=()) - assert pkg.package_id == "_shared/zsh" -``` - -- [ ] **Step 3: Run tests** - -Run: `python -m pytest tests/test_domain_dotfiles_models.py -v` -Expected: All passed - -- [ ] **Step 4: Commit** - -```bash -git add src/flow/domain/__init__.py src/flow/domain/dotfiles/__init__.py \ - src/flow/domain/dotfiles/models.py tests/test_domain_dotfiles_models.py -git commit -m "feat: add dotfiles domain models" -``` - ---- - -### Task 9: Dotfiles modules (mount path, cache dir, source normalization) - -**Files:** -- Create: `src/flow/domain/dotfiles/modules.py` -- Create: `tests/test_domain_dotfiles_modules.py` - -- [ ] **Step 1: Write tests** - -```python -# tests/test_domain_dotfiles_modules.py -"""Tests for dotfiles module resolution -- the core bug fix.""" - -from pathlib import Path - -import pytest - -from flow.core.errors import ConfigError -from flow.domain.dotfiles.modules import ( - compute_mount_path, - module_cache_dir, - normalize_source, - parse_module_ref, -) - - -class TestComputeMountPath: - def test_nested_module(self): - """_shared/nvim/.config/nvim/_module.yaml -> .config/nvim""" - result = compute_mount_path( - module_yaml=Path("/dots/_shared/nvim/.config/nvim/_module.yaml"), - package_dir=Path("/dots/_shared/nvim"), - ) - assert result == Path(".config/nvim") - - def test_root_level_module(self): - """_shared/nvim/_module.yaml -> Path('.')""" - result = compute_mount_path( - module_yaml=Path("/dots/_shared/nvim/_module.yaml"), - package_dir=Path("/dots/_shared/nvim"), - ) - assert result == Path(".") - - def test_deeply_nested(self): - result = compute_mount_path( - module_yaml=Path("/dots/_shared/pkg/.config/a/b/c/_module.yaml"), - package_dir=Path("/dots/_shared/pkg"), - ) - assert result == Path(".config/a/b/c") - - -class TestModuleCacheDir: - def test_simple_name(self): - result = module_cache_dir("_shared/nvim", Path("/home/x/.local/share/flow/modules")) - assert result == Path("/home/x/.local/share/flow/modules/_shared--nvim") - - def test_profile_name(self): - result = module_cache_dir("linux-work/nvim", Path("/m")) - assert result == Path("/m/linux-work--nvim") - - -class TestNormalizeSource: - def test_github_shorthand(self): - assert normalize_source("github:org/repo") == "https://github.com/org/repo.git" - - def test_full_url_passthrough(self): - assert normalize_source("https://example.com/repo.git") == "https://example.com/repo.git" - - def test_ssh_passthrough(self): - assert normalize_source("git@github.com:org/repo.git") == "git@github.com:org/repo.git" - - -class TestParseModuleRef: - def test_branch_ref(self): - raw = {"source": "github:org/nvim-config", "ref": {"branch": "main"}} - ref = parse_module_ref( - raw, package_id="_shared/nvim", - mount_path=Path(".config/nvim"), - modules_base=Path("/modules"), - ) - assert ref.source == "https://github.com/org/nvim-config.git" - assert ref.ref_type == "branch" - assert ref.ref_value == "main" - assert ref.mount_path == Path(".config/nvim") - assert ref.cache_dir == Path("/modules/_shared--nvim") - - def test_tag_ref(self): - raw = {"source": "github:org/repo", "ref": {"tag": "v1.0"}} - ref = parse_module_ref(raw, "p/x", Path("."), Path("/m")) - assert ref.ref_type == "tag" - assert ref.ref_value == "v1.0" - - def test_missing_source_raises(self): - with pytest.raises(ConfigError): - parse_module_ref({}, "p/x", Path("."), Path("/m")) - - def test_missing_ref_raises(self): - raw = {"source": "github:org/repo"} - with pytest.raises(ConfigError): - parse_module_ref(raw, "p/x", Path("."), Path("/m")) - - def test_ref_not_dict_raises(self): - raw = {"source": "github:org/repo", "ref": "main"} - with pytest.raises(ConfigError): - parse_module_ref(raw, "p/x", Path("."), Path("/m")) - - def test_ambiguous_ref_raises(self): - raw = {"source": "github:org/repo", "ref": {"branch": "main", "tag": "v1"}} - with pytest.raises(ConfigError): - parse_module_ref(raw, "p/x", Path("."), Path("/m")) -``` - -- [ ] **Step 2: Implement modules.py** - -```python -# src/flow/domain/dotfiles/modules.py -"""Module metadata resolution -- pure functions.""" - -from pathlib import Path -from typing import Tuple - -from flow.core.errors import ConfigError -from flow.domain.dotfiles.models import ModuleRef - - -def compute_mount_path(module_yaml: Path, package_dir: Path) -> Path: - """Relative path from package root to _module.yaml parent.""" - rel = module_yaml.parent.relative_to(package_dir) - return rel - - -def module_cache_dir(package_id: str, modules_base: Path) -> Path: - """Cache dir for a module clone. '/' -> '--' to avoid collisions.""" - return modules_base / package_id.replace("/", "--") - - -def normalize_source(source: str) -> str: - """Normalize git source URL. github:org/repo -> https://github.com/org/repo.git""" - if source.startswith("github:"): - repo = source.split(":", 1)[1] - return f"https://github.com/{repo}.git" - return source - - -def parse_module_ref( - raw: dict, - package_id: str, - mount_path: Path, - modules_base: Path, -) -> ModuleRef: - """Build ModuleRef from parsed _module.yaml content.""" - source = raw.get("source") - if not isinstance(source, str) or not source: - raise ConfigError(f"Module for {package_id}: 'source' must be a non-empty string") - - ref = raw.get("ref") - if not isinstance(ref, dict): - raise ConfigError(f"Module for {package_id}: 'ref' must be a mapping") - - choices = [k for k in ("branch", "tag", "commit") if isinstance(ref.get(k), str) and ref[k]] - if len(choices) != 1: - raise ConfigError(f"Module for {package_id}: 'ref' must have exactly one of: branch, tag, commit") - - ref_type = choices[0] - ref_value = str(ref[ref_type]) - - return ModuleRef( - source=normalize_source(source), - ref_type=ref_type, - ref_value=ref_value, - mount_path=mount_path, - cache_dir=module_cache_dir(package_id, modules_base), - module_files=(), # Populated by service after cloning - ) -``` - -- [ ] **Step 3: Run tests** - -Run: `python -m pytest tests/test_domain_dotfiles_modules.py -v` -Expected: All passed - -- [ ] **Step 4: Commit** - -```bash -git add src/flow/domain/dotfiles/modules.py tests/test_domain_dotfiles_modules.py -git commit -m "feat: add dotfiles module resolution (fixes _module.yaml path bug)" -``` - ---- - -### Task 10: Dotfiles resolution (package -> LinkTargets) - -**Files:** -- Create: `src/flow/domain/dotfiles/resolution.py` -- Create: `tests/test_domain_dotfiles_resolution.py` - -- [ ] **Step 1: Write tests** - -```python -# tests/test_domain_dotfiles_resolution.py -"""Tests for dotfiles path resolution.""" - -from pathlib import Path - -import pytest - -from flow.core.errors import PlanConflict -from flow.domain.dotfiles.models import LinkTarget, ModuleRef, Package -from flow.domain.dotfiles.resolution import resolve_all_targets, resolve_package_targets - -RESERVED_ROOT = "_root" -HOME = Path("/home/testuser") - - -def _pkg(name, layer="_shared", files=(), module=None): - return Package( - name=name, - layer=layer, - package_id=f"{layer}/{name}", - source_dir=Path(f"/dots/{layer}/{name}"), - module=module, - local_files=tuple(files), - ) - - -class TestResolvePackageTargets: - def test_simple_file(self): - pkg = _pkg("zsh", files=[ - (Path("/dots/_shared/zsh/.zshrc"), Path(".zshrc")), - ]) - targets = resolve_package_targets(pkg, HOME, set()) - assert len(targets) == 1 - assert targets[0].target == HOME / ".zshrc" - assert targets[0].source == Path("/dots/_shared/zsh/.zshrc") - assert targets[0].from_module is False - - def test_nested_config(self): - pkg = _pkg("git", files=[ - (Path("/dots/_shared/git/.config/git/config"), Path(".config/git/config")), - ]) - targets = resolve_package_targets(pkg, HOME, set()) - assert targets[0].target == HOME / ".config" / "git" / "config" - - def test_root_marker(self): - pkg = _pkg("dns", files=[ - (Path("/dots/_shared/dns/_root/etc/hosts"), Path("_root/etc/hosts")), - ]) - targets = resolve_package_targets(pkg, HOME, set()) - assert targets[0].target == Path("/etc/hosts") - assert targets[0].needs_sudo is True - - def test_root_marker_skipped_when_in_skip_set(self): - pkg = _pkg("dns", files=[ - (Path("/dots/_shared/dns/_root/etc/hosts"), Path("_root/etc/hosts")), - ]) - targets = resolve_package_targets(pkg, HOME, {"_root"}) - assert len(targets) == 0 - - def test_skip_package_by_name(self): - pkg = _pkg("nvim", files=[ - (Path("/dots/_shared/nvim/.config/nvim/init.lua"), Path(".config/nvim/init.lua")), - ]) - targets = resolve_package_targets(pkg, HOME, {"nvim"}) - assert len(targets) == 0 - - def test_module_files_linked_under_mount_path(self): - module = ModuleRef( - source="https://github.com/org/nvim-config.git", - ref_type="branch", - ref_value="main", - mount_path=Path(".config/nvim"), - cache_dir=Path("/modules/_shared--nvim"), - module_files=( - (Path("/modules/_shared--nvim/init.lua"), Path("init.lua")), - (Path("/modules/_shared--nvim/lua/plugins.lua"), Path("lua/plugins.lua")), - ), - ) - pkg = _pkg("nvim", files=[ - (Path("/dots/_shared/nvim/.local/bin/nvim-wrapper"), Path(".local/bin/nvim-wrapper")), - ], module=module) - - targets = resolve_package_targets(pkg, HOME, set()) - - # Local file outside mount_path - local_targets = [t for t in targets if not t.from_module] - assert len(local_targets) == 1 - assert local_targets[0].target == HOME / ".local" / "bin" / "nvim-wrapper" - - # Module files under mount_path - module_targets = [t for t in targets if t.from_module] - assert len(module_targets) == 2 - module_target_paths = {t.target for t in module_targets} - assert HOME / ".config" / "nvim" / "init.lua" in module_target_paths - assert HOME / ".config" / "nvim" / "lua" / "plugins.lua" in module_target_paths - - def test_module_yaml_file_not_linked(self): - """The _module.yaml marker itself should never be linked.""" - pkg = _pkg("nvim", files=[ - (Path("/dots/_shared/nvim/.config/nvim/_module.yaml"), Path(".config/nvim/_module.yaml")), - ], module=ModuleRef( - source="x", ref_type="branch", ref_value="main", - mount_path=Path(".config/nvim"), - cache_dir=Path("/m"), module_files=(), - )) - targets = resolve_package_targets(pkg, HOME, set()) - assert not any(t.target.name == "_module.yaml" for t in targets) - - def test_root_level_module_skips_all_local_files(self): - """When mount_path is '.', all local files are from the module, not dotfiles repo.""" - module = ModuleRef( - source="x", ref_type="branch", ref_value="main", - mount_path=Path("."), - cache_dir=Path("/m"), - module_files=( - (Path("/m/init.lua"), Path("init.lua")), - ), - ) - pkg = _pkg("nvim", files=[ - (Path("/dots/_shared/nvim/_module.yaml"), Path("_module.yaml")), - (Path("/dots/_shared/nvim/stale-file.txt"), Path("stale-file.txt")), - ], module=module) - targets = resolve_package_targets(pkg, HOME, set()) - # Only module files should appear, local files skipped - assert len(targets) == 1 - assert targets[0].from_module is True - assert targets[0].target == HOME / "init.lua" - - -class TestResolveAllTargets: - def test_no_conflicts(self): - pkgs = [ - _pkg("zsh", files=[(Path("/a/.zshrc"), Path(".zshrc"))]), - _pkg("git", files=[(Path("/a/.gitconfig"), Path(".gitconfig"))]), - ] - targets = resolve_all_targets(pkgs, HOME, set()) - assert len(targets) == 2 - - def test_duplicate_target_raises(self): - pkgs = [ - _pkg("zsh", layer="_shared", files=[(Path("/a/.zshrc"), Path(".zshrc"))]), - _pkg("zsh", layer="work", files=[(Path("/b/.zshrc"), Path(".zshrc"))]), - ] - with pytest.raises(PlanConflict): - resolve_all_targets(pkgs, HOME, set()) -``` - -- [ ] **Step 2: Implement resolution.py** - -```python -# src/flow/domain/dotfiles/resolution.py -"""Path resolution: package -> home-relative LinkTargets. Pure functions.""" - -from pathlib import Path - -from flow.core.errors import PlanConflict -from flow.domain.dotfiles.models import LinkTarget, Package - -RESERVED_ROOT = "_root" -MODULE_FILE = "_module.yaml" - - -def resolve_package_targets( - package: Package, - home: Path, - skip: set[str], -) -> list[LinkTarget]: - """Resolve all LinkTargets for a package, handling modules correctly.""" - if package.name in skip: - return [] - - targets: list[LinkTarget] = [] - mount_path = package.module.mount_path if package.module else None - - # Local files (from dotfiles repo) - for abs_source, rel in package.local_files: - # Skip _module.yaml - if rel.name == MODULE_FILE: - continue - - # If module exists, skip files inside mount_path (module provides those) - if mount_path is not None: - if mount_path == Path("."): - continue # Root-level module: all local files are superseded by module - try: - rel.relative_to(mount_path) - continue # Inside mount_path, skip - except ValueError: - pass # Outside mount_path, process normally - - target, needs_sudo = _resolve_target(rel, home, skip) - if target is None: - continue - targets.append(LinkTarget( - source=abs_source, target=target, - package=package.package_id, - from_module=False, needs_sudo=needs_sudo, - )) - - # Module files - if package.module: - for abs_source, rel in package.module.module_files: - mounted = package.module.mount_path / rel if package.module.mount_path != Path(".") else rel - target, needs_sudo = _resolve_target(mounted, home, skip) - if target is None: - continue - targets.append(LinkTarget( - source=abs_source, target=target, - package=package.package_id, - from_module=True, needs_sudo=needs_sudo, - )) - - return targets - - -def _resolve_target(rel: Path, home: Path, skip: set[str]) -> tuple[Path | None, bool]: - """Resolve a relative path to an absolute target. Returns (target, needs_sudo).""" - parts = rel.parts - if parts and parts[0] == RESERVED_ROOT: - if RESERVED_ROOT in skip: - return None, False - if len(parts) < 2: - return None, False - return Path("/") / Path(*parts[1:]), True - return home / rel, False - - -def resolve_all_targets( - packages: list[Package], - home: Path, - skip: set[str], -) -> list[LinkTarget]: - """Resolve targets for all packages. Raises PlanConflict on duplicate targets.""" - all_targets: list[LinkTarget] = [] - seen: dict[Path, str] = {} - - for pkg in packages: - targets = resolve_package_targets(pkg, home, skip) - for t in targets: - if t.target in seen: - conflicts = [ - f"{t.target} claimed by both {seen[t.target]} and {t.package}" - ] - raise PlanConflict( - f"Conflicting dotfile targets across packages", - conflicts, - ) - seen[t.target] = t.package - all_targets.append(t) - - return all_targets -``` - -- [ ] **Step 3: Run tests** - -Run: `python -m pytest tests/test_domain_dotfiles_resolution.py -v` -Expected: All passed - -- [ ] **Step 4: Commit** - -```bash -git add src/flow/domain/dotfiles/resolution.py tests/test_domain_dotfiles_resolution.py -git commit -m "feat: add dotfiles path resolution with correct module mounting" -``` - ---- - -### Task 11: Dotfiles planning (link/unlink plans) - -**Note:** The spec's file layout lists `domain/dotfiles/conflicts.py` as a separate file. This is intentionally merged into `planning.py` -- cross-package collisions are caught by `resolve_all_targets` (Task 10), and filesystem conflicts are detected inside `plan_link` via the injected `filesystem_check` callback. No separate `conflicts.py` file is needed. - -**Files:** -- Create: `src/flow/domain/dotfiles/planning.py` -- Create: `tests/test_domain_dotfiles_planning.py` - -- [ ] **Step 1: Write tests** - -```python -# tests/test_domain_dotfiles_planning.py -"""Tests for dotfiles link planning.""" - -from pathlib import Path -from typing import Optional - -from flow.domain.dotfiles.models import ( - LinkOp, - LinkTarget, - LinkedState, -) -from flow.domain.dotfiles.planning import plan_link, plan_unlink - - -def _lt(target, source="/a", pkg="_shared/zsh", module=False, sudo=False): - return LinkTarget( - source=Path(source), target=Path(target), - package=pkg, from_module=module, needs_sudo=sudo, - ) - - -def _fs_check_none(path: Path) -> Optional[str]: - """Fake filesystem_check: nothing exists.""" - return None - - -def _fs_check_file(path: Path) -> Optional[str]: - """Fake: everything is a file.""" - return "file" - - -class TestPlanLink: - def test_new_target_creates_link(self): - desired = [_lt("/home/x/.zshrc")] - plan = plan_link(desired, LinkedState(), _fs_check_none) - assert len(plan.operations) == 1 - assert plan.operations[0].type == "create_link" - assert plan.summary.added == 1 - - def test_existing_correct_link_unchanged(self): - lt = _lt("/home/x/.zshrc") - current = LinkedState(links={Path("/home/x/.zshrc"): lt}) - plan = plan_link([lt], current, _fs_check_none) - assert len(plan.operations) == 0 - assert plan.summary.unchanged == 1 - - def test_stale_link_removed(self): - old = _lt("/home/x/.old") - current = LinkedState(links={Path("/home/x/.old"): old}) - plan = plan_link([], current, _fs_check_none) - assert len(plan.operations) == 1 - assert plan.operations[0].type == "remove_link" - assert plan.summary.removed == 1 - - def test_changed_source_produces_remove_then_create(self): - old = _lt("/home/x/.zshrc", source="/old") - new = _lt("/home/x/.zshrc", source="/new") - current = LinkedState(links={Path("/home/x/.zshrc"): old}) - plan = plan_link([new], current, _fs_check_none) - types = [op.type for op in plan.operations] - assert types == ["remove_link", "create_link"] - - def test_unmanaged_file_at_target_is_conflict(self): - desired = [_lt("/home/x/.zshrc")] - plan = plan_link(desired, LinkedState(), _fs_check_file) - assert len(plan.conflicts) == 1 - assert ".zshrc" in plan.conflicts[0] - - def test_module_targets_counted(self): - desired = [_lt("/home/x/.config/nvim/init.lua", module=True)] - plan = plan_link(desired, LinkedState(), _fs_check_none) - assert plan.summary.from_modules == 1 - - -class TestPlanUnlink: - def test_unlink_all(self): - lt = _lt("/home/x/.zshrc") - current = LinkedState(links={Path("/home/x/.zshrc"): lt}) - plan = plan_unlink(current, packages=None) - assert len(plan.operations) == 1 - assert plan.operations[0].type == "remove_link" - - def test_unlink_specific_package(self): - zsh = _lt("/home/x/.zshrc", pkg="_shared/zsh") - git = _lt("/home/x/.gitconfig", pkg="_shared/git") - current = LinkedState(links={ - Path("/home/x/.zshrc"): zsh, - Path("/home/x/.gitconfig"): git, - }) - plan = plan_unlink(current, packages=["_shared/zsh"]) - assert len(plan.operations) == 1 - assert plan.operations[0].target == Path("/home/x/.zshrc") - - def test_unlink_by_basename(self): - zsh = _lt("/home/x/.zshrc", pkg="_shared/zsh") - current = LinkedState(links={Path("/home/x/.zshrc"): zsh}) - plan = plan_unlink(current, packages=["zsh"]) - assert len(plan.operations) == 1 -``` - -- [ ] **Step 2: Implement planning.py** - -```python -# src/flow/domain/dotfiles/planning.py -"""Link/unlink plan computation. Pure functions with injected I/O.""" - -from pathlib import Path -from typing import Callable, Optional - -from flow.domain.dotfiles.models import ( - LinkOp, - LinkPlan, - LinkTarget, - LinkedState, - PlanSummary, -) - - -def plan_link( - desired: list[LinkTarget], - current: LinkedState, - filesystem_check: Callable[[Path], Optional[str]], -) -> LinkPlan: - """Build reconciliation plan. - - filesystem_check: injected by service. Returns "file", "dir", "symlink", or None. - """ - ops: list[LinkOp] = [] - conflicts: list[str] = [] - added = 0 - removed = 0 - unchanged = 0 - from_modules = 0 - - desired_map = {t.target: t for t in desired} - desired_targets = set(desired_map.keys()) - current_targets = set(current.links.keys()) - - # Removals: in current but not desired - for target in sorted(current_targets - desired_targets): - ops.append(LinkOp( - type="remove_link", target=target, source=None, - package=current.links[target].package, - needs_sudo=current.links[target].needs_sudo, - )) - removed += 1 - - # Additions, updates, and unchanged - for target in sorted(desired_targets): - spec = desired_map[target] - - if target in current.links: - cur = current.links[target] - if cur.source == spec.source: - unchanged += 1 - if spec.from_module: - from_modules += 1 - continue - # Source changed: remove old link, then create new one - ops.append(LinkOp( - type="remove_link", target=target, source=cur.source, - package=cur.package, needs_sudo=cur.needs_sudo, - )) - ops.append(LinkOp( - type="create_link", target=target, source=spec.source, - package=spec.package, needs_sudo=spec.needs_sudo, - )) - added += 1 - if spec.from_module: - from_modules += 1 - continue - - # New target: check filesystem for conflicts - fs_state = filesystem_check(target) - if fs_state is not None: - conflicts.append( - f"{target} already exists ({fs_state}) and is not managed by flow" - ) - continue - - ops.append(LinkOp( - type="create_link", target=target, source=spec.source, - package=spec.package, needs_sudo=spec.needs_sudo, - )) - added += 1 - if spec.from_module: - from_modules += 1 - - return LinkPlan( - operations=ops, - conflicts=conflicts, - summary=PlanSummary( - added=added, removed=removed, - unchanged=unchanged, from_modules=from_modules, - ), - ) - - -def plan_unlink( - current: LinkedState, - packages: Optional[list[str]], -) -> LinkPlan: - """Plan removal of managed links.""" - ops: list[LinkOp] = [] - - for target in sorted(current.links.keys()): - lt = current.links[target] - if packages is not None: - # Match by full package_id or by basename - basename = lt.package.split("/", 1)[-1] if "/" in lt.package else lt.package - if lt.package not in packages and basename not in packages: - continue - - ops.append(LinkOp( - type="remove_link", target=target, source=lt.source, - package=lt.package, needs_sudo=lt.needs_sudo, - )) - - return LinkPlan( - operations=ops, - conflicts=[], - summary=PlanSummary(added=0, removed=len(ops), unchanged=0, from_modules=0), - ) -``` - -- [ ] **Step 3: Run tests** - -Run: `python -m pytest tests/test_domain_dotfiles_planning.py -v` -Expected: All passed - -- [ ] **Step 4: Commit** - -```bash -git add src/flow/domain/dotfiles/planning.py tests/test_domain_dotfiles_planning.py -git commit -m "feat: add dotfiles link planning with conflict detection" -``` - ---- - -## Chunk 3: Packages Domain - -### Task 12: Package models - -**Files:** -- Create: `src/flow/domain/packages/__init__.py` (empty) -- Create: `src/flow/domain/packages/models.py` -- Create: `tests/test_domain_packages_models.py` - -- [ ] **Step 1: Write models** - -All frozen dataclasses as specified in spec Section 5.1: `PackageDef`, `ProfilePackageRef`, `PkgInstallOp`, `PkgRemoveOp`, `PackagePlan`, `InstalledPackage`, `InstalledState`. - -`InstalledState` serialization format: - -```python -# as_dict() produces: -{ - "version": 1, - "packages": { - "neovim": { - "version": "0.10.4", - "type": "binary", - "files": ["/home/x/.local/bin/nvim", "/home/x/.local/share/nvim"] - } - } -} - -# from_dict() parses the above. Raises ConfigError on version mismatch. -``` - -- [ ] **Step 2: Write tests** - -```python -# tests/test_domain_packages_models.py -"""Tests for packages domain models.""" - -from pathlib import Path - -import pytest - -from flow.core.errors import ConfigError -from flow.domain.packages.models import InstalledPackage, InstalledState, PackageDef - - -def test_installed_state_roundtrip(): - state = InstalledState(packages={ - "neovim": InstalledPackage( - name="neovim", version="0.10.4", type="binary", - files=[Path("/home/x/.local/bin/nvim")], - ), - }) - data = state.as_dict() - restored = InstalledState.from_dict(data) - assert "neovim" in restored.packages - assert restored.packages["neovim"].version == "0.10.4" - assert restored.packages["neovim"].files == [Path("/home/x/.local/bin/nvim")] - - -def test_installed_state_empty(): - state = InstalledState.from_dict({}) - assert state.packages == {} - - -def test_installed_state_version_mismatch(): - with pytest.raises(ConfigError): - InstalledState.from_dict({"version": 99, "packages": {}}) - - -def test_package_def_fields(): - pkg = PackageDef( - name="fd", type="pkg", sources={"apt": "fd-find"}, - source=None, version=None, asset_pattern=None, - platform_map={}, extract_dir=None, install={}, - post_install=None, allow_sudo=False, - ) - assert pkg.name == "fd" - assert pkg.type == "pkg" -``` - -- [ ] **Step 3: Run tests** - -Run: `python -m pytest tests/test_domain_packages_models.py -v` -Expected: All passed - -- [ ] **Step 4: Commit** - -```bash -git add src/flow/domain/packages/ tests/test_domain_packages_models.py -git commit -m "feat: add packages domain models" -``` - ---- - -### Task 13: Package catalog and resolution - -**Files:** -- Create: `src/flow/domain/packages/catalog.py` -- Create: `src/flow/domain/packages/resolution.py` -- Create: `tests/test_domain_packages.py` - -- [ ] **Step 1: Write tests covering:** -- `parse_catalog` with list and dict manifest formats -- `normalize_profile_entry` with string shorthand ("binary/neovim"), plain name, dict -- `resolve_spec` merging catalog and profile overrides -- `resolve_source_name` with apt/brew/dnf fallbacks -- `resolve_binary_asset` with platform map and asset pattern -- `resolve_download_url` with github shorthand and direct URL -- `pm_update_command` and `pm_install_command` for apt/dnf/brew -- `detect_package_manager` returning apt/dnf/None - -- [ ] **Step 2: Implement catalog.py and resolution.py** - -Implement the functions listed in spec Section 5.2 matching those exact signatures. Use `src/flow/services/package_defs.py` only as reference for resolution logic, not as source of truth -- the spec signatures are canonical. This is the single copy of all package resolution logic (no duplication with bootstrap). - -- [ ] **Step 3: Run tests** - -Run: `python -m pytest tests/test_domain_packages.py -v` - -- [ ] **Step 4: Commit** - -```bash -git add src/flow/domain/packages/ tests/test_domain_packages.py -git commit -m "feat: add packages catalog and resolution (single source of truth)" -``` - ---- - -### Task 14: Package planning - -**Files:** -- Create: `src/flow/domain/packages/planning.py` -- Create: `tests/test_domain_packages_planning.py` - -- [ ] **Step 1: Write tests for `plan_install` and `plan_remove`** - -- [ ] **Step 2: Implement planning.py** - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -m "feat: add package install/remove planning" -``` - ---- - -## Chunk 4: Bootstrap Domain - -### Task 15: Bootstrap models and setup modules - -**Files:** -- Create: `src/flow/domain/bootstrap/__init__.py` -- Create: `src/flow/domain/bootstrap/models.py` -- Create: `src/flow/domain/bootstrap/modules.py` -- Create: `tests/test_domain_bootstrap_modules.py` - -- [ ] **Step 1: Write models**: `Profile`, `SetupModuleDef`, `BootstrapAction`, `BootstrapPlan` - -- [ ] **Step 2: Write and test setup modules**: `HostnameModule`, `LocaleModule`, `ShellModule`, `SSHKeygenModule`, `RuncmdModule`. Each returns shell commands from `.plan()` and a human-readable string from `.describe()`. - -`describe()` examples (used in dry-run output): -- `HostnameModule.describe()` -> `"Set hostname to my-host"` -- `LocaleModule.describe()` -> `"Set locale to en_US.UTF-8"` -- `ShellModule.describe()` -> `"Install and configure shell: zsh"` -- `SSHKeygenModule.describe()` -> `"Generate 1 SSH key(s)"` -- `RuncmdModule.describe()` -> `"Run 3 custom command(s)"` - -Add a test for each module asserting `isinstance(module.describe(), str)` and that `.plan()` returns a non-empty list of strings. - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -m "feat: add bootstrap models and setup modules" -``` - ---- - -### Task 16: Bootstrap planning - -**Files:** -- Create: `src/flow/domain/bootstrap/planning.py` -- Create: `tests/test_domain_bootstrap_planning.py` - -- [ ] **Step 1: Write tests** for `parse_profile` and `plan_bootstrap` -- verify it produces correct ordered actions (validate env, setup modules, packages, shell, dotfiles link). - -- [ ] **Step 2: Implement** -- `plan_bootstrap` raises `ConfigError` if required env vars are missing, then builds ordered action list using packages domain for install planning. - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -m "feat: add bootstrap planning (orchestrator over packages + dotfiles)" -``` - ---- - -## Chunk 5: Remote + Containers + Projects Domains - -### Task 17: Remote domain - -**Files:** -- Create: `src/flow/domain/remote/__init__.py` -- Create: `src/flow/domain/remote/models.py` -- contains `Target` and `SSHCommand` only. **`TargetConfig` lives in `core/config.py`** (already created in Task 7). Import it from there. -- Create: `src/flow/domain/remote/resolution.py` -- Create: `tests/test_domain_remote.py` - -- [ ] **Step 1: Write tests** for `parse_target`, `resolve_target`, `build_ssh_command`, `terminfo_fix_command` - -- [ ] **Step 2: Implement models and resolution.** Port from current `src/flow/services/ssh.py`. `resolve_target` receives `list[TargetConfig]` imported from `flow.core.config`. - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -m "feat: add remote domain (SSH target resolution)" -``` - ---- - -### Task 18: Containers domain - -**Files:** -- Create: `src/flow/domain/containers/__init__.py` -- Create: `src/flow/domain/containers/models.py` -- Create: `src/flow/domain/containers/resolution.py` -- Create: `tests/test_domain_containers.py` - -- [ ] **Step 1: Write tests** for `parse_image_ref`, `container_name`, `resolve_mounts`, `build_container_spec` - -- [ ] **Step 2: Implement.** Port from current `src/flow/services/containers.py`. - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -m "feat: add containers domain (image resolution, mount computation)" -``` - ---- - -## Chunk 6: Services Layer (Part 1) - -### Task 19: DotfilesService - -**Files:** -- Create: `src/flow/services/__init__.py` -- Create: `src/flow/services/dotfiles.py` -- Create: `tests/test_service_dotfiles.py` - -- [ ] **Step 1: Write integration tests** using `tmp_path` for real filesystem + `FakeRunner` for git: - - `test_link_creates_symlinks` -- set up dotfiles dir, call `.link()`, verify symlinks - - `test_link_with_module` -- set up package with `_module.yaml` and cloned module dir, verify correct target paths - - `test_unlink_removes_symlinks` -- link then unlink, verify cleaned up - - `test_link_dry_run_no_changes` -- dry run does not create symlinks - - `test_status_shows_packages` -- set up linked state, verify output - -- [ ] **Step 2: Implement DotfilesService** - -The service performs all I/O: -1. `_discover_packages`: walks dotfiles dir, reads `_module.yaml` files, builds `Package` objects with `local_files` populated -2. Calls pure domain functions for resolution and planning -3. Executes the plan (create/remove symlinks via `FileSystem`) -4. Persists `LinkedState` to JSON - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -m "feat: add DotfilesService with link/unlink/status/edit" -``` - ---- - -### Task 20: PackageService - -**Files:** -- Create: `src/flow/services/packages.py` -- Create: `tests/test_service_packages.py` - -- [ ] **Step 1: Write tests** for install, list, remove flows - -- [ ] **Step 2: Implement PackageService** -- uses domain planning, executes via runtime - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -m "feat: add PackageService" -``` - ---- - -### Task 21: BootstrapService - -**Files:** -- Create: `src/flow/services/bootstrap.py` -- Create: `tests/test_service_bootstrap.py` - -- [ ] **Step 1: Write tests** -- bootstrap run with dry_run, show, list - -- [ ] **Step 2: Implement** -- uses domain planning, executes actions sequentially, delegates to PackageService and DotfilesService - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -m "feat: add BootstrapService" -``` - ---- - -## Chunk 7: Services Layer (Part 2) - -### Task 22: RemoteService - -**Files:** -- Create: `src/flow/services/remote.py` -- Create: `tests/test_service_remote.py` - -- [ ] **Step 1: Write tests** -- enter dry_run, list targets, terminfo warning - -- [ ] **Step 2: Implement** - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -m "feat: add RemoteService" -``` - ---- - -### Task 23: ContainerService - -**Files:** -- Create: `src/flow/services/containers.py` -- Create: `tests/test_service_containers.py` - -- [ ] **Step 1: Write tests** -- create, list, stop, remove with FakeRunner - -- [ ] **Step 2: Implement** - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -m "feat: add ContainerService" -``` - ---- - -### Task 24: ProjectService - -**Note:** `ProjectService` has no domain layer -- it's a thin service that runs git commands directly. All logic is I/O-bound (listing dirs, running git). No separate domain module needed. - -**Files:** -- Create: `src/flow/services/projects.py` -- Create: `tests/test_service_projects.py` - -- [ ] **Step 1: Write tests** - -```python -# tests/test_service_projects.py -"""Tests for ProjectService.""" - -def test_check_clean_repo(tmp_path): - """Create a git repo at tmp_path/projects/myrepo, commit a file. - Call service.check(fetch=False). Verify output contains 'clean'.""" - -def test_check_uncommitted_changes(tmp_path): - """Create repo, modify a tracked file without committing. - Verify 'uncommitted changes' in output.""" - -def test_check_no_git_repos(tmp_path): - """Empty projects dir. Verify info message.""" - -def test_summary_shows_all_dirs(tmp_path): - """Mix of git and non-git dirs. Verify table output.""" -``` - -- [ ] **Step 2: Implement** -- `check()` iterates dirs in `projects_dir`, runs git status/diff/rev-list per repo. `fetch()` runs `git fetch --all`. `summary()` is `check(fetch=False)`. Use `self.ctx.runtime.git` for all git calls, `self.ctx.console.table` for output. - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -m "feat: add ProjectService" -``` - ---- - -## Chunk 8: Commands + CLI - -### Task 25: CLI entry point + context validation - -**Files:** -- Create: `src/flow/cli.py` (rewrite) -- Create: `tests/test_cli.py` - -- [ ] **Step 1: Write tests** -- non-root check, context validation (remote blocked in VM), version flag, dry_run passed through - -- [ ] **Step 2: Implement cli.py** per spec Section 10 - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -m "feat: add CLI entry point with context awareness" -``` - ---- - -### Task 26: Command modules - -**Files:** -- Create: `src/flow/commands/remote.py` -- Create: `src/flow/commands/dev.py` -- Create: `src/flow/commands/dotfiles.py` -- Create: `src/flow/commands/setup.py` -- Create: `src/flow/commands/packages.py` -- Create: `src/flow/commands/projects.py` -- Create: `src/flow/commands/__init__.py` - -Each command module: register argparse subcommands, handler functions that construct the service and call one method. - -- [ ] **Step 1: Implement all command modules** (each is 30-60 lines) -- [ ] **Step 2: Write integration test** that runs `flow --help`, `flow dotfiles --help`, etc. -- [ ] **Step 3: Commit** - -```bash -git commit -m "feat: add command modules (thin CLI adapters)" -``` - ---- - -### Task 27: Zsh completion - -**Files:** -- Create: `src/flow/commands/completion.py` -- Create: `tests/test_completion.py` - -- [ ] **Step 1: Implement** -- port and adapt from current completion.py with updated command names - -- [ ] **Step 2: Write tests** for `complete()` function with the new command surface - -- [ ] **Step 3: Commit** - -```bash -git commit -m "feat: add zsh completion with updated command surface" -``` - ---- - -## Chunk 9: Cleanup + Integration - -### Task 28: Delete old code - -- [ ] **Step 1: Remove old files** - -```bash -# Remove old service/command/core files that have been replaced -rm -f src/flow/core/action.py -rm -f src/flow/core/stow.py -rm -f src/flow/core/process.py -# Old core files replaced by new ones: -# system.py -> runtime.py (already handled) -# config.py, console.py, platform.py, paths.py, errors.py -> rewritten in place -# variables.py -> template.py - -# Remove old command modules -rm -rf src/flow/commands/ # Already replaced by new commands/ - -# Remove old services -rm -rf src/flow/services/ # Already replaced by new services/ - -# Remove old tests -rm -f tests/test_action.py tests/test_stow.py tests/test_commands.py -rm -f tests/test_dotfiles_folding.py tests/test_self_hosting.py -``` - -- [ ] **Step 2: Run full test suite** - -Run: `python -m pytest tests/ -v` -Expected: All new tests pass, no imports of deleted modules - -- [ ] **Step 3: Update pyproject.toml entry point if needed** - -Verify `[project.scripts] flow = "flow.cli:main"` still works. - -- [ ] **Step 4: Test binary build** - -```bash -make build -./dist/flow --help -./dist/flow --version -``` - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "chore: remove old code, complete rewrite" -``` - ---- - -### Task 29: Update README - -- [ ] **Step 1: Rewrite README.md** to match the new command surface, config format, and architecture. - -- [ ] **Step 2: Commit** - -```bash -git add README.md -git commit -m "docs: update README for new architecture" -``` diff --git a/docs/superpowers/specs/2026-03-16-flow-architecture-redesign.md b/docs/superpowers/specs/2026-03-16-flow-architecture-redesign.md deleted file mode 100644 index db51804..0000000 --- a/docs/superpowers/specs/2026-03-16-flow-architecture-redesign.md +++ /dev/null @@ -1,1273 +0,0 @@ -# Flow CLI Architecture Redesign - -## Overview - -Full rewrite of the `flow` CLI: a personal dev environment tool managing the host -> VM -> container workflow stack. Single Python binary (PyInstaller), context-aware (host/vm/container), no backward compatibility constraints. - -### Goals - -- Correct abstractions with pure domain logic separated from I/O -- Plan-then-execute pattern for all mutating operations (native dry-run) -- No code duplication across domains -- Every domain unit independently testable without mocks -- Module path resolution works correctly for `_module.yaml`-backed packages - ---- - -## 1. Layer Architecture - -Four layers with strict dependency direction (each layer only imports from layers below): - -``` -commands/ Layer 4: CLI parsing + dispatch (no logic) - | -services/ Layer 3: Orchestration + side effects (thin) - | -domain/ Layer 2: Pure logic, models, planning (no I/O) - | -core/ Layer 1: Runtime primitives + config loading -``` - -### Rules - -- `domain/` never imports `core/runtime`, `services/`, or `commands/`. It only imports `core/template.py`, `core/errors.py`, `core/paths.py` (constants only), and stdlib. Domain functions that need filesystem information (directory listings, file existence) receive it as pre-built data or injected callbacks -- never by reading the filesystem directly. -- `services/` receives all dependencies via constructor (runtime, config, console). No module-level singletons. Cross-service calls use the shared `FlowContext` (e.g. `BootstrapService` constructs `DotfilesService(self.ctx)` internally). This is the intended pattern since all services share the same runtime context. -- `commands/` are trivial: parse args into a typed namespace, call one service method. -- Every plan is a frozen dataclass returned by domain functions. Services decide whether to execute or dry-run print them. - -### File Layout - -``` -src/flow/ - __init__.py - __main__.py - cli.py # argparse, context creation, error handling - - core/ - runtime.py # CommandRunner, FileSystem, GitClient, SystemRuntime - config.py # YAML loading, AppConfig, FlowContext - platform.py # OS/arch detection, execution context (host/vm/container) - paths.py # XDG path constants - console.py # Output formatting with TTY detection, --no-color, --quiet - errors.py # FlowError hierarchy - template.py # Variable/template substitution (pure) - - domain/ - dotfiles/ - models.py # Package, ModuleRef, LinkTarget, LinkedState - resolution.py # Path resolution: package -> home-relative targets - modules.py # Module source normalization, cache path computation - planning.py # Compute LinkPlan from desired vs current state - conflicts.py # Conflict detection - - packages/ - models.py # PackageDef, ProfilePackageRef, InstalledState, InstalledPackage - catalog.py # Manifest parsing, profile entry normalization, spec resolution - resolution.py # Source name resolution, platform mapping, URL building - planning.py # Compute PackagePlan (install/remove operations) - - bootstrap/ - models.py # Profile, SetupModuleDef, BootstrapPlan - planning.py # Profile -> ordered action list (packages + modules + dotfiles) - modules.py # Built-in setup module definitions (hostname, locale, shell, ssh-keygen, runcmd) - - remote/ - models.py # Target, TargetConfig, SSHCommand - resolution.py # Target parsing, host template expansion - - containers/ - models.py # ContainerSpec, ContainerInfo, ImageRef - resolution.py # Image ref parsing, name normalization, mount computation - - services/ - dotfiles.py # DotfilesService - packages.py # PackageService - bootstrap.py # BootstrapService - remote.py # RemoteService - containers.py # ContainerService - projects.py # ProjectService - - commands/ - __init__.py - remote.py - dev.py - dotfiles.py - setup.py - packages.py - projects.py - completion.py -``` - ---- - -## 2. Command Surface - -### Context awareness - -Flow detects execution context via environment variables (`DF_NAMESPACE`, `DF_PLATFORM`) and restricts commands accordingly: - -| Context | Detection | Available commands | -|---------|-----------|-------------------| -| Host | No `DF_NAMESPACE`, no `DF_PLATFORM` | `remote`, `dotfiles`, `setup`, `packages` | -| VM | `DF_NAMESPACE` set, no container indicators | `dev`, `projects`, `dotfiles`, `setup`, `packages` | -| Container | Inside container (e.g., `/.dockerenv` exists) | `dotfiles`, `setup`, `packages` | - -Unavailable commands print a clear message (e.g., "flow remote is only available on the host machine"). - -### Commands - -Canonical names are plural where they manage collections. Short aliases in parentheses. - -``` -flow remote enter # Host only -flow remote list - -flow dev create -i # VM only -flow dev attach -flow dev exec [cmd...] -flow dev list -flow dev stop -flow dev rm -flow dev respawn - -flow dotfiles init --repo # (dot) Everywhere -flow dotfiles link [--profile p] -flow dotfiles unlink [packages...] -flow dotfiles status [packages...] -flow dotfiles edit - -flow dotfiles repos list # (repo) -flow dotfiles repos status [--repo=x] -flow dotfiles repos pull [--repo=x] -flow dotfiles repos push [--repo=x] - -flow setup run [--profile p] # (bootstrap) Everywhere -flow setup list -flow setup show - -flow packages install # (package, pkg) Everywhere -flow packages list [--all] -flow packages remove - -flow projects check [--fetch] # (project) VM only -flow projects fetch -flow projects summary -``` - -### Global flags - -``` -flow --version -flow --quiet # Suppress info messages -flow --no-color # Disable ANSI colors -flow --dry-run # Available on all mutating commands (passed through to service) -``` - -`--dry-run` as a global flag means the plan-then-execute pattern works uniformly: every service method that mutates state accepts `dry_run: bool` and either executes the plan or prints it. - ---- - -## 3. Core Layer - -### 3.1 Errors - -Small hierarchy. Every domain function raises `FlowError` subtypes. `cli.py` catches `FlowError` at the top level. - -```python -class FlowError(Exception): - """Base for all user-facing errors.""" - -class ConfigError(FlowError): - """Invalid config/manifest YAML.""" - -class PlanConflict(FlowError): - """Conflicts detected during planning.""" - def __init__(self, message: str, conflicts: list[str]): - super().__init__(message) - self.conflicts = conflicts - -class ExecutionError(FlowError): - """A plan step failed during execution.""" -``` - -No bare `RuntimeError` anywhere in the codebase. - -### 3.2 Console - -```python -class Console: - def __init__(self, *, quiet: bool = False, color: bool | None = None): - # color=None means auto-detect via os.isatty(1) - - def info(self, msg: str) -> None: ... - def warn(self, msg: str) -> None: ... - def error(self, msg: str) -> None: ... - def success(self, msg: str) -> None: ... - def table(self, headers: list[str], rows: list[list[str]]) -> None: ... - def print_plan(self, operations: list[Any], *, verb: str = "execute") -> None: ... -``` - -### 3.3 Runtime - -```python -class CommandRunner: - def run(self, argv, *, cwd, env, capture_output, check, timeout) -> CompletedProcess: ... - def run_shell(self, command, *, cwd, env, capture_output, check) -> CompletedProcess: ... - def stream_shell(self, command, console, *, check) -> CompletedProcess: ... - def require_binary(self, name) -> str: ... - -class FileSystem: - def ensure_dir(self, path, *, sudo, runner, mode) -> None: ... - def remove_file(self, path, *, sudo, runner, missing_ok) -> None: ... - def remove_tree(self, path) -> None: ... - def copy_file(self, source, target, *, sudo, runner) -> None: ... - def copy_tree(self, source, target) -> None: ... - def create_symlink(self, source, target, *, sudo, runner) -> None: ... - def same_symlink(self, target, source) -> bool: ... - def read_text(self, path, *, default) -> str: ... - def write_text(self, path, content) -> None: ... - def read_json(self, path, *, default) -> Any: ... - def write_json(self, path, data) -> None: ... - -class GitClient: - def __init__(self, runner: CommandRunner): ... - def run(self, repo_dir, *args, capture_output, check) -> CompletedProcess: ... - -@dataclass -class SystemRuntime: - runner: CommandRunner - fs: FileSystem - git: GitClient -``` - -### 3.4 FlowContext - -```python -@dataclass -class FlowContext: - config: AppConfig - manifest: dict[str, Any] - platform: PlatformInfo - console: Console - runtime: SystemRuntime -``` - -Created once in `cli.py`, passed to services via constructor. Never accessed as a global. - -### 3.5 Template - -Pure functions. No I/O. - -```python -def substitute(text: str, variables: dict[str, str]) -> str: - """Replace $VAR and ${VAR} with values.""" - -def substitute_template(text: str, context: dict[str, Any]) -> str: - """Replace {{expr}} placeholders.""" -``` - ---- - -## 4. Dotfiles Domain - -### 4.1 Models - -```python -@dataclass(frozen=True) -class Package: - """A dotfiles package: a named set of files mapping to home-relative targets.""" - name: str # e.g. "zsh", "nvim" - layer: str # "_shared" or profile name - package_id: str # Qualified: "layer/name" (e.g. "_shared/nvim") - source_dir: Path # Absolute path in dotfiles repo - module: ModuleRef | None # If backed by external repo - local_files: list[tuple[Path, Path]] # (absolute_source, relative_to_package_root) - # Pre-walked by the service layer (no I/O in domain) - -@dataclass(frozen=True) -class ModuleRef: - """An external git repo providing content for a package subtree.""" - source: str # Normalized git URL - ref_type: str # "branch" | "tag" | "commit" - ref_value: str # e.g. "main", "v1.0", "abc123" - mount_path: Path # Relative path within package to _module.yaml parent - # e.g. Path(".config/nvim") - cache_dir: Path # ~/.local/share/flow/modules/ - module_files: list[tuple[Path, Path]] # (absolute_source, relative_to_cache_root) - # Pre-walked by the service layer - -@dataclass(frozen=True) -class LinkTarget: - """A single file that should be linked into the filesystem.""" - source: Path # Where file lives (dotfiles repo or module cache) - target: Path # Where symlink goes (e.g. ~/.config/nvim/init.lua) - package: str # Owning package (e.g. "_shared/nvim") - from_module: bool # Whether source is from a module repo - needs_sudo: bool # True for _root/ targets outside $HOME - -@dataclass(frozen=True) -class LinkOp: - """A single operation in a link plan.""" - type: str # "create_link" | "remove_link" | "create_dir" - target: Path - source: Path | None - package: str - needs_sudo: bool - -@dataclass(frozen=True) -class PlanSummary: - added: int - removed: int - unchanged: int - from_modules: int - -@dataclass(frozen=True) -class LinkPlan: - """Complete reconciliation plan.""" - operations: list[LinkOp] - conflicts: list[str] - summary: PlanSummary - -@dataclass -class LinkedState: - """Persisted to ~/.local/state/flow/linked.json.""" - links: dict[Path, LinkTarget] - - def as_dict(self) -> dict: ... - @classmethod - def from_dict(cls, data: dict) -> LinkedState: ... - -@dataclass(frozen=True) -class RepoInfo: - """A managed git repo (dotfiles or module).""" - name: str # "dotfiles" or module package name - path: Path # Local clone path - source: str # Remote URL - is_module: bool -``` - -### 4.2 Module Path Resolution - -The core algorithm that fixes the current bug. Given a dotfiles repo layout: - -``` -_shared/ - nvim/ # Package: "nvim", layer: "_shared" - .config/nvim/ - _module.yaml # Module definition - .local/bin/nvim-wrapper # Normal local file -``` - -Resolution steps: - -1. **Discover packages**: scan `_shared/` and profile dirs for first-level subdirectories. -2. **For each package**, walk its directory tree: - - Find `_module.yaml` if present. Its **parent relative to the package root** is the `mount_path`. - - For `_shared/nvim/.config/nvim/_module.yaml`: mount_path = `.config/nvim` -3. **Resolve files to LinkTargets**: - - Files **outside** mount_path come from the dotfiles repo directly. - - `.local/bin/nvim-wrapper` -> `LinkTarget(source=/_shared/nvim/.local/bin/nvim-wrapper, target=~/.local/bin/nvim-wrapper)` - - Files **inside** mount_path come from the module cache. - - The module repo is cloned to `~/.local/share/flow/modules/nvim`. - - Every file in the clone maps to `~//`. - - `modules/nvim/init.lua` -> `LinkTarget(source=/nvim/init.lua, target=~/.config/nvim/init.lua)` - - The `_module.yaml` file itself is never linked. - -```python -# domain/dotfiles/resolution.py -# All functions are PURE: they operate on pre-built Package data (including -# pre-walked file lists), never touching the filesystem directly. - -def resolve_package_targets( - package: Package, - home: Path, - skip: set[str], -) -> list[LinkTarget]: - """Resolve all LinkTargets for a package, handling modules correctly. - - Uses package.local_files and package.module.module_files (pre-walked - by service layer) to compute targets. No filesystem I/O. - """ - ... - -def resolve_all_targets( - packages: list[Package], - home: Path, - skip: set[str], -) -> list[LinkTarget]: - """Resolve targets for all packages. Raises PlanConflict on duplicate targets - across packages (e.g. _shared/zsh and linux-work/zsh both targeting ~/.zshrc). - This is a pure cross-package collision check, not a filesystem conflict check. - """ - ... -``` - -```python -# domain/dotfiles/modules.py -# Pure functions for module metadata. I/O (reading YAML, walking dirs) is done -# by the service layer, which passes parsed data into these functions. - -def parse_module_ref( - raw: dict, # Pre-loaded YAML content (dict, not file path) - package_id: str, - mount_path: Path, - modules_base: Path, -) -> ModuleRef: - """Build a ModuleRef from parsed _module.yaml content. - - Args: - raw: The parsed YAML dict (service reads the file and passes content in). - package_id: Qualified name like "_shared/nvim". - mount_path: Relative path from package root to _module.yaml parent. - modules_base: Base directory for module caches (from core/paths.py). - """ - ... - -def compute_mount_path(module_yaml: Path, package_dir: Path) -> Path: - """The key function: relative path from package root to _module.yaml parent.""" - return module_yaml.parent.relative_to(package_dir) - -def module_cache_dir(package_id: str, modules_base: Path) -> Path: - """Compute cache dir for a module. Uses package_id (e.g. '_shared/nvim') - with '/' replaced by '--' to avoid collisions between same-named packages - in different layers.""" - return modules_base / package_id.replace("/", "--") - -def normalize_source(source: str) -> str: - """github:org/repo -> https://github.com/org/repo.git""" - ... -``` - -**I/O boundary note:** The service layer (`DotfilesService`) is responsible for: -1. Walking the dotfiles dir to find packages and `_module.yaml` files -2. Reading `_module.yaml` files and passing parsed dicts to `parse_module_ref` -3. Walking module cache dirs to build file lists -4. Constructing `Package` objects with pre-populated `local_files` and `ModuleRef.module_files` -5. Passing these fully-built objects to pure domain functions - -### 4.3 Planning and Conflict Detection - -Conflict detection and planning are integrated into a single flow. There are two kinds of conflicts: - -1. **Cross-package collisions** (pure): two packages want the same target path. Detected by `resolve_all_targets` which raises `PlanConflict`. -2. **Filesystem conflicts** (requires I/O): a target path already exists on disk and is not managed by flow. Detected by `plan_link` via an injected callback. - -```python -# domain/dotfiles/planning.py - -def plan_link( - desired: list[LinkTarget], - current: LinkedState, - filesystem_check: Callable[[Path], str | None], - # ^^ Injected by service layer. Returns "file", "dir", "symlink", or None. - # This is the ONLY I/O dependency in the planning layer. - # Tests provide a fake (e.g., lambda p: None). -) -> LinkPlan: - """Compare desired targets with current state, produce reconciliation plan. - - - New targets: create_link ops - - Removed targets (in current but not desired): remove_link ops - - Existing correct symlinks: unchanged (no op) - - Filesystem conflicts: listed in plan.conflicts (target exists, not managed) - - Directory conflicts: listed in plan.conflicts (target is a dir, cannot overwrite) - - The service checks plan.conflicts and raises PlanConflict if non-empty and - --force was not passed. - """ - ... - -def plan_unlink( - current: LinkedState, - packages: list[str] | None, # None = unlink all -) -> LinkPlan: - """Plan removal of managed links.""" - ... -``` - ---- - -## 5. Packages Domain - -Shared by `flow packages` and `flow setup`. Single source of truth. - -### 5.1 Models - -```python -@dataclass(frozen=True) -class PackageDef: - """A package as defined in the manifest.""" - name: str - type: str # "pkg" | "binary" | "cask" - sources: dict[str, str] # {"apt": "fd-find", "brew": "fd"} - source: str | None # Binary: "github:neovim/neovim" - version: str | None # Binary: "0.10.4" - asset_pattern: str | None - platform_map: dict[str, dict] - extract_dir: str | None - install: dict[str, list[str]] # {"bin": ["bin/nvim"], "share": [...]} - post_install: str | None - allow_sudo: bool - -@dataclass(frozen=True) -class ProfilePackageRef: - """A package reference from a profile's package list.""" - name: str - type: str | None - allow_sudo: bool - post_install: str | None - -@dataclass(frozen=True) -class PkgInstallOp: - type: str # "pm_update" | "pm_install" | "binary_install" | "run_hook" - package: str | None - command: str | None # Shell command for pm_update/pm_install/run_hook - download_url: str | None # For binary_install - install_map: dict[str, list[str]] | None - description: str # Human-readable for dry-run - -@dataclass(frozen=True) -class PkgRemoveOp: - package: str - files: list[Path] # Files to delete - -@dataclass(frozen=True) -class PackagePlan: - operations: list[PkgInstallOp | PkgRemoveOp] - summary: str - -@dataclass(frozen=True) -class InstalledPackage: - name: str - version: str - type: str - files: list[Path] # Track installed files for real removal - -@dataclass -class InstalledState: - packages: dict[str, InstalledPackage] - - def as_dict(self) -> dict: ... - @classmethod - def from_dict(cls, data: dict) -> InstalledState: ... -``` - -### 5.2 Pure Functions - -```python -# domain/packages/catalog.py -def parse_catalog(raw: Any) -> dict[str, PackageDef]: ... -def normalize_profile_entry(entry: Any) -> ProfilePackageRef: ... -def resolve_spec(catalog: dict[str, PackageDef], ref: ProfilePackageRef) -> PackageDef: ... - -# domain/packages/resolution.py -def resolve_source_name(spec: PackageDef, pm: str) -> str: ... -def resolve_binary_asset(spec: PackageDef, platform: PlatformInfo) -> str: ... -def resolve_download_url(spec: PackageDef, asset: str, template_ctx: dict) -> str: ... -def detect_package_manager(os_name: str) -> str | None: ... -def pm_update_command(pm: str) -> str: ... -def pm_install_command(pm: str, packages: list[str], pkg_type: str) -> str: ... - -# domain/packages/planning.py -def plan_install( - specs: list[PackageDef], - pm: str, - platform: PlatformInfo, - template_ctx: dict, -) -> PackagePlan: ... - -def plan_remove( - names: list[str], - installed: InstalledState, -) -> PackagePlan: ... -``` - ---- - -## 6. Bootstrap Domain - -Bootstrap is an **orchestrator**. It does not have unique domain logic -- it composes packages, dotfiles, and setup modules into an ordered plan. - -### 6.1 Models - -```python -@dataclass(frozen=True) -class Profile: - """A bootstrap profile from the manifest.""" - name: str - os: str # "linux" | "macos" - package_manager: str | None # None = auto-detect - packages: list[Any] # Raw entries, normalized via packages domain - setup_modules: list[SetupModuleDef] # Parsed setup module definitions - requires: list[str] # Required env vars - shell: str | None # e.g. "zsh" - dotfiles_profile: str | None # Profile name for dotfiles linking - -@dataclass(frozen=True) -class SetupModuleDef: - """Configuration for a single setup module step, parsed from YAML. - - Examples: - SetupModuleDef(type="hostname", config={"value": "my-host"}) - SetupModuleDef(type="ssh-keygen", config={"keys": [{"type": "ed25519", ...}]}) - SetupModuleDef(type="runcmd", config={"commands": ["sudo groupadd docker || true"]}) - """ - type: str # "hostname" | "locale" | "shell" | "ssh-keygen" | "runcmd" - config: dict # Type-specific configuration from YAML - -@dataclass(frozen=True) -class BootstrapAction: - """A single step in the bootstrap plan.""" - type: str # "install_packages" | "run_setup_module" - # | "link_dotfiles" | "set_shell" - description: str - payload: Any # Type-specific data: - # install_packages -> PackagePlan - # run_setup_module -> tuple[SetupModuleDef, list[str]] - # (the module def + pre-computed shell commands) - # link_dotfiles -> profile name (str) - # set_shell -> shell name (str) - critical: bool # Stop on failure? - -@dataclass(frozen=True) -class BootstrapPlan: - profile: str - actions: list[BootstrapAction] - summary: str -``` - -**Note on env var validation:** `plan_bootstrap` validates required env vars eagerly and raises `ConfigError` if any are missing. This is a pure check (comparing `profile.requires` against the provided `env` dict) and happens before any actions are generated. It is not an action in the plan -- missing env vars are a precondition failure that prevents plan creation. - -### 6.2 Setup Modules - -Each module type is a small class with a plan method (pure) that returns shell commands to execute. - -```python -class SetupModule(Protocol): - def plan(self, config: dict, template_ctx: dict) -> list[str]: - """Return shell commands to execute. Pure -- no side effects.""" - ... - - def describe(self) -> str: - """Human-readable description for dry-run output.""" - ... -``` - -Built-in modules in `domain/bootstrap/modules.py`: - -- `HostnameModule` -- sets hostname via hostnamectl (linux) or scutil (macos) -- `LocaleModule` -- sets locale via locale-gen + update-locale (linux only) -- `ShellModule` -- installs shell if missing, adds to /etc/shells, runs chsh -- `SSHKeygenModule` -- generates SSH keys per spec -- `RuncmdModule` -- runs arbitrary shell commands from profile - -### 6.3 Planning - -```python -# domain/bootstrap/planning.py - -def parse_profile(name: str, raw: dict) -> Profile: ... - -def plan_bootstrap( - profile: Profile, - catalog: dict[str, PackageDef], - platform: PlatformInfo, - env: dict[str, str], - template_ctx: dict, -) -> BootstrapPlan: - """Build the full ordered action list: - 1. Validate required env vars - 2. Setup modules (hostname, locale, etc.) - 3. Install packages (delegates to packages domain) - 4. Set shell - 5. SSH keygen - 6. Runcmd - 7. Link dotfiles - """ - ... -``` - -### 6.4 Config Format - -```yaml -profiles: - linux-work: - os: linux - shell: zsh - dotfiles-profile: linux-work - requires: [USER_EMAIL] - modules: - - type: hostname - value: "{{ env.HOSTNAME }}" - - type: locale - value: en_US.UTF-8 - - type: ssh-keygen - keys: - - type: ed25519 - comment: "{{ env.USER_EMAIL }}" - - type: runcmd - commands: - - "sudo groupadd docker || true" - - "sudo usermod -aG docker $USER" - packages: - - git - - fd - - binary/neovim -``` - ---- - -## 7. Remote Domain - -### 7.1 Models - -```python -@dataclass(frozen=True) -class TargetConfig: - """A target as defined in config YAML. Parsed from the 'targets' section. - - Supports two YAML formats: - personal@orb: personal.orb # shorthand: key is namespace@platform, value is host - work@ec2: # dict form - host: work.ec2.internal - identity: ~/.ssh/id_work - """ - namespace: str - platform: str - host: str - identity: str | None = None - -@dataclass(frozen=True) -class Target: - """A fully resolved SSH target (after merging CLI args + config + templates).""" - user: str - namespace: str - platform: str - host: str - identity: str | None - -@dataclass(frozen=True) -class SSHCommand: - """A fully resolved SSH command ready to exec.""" - argv: list[str] - destination: str - tmux_session: str | None - env_vars: dict[str, str] # DF_NAMESPACE, DF_PLATFORM -``` - -### 7.2 Resolution - -```python -# domain/remote/resolution.py - -HOST_TEMPLATES = { - "orb": ".orb", - "utm": ".utm.local", - "core": ".core.lan", -} - -def parse_target(target: str) -> tuple[str | None, str | None, str | None]: - """Parse [user@]namespace@platform.""" - ... - -def resolve_target( - parsed: tuple, - config_targets: list[TargetConfig], - default_user: str, -) -> Target: ... - -def build_ssh_command( - target: Target, - *, - tmux_session: str | None, - no_tmux: bool, -) -> SSHCommand: ... - -def terminfo_fix_command(term: str | None, destination: str) -> str | None: ... -``` - ---- - -## 8. Containers Domain - -### 8.1 Models - -```python -@dataclass(frozen=True) -class ImageRef: - """A resolved container image reference.""" - full_ref: str - registry: str - repo: str - tag: str - label: str - -@dataclass(frozen=True) -class ContainerSpec: - """Everything needed to create a container.""" - name: str # dev- - image: ImageRef - project_path: Path | None - mounts: list[tuple[str, str]] - labels: dict[str, str] - network: str # "host" - -@dataclass(frozen=True) -class ContainerInfo: - """Runtime state of an existing container.""" - name: str - image: str - project: str - status: str -``` - -### 8.2 Resolution - -```python -# domain/containers/resolution.py - -def parse_image_ref( - image: str, - *, - default_registry: str, - default_tag: str, -) -> ImageRef: ... - -def container_name(name: str) -> str: - """Ensure dev- prefix.""" - ... - -def resolve_mounts(home: str) -> list[tuple[str, str]]: - """Standard host mounts: .ssh (ro), .npmrc (ro), .npm, docker socket.""" - ... - -def build_container_spec( - name: str, - image: ImageRef, - project_path: Path | None, - home: str, -) -> ContainerSpec: ... -``` - ---- - -## 9. Services Layer - -Each service is a class receiving dependencies via constructor. All mutating methods accept `dry_run: bool`. - -### 9.1 DotfilesService - -```python -class DotfilesService: - def __init__(self, ctx: FlowContext): ... - - def init(self, repo_url: str, *, dry_run: bool) -> None: - """Clone dotfiles repo + discover and clone all module repos. - dry_run: prints repos that would be cloned without cloning.""" - - def link(self, *, profile: str | None, packages: list[str] | None, - force: bool, dry_run: bool) -> None: - """Reconcile links: discover -> resolve -> plan -> execute.""" - - def unlink(self, packages: list[str] | None, *, dry_run: bool) -> None: - """Remove managed links.""" - - def status(self, packages: list[str] | None) -> None: - """Show package list, link health, module info. Read-only.""" - - def edit(self, target: str, *, no_commit: bool) -> None: - """Pull relevant repo -> open editor -> commit + push. - Interactive command -- dry_run not applicable (editor is the point). - no_commit: skip the auto-commit/push after editing.""" - - def repos_list(self) -> None: - """List all managed repos. Read-only.""" - - def repos_status(self, repo_filter: str | None) -> None: - """Git status for repos. Read-only.""" - - def repos_pull(self, repo_filter: str | None, *, dry_run: bool) -> None: - """Pull one or all repos. dry_run: shows what would be pulled.""" - - def repos_push(self, repo_filter: str | None, *, dry_run: bool) -> None: - """Push one or all repos. dry_run: shows what would be pushed.""" -``` - -#### Link flow detail - -The service layer performs all I/O (directory walking, YAML reading, filesystem checks) and passes pre-built data structures to pure domain functions: - -```python -def link(self, ...): - # I/O: walk dirs, read _module.yaml files, build Package objects - packages = self._discover_packages(profile) - - # Pure: resolve file paths to LinkTargets - targets = resolve_all_targets(packages, home, skip) - - # I/O: load persisted state - current = self._load_linked_state() - - # Pure (with injected callback): build plan with filesystem checks - plan = plan_link(targets, current, - filesystem_check=self._check_path_on_disk) - - if plan.conflicts and not force: - raise PlanConflict(...) - - if dry_run: - self.console.print_plan(plan.operations) - return - - self._execute_link_plan(plan) # I/O - self._save_linked_state(...) # I/O -``` - -### 9.2 PackageService - -```python -class PackageService: - def __init__(self, ctx: FlowContext): ... - - def install(self, names: list[str], *, dry_run: bool) -> None: ... - def list(self, *, show_all: bool) -> None: ... - def remove(self, names: list[str], *, dry_run: bool) -> None: ... -``` - -### 9.3 BootstrapService - -```python -class BootstrapService: - def __init__(self, ctx: FlowContext): ... - - def run(self, *, profile: str | None, variables: dict[str, str], - dry_run: bool) -> None: - """Parse profile -> build plan -> execute actions sequentially.""" - - def list(self) -> None: - """List available profiles with summary info. Read-only.""" - - def show(self, profile: str, *, dry_run: bool) -> None: - """Show profile details. When dry_run=True (or always, effectively), - builds and prints the full BootstrapPlan without executing. - 'setup show' is conceptually 'setup run --dry-run' scoped to one profile.""" -``` - -#### Run flow detail - -```python -def run(self, ...): - profile = parse_profile(name, raw) # domain - catalog = parse_catalog(manifest["packages"]) # domain/packages - plan = plan_bootstrap(profile, catalog, platform, ...) # domain - - if dry_run: - self.console.print_plan(plan.actions) - return - - for action in plan.actions: - match action.type: - case "install_packages": - self._execute_package_plan(action.payload) - case "run_module": - self._execute_setup_module(action.payload) - case "link_dotfiles": - DotfilesService(self.ctx).link(profile=action.payload, dry_run=False) - case "set_shell": - self._set_shell(action.payload) -``` - -### 9.4 RemoteService - -```python -class RemoteService: - def __init__(self, ctx: FlowContext): ... - - def enter(self, target_str: str, *, user: str | None, namespace: str | None, - platform: str | None, session: str, no_tmux: bool, - dry_run: bool) -> None: ... - - def list(self) -> None: ... -``` - -### 9.5 ContainerService - -```python -class ContainerService: - def __init__(self, ctx: FlowContext): ... - - def create(self, name: str, image: str, project: str | None, *, - dry_run: bool) -> None: ... - def attach(self, name: str) -> None: - """Interactive -- attach to tmux session. No dry_run.""" - def exec(self, name: str, cmd: list[str] | None) -> None: - """Interactive -- exec into container. No dry_run.""" - def list(self) -> None: - """Read-only.""" - def stop(self, name: str, *, kill: bool, dry_run: bool) -> None: ... - def remove(self, name: str, *, force: bool, dry_run: bool) -> None: ... - def respawn(self, name: str, *, dry_run: bool) -> None: ... -``` - -### 9.6 ProjectService - -```python -class ProjectService: - def __init__(self, ctx: FlowContext): ... - - def check(self, *, fetch: bool) -> None: - """Read-only (fetch is network I/O but does not mutate local state - beyond FETCH_HEAD -- acceptable for a check command).""" - def fetch(self) -> None: - """Fetch all remotes. Network I/O only, no local mutations beyond - remote tracking refs. dry_run not applicable.""" - def summary(self) -> None: - """Read-only.""" -``` - ---- - -## 10. CLI Entry Point - -```python -# cli.py - -def main(): - parser = build_parser() - args = parser.parse_args() - - console = Console( - quiet=args.quiet, - color=not args.no_color if args.no_color else None, - ) - - context_type = detect_context() - validate_command_context(args.command, context_type, console) - ensure_non_root(console) - - platform = detect_platform() - config = load_config() - manifest = load_manifest() - runtime = SystemRuntime() - - ctx = FlowContext(config, manifest, platform, console, runtime) - - try: - args.handler(ctx, args) - except PlanConflict as e: - for conflict in e.conflicts: - console.error(conflict) - console.error(str(e)) - sys.exit(1) - except FlowError as e: - console.error(str(e)) - sys.exit(1) -``` - -Command modules are trivial: - -```python -# commands/dotfiles.py - -def register(subparsers): - p = subparsers.add_parser("dotfiles", aliases=["dot"], help="Manage dotfiles") - sub = p.add_subparsers(dest="dotfiles_command") - - link = sub.add_parser("link", help="Reconcile dotfile symlinks") - link.add_argument("--profile") - link.add_argument("packages", nargs="*") - link.add_argument("--force", action="store_true") - link.set_defaults(handler=_run_link) - # ... - -def _run_link(ctx, args): - DotfilesService(ctx).link( - profile=args.profile, - packages=args.packages or None, - force=args.force, - dry_run=args.dry_run, - ) -``` - ---- - -## 11. Config Format - -Single unified YAML format, loaded from `~/.config/flow/` or self-hosted from dotfiles repo. - -```yaml -repository: - url: git@github.com:user/dotfiles.git - branch: main - -paths: - projects: ~/projects - -defaults: - container-registry: registry.tomastm.com - container-tag: latest - tmux-session: default - -# Targets: two formats supported -# Shorthand: "namespace@platform: host [identity]" -# Dict form: "namespace@platform: {host: ..., identity: ...}" -targets: - personal@orb: personal.orb - work@ec2: - host: work.ec2.internal - identity: ~/.ssh/id_work - -packages: - - name: fd - type: pkg - sources: - apt: fd-find - brew: fd - - - name: neovim - type: binary - source: github:neovim/neovim - version: "0.10.4" - asset-pattern: "nvim-{{os}}-{{arch}}.tar.gz" - platform-map: - linux-x64: { os: linux, arch: x64 } - darwin-arm64: { os: macos, arch: arm64 } - extract-dir: "nvim-{{os}}64" - install: - bin: [bin/nvim] - share: [share/nvim] - - - name: docker - type: pkg - sources: - apt: docker-ce - allow-sudo: true # Allow sudo in post-install hook - post-install: | # Shell script, runs after install - sudo groupadd docker || true - sudo usermod -aG docker $USER - -profiles: - linux-work: - os: linux - shell: zsh - dotfiles-profile: linux-work - requires: [USER_EMAIL] - modules: - - type: hostname - value: "{{ env.HOSTNAME }}" - - type: locale - value: en_US.UTF-8 - - type: ssh-keygen - keys: - - type: ed25519 - comment: "{{ env.USER_EMAIL }}" - - type: runcmd - commands: - - "sudo groupadd docker || true" - packages: - - git - - fd - - binary/neovim - - name: docker - allow-sudo: true -``` - -### Path constants (`core/paths.py`) - -All XDG-compliant, hardcoded in `core/paths.py`: - -```python -CONFIG_DIR = XDG_CONFIG_HOME / "flow" # ~/.config/flow/ -DATA_DIR = XDG_DATA_HOME / "flow" # ~/.local/share/flow/ -STATE_DIR = XDG_STATE_HOME / "flow" # ~/.local/state/flow/ - -DOTFILES_DIR = DATA_DIR / "dotfiles" # Cloned dotfiles repo -MODULES_DIR = DATA_DIR / "modules" # Module cache (each module cloned here) -PACKAGES_DIR = DATA_DIR / "packages" # Binary package scratch - -LINKED_STATE = STATE_DIR / "linked.json" # Current link state -INSTALLED_STATE = STATE_DIR / "installed.json" # Installed packages state - -# Self-hosted config (from dotfiles repo, takes priority over CONFIG_DIR) -DOTFILES_FLOW_CONFIG = DOTFILES_DIR / "_shared" / "flow" / ".config" / "flow" -``` - -`MODULES_DIR` is the `modules_base` passed to `module_cache_dir()` and other domain functions. - -### Target config normalization - -The config parser in `core/config.py` normalizes both target formats into `TargetConfig` objects: - -```python -# Shorthand: "personal@orb: personal.orb" -# -> TargetConfig(namespace="personal", platform="orb", host="personal.orb", identity=None) - -# Dict: "work@ec2: {host: ..., identity: ...}" -# -> TargetConfig(namespace="work", platform="ec2", host="work.ec2.internal", identity="~/.ssh/id_work") -``` - ---- - -## 12. Testing Strategy - -### Domain tests (majority of tests) - -Pure function tests. No mocks, no filesystem, no subprocess. Fast. - -```python -def test_module_mount_path(): - mount = compute_mount_path( - module_yaml=Path("/dots/_shared/nvim/.config/nvim/_module.yaml"), - package_dir=Path("/dots/_shared/nvim"), - ) - assert mount == Path(".config/nvim") - -def test_plan_link_creates_ops_for_new_targets(): - desired = [LinkTarget(source=Path("/a"), target=Path("/home/x/.zshrc"), - package="_shared/zsh", from_module=False, needs_sudo=False)] - current = LinkedState(links={}) - plan = plan_link(desired, current, filesystem_check=lambda p: None) - assert len(plan.operations) == 1 - assert plan.operations[0].type == "create_link" - -def test_resolve_binary_url(): - spec = PackageDef(name="nvim", type="binary", - source="github:neovim/neovim", version="0.10.4", ...) - url = resolve_download_url(spec, "nvim-linux-x64.tar.gz", {...}) - assert url == "https://github.com/neovim/neovim/releases/download/v0.10.4/nvim-linux-x64.tar.gz" -``` - -### Service tests (integration) - -Use `tmp_path` fixtures with real filesystem but fake `CommandRunner` that records calls. - -```python -class FakeRunner: - def __init__(self): - self.calls: list[list[str]] = [] - def run(self, argv, **kwargs): - self.calls.append(list(argv)) - return CompletedProcess(argv, 0, stdout="", stderr="") - -def test_dotfiles_link_creates_symlinks(tmp_path): - # Set up real dotfiles dir, real home dir - # Inject FakeRunner for git calls - # Call DotfilesService.link() - # Assert symlinks exist on disk -``` - -### E2E tests (opt-in, container-based) - -Same pattern as current `test_dotfiles_e2e_container.py`. Run with `FLOW_RUN_E2E=1`. - ---- - -## 13. Migration Notes - -This is a full rewrite. The approach is: - -1. Build `domain/` layer first with comprehensive tests -2. Build `services/` layer on top -3. Build `commands/` + `cli.py` last -4. Delete all old code - -No incremental migration. The old code serves as reference but is not preserved. diff --git a/example/README.md b/example/README.md index 1e57016..fe53564 100644 --- a/example/README.md +++ b/example/README.md @@ -1,102 +1,109 @@ -# Example working scenario +# Example Dotfiles Repository -This folder contains a complete dotfiles + setup configuration for the current `flow` schema. - -## What this example shows - -- Flat repo-root layout with reserved dirs: - - `_shared/` (shared configs) - - profile dirs (`linux-auto/`, `macos-dev/`) - - package-local `_root/` marker for root-targeted files -- Unified YAML config under `_shared/flow/.config/flow/*.yaml` -- Profile package list syntax: string, type prefix, and object entries -- Binary install definition with `asset-pattern`, `platform-map`, `extract-dir`, and `install` -- Required env vars, templating, SSH keygen, runcmd, post-link, and config skip patterns +`example/dotfiles-repo` is a complete fixture for the current flow schema. It +contains shared dotfiles, profile-specific dotfiles, package definitions, setup +profiles, root-targeted files, templates, and shell hooks. ## Layout -- `dotfiles-repo/_shared/flow/.config/flow/config.yaml` -- `dotfiles-repo/_shared/flow/.config/flow/packages.yaml` -- `dotfiles-repo/_shared/flow/.config/flow/profiles.yaml` -- `dotfiles-repo/_shared/...` -- `dotfiles-repo/linux-auto/...` -- `dotfiles-repo/macos-dev/...` - -## Quick start - -Use the absolute path to this local example repo: - -```bash -EXAMPLE_REPO="/ABSOLUTE/PATH/TO/flow-cli/example/dotfiles-repo" +```text +dotfiles-repo/ + _shared/ + bin/.local/bin/flow-hello + flow/.config/flow/config.yaml + flow/.config/flow/packages.yaml + flow/.config/flow/profiles.yaml + git/.gitconfig + nvim/.config/nvim/init.lua + system/_root/etc/hostname + system/_root/usr/local/bin/custom-script.sh + tmux/.tmux.conf + zsh/.zshrc + linux-auto/ + ssh/.ssh/config + macos-dev/ + ghostty/.config/ghostty/config ``` -Initialize and link dotfiles: +The fixture demonstrates: + +- `_shared/` plus profile-specific layers +- `_root/` absolute-path planning for sudo-backed links +- flow config and manifest overlay under `_shared/flow/.config/flow` +- package-manager, cask, and binary package definitions +- profile package shorthand and object overrides +- required env vars, templating, SSH key generation, `runcmd`, `post-link`, and + config skip patterns + +## Try It Safely + +Use a temporary HOME/XDG sandbox so the example cannot touch your real dotfiles. +`dotfiles init` clones with git, so first copy the fixture into a temporary git +repo: ```bash -flow dotfiles init --repo "$EXAMPLE_REPO" -flow dotfiles link --profile linux-auto -flow dotfiles status -flow dotfiles unlink # remove all managed symlinks -flow dotfiles unlink git tmux # remove only specific packages +DEMO="$(mktemp -d)" +mkdir -p "$DEMO/home" +cp -a example/dotfiles-repo "$DEMO/dotfiles-src" +git -C "$DEMO/dotfiles-src" init -q -b main +git -C "$DEMO/dotfiles-src" config user.email e2e@example.com +git -C "$DEMO/dotfiles-src" config user.name "flow example" +git -C "$DEMO/dotfiles-src" add -A +git -C "$DEMO/dotfiles-src" commit -q -m initial ``` -Manage the dotfiles and any module repos as a unified set: +Run flow against that sandbox: ```bash -flow dotfiles repos list -flow dotfiles repos status -flow dotfiles repos pull -flow dotfiles repos push +HOME="$DEMO/home" \ +XDG_CONFIG_HOME="$DEMO/config" \ +XDG_DATA_HOME="$DEMO/data" \ +XDG_STATE_HOME="$DEMO/state" \ +uv run flow dotfiles init --repo "$DEMO/dotfiles-src" + +HOME="$DEMO/home" \ +XDG_CONFIG_HOME="$DEMO/config" \ +XDG_DATA_HOME="$DEMO/data" \ +XDG_STATE_HOME="$DEMO/state" \ +uv run flow dotfiles link --profile linux-auto --skip system --dry-run + +HOME="$DEMO/home" \ +XDG_CONFIG_HOME="$DEMO/config" \ +XDG_DATA_HOME="$DEMO/data" \ +XDG_STATE_HOME="$DEMO/state" \ +uv run flow dotfiles link --profile linux-auto --skip system ``` -Edit a package or a specific file under the dotfiles repo: +`--skip system` avoids `_root/` paths such as `/etc/hostname` during the demo. + +Useful follow-up commands: ```bash -flow dotfiles edit git --no-commit -flow dotfiles edit _shared/flow/.config/flow/profiles.yaml --no-commit +HOME="$DEMO/home" XDG_CONFIG_HOME="$DEMO/config" XDG_DATA_HOME="$DEMO/data" XDG_STATE_HOME="$DEMO/state" uv run flow dotfiles status +HOME="$DEMO/home" XDG_CONFIG_HOME="$DEMO/config" XDG_DATA_HOME="$DEMO/data" XDG_STATE_HOME="$DEMO/state" uv run flow dotfiles repos list +HOME="$DEMO/home" XDG_CONFIG_HOME="$DEMO/config" XDG_DATA_HOME="$DEMO/data" XDG_STATE_HOME="$DEMO/state" uv run flow setup list +HOME="$DEMO/home" XDG_CONFIG_HOME="$DEMO/config" XDG_DATA_HOME="$DEMO/data" XDG_STATE_HOME="$DEMO/state" uv run flow setup show linux-auto +HOME="$DEMO/home" XDG_CONFIG_HOME="$DEMO/config" XDG_DATA_HOME="$DEMO/data" XDG_STATE_HOME="$DEMO/state" uv run flow packages install --profile linux-auto --dry-run ``` -Inspect setup profiles and run a setup: +Remove the sandbox when done: ```bash -flow setup list -flow setup show linux-auto -flow setup run linux-auto \ - --var TARGET_HOSTNAME=devbox \ - --var USER_EMAIL=you@example.com \ - --dry-run -flow setup run macos-dev --dry-run +rm -rf "$DEMO" ``` -`bootstrap` and `provision` remain as aliases for `setup`, so `flow bootstrap run linux-auto` still works. +## External Modules -Install or list packages directly (independent of a setup run): - -```bash -flow packages list -flow packages list --all -flow packages install --profile linux-auto --dry-run -flow packages install fd ripgrep --dry-run -flow packages remove docker --dry-run -``` - -## External modules - -A package directory inside the dotfiles repo can pull its contents from a separate -git repository by placing a `_module.yaml` file at the package root. Flow clones -the module into a shared cache and links from the cached path, so updates flow -through `flow dotfiles repos pull`. - -This example does NOT include a real `_module.yaml` (it would pin the example to -a flaky external dependency). For reference, a hypothetical `nvim/_module.yaml` -would look like: +A package directory may mount a separate git repo by adding `_module.yaml` at +the package root: ```yaml -# example/dotfiles-repo/nvim/_module.yaml (not committed -- format reference only) -source: github:your-org/nvim-config # or a full git URL +source: github:your-org/nvim-config ref: - branch: main # exactly one of: branch, tag, commit + branch: main ``` -After adding such a file, the next `flow dotfiles repos pull` will clone the -module and `flow dotfiles link --profile linux-auto` will link its contents. +This fixture does not include a real module because that would make the example +depend on an external network repo. After adding one, `flow dotfiles repos pull` +clones or updates the module cache, and `flow dotfiles link` links from that +cache. diff --git a/pyproject.toml b/pyproject.toml index d5bbe5d..0f062d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,8 @@ build-backend = "hatchling.build" [project] name = "flow" dynamic = ["version"] -description = "DevFlow - A unified toolkit for managing development instances, containers, and profiles" +description = "Action-centered CLI for dotfiles, packages, setup, dev containers, and remote targets" +readme = "README.md" requires-python = ">=3.9" dependencies = ["pyyaml>=6.0", "rich>=13.7", "typer>=0.12"] diff --git a/src/flow/adapters/packages.py b/src/flow/adapters/packages.py new file mode 100644 index 0000000..f2d4943 --- /dev/null +++ b/src/flow/adapters/packages.py @@ -0,0 +1,46 @@ +"""Package-manager command adapter helpers.""" + +from __future__ import annotations + +import shutil + +from flow.core.errors import FlowError + + +def detect_package_manager() -> str | None: + """Detect the system package manager from the current host.""" + if shutil.which("apt-get"): + return "apt" + if shutil.which("dnf"): + return "dnf" + if shutil.which("brew"): + return "brew" + return None + + +def pm_update_argv(pm: str) -> list[str]: + commands = { + "apt": ["sudo", "apt-get", "update", "-qq"], + "dnf": ["sudo", "dnf", "check-update", "-q"], + "brew": ["brew", "update"], + } + if pm not in commands: + raise FlowError(f"Unsupported package manager: {pm}") + return commands[pm] + + +def pm_install_argv(pm: str, packages: list[str]) -> list[str]: + commands = { + "apt": ["sudo", "apt-get", "install", "-y", "-qq", *packages], + "dnf": ["sudo", "dnf", "install", "-y", "-q", *packages], + "brew": ["brew", "install", *packages], + } + if pm not in commands: + raise FlowError(f"Unsupported package manager: {pm}") + return commands[pm] + + +def pm_cask_install_argv(pm: str, packages: list[str]) -> list[str]: + if pm != "brew": + raise FlowError(f"Package manager '{pm}' does not support casks") + return ["brew", "install", "--cask", *packages] diff --git a/src/flow/adapters/tmux.py b/src/flow/adapters/tmux.py index aa30504..acc1c41 100644 --- a/src/flow/adapters/tmux.py +++ b/src/flow/adapters/tmux.py @@ -72,15 +72,3 @@ class TmuxClient: if os.environ.get("TMUX"): os.execvp("tmux", ["tmux", "switch-client", "-t", session]) os.execvp("tmux", ["tmux", "attach", "-t", session]) - - -def build_new_session_argv( - session: str, - *, - env: Optional[dict[str, str]] = None, -) -> list[str]: - """Build tmux new-session argv for remote/SSH use (pure, no I/O).""" - argv = ["tmux", "new-session", "-As", session] - for key, value in (env or {}).items(): - argv.extend(["-e", f"{key}={value}"]) - return argv diff --git a/src/flow/app/completion.py b/src/flow/app/completion.py index b2c0308..bc082c2 100644 --- a/src/flow/app/completion.py +++ b/src/flow/app/completion.py @@ -6,6 +6,7 @@ import subprocess from pathlib import Path from typing import Sequence +from flow.actions import ActionExecutor, ActionPlan, DomainAction, PrimitiveAction from flow.core.config import FlowContext from flow.core import paths from flow.core.errors import FlowError @@ -343,6 +344,56 @@ def _complete_completion(before: Sequence[str], current: str) -> list[str]: return [] +def install_zsh_completion( + ctx: FlowContext, + *, + directory: str = "~/.zsh/completions", + rc: str = "~/.zshrc", + update_rc: bool = True, +) -> Path: + completions_dir = Path(directory).expanduser() + completion_file = completions_dir / "_flow" + primitives: list[PrimitiveAction] = [ + PrimitiveAction( + id="completion.zsh.write-script", + type="file.write", + description=f"Write zsh completion script to {completion_file}", + payload={"path": completion_file, "content": _zsh_script_text()}, + ) + ] + + if update_rc: + rc_path = Path(rc).expanduser() + content = rc_path.read_text(encoding="utf-8") if rc_path.exists() else "" + primitives.append( + PrimitiveAction( + id="completion.zsh.write-rc", + type="file.write", + description=f"Update shell rc {rc_path}", + payload={ + "path": rc_path, + "content": render_zsh_rc_update(content, completions_dir), + }, + ) + ) + + ActionExecutor(ctx).execute( + ActionPlan( + name="completion.install-zsh", + domain_actions=( + DomainAction( + id="completion.zsh.install", + kind="completion", + action="install-zsh", + description="Install zsh completion", + payload={"primitive_actions": tuple(primitives)}, + ), + ), + ) + ) + return completion_file + + def _zsh_script_text() -> str: return r'''#compdef flow diff --git a/src/flow/app/containers.py b/src/flow/app/containers.py index 314fdfb..3fa55b4 100644 --- a/src/flow/app/containers.py +++ b/src/flow/app/containers.py @@ -108,11 +108,11 @@ class ContainerService: raise FlowError(f"Container {cname} not running") if command: - rc = self.rt.exec_in(cname, command, interactive=os.isatty(0)) + rc = self._exec_in_container(cname, command, interactive=os.isatty(0)) raise SystemExit(rc) for shell in (["zsh", "-l"], ["bash", "-l"], ["sh"]): - rc = self.rt.exec_in( + rc = self._exec_in_container( cname, shell, interactive=True, detach_keys="ctrl-q,ctrl-p", ) if rc not in (126, 127): @@ -276,3 +276,32 @@ class ContainerService: rows.append([name, image, project or "-", status]) self.ctx.console.table(["NAME", "IMAGE", "PROJECT", "STATUS"], rows) + + def _exec_in_container( + self, + cname: str, + command: list[str], + *, + interactive: bool, + detach_keys: str | None = None, + ) -> int: + summary = ActionExecutor(self.ctx).execute( + ActionPlan( + name=f"container.exec.{cname}", + primitive_actions=( + PrimitiveAction( + id=f"container.{cname}.exec", + type="container.exec", + description=f"Run command in container {cname}", + payload={ + "name": cname, + "argv": tuple(command), + "interactive": interactive, + "detach_keys": detach_keys, + }, + rollback_policy=RollbackPolicy.BARRIER, + ), + ), + ) + ) + return summary.results[0].returncode or 0 diff --git a/src/flow/app/packages.py b/src/flow/app/packages.py index ea29b54..ab84f95 100644 --- a/src/flow/app/packages.py +++ b/src/flow/app/packages.py @@ -23,12 +23,14 @@ from flow.domain.packages.models import ( from flow.domain.packages.planning import plan_install, plan_remove from flow.domain.packages.resolution import ( binary_template_context, + resolve_extract_dir, + resolve_spec, +) +from flow.adapters.packages import ( detect_package_manager, pm_cask_install_argv, pm_install_argv, pm_update_argv, - resolve_extract_dir, - resolve_spec, ) @@ -169,76 +171,6 @@ class PackageService: **binary_template_context(pkg, self.ctx.platform.platform), } - def _copy_install_item( - self, - package_name: str, - source_root: Path, - source_root_resolved: Path, - section: str, - raw_path: str, - ) -> Path: - declared_path = Path(raw_path) - self._validate_install_path(package_name, declared_path) - - source = (source_root / declared_path).resolve(strict=False) - if not source.is_relative_to(source_root_resolved): - raise FlowError( - f"Install path escapes extract-dir for '{package_name}': {declared_path}" - ) - if not source.exists(): - raise FlowError( - f"Install path not found for '{package_name}': {declared_path}" - ) - - destination_root = self._install_destination(section) - stripped_path = self._strip_prefix( - declared_path, self._install_strip_prefix(section), - package_name, section, - ) - destination = destination_root / stripped_path - - self._executor().execute( - ActionPlan( - name=f"package.copy-install-item.{package_name}", - primitive_actions=( - PrimitiveAction( - id=f"package.{package_name}.copy-install-item", - type="file.copy", - description=f"Install {declared_path} to {destination}", - payload={ - "source": source, - "target": destination, - "source_root": source_root_resolved, - "make_executable": section == "bin", - }, - rollback_policy=RollbackPolicy.ROLLBACKABLE, - ), - ), - ) - ) - - return destination - - def _run_post_install(self, pkg: PackageDef) -> None: - if not pkg.post_install: - return - - script = substitute_template(pkg.post_install, self._binary_context(pkg)) - self._executor().execute( - ActionPlan( - name=f"package.post-install.{pkg.name}", - primitive_actions=( - PrimitiveAction( - id=f"package.{pkg.name}.post-install", - type="process.shell_user_hook", - description=f"Run post-install hook for {pkg.name}", - payload={"command": script}, - rollback_policy=RollbackPolicy.BARRIER, - ), - ), - ) - ) - def _install_destination(self, section: str) -> Path: destinations = { "bin": paths.HOME / ".local" / "bin", diff --git a/src/flow/cli.py b/src/flow/cli.py index d77b496..c9fd9e7 100644 --- a/src/flow/cli.py +++ b/src/flow/cli.py @@ -4,13 +4,11 @@ from __future__ import annotations import os import sys -from pathlib import Path from typing import Optional import typer from flow import __version__ -from flow.actions import ActionExecutor, ActionPlan, DomainAction, PrimitiveAction from flow.core import paths from flow.core.config import FlowContext, load_config, load_manifest from flow.core.console import Console @@ -495,43 +493,13 @@ def completion_install_zsh( no_rc: bool = typer.Option(False, "--no-rc"), ) -> None: def _install(flow_ctx: FlowContext) -> None: - from flow.app.completion import render_zsh_rc_update, _zsh_script_text + from flow.app.completion import install_zsh_completion - completions_dir = Path(directory).expanduser() - completion_file = completions_dir / "_flow" - primitives: list[PrimitiveAction] = [ - PrimitiveAction( - id="completion.zsh.write-script", - type="file.write", - description=f"Write zsh completion script to {completion_file}", - payload={"path": completion_file, "content": _zsh_script_text()}, - ) - ] - if not no_rc: - rc_path = Path(rc).expanduser() - content = rc_path.read_text(encoding="utf-8") if rc_path.exists() else "" - updated = render_zsh_rc_update(content, completions_dir) - primitives.append( - PrimitiveAction( - id="completion.zsh.write-rc", - type="file.write", - description=f"Update shell rc {rc_path}", - payload={"path": rc_path, "content": updated}, - ) - ) - ActionExecutor(flow_ctx).execute( - ActionPlan( - name="completion.install-zsh", - domain_actions=( - DomainAction( - id="completion.zsh.install", - kind="completion", - action="install-zsh", - description="Install zsh completion", - payload={"primitive_actions": tuple(primitives)}, - ), - ), - ) + completion_file = install_zsh_completion( + flow_ctx, + directory=directory, + rc=rc, + update_rc=not no_rc, ) flow_ctx.console.success(f"Installed completion script: {completion_file}") diff --git a/src/flow/domain/packages/resolution.py b/src/flow/domain/packages/resolution.py index eebf088..d9ac441 100644 --- a/src/flow/domain/packages/resolution.py +++ b/src/flow/domain/packages/resolution.py @@ -1,6 +1,5 @@ """Package resolution: resolving what to install and how.""" -import shutil from typing import Optional from flow.core.template import substitute_template @@ -135,73 +134,3 @@ def platform_lookup_keys(platform_str: str) -> list[str]: if os_name == "macos": keys.append("darwin-amd64") return keys - - -def detect_package_manager() -> Optional[str]: - """Detect the system package manager.""" - if shutil.which("apt-get"): - return "apt" - if shutil.which("dnf"): - return "dnf" - if shutil.which("brew"): - return "brew" - return None - - -def pm_update_command(pm: str) -> str: - """Return the package manager update command.""" - commands = { - "apt": "sudo apt-get update -qq", - "dnf": "sudo dnf check-update -q || true", - "brew": "brew update", - } - if pm not in commands: - raise FlowError(f"Unsupported package manager: {pm}") - return commands[pm] - - -def pm_update_argv(pm: str) -> list[str]: - commands = { - "apt": ["sudo", "apt-get", "update", "-qq"], - "dnf": ["sudo", "dnf", "check-update", "-q"], - "brew": ["brew", "update"], - } - if pm not in commands: - raise FlowError(f"Unsupported package manager: {pm}") - return commands[pm] - - -def pm_install_command(pm: str, packages: list[str]) -> str: - """Return the package manager install command.""" - pkg_str = " ".join(packages) - commands = { - "apt": f"sudo apt-get install -y -qq {pkg_str}", - "dnf": f"sudo dnf install -y -q {pkg_str}", - "brew": f"brew install {pkg_str}", - } - if pm not in commands: - raise FlowError(f"Unsupported package manager: {pm}") - return commands[pm] - - -def pm_install_argv(pm: str, packages: list[str]) -> list[str]: - commands = { - "apt": ["sudo", "apt-get", "install", "-y", "-qq", *packages], - "dnf": ["sudo", "dnf", "install", "-y", "-q", *packages], - "brew": ["brew", "install", *packages], - } - if pm not in commands: - raise FlowError(f"Unsupported package manager: {pm}") - return commands[pm] - - -def pm_cask_install_command(pm: str, packages: list[str]) -> str: - if pm != "brew": - raise FlowError(f"Package manager '{pm}' does not support casks") - return f"brew install --cask {' '.join(packages)}" - - -def pm_cask_install_argv(pm: str, packages: list[str]) -> list[str]: - if pm != "brew": - raise FlowError(f"Package manager '{pm}' does not support casks") - return ["brew", "install", "--cask", *packages] diff --git a/src/flow/domain/remote/resolution.py b/src/flow/domain/remote/resolution.py index 8b6a6c2..73fe9d8 100644 --- a/src/flow/domain/remote/resolution.py +++ b/src/flow/domain/remote/resolution.py @@ -4,7 +4,6 @@ from typing import Optional from flow.core.config import TargetConfig from flow.core.errors import FlowError -from flow.adapters.tmux import build_new_session_argv from flow.domain.remote.models import SSHCommand, Target @@ -96,7 +95,7 @@ def build_ssh_command( } if not no_tmux: - argv.extend(build_new_session_argv( + argv.extend(build_remote_tmux_argv( tmux_session, env=env, )) @@ -117,6 +116,18 @@ def build_destination(user: str, host: str) -> str: return f"{user}@{host}" +def build_remote_tmux_argv( + session: str, + *, + env: Optional[dict[str, str]] = None, +) -> list[str]: + """Build argv appended to ssh for the remote tmux session command.""" + argv = ["tmux", "new-session", "-As", session] + for key, value in (env or {}).items(): + argv.extend(["-e", f"{key}={value}"]) + return argv + + def list_targets(targets: list[TargetConfig]) -> list[Target]: """Convert config targets to domain targets.""" return [ diff --git a/tests/test_actions_executor.py b/tests/test_actions_executor.py index d979ce4..dde0e8d 100644 --- a/tests/test_actions_executor.py +++ b/tests/test_actions_executor.py @@ -7,12 +7,15 @@ import sys import pytest -from flow.actions import ActionExecutor, ActionPlan, PrimitiveAction, RollbackPolicy +from flow.actions import ActionExecutor, ActionPlan, DomainAction, PrimitiveAction, RollbackPolicy +from flow.adapters.containers import ContainerRuntime +from flow.adapters.tmux import TmuxClient from flow.core.config import AppConfig, FlowContext from flow.core.console import Console from flow.core.errors import FlowError from flow.core.platform import PlatformInfo from flow.core.runtime import SystemRuntime +from tests.fakes import FakeRunner def _ctx() -> FlowContext: @@ -138,3 +141,72 @@ def test_barrier_prevents_rollback_across_external_boundary(tmp_path): assert target.is_symlink() + +def test_domain_action_expands_embedded_primitives(tmp_path): + target = tmp_path / "completion" + primitive = PrimitiveAction( + id="completion.write", + type="file.write", + description="Write completion", + payload={"path": target, "content": "complete"}, + ) + plan = ActionPlan( + name="completion.install", + domain_actions=( + DomainAction( + id="completion.install", + kind="completion", + action="install-zsh", + description="Install completion", + payload={"primitive_actions": (primitive,)}, + ), + ), + ) + + ActionExecutor(_ctx(), audit_path=tmp_path / "actions.jsonl").execute(plan) + + assert target.read_text() == "complete" + + +def test_executor_dispatches_container_and_tmux_primitives(tmp_path): + runner = FakeRunner() + ctx = _ctx() + ctx.runtime.runner = runner + ctx.runtime.containers = ContainerRuntime(runner, binary="docker") + ctx.runtime.tmux = TmuxClient(runner) + plan = ActionPlan( + name="runtime-dispatch", + primitive_actions=( + PrimitiveAction( + id="container.exec", + type="container.exec", + description="Run command in container", + payload={"name": "dev-api", "argv": ("echo", "hello")}, + ), + PrimitiveAction( + id="tmux.session", + type="tmux.new_session", + description="Create session", + payload={"name": "dev-api", "detached": True, "command": "flow dev exec api"}, + ), + PrimitiveAction( + id="tmux.option", + type="tmux.set_option", + description="Set option", + payload={ + "session": "dev-api", + "option": "default-command", + "value": "flow dev exec api", + }, + ), + ), + ) + + summary = ActionExecutor(ctx, audit_path=tmp_path / "actions.jsonl").execute(plan) + + assert summary.results[0].returncode == 0 + assert ["docker", "exec", "dev-api", "echo", "hello"] in runner.calls + assert ["tmux", "new-session", "-ds", "dev-api", "flow dev exec api"] in runner.calls + assert [ + "tmux", "set-option", "-t", "dev-api", "default-command", "flow dev exec api", + ] in runner.calls diff --git a/tests/test_completion.py b/tests/test_completion.py index 2161c2a..1c6914f 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -2,7 +2,8 @@ import subprocess -from flow.app.completion import complete +from flow.app.completion import complete, install_zsh_completion +from flow.core import paths from flow.core.config import AppConfig, FlowContext from flow.core.console import Console from flow.core.platform import PlatformInfo @@ -96,3 +97,20 @@ def test_complete_dev_attach_returns_empty_on_timeout(): ctx.runtime.containers.ps = fake_ps # type: ignore[method-assign] result = complete(ctx, ["flow", "dev", "attach", ""], 3) assert result == [] + + +def test_install_zsh_completion_uses_action_runtime(tmp_path, monkeypatch): + monkeypatch.setattr(paths, "HOME", tmp_path) + monkeypatch.setattr(paths, "STATE_DIR", tmp_path / "state") + ctx = _make_ctx() + + completion_file = install_zsh_completion( + ctx, + directory=str(tmp_path / "completions"), + rc=str(tmp_path / ".zshrc"), + ) + + assert completion_file == tmp_path / "completions" / "_flow" + assert "compdef _flow flow" in completion_file.read_text() + assert "# >>> flow completion >>>" in (tmp_path / ".zshrc").read_text() + assert (tmp_path / "state" / "actions.jsonl").exists() diff --git a/tests/test_core_tmux.py b/tests/test_core_tmux.py index 2ccf14d..b9f4100 100644 --- a/tests/test_core_tmux.py +++ b/tests/test_core_tmux.py @@ -2,7 +2,8 @@ import subprocess -from flow.adapters.tmux import TmuxClient, build_new_session_argv +from flow.adapters.tmux import TmuxClient +from flow.domain.remote.resolution import build_remote_tmux_argv from tests.fakes import FakeRunner @@ -78,11 +79,11 @@ class TestTmuxClient: class TestBuildNewSessionArgv: def test_basic(self): - argv = build_new_session_argv("default") + argv = build_remote_tmux_argv("default") assert argv == ["tmux", "new-session", "-As", "default"] def test_with_env(self): - argv = build_new_session_argv( + argv = build_remote_tmux_argv( "main", env={"DF_NAMESPACE": "personal", "DF_PLATFORM": "orb"}, ) @@ -93,5 +94,5 @@ class TestBuildNewSessionArgv: ] def test_empty_env(self): - argv = build_new_session_argv("sess", env={}) + argv = build_remote_tmux_argv("sess", env={}) assert argv == ["tmux", "new-session", "-As", "sess"] diff --git a/tests/test_domain_packages.py b/tests/test_domain_packages.py index f13eed7..c704843 100644 --- a/tests/test_domain_packages.py +++ b/tests/test_domain_packages.py @@ -1,12 +1,14 @@ """Tests for packages catalog and resolution.""" +from flow.adapters.packages import ( + detect_package_manager, + pm_cask_install_argv, + pm_install_argv, + pm_update_argv, +) from flow.domain.packages.catalog import normalize_profile_entry, parse_catalog from flow.domain.packages.planning import plan_install from flow.domain.packages.resolution import ( - detect_package_manager, - pm_cask_install_command, - pm_install_command, - pm_update_command, resolve_binary_asset, resolve_download_url, resolve_extract_dir, @@ -210,24 +212,23 @@ class TestResolveDownloadUrl: class TestPmCommands: def test_apt_update(self): - assert "apt-get update" in pm_update_command("apt") + assert pm_update_argv("apt") == ["sudo", "apt-get", "update", "-qq"] def test_dnf_update(self): - assert "dnf" in pm_update_command("dnf") + assert pm_update_argv("dnf") == ["sudo", "dnf", "check-update", "-q"] def test_brew_install(self): - cmd = pm_install_command("brew", ["fd", "rg"]) - assert "brew install" in cmd - assert "fd" in cmd + assert pm_install_argv("brew", ["fd", "rg"]) == ["brew", "install", "fd", "rg"] def test_apt_install(self): - cmd = pm_install_command("apt", ["fd-find"]) - assert "apt-get install" in cmd + assert pm_install_argv("apt", ["fd-find"]) == [ + "sudo", "apt-get", "install", "-y", "-qq", "fd-find", + ] def test_brew_cask_install(self): - cmd = pm_cask_install_command("brew", ["wezterm"]) - assert "--cask" in cmd - assert "wezterm" in cmd + assert pm_cask_install_argv("brew", ["wezterm"]) == [ + "brew", "install", "--cask", "wezterm", + ] def test_detect_package_manager_returns_something(self): # Just verify it doesn't error diff --git a/tests/test_service_containers.py b/tests/test_service_containers.py index 0fec02c..7890010 100644 --- a/tests/test_service_containers.py +++ b/tests/test_service_containers.py @@ -66,3 +66,17 @@ class TestContainerService: svc = ContainerService(ctx) svc.remove("api") assert any("docker" in str(c) and "rm" in str(c) for c in runner.calls) + + def test_exec_command_uses_container_exec_action(self, tmp_path): + runner = FakeRunner(responses={ + ("ps", "{{.Names}}"): subprocess.CompletedProcess([], 0, stdout="dev-api\n"), + }) + ctx = _make_ctx(tmp_path, runner=runner) + svc = ContainerService(ctx) + + try: + svc.exec("api", ["echo", "hello"]) + except SystemExit as e: + assert e.code == 0 + + assert ["docker", "exec", "dev-api", "echo", "hello"] in runner.calls diff --git a/tests/test_service_packages.py b/tests/test_service_packages.py index e197b8b..5cc8235 100644 --- a/tests/test_service_packages.py +++ b/tests/test_service_packages.py @@ -7,6 +7,7 @@ from pathlib import Path import pytest +from flow.actions import ActionExecutor, ActionPlan, PrimitiveAction, RollbackPolicy from flow.core.config import AppConfig, FlowContext from flow.core.console import Console from flow.core.errors import FlowError @@ -135,26 +136,7 @@ class TestPackageService: def test_post_install_with_sudo_runs_unchecked(self, tmp_path, monkeypatch): """No allow_sudo gate -- post-install scripts run as written.""" - home = tmp_path / "home" - home.mkdir() - monkeypatch.setattr(paths, "HOME", home) - monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json") - - calls: list[str] = [] - - class _Runner: - def run_shell(self, command, **kwargs): - calls.append(command) - - class _Result: - returncode = 0 - stdout = "" - stderr = "" - - return _Result() - ctx = _make_ctx(tmp_path) - ctx.runtime.runner = _Runner() svc = PackageService(ctx) pkg = PackageDef( name="docker", type="pkg", sources={}, @@ -162,8 +144,11 @@ class TestPackageService: platform_map={}, extract_dir=None, install={}, post_install="sudo groupadd docker || true", ) - svc._run_post_install(pkg) - assert calls == ["sudo groupadd docker || true"] + primitive = svc._post_install_primitive(pkg) + assert primitive is not None + assert primitive.type == "process.shell_user_hook" + assert primitive.payload["command"] == "sudo groupadd docker || true" + assert primitive.rollback_policy == RollbackPolicy.BARRIER def test_install_binary_url_failure_raises_flow_error(self, tmp_path, monkeypatch): home = tmp_path / "home" @@ -226,11 +211,21 @@ class TestPackageService: link = extract_root / "evil" link.symlink_to(sibling) - with pytest.raises(FlowError, match="escapes extract-dir"): - svc._copy_install_item( - "pkg", - extract_root, - extract_root.resolve(), - "bin", - "evil/escape", + with pytest.raises(FlowError, match="escapes allowed root"): + ActionExecutor(ctx, audit_path=tmp_path / "actions.jsonl").execute( + ActionPlan( + name="copy-escape", + primitive_actions=( + PrimitiveAction( + id="copy", + type="file.copy", + description="Copy escaped source", + payload={ + "source": link / "escape", + "target": tmp_path / "target", + "source_root": extract_root.resolve(), + }, + ), + ), + ) ) diff --git a/tests/test_static_mutation_guard.py b/tests/test_static_mutation_guard.py index 49191ef..1dcae92 100644 --- a/tests/test_static_mutation_guard.py +++ b/tests/test_static_mutation_guard.py @@ -15,9 +15,9 @@ MUTATING_PATTERNS = ( COMMAND_PATTERNS = ( re.compile(r"runtime\.runner\.(run|run_shell)\("), re.compile(r"runtime\.git\.run\("), - re.compile(r"runtime\.containers\.(run_container|start|stop|kill|rm)\("), + re.compile(r"runtime\.containers\.(run_container|start|stop|kill|rm|exec_in)\("), re.compile(r"runtime\.tmux\.(new_session|set_option|respawn_pane)\("), - re.compile(r"self\.(rt|tmux)\.(run_container|start|stop|kill|rm|new_session|set_option|respawn_pane)\("), + re.compile(r"self\.(rt|tmux)\.(run_container|start|stop|kill|rm|exec_in|new_session|set_option|respawn_pane)\("), ) ALLOWED_PREFIXES = (