Clean action runtime project state
This commit is contained in:
14
.github/workflows/test.yml
vendored
14
.github/workflows/test.yml
vendored
@@ -17,11 +17,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: make deps
|
run: make deps
|
||||||
|
|
||||||
- name: Run unit tests
|
- 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:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -33,6 +38,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: make deps
|
run: make deps
|
||||||
|
|
||||||
@@ -42,4 +52,4 @@ jobs:
|
|||||||
- name: Run Docker-backed e2e tests
|
- name: Run Docker-backed e2e tests
|
||||||
env:
|
env:
|
||||||
FLOW_RUN_E2E: "1"
|
FLOW_RUN_E2E: "1"
|
||||||
run: .venv/bin/python -m pytest tests/e2e/ -v
|
run: uv run pytest tests/e2e/ -v
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,11 +1,21 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
|
*.egg
|
||||||
|
*.whl
|
||||||
|
*.tar.gz
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
*.spec
|
*.spec
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.claude/
|
.claude/
|
||||||
.venv/
|
.venv/
|
||||||
|
|||||||
42
CLAUDE.md
42
CLAUDE.md
@@ -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.
|
|
||||||
83
Makefile
83
Makefile
@@ -1,7 +1,4 @@
|
|||||||
PYTHON ?= python3
|
UV ?= uv
|
||||||
VENV_DIR ?= .venv
|
|
||||||
VENV_BIN := $(VENV_DIR)/bin
|
|
||||||
VENV_PYTHON := $(VENV_BIN)/python
|
|
||||||
SRC_DIR := $(CURDIR)/src
|
SRC_DIR := $(CURDIR)/src
|
||||||
ENTRYPOINT := src/flow/__main__.py
|
ENTRYPOINT := src/flow/__main__.py
|
||||||
DIST_DIR := dist
|
DIST_DIR := dist
|
||||||
@@ -10,64 +7,50 @@ SPEC_FILE := flow.spec
|
|||||||
BINARY := $(DIST_DIR)/flow
|
BINARY := $(DIST_DIR)/flow
|
||||||
INSTALL_DIR ?= $(HOME)/.local/bin
|
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:
|
help:
|
||||||
@printf "Targets:\n"
|
@printf "Targets:\n"
|
||||||
@printf " make deps Create .venv and install build+dev dependencies\n"
|
@printf " make deps Sync locked dev/build dependencies into .venv\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 test Run unit tests\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 test-e2e Run Docker/Podman e2e tests (FLOW_RUN_E2E=1)\n"
|
||||||
@printf " make clean Remove build artifacts\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:
|
deps:
|
||||||
@set -eu; \
|
$(UV) sync --locked --extra build --extra dev
|
||||||
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]"
|
|
||||||
|
|
||||||
build: deps
|
test:
|
||||||
@set -eu; \
|
$(UV) run pytest tests/ -q --ignore=tests/e2e
|
||||||
. "$(VENV_BIN)/activate"; \
|
|
||||||
python -m PyInstaller --noconfirm --clean --onefile --name flow --paths "$(SRC_DIR)" "$(ENTRYPOINT)"
|
|
||||||
|
|
||||||
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)"
|
mkdir -p "$(INSTALL_DIR)"
|
||||||
install -m 755 "$(BINARY)" "$(INSTALL_DIR)/flow"
|
install -m 755 "$(BINARY)" "$(INSTALL_DIR)/flow"
|
||||||
@printf "Installed flow to $(INSTALL_DIR)/flow\n"
|
@printf "Installed flow to $(INSTALL_DIR)/flow\n"
|
||||||
|
|
||||||
install: deps build install-local
|
|
||||||
|
|
||||||
check-binary:
|
check-binary:
|
||||||
"./$(BINARY)" --help
|
"$(BINARY)" --help
|
||||||
|
|
||||||
test:
|
|
||||||
$(VENV_PYTHON) -m pytest tests/ -q
|
|
||||||
|
|
||||||
test-e2e:
|
|
||||||
FLOW_RUN_E2E=1 $(VENV_PYTHON) -m pytest tests/e2e/ -v
|
|
||||||
|
|
||||||
clean:
|
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
|
||||||
|
|||||||
244
README.md
244
README.md
@@ -1,27 +1,58 @@
|
|||||||
# flow
|
# 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
|
```bash
|
||||||
make build && make install-local # installs to ~/.local/bin/flow
|
uv sync --locked --extra dev --extra build
|
||||||
flow setup run linux-work # bootstrap a machine
|
uv run flow --help
|
||||||
flow dotfiles link # symlink dotfiles
|
uv run flow --version
|
||||||
flow dev create api -i tm0/node # spin up a dev container
|
|
||||||
flow dev attach api # attach via tmux
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
# Dotfiles
|
# Dotfiles
|
||||||
|
flow dotfiles init [--repo URL]
|
||||||
flow dotfiles link [--profile NAME] [--dry-run] [--skip PKG...]
|
flow dotfiles link [--profile NAME] [--dry-run] [--skip PKG...]
|
||||||
flow dotfiles unlink [PACKAGES...] [--dry-run]
|
flow dotfiles unlink [PACKAGES...] [--dry-run]
|
||||||
flow dotfiles status [PACKAGES...]
|
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 list
|
||||||
flow dotfiles repos status [--repo NAME]
|
flow dotfiles repos status [--repo NAME]
|
||||||
flow dotfiles repos pull [--repo NAME] [--dry-run]
|
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 remove NAMES... [--dry-run]
|
||||||
flow packages list [--all]
|
flow packages list [--all]
|
||||||
|
|
||||||
# Bootstrap
|
# Setup/bootstrap
|
||||||
flow setup run [PROFILE|--profile NAME] [--dry-run] [--var KEY=VALUE]
|
|
||||||
flow setup show PROFILE # preview profile steps
|
|
||||||
flow setup list
|
flow setup list
|
||||||
|
flow setup show PROFILE
|
||||||
|
flow setup run [PROFILE|--profile NAME] [--dry-run] [--var KEY=VALUE]
|
||||||
|
|
||||||
# Remote targets
|
# Remote targets
|
||||||
flow remote enter TARGET [--dry-run] # ssh + tmux into a remote target
|
|
||||||
flow remote list
|
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)
|
# Dev containers
|
||||||
flow dev create NAME -i IMAGE [-p PROJECT] [--dry-run]
|
flow dev create NAME --image IMAGE [--project PATH] [--dry-run]
|
||||||
flow dev attach NAME # tmux session into container
|
flow dev attach NAME
|
||||||
flow dev exec NAME [-- CMD...] # run a command in container
|
flow dev exec NAME [CMD...]
|
||||||
flow dev enter NAME # interactive shell
|
flow dev enter NAME
|
||||||
flow dev stop NAME [--kill]
|
flow dev stop NAME [--kill]
|
||||||
flow dev remove NAME [-f]
|
flow dev remove NAME [--force]
|
||||||
flow dev respawn NAME # restart all tmux panes
|
flow dev respawn NAME
|
||||||
flow dev list
|
flow dev list
|
||||||
|
|
||||||
# Projects
|
# Projects
|
||||||
flow projects check [--fetch] # scan ~/projects for dirty repos
|
flow projects check [--fetch]
|
||||||
flow projects fetch # fetch all project remotes
|
flow projects fetch
|
||||||
flow projects summary # quick overview
|
flow projects summary
|
||||||
|
flow sync
|
||||||
|
|
||||||
# Shell completion
|
# Shell completion
|
||||||
flow completion zsh # print zsh completion script
|
flow completion zsh
|
||||||
flow completion install-zsh # install to ~/.zsh/completions
|
flow completion install-zsh [--dir DIR] [--rc FILE] [--no-rc]
|
||||||
|
|
||||||
# Global flags
|
# Global flags
|
||||||
flow --version
|
flow --version
|
||||||
flow --quiet # suppress info output
|
flow --quiet
|
||||||
flow --no-color # disable colored output
|
flow --no-color
|
||||||
```
|
```
|
||||||
|
|
||||||
### Aliases
|
Aliases:
|
||||||
|
|
||||||
- `dotfiles` -> `dot`
|
- `dotfiles` -> `dot`
|
||||||
- `dotfiles repos` -> `dotfiles repo`
|
- `dotfiles repos` -> `dotfiles repo`
|
||||||
@@ -79,12 +112,21 @@ flow --no-color # disable colored output
|
|||||||
|
|
||||||
## Configuration
|
## 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
|
```yaml
|
||||||
repository:
|
repository:
|
||||||
url: git@github.com:you/dotfiles.git
|
url: git@github.com:you/dotfiles.git
|
||||||
branch: main
|
branch: main
|
||||||
|
pull-before-edit: true
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
projects: ~/projects
|
projects: ~/projects
|
||||||
@@ -93,61 +135,17 @@ defaults:
|
|||||||
container-runtime: auto # auto | docker | podman | podman-rootful
|
container-runtime: auto # auto | docker | podman | podman-rootful
|
||||||
container-registry: registry.example.com
|
container-registry: registry.example.com
|
||||||
container-tag: latest
|
container-tag: latest
|
||||||
tmux-session: main
|
tmux-session: default
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
personal@orb: personal.orb
|
personal@orb: personal.orb
|
||||||
work@ec2:
|
work@ec2:
|
||||||
host: work.ec2.internal
|
host: work.internal
|
||||||
identity: ~/.ssh/id_work
|
identity: ~/.ssh/id_work
|
||||||
```
|
```
|
||||||
|
|
||||||
### Container runtime
|
Packages and setup profiles are YAML manifest data loaded from the same config
|
||||||
|
directories:
|
||||||
`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.
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
packages:
|
packages:
|
||||||
@@ -163,45 +161,95 @@ packages:
|
|||||||
version: "0.10.4"
|
version: "0.10.4"
|
||||||
platform-map:
|
platform-map:
|
||||||
linux-x64: nvim-linux-x86_64.tar.gz
|
linux-x64: nvim-linux-x86_64.tar.gz
|
||||||
|
install:
|
||||||
|
bin: [bin/nvim]
|
||||||
|
|
||||||
profiles:
|
profiles:
|
||||||
linux-work:
|
linux-work:
|
||||||
os: linux
|
os: linux
|
||||||
hostname: dev-box
|
|
||||||
locale: en_US.UTF-8
|
|
||||||
shell: zsh
|
shell: zsh
|
||||||
packages:
|
packages:
|
||||||
- fd
|
- fd
|
||||||
- binary/neovim
|
- binary/neovim
|
||||||
ssh-keys:
|
|
||||||
- path: ~/.ssh/id_ed25519
|
|
||||||
type: ed25519
|
|
||||||
runcmd:
|
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
|
## Architecture
|
||||||
|
|
||||||
Flow uses an action-centered runtime:
|
Flow uses a single execution boundary:
|
||||||
|
|
||||||
- **cli** parses Typer command arguments and calls app use-cases.
|
```text
|
||||||
- **app** resolves config/state, builds `ActionPlan` objects for executor-managed work, and keeps only explicit interactive boundaries outside the executor.
|
cli -> app -> domain -> actions -> adapters
|
||||||
- **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.
|
|
||||||
|
|
||||||
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
|
Action audit records are append-only JSONL files under the relevant state
|
||||||
- `_root/` dotfile paths require sudo for linking
|
directory, normally `~/.local/state/flow/actions.jsonl`.
|
||||||
- Package post-install hooks run without sudo by default
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make deps # create .venv + install deps
|
make deps # uv sync --locked --extra build --extra dev
|
||||||
.venv/bin/python -m pytest tests/ -v --ignore=tests/e2e
|
make test # unit tests, excluding e2e
|
||||||
FLOW_RUN_E2E=1 .venv/bin/python -m pytest tests/e2e/ -v # requires docker or podman
|
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
124
docs/architecture.md
Normal 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.
|
||||||
@@ -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.
|
|
||||||
@@ -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
@@ -1,102 +1,109 @@
|
|||||||
# Example working scenario
|
# Example Dotfiles Repository
|
||||||
|
|
||||||
This folder contains a complete dotfiles + setup configuration for the current `flow` schema.
|
`example/dotfiles-repo` is a complete fixture for the current flow schema. It
|
||||||
|
contains shared dotfiles, profile-specific dotfiles, package definitions, setup
|
||||||
## What this example shows
|
profiles, root-targeted files, templates, and shell hooks.
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `dotfiles-repo/_shared/flow/.config/flow/config.yaml`
|
```text
|
||||||
- `dotfiles-repo/_shared/flow/.config/flow/packages.yaml`
|
dotfiles-repo/
|
||||||
- `dotfiles-repo/_shared/flow/.config/flow/profiles.yaml`
|
_shared/
|
||||||
- `dotfiles-repo/_shared/...`
|
bin/.local/bin/flow-hello
|
||||||
- `dotfiles-repo/linux-auto/...`
|
flow/.config/flow/config.yaml
|
||||||
- `dotfiles-repo/macos-dev/...`
|
flow/.config/flow/packages.yaml
|
||||||
|
flow/.config/flow/profiles.yaml
|
||||||
## Quick start
|
git/.gitconfig
|
||||||
|
nvim/.config/nvim/init.lua
|
||||||
Use the absolute path to this local example repo:
|
system/_root/etc/hostname
|
||||||
|
system/_root/usr/local/bin/custom-script.sh
|
||||||
```bash
|
tmux/.tmux.conf
|
||||||
EXAMPLE_REPO="/ABSOLUTE/PATH/TO/flow-cli/example/dotfiles-repo"
|
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
|
```bash
|
||||||
flow dotfiles init --repo "$EXAMPLE_REPO"
|
DEMO="$(mktemp -d)"
|
||||||
flow dotfiles link --profile linux-auto
|
mkdir -p "$DEMO/home"
|
||||||
flow dotfiles status
|
cp -a example/dotfiles-repo "$DEMO/dotfiles-src"
|
||||||
flow dotfiles unlink # remove all managed symlinks
|
git -C "$DEMO/dotfiles-src" init -q -b main
|
||||||
flow dotfiles unlink git tmux # remove only specific packages
|
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
|
```bash
|
||||||
flow dotfiles repos list
|
HOME="$DEMO/home" \
|
||||||
flow dotfiles repos status
|
XDG_CONFIG_HOME="$DEMO/config" \
|
||||||
flow dotfiles repos pull
|
XDG_DATA_HOME="$DEMO/data" \
|
||||||
flow dotfiles repos push
|
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
|
```bash
|
||||||
flow dotfiles edit git --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
|
||||||
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 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
|
```bash
|
||||||
flow setup list
|
rm -rf "$DEMO"
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`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):
|
A package directory may mount a separate git repo by adding `_module.yaml` at
|
||||||
|
the package root:
|
||||||
```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:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# example/dotfiles-repo/nvim/_module.yaml (not committed -- format reference only)
|
source: github:your-org/nvim-config
|
||||||
source: github:your-org/nvim-config # or a full git URL
|
|
||||||
ref:
|
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
|
This fixture does not include a real module because that would make the example
|
||||||
module and `flow dotfiles link --profile linux-auto` will link its contents.
|
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.
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ build-backend = "hatchling.build"
|
|||||||
[project]
|
[project]
|
||||||
name = "flow"
|
name = "flow"
|
||||||
dynamic = ["version"]
|
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"
|
requires-python = ">=3.9"
|
||||||
dependencies = ["pyyaml>=6.0", "rich>=13.7", "typer>=0.12"]
|
dependencies = ["pyyaml>=6.0", "rich>=13.7", "typer>=0.12"]
|
||||||
|
|
||||||
|
|||||||
46
src/flow/adapters/packages.py
Normal file
46
src/flow/adapters/packages.py
Normal 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]
|
||||||
@@ -72,15 +72,3 @@ class TmuxClient:
|
|||||||
if os.environ.get("TMUX"):
|
if os.environ.get("TMUX"):
|
||||||
os.execvp("tmux", ["tmux", "switch-client", "-t", session])
|
os.execvp("tmux", ["tmux", "switch-client", "-t", session])
|
||||||
os.execvp("tmux", ["tmux", "attach", "-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
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
from flow.actions import ActionExecutor, ActionPlan, DomainAction, PrimitiveAction
|
||||||
from flow.core.config import FlowContext
|
from flow.core.config import FlowContext
|
||||||
from flow.core import paths
|
from flow.core import paths
|
||||||
from flow.core.errors import FlowError
|
from flow.core.errors import FlowError
|
||||||
@@ -343,6 +344,56 @@ def _complete_completion(before: Sequence[str], current: str) -> list[str]:
|
|||||||
return []
|
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:
|
def _zsh_script_text() -> str:
|
||||||
return r'''#compdef flow
|
return r'''#compdef flow
|
||||||
|
|
||||||
|
|||||||
@@ -108,11 +108,11 @@ class ContainerService:
|
|||||||
raise FlowError(f"Container {cname} not running")
|
raise FlowError(f"Container {cname} not running")
|
||||||
|
|
||||||
if command:
|
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)
|
raise SystemExit(rc)
|
||||||
|
|
||||||
for shell in (["zsh", "-l"], ["bash", "-l"], ["sh"]):
|
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",
|
cname, shell, interactive=True, detach_keys="ctrl-q,ctrl-p",
|
||||||
)
|
)
|
||||||
if rc not in (126, 127):
|
if rc not in (126, 127):
|
||||||
@@ -276,3 +276,32 @@ class ContainerService:
|
|||||||
rows.append([name, image, project or "-", status])
|
rows.append([name, image, project or "-", status])
|
||||||
|
|
||||||
self.ctx.console.table(["NAME", "IMAGE", "PROJECT", "STATUS"], rows)
|
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
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ from flow.domain.packages.models import (
|
|||||||
from flow.domain.packages.planning import plan_install, plan_remove
|
from flow.domain.packages.planning import plan_install, plan_remove
|
||||||
from flow.domain.packages.resolution import (
|
from flow.domain.packages.resolution import (
|
||||||
binary_template_context,
|
binary_template_context,
|
||||||
|
resolve_extract_dir,
|
||||||
|
resolve_spec,
|
||||||
|
)
|
||||||
|
from flow.adapters.packages import (
|
||||||
detect_package_manager,
|
detect_package_manager,
|
||||||
pm_cask_install_argv,
|
pm_cask_install_argv,
|
||||||
pm_install_argv,
|
pm_install_argv,
|
||||||
pm_update_argv,
|
pm_update_argv,
|
||||||
resolve_extract_dir,
|
|
||||||
resolve_spec,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -169,76 +171,6 @@ class PackageService:
|
|||||||
**binary_template_context(pkg, self.ctx.platform.platform),
|
**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:
|
def _install_destination(self, section: str) -> Path:
|
||||||
destinations = {
|
destinations = {
|
||||||
"bin": paths.HOME / ".local" / "bin",
|
"bin": paths.HOME / ".local" / "bin",
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from flow import __version__
|
from flow import __version__
|
||||||
from flow.actions import ActionExecutor, ActionPlan, DomainAction, PrimitiveAction
|
|
||||||
from flow.core import paths
|
from flow.core import paths
|
||||||
from flow.core.config import FlowContext, load_config, load_manifest
|
from flow.core.config import FlowContext, load_config, load_manifest
|
||||||
from flow.core.console import Console
|
from flow.core.console import Console
|
||||||
@@ -495,43 +493,13 @@ def completion_install_zsh(
|
|||||||
no_rc: bool = typer.Option(False, "--no-rc"),
|
no_rc: bool = typer.Option(False, "--no-rc"),
|
||||||
) -> None:
|
) -> None:
|
||||||
def _install(flow_ctx: FlowContext) -> 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 = install_zsh_completion(
|
||||||
completion_file = completions_dir / "_flow"
|
flow_ctx,
|
||||||
primitives: list[PrimitiveAction] = [
|
directory=directory,
|
||||||
PrimitiveAction(
|
rc=rc,
|
||||||
id="completion.zsh.write-script",
|
update_rc=not no_rc,
|
||||||
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)},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
flow_ctx.console.success(f"Installed completion script: {completion_file}")
|
flow_ctx.console.success(f"Installed completion script: {completion_file}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Package resolution: resolving what to install and how."""
|
"""Package resolution: resolving what to install and how."""
|
||||||
|
|
||||||
import shutil
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from flow.core.template import substitute_template
|
from flow.core.template import substitute_template
|
||||||
@@ -135,73 +134,3 @@ def platform_lookup_keys(platform_str: str) -> list[str]:
|
|||||||
if os_name == "macos":
|
if os_name == "macos":
|
||||||
keys.append("darwin-amd64")
|
keys.append("darwin-amd64")
|
||||||
return keys
|
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]
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from typing import Optional
|
|||||||
|
|
||||||
from flow.core.config import TargetConfig
|
from flow.core.config import TargetConfig
|
||||||
from flow.core.errors import FlowError
|
from flow.core.errors import FlowError
|
||||||
from flow.adapters.tmux import build_new_session_argv
|
|
||||||
from flow.domain.remote.models import SSHCommand, Target
|
from flow.domain.remote.models import SSHCommand, Target
|
||||||
|
|
||||||
|
|
||||||
@@ -96,7 +95,7 @@ def build_ssh_command(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if not no_tmux:
|
if not no_tmux:
|
||||||
argv.extend(build_new_session_argv(
|
argv.extend(build_remote_tmux_argv(
|
||||||
tmux_session,
|
tmux_session,
|
||||||
env=env,
|
env=env,
|
||||||
))
|
))
|
||||||
@@ -117,6 +116,18 @@ def build_destination(user: str, host: str) -> str:
|
|||||||
return f"{user}@{host}"
|
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]:
|
def list_targets(targets: list[TargetConfig]) -> list[Target]:
|
||||||
"""Convert config targets to domain targets."""
|
"""Convert config targets to domain targets."""
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import sys
|
|||||||
|
|
||||||
import pytest
|
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.config import AppConfig, FlowContext
|
||||||
from flow.core.console import Console
|
from flow.core.console import Console
|
||||||
from flow.core.errors import FlowError
|
from flow.core.errors import FlowError
|
||||||
from flow.core.platform import PlatformInfo
|
from flow.core.platform import PlatformInfo
|
||||||
from flow.core.runtime import SystemRuntime
|
from flow.core.runtime import SystemRuntime
|
||||||
|
from tests.fakes import FakeRunner
|
||||||
|
|
||||||
|
|
||||||
def _ctx() -> FlowContext:
|
def _ctx() -> FlowContext:
|
||||||
@@ -138,3 +141,72 @@ def test_barrier_prevents_rollback_across_external_boundary(tmp_path):
|
|||||||
|
|
||||||
assert target.is_symlink()
|
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
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import subprocess
|
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.config import AppConfig, FlowContext
|
||||||
from flow.core.console import Console
|
from flow.core.console import Console
|
||||||
from flow.core.platform import PlatformInfo
|
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]
|
ctx.runtime.containers.ps = fake_ps # type: ignore[method-assign]
|
||||||
result = complete(ctx, ["flow", "dev", "attach", ""], 3)
|
result = complete(ctx, ["flow", "dev", "attach", ""], 3)
|
||||||
assert result == []
|
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()
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import subprocess
|
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
|
from tests.fakes import FakeRunner
|
||||||
|
|
||||||
@@ -78,11 +79,11 @@ class TestTmuxClient:
|
|||||||
|
|
||||||
class TestBuildNewSessionArgv:
|
class TestBuildNewSessionArgv:
|
||||||
def test_basic(self):
|
def test_basic(self):
|
||||||
argv = build_new_session_argv("default")
|
argv = build_remote_tmux_argv("default")
|
||||||
assert argv == ["tmux", "new-session", "-As", "default"]
|
assert argv == ["tmux", "new-session", "-As", "default"]
|
||||||
|
|
||||||
def test_with_env(self):
|
def test_with_env(self):
|
||||||
argv = build_new_session_argv(
|
argv = build_remote_tmux_argv(
|
||||||
"main",
|
"main",
|
||||||
env={"DF_NAMESPACE": "personal", "DF_PLATFORM": "orb"},
|
env={"DF_NAMESPACE": "personal", "DF_PLATFORM": "orb"},
|
||||||
)
|
)
|
||||||
@@ -93,5 +94,5 @@ class TestBuildNewSessionArgv:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def test_empty_env(self):
|
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"]
|
assert argv == ["tmux", "new-session", "-As", "sess"]
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""Tests for packages catalog and resolution."""
|
"""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.catalog import normalize_profile_entry, parse_catalog
|
||||||
from flow.domain.packages.planning import plan_install
|
from flow.domain.packages.planning import plan_install
|
||||||
from flow.domain.packages.resolution import (
|
from flow.domain.packages.resolution import (
|
||||||
detect_package_manager,
|
|
||||||
pm_cask_install_command,
|
|
||||||
pm_install_command,
|
|
||||||
pm_update_command,
|
|
||||||
resolve_binary_asset,
|
resolve_binary_asset,
|
||||||
resolve_download_url,
|
resolve_download_url,
|
||||||
resolve_extract_dir,
|
resolve_extract_dir,
|
||||||
@@ -210,24 +212,23 @@ class TestResolveDownloadUrl:
|
|||||||
|
|
||||||
class TestPmCommands:
|
class TestPmCommands:
|
||||||
def test_apt_update(self):
|
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):
|
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):
|
def test_brew_install(self):
|
||||||
cmd = pm_install_command("brew", ["fd", "rg"])
|
assert pm_install_argv("brew", ["fd", "rg"]) == ["brew", "install", "fd", "rg"]
|
||||||
assert "brew install" in cmd
|
|
||||||
assert "fd" in cmd
|
|
||||||
|
|
||||||
def test_apt_install(self):
|
def test_apt_install(self):
|
||||||
cmd = pm_install_command("apt", ["fd-find"])
|
assert pm_install_argv("apt", ["fd-find"]) == [
|
||||||
assert "apt-get install" in cmd
|
"sudo", "apt-get", "install", "-y", "-qq", "fd-find",
|
||||||
|
]
|
||||||
|
|
||||||
def test_brew_cask_install(self):
|
def test_brew_cask_install(self):
|
||||||
cmd = pm_cask_install_command("brew", ["wezterm"])
|
assert pm_cask_install_argv("brew", ["wezterm"]) == [
|
||||||
assert "--cask" in cmd
|
"brew", "install", "--cask", "wezterm",
|
||||||
assert "wezterm" in cmd
|
]
|
||||||
|
|
||||||
def test_detect_package_manager_returns_something(self):
|
def test_detect_package_manager_returns_something(self):
|
||||||
# Just verify it doesn't error
|
# Just verify it doesn't error
|
||||||
|
|||||||
@@ -66,3 +66,17 @@ class TestContainerService:
|
|||||||
svc = ContainerService(ctx)
|
svc = ContainerService(ctx)
|
||||||
svc.remove("api")
|
svc.remove("api")
|
||||||
assert any("docker" in str(c) and "rm" in str(c) for c in runner.calls)
|
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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from flow.actions import ActionExecutor, ActionPlan, PrimitiveAction, RollbackPolicy
|
||||||
from flow.core.config import AppConfig, FlowContext
|
from flow.core.config import AppConfig, FlowContext
|
||||||
from flow.core.console import Console
|
from flow.core.console import Console
|
||||||
from flow.core.errors import FlowError
|
from flow.core.errors import FlowError
|
||||||
@@ -135,26 +136,7 @@ class TestPackageService:
|
|||||||
|
|
||||||
def test_post_install_with_sudo_runs_unchecked(self, tmp_path, monkeypatch):
|
def test_post_install_with_sudo_runs_unchecked(self, tmp_path, monkeypatch):
|
||||||
"""No allow_sudo gate -- post-install scripts run as written."""
|
"""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 = _make_ctx(tmp_path)
|
||||||
ctx.runtime.runner = _Runner()
|
|
||||||
svc = PackageService(ctx)
|
svc = PackageService(ctx)
|
||||||
pkg = PackageDef(
|
pkg = PackageDef(
|
||||||
name="docker", type="pkg", sources={},
|
name="docker", type="pkg", sources={},
|
||||||
@@ -162,8 +144,11 @@ class TestPackageService:
|
|||||||
platform_map={}, extract_dir=None, install={},
|
platform_map={}, extract_dir=None, install={},
|
||||||
post_install="sudo groupadd docker || true",
|
post_install="sudo groupadd docker || true",
|
||||||
)
|
)
|
||||||
svc._run_post_install(pkg)
|
primitive = svc._post_install_primitive(pkg)
|
||||||
assert calls == ["sudo groupadd docker || true"]
|
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):
|
def test_install_binary_url_failure_raises_flow_error(self, tmp_path, monkeypatch):
|
||||||
home = tmp_path / "home"
|
home = tmp_path / "home"
|
||||||
@@ -226,11 +211,21 @@ class TestPackageService:
|
|||||||
link = extract_root / "evil"
|
link = extract_root / "evil"
|
||||||
link.symlink_to(sibling)
|
link.symlink_to(sibling)
|
||||||
|
|
||||||
with pytest.raises(FlowError, match="escapes extract-dir"):
|
with pytest.raises(FlowError, match="escapes allowed root"):
|
||||||
svc._copy_install_item(
|
ActionExecutor(ctx, audit_path=tmp_path / "actions.jsonl").execute(
|
||||||
"pkg",
|
ActionPlan(
|
||||||
extract_root,
|
name="copy-escape",
|
||||||
extract_root.resolve(),
|
primitive_actions=(
|
||||||
"bin",
|
PrimitiveAction(
|
||||||
"evil/escape",
|
id="copy",
|
||||||
|
type="file.copy",
|
||||||
|
description="Copy escaped source",
|
||||||
|
payload={
|
||||||
|
"source": link / "escape",
|
||||||
|
"target": tmp_path / "target",
|
||||||
|
"source_root": extract_root.resolve(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ MUTATING_PATTERNS = (
|
|||||||
COMMAND_PATTERNS = (
|
COMMAND_PATTERNS = (
|
||||||
re.compile(r"runtime\.runner\.(run|run_shell)\("),
|
re.compile(r"runtime\.runner\.(run|run_shell)\("),
|
||||||
re.compile(r"runtime\.git\.run\("),
|
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"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 = (
|
ALLOWED_PREFIXES = (
|
||||||
|
|||||||
Reference in New Issue
Block a user