Clean action runtime project state

This commit is contained in:
2026-05-14 13:58:45 +03:00
parent b05d3589b7
commit 4ce98d0ff1
27 changed files with 711 additions and 4772 deletions

View File

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

10
.gitignore vendored
View File

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

View File

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

View File

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

246
README.md
View File

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

124
docs/architecture.md Normal file
View File

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

View File

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

View File

@@ -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 <target> # Host only. SSH+tmux into VM.
flow remote list # List configured targets.
flow dev create <name> -i <image> # VM only. Create+start container.
flow dev attach <name> # Attach to container tmux session.
flow dev exec <name> [cmd...] # Run command in container.
flow dev enter <name> # Interactive shell in container.
flow dev list # List dev containers.
flow dev stop <name> # Stop container.
flow dev rm <name> # Remove container.
flow dev respawn <name> # Respawn tmux panes.
flow dotfiles init --repo <url> # 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 <package> # 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 <profile> # Show profile plan.
flow packages install [name...] [--profile p] [--dry-run]
flow packages list [--all] # List packages.
flow packages remove <name...> [--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=<name>` 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`.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),
},
),
),
)
)

View File

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