Complete action runtime rewrite

This commit is contained in:
2026-05-14 13:40:11 +03:00
parent 3503d81b06
commit b05d3589b7
41 changed files with 700 additions and 899 deletions

View File

@@ -20,5 +20,26 @@ jobs:
- name: Install dependencies
run: make deps
- name: Run tests
- name: Run unit tests
run: .venv/bin/python -m pytest tests/ -v --ignore=tests/e2e
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
run: make deps
- name: Verify Docker
run: docker version
- name: Run Docker-backed e2e tests
env:
FLOW_RUN_E2E: "1"
run: .venv/bin/python -m pytest tests/e2e/ -v

View File

@@ -29,11 +29,11 @@ flow dotfiles repos push [--repo NAME] [--dry-run]
# Packages
flow packages install [NAMES...] [--profile NAME] [--dry-run]
flow packages remove NAMES...
flow packages remove NAMES... [--dry-run]
flow packages list [--all]
# Bootstrap
flow setup run PROFILE [--dry-run] # run a full bootstrap profile
flow setup run [PROFILE|--profile NAME] [--dry-run] [--var KEY=VALUE]
flow setup show PROFILE # preview profile steps
flow setup list
@@ -71,8 +71,9 @@ flow --no-color # disable colored output
- `dotfiles` -> `dot`
- `dotfiles repos` -> `dotfiles repo`
- `packages` -> `package`, `pkg`
- `projects` -> `project`, `sync` (with `--fetch` default)
- `setup` -> `bootstrap`
- `projects` -> `project`; `flow sync` -> `flow projects check --fetch`
- `setup` -> `bootstrap`, `provision`
- `remote enter` -> `enter`
- `dev attach` -> `dev connect`
- `dev remove` -> `dev rm`
@@ -181,9 +182,15 @@ profiles:
## Architecture
Four layers: **core** (runtime primitives, config, errors) -> **domain** (pure functions, frozen dataclasses) -> **services** (I/O orchestration) -> **commands** (thin CLI adapters).
Flow uses an action-centered runtime:
Core primitives (`SystemRuntime`): `CommandRunner`, `FileSystem`, `GitClient`, `TmuxClient`, `ContainerRuntime`.
- **cli** parses Typer command arguments and calls app use-cases.
- **app** resolves config/state, builds `ActionPlan` objects for executor-managed work, and keeps only explicit interactive boundaries outside the executor.
- **domain** modules keep planning and resolution logic pure with frozen dataclasses.
- **actions** are the execution boundary: `DomainAction` records domain intent, expansion converts it to `PrimitiveAction`, and `ActionExecutor` handles dry-run rendering, audit logging, rollback, and dispatch.
- **adapters** provide runtime primitives through `SystemRuntime`: `CommandRunner`, `FileSystem`, `GitClient`, `TmuxClient`, `ContainerRuntime`, download, and archive adapters.
Action audit records are appended to `actions.jsonl` under the relevant flow state directory.
## Security
@@ -195,5 +202,6 @@ Core primitives (`SystemRuntime`): `CommandRunner`, `FileSystem`, `GitClient`, `
```bash
make deps # create .venv + install deps
.venv/bin/python -m pytest tests/ -v # run tests
.venv/bin/python -m pytest tests/ -v --ignore=tests/e2e
FLOW_RUN_E2E=1 .venv/bin/python -m pytest tests/e2e/ -v # requires docker or podman
```

View File

@@ -1,23 +1,23 @@
# Flow CLI Refactor Plan
# Flow CLI Refactor Status
> Based on code review (2026-03-22) and architecture discussion.
> 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 rewrite from the original "vibe-coded" codebase is **largely complete**. The four-layer
architecture (core -> domain -> services -> commands) is in place, 303 tests pass, and the major
structural problems from the old codebase (duplicated code, monkeypatching, dead modules, singleton
abuse) have been resolved.
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.
What remains is a second pass: finishing incomplete features, unifying the repo abstraction, and
trimming redundant commands.
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.
---
## Agreed Command Surface
## Command Surface
From the architecture discussion. This is the target.
This is the implemented command surface.
```
flow remote enter <target> # Host only. SSH+tmux into VM.
@@ -40,16 +40,16 @@ 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] # Pull one or all repos.
flow dotfiles repos push [--repo=x] # Push one or all repos.
flow dotfiles repos pull [--repo=x] [--dry-run]
flow dotfiles repos push [--repo=x] [--dry-run]
flow setup run [--profile p] # Bootstrap a machine.
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...> # Install packages from manifest.
flow packages install [name...] [--profile p] [--dry-run]
flow packages list [--all] # List packages.
flow packages remove <name...> # Remove packages.
flow packages remove <name...> [--dry-run]
flow projects check [--fetch] # VM only. Git health across ~/projects.
flow projects fetch # Fetch all project remotes.
@@ -60,8 +60,9 @@ flow projects summary # Quick status overview.
- `dotfiles` -> `dot`
- `packages` -> `package`, `pkg`
- `projects` -> `project`
- `setup` -> `bootstrap`
- `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`
@@ -72,7 +73,7 @@ flow projects summary # Quick status overview.
- `--quiet` / `-q`
- `--no-color`
### Commands removed (vs current implementation)
### Commands Removed During Refactor
| Removed | Reason |
|---------|--------|
@@ -83,7 +84,31 @@ flow projects summary # Quick status overview.
| `dotfiles modules list` | Replaced by `dotfiles repos list` |
| `dotfiles modules sync` | Replaced by `dotfiles repos pull` |
### Key design decision: modules are repos
## 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.
@@ -97,185 +122,59 @@ needed (dotfiles repo is named `dotfiles`, module repos are named by their packa
---
## 1. Unified Repos Abstraction
## Completed Work
This is the most impactful change. Currently:
- `repo_status/pull/push` operate only on the dotfiles repo
- `sync_modules` handles module repos separately
- `list_modules` is a standalone method
### Unified Repos Abstraction
**Target**: a single `_discover_repos() -> list[RepoInfo]` that returns all managed repos, and
repo commands iterate over them.
`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.
### 1.1 Add `RepoInfo` model
### Command Trimming
```python
# domain/dotfiles/models.py
@dataclass(frozen=True)
class RepoInfo:
name: str # "dotfiles" or module package name (e.g. "nvim")
path: Path # Local clone path
remote: str # Remote URL
is_module: bool # False for dotfiles repo, True for module repos
```
Removed redundant dotfiles commands:
### 1.2 Implement `_discover_repos`
- `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`
In `DotfilesService`: walk packages to find `_module.yaml` files, build `RepoInfo` for each
module repo, plus one for the dotfiles repo itself.
### Feature Completion
### 1.3 Refactor repo commands
- `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.
Replace `repo_status`, `repo_pull`, `repo_push` with methods that iterate `_discover_repos()`,
filtered by `--repo`. Add `repos_list`.
### 1.4 Remove `dotfiles modules` subcommand group
Delete `modules list` and `modules sync` subparsers. Remove `sync_modules`, `list_modules`
methods. Remove from completion candidates.
### 1.5 Update `dotfiles init`
`init` should clone the dotfiles repo, then discover `_module.yaml` files and clone all module
repos. Currently it calls `sync_modules()` -- this should call `repos_pull()` instead (which
pulls/clones all repos).
**Files**: `models.py`, `services/dotfiles.py`, `commands/dotfiles.py`, `commands/completion.py`.
---
## 2. Remove Redundant Commands
### 2.1 Remove `dotfiles sync`
Currently does `git pull --ff-only` + `sync_modules` + optional relink. After the repos
unification, this is just `repos pull` + `link`. No need for a dedicated command.
### 2.2 Remove `dotfiles relink`
Currently calls `link()`. `link` is already idempotent -- calling it again reconciles state.
### 2.3 Remove `dotfiles undo`
`unlink` is the inverse of `link`. The backup/undo transaction machinery (`_save_backup`,
`_load_backup`, `_backup_path`) can be deleted.
### 2.4 Fold `dotfiles clean` into `dotfiles link`
`link` should detect and remove broken symlinks as part of reconciliation, not require a separate
`clean` step. Modify `plan_link` in `domain/dotfiles/planning.py` to include broken link removal
in its plan.
**Files**: `services/dotfiles.py`, `commands/dotfiles.py`, `commands/completion.py`,
`domain/dotfiles/planning.py`.
---
## 3. Previously Incomplete Features -- DONE
### 3.1 `dotfiles edit` -- DONE
Implemented: pull -> `$EDITOR`/`$VISUAL` -> scoped `git add` -> commit+push.
Flag: `--no-commit` to skip auto-commit/push.
### 3.2 `dotfiles status` -- DONE
Enhanced: shows module info (`branch:main`), link health (`ok`/`broken`/`not linked`),
package name filtering via positional args.
### 3.3 `dotfiles repos list` -- DONE
Shows all managed repos (dotfiles + modules) with: name, type, local path, clone status.
---
## 4. Spec Deviations
### 4.1 `--no-color` global flag -- DONE
Added to `cli.py`.
### 4.2 `--dry-run` coverage -- DONE for dotfiles
`repos pull` and `repos push` now have `--dry-run`. Remaining:
| Command | Has it | Should have |
|---------|--------|-------------|
| `dev stop` | No | Consider |
| `dev rm` | No | Consider |
### 4.3 Improvements over spec
### Improvements Over Spec
These are correct deviations -- the implementation improved on the spec:
- `core/containers.py` + `core/tmux.py` extracted as adapters (spec had them inline)
- `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` and `.tmux` fields
- `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
---
## 5. Code Quality (done)
## CI
These were fixed in this session:
The GitHub Actions workflow is split into two jobs:
- `FakeRunner` consolidated from 3 test files into `tests/fakes.py`
- `services/dotfiles.py` now uses `flow.core.yaml.load_yaml_file` instead of raw `yaml`
- `ContainerRuntime.binary` no longer eagerly validates PATH for explicit modes
- `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`
---
## 6. Future (defer)
## Optional Future Work
### 6.1 Bootstrap as orchestrator
These are optional refinements, not blockers for the action-centered rewrite.
The spec envisions bootstrap as a pure orchestrator over packages + dotfiles + setup modules.
Current implementation works but has its own package resolution logic. Defer until dotfiles and
packages domains are fully stable.
### 6.2 Global `--dry-run`
### Global `--dry-run`
If per-command `--dry-run` becomes a maintenance burden, promote to a global flag in `cli.py`.
---
## 7. Execution Status
All phases complete. 315 tests pass, 0 failures.
### Phase A: Unify repos + trim commands -- DONE
1. `RepoInfo` model with `module_ref` field
2. `_discover_repos()` finds dotfiles + module repos
3. `repos_list/status/pull/push` iterate all repos with `--repo` filter
4. `repos list` subcommand added
5. `dotfiles modules` subcommand removed entirely
6. `dotfiles sync`, `relink`, `undo`, `clean` removed
7. Broken-symlink handling folded into `plan_link`
8. `dotfiles init` uses unified `repos_pull()`
### Phase B: Complete features -- DONE
9. `dotfiles edit` implemented (pull -> $EDITOR -> commit+push, scoped git add)
10. `dotfiles status` enhanced (module info, link health, package filtering)
### Phase C: CLI polish -- DONE
11. `--no-color` global flag added to `cli.py`
12. `--dry-run` added to `repos pull` and `repos push`
13. Zsh completion updated for new command surface
### Phase D: Code quality -- DONE
14. Dispatch patterns: completion `complete()`, dotfiles `_git_checkout_ref`, bootstrap phases
15. `FakeRunner` consolidated to single `tests/fakes.py` (all 4 test files)
16. Bootstrap `VALID_PHASES` as single source of truth in models
17. Bootstrap models: `Any` types replaced with `ProfilePackageEntry` and `PackageDef`
18. Canonical `ssh-keys` field (removed `ssh-keygen` alias)
19. `getattr` defensive patterns removed from command handlers
20. Test coverage added: `repos_status`, `repos_push`, `repos_pull --dry-run`, status filtering,
broken symlink repair
21. README updated to reflect new command surface
### Remaining (deferred)
- Bootstrap as orchestrator (section 6.1)
- Global `--dry-run` (section 6.2)

View File

@@ -52,8 +52,10 @@ class ActionExecutor:
self.audit.write("action_start", {"plan": plan.name, "action": action})
try:
rollback = self._rollback_for(action)
self._execute_primitive(action)
results.append(ActionResult(action.id, action.type, "success"))
primitive_result = self._execute_primitive(action)
if primitive_result is None:
primitive_result = ActionResult(action.id, action.type, "success")
results.append(primitive_result)
self.audit.write(
"action_success",
{"plan": plan.name, "action": action},
@@ -116,7 +118,7 @@ class ActionExecutor:
)
return tuple(results)
def _execute_primitive(self, action: PrimitiveAction) -> None:
def _execute_primitive(self, action: PrimitiveAction) -> ActionResult | None:
t = action.type
p = action.payload
@@ -193,24 +195,45 @@ class ActionExecutor:
if not msg:
msg = f"Command failed with exit code {completed.returncode}"
raise FlowError(msg)
return
return ActionResult(
action.id,
action.type,
"success",
stdout=completed.stdout,
stderr=completed.stderr,
returncode=completed.returncode,
)
if t == "process.shell_user_hook":
self.ctx.runtime.runner.run_shell(
completed = self.ctx.runtime.runner.run_shell(
str(p["command"]),
cwd=Path(p["cwd"]) if p.get("cwd") else None,
env=p.get("env"),
capture_output=bool(p.get("capture_output", True)),
check=True,
)
return
return ActionResult(
action.id,
action.type,
"success",
stdout=completed.stdout,
stderr=completed.stderr,
returncode=completed.returncode,
)
if t == "git.clone":
argv = ["git", "clone"]
if p.get("branch"):
argv.extend(["-b", str(p["branch"])])
argv.extend([str(p["source"]), str(p["target"])])
self.ctx.runtime.runner.run(argv, check=True)
return
completed = self.ctx.runtime.runner.run(argv, check=True)
return ActionResult(
action.id,
action.type,
"success",
stdout=completed.stdout,
stderr=completed.stderr,
returncode=completed.returncode,
)
if t in {"git.pull", "git.push", "git.fetch", "git.checkout", "git.status"}:
repo = Path(p["repo"])
args = tuple(str(arg) for arg in p.get("args", ()))
@@ -222,8 +245,15 @@ class ActionExecutor:
args = args or ("fetch", "--all")
elif t == "git.status":
args = args or ("status", "--short", "--branch")
self.ctx.runtime.git.run(repo, *args, check=True)
return
completed = self.ctx.runtime.git.run(repo, *args, check=True)
return ActionResult(
action.id,
action.type,
"success",
stdout=completed.stdout,
stderr=completed.stderr,
returncode=completed.returncode,
)
if t == "download.file":
self.ctx.runtime.download.download_file(
@@ -252,13 +282,13 @@ class ActionExecutor:
self.ctx.runtime.containers.rm(str(p["name"]), force=bool(p.get("force", False)))
return
if t == "container.exec":
self.ctx.runtime.containers.exec_in(
returncode = self.ctx.runtime.containers.exec_in(
str(p["name"]),
tuple(str(arg) for arg in p["argv"]),
interactive=bool(p.get("interactive", False)),
detach_keys=p.get("detach_keys"),
)
return
return ActionResult(action.id, action.type, "success", returncode=returncode)
if t == "tmux.new_session":
self.ctx.runtime.tmux.new_session(

View File

@@ -54,6 +54,9 @@ class ActionResult:
action_type: str
status: str
message: str = ""
stdout: str = ""
stderr: str = ""
returncode: int = 0
@dataclass(frozen=True)

View File

@@ -2,7 +2,8 @@
from __future__ import annotations
import shutil
import tarfile
import zipfile
from pathlib import Path
from flow.adapters.filesystem import FileSystem
@@ -16,7 +17,42 @@ class ArchiveClient:
def extract(self, archive: Path, target: Path) -> None:
self.fs.ensure_dir(target)
try:
shutil.unpack_archive(str(archive), str(target))
except (shutil.ReadError, ValueError) as e:
if tarfile.is_tarfile(archive):
self._extract_tar(archive, target)
return
if zipfile.is_zipfile(archive):
self._extract_zip(archive, target)
return
except (tarfile.TarError, zipfile.BadZipFile, OSError) as e:
raise FlowError(f"Could not extract archive {archive}: {e}") from e
raise FlowError(f"Unsupported archive format: {archive}")
def _extract_tar(self, archive: Path, target: Path) -> None:
with tarfile.open(archive) as tar:
for member in tar.getmembers():
self._validate_member_path(target, member.name)
if not member.isfile() and not member.isdir():
raise FlowError(
f"Archive member type is not supported: {member.name}"
)
try:
tar.extractall(target, filter="data")
except TypeError:
tar.extractall(target)
def _extract_zip(self, archive: Path, target: Path) -> None:
with zipfile.ZipFile(archive) as zf:
for member in zf.infolist():
self._validate_member_path(target, member.filename)
zf.extractall(target)
def _validate_member_path(self, target: Path, name: str) -> None:
member_path = Path(name)
if member_path.is_absolute():
raise FlowError(f"Archive member uses an absolute path: {name}")
if any(part == ".." for part in member_path.parts):
raise FlowError(f"Archive member escapes destination: {name}")
destination = (target / member_path).resolve(strict=False)
root = target.resolve(strict=False)
if not destination.is_relative_to(root):
raise FlowError(f"Archive member escapes destination: {name}")

2
src/flow/app/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Application use-cases for CLI commands."""

View File

@@ -1,4 +1,3 @@
# src/flow/services/bootstrap.py
"""BootstrapService -- orchestrates system setup."""
from __future__ import annotations
@@ -86,14 +85,14 @@ class BootstrapService:
without a handler here surfaces loudly at runtime.
"""
if action.phase == "packages":
from flow.services.packages import PackageService
from flow.app.packages import PackageService
pkg_svc = PackageService(self.ctx)
if plan.packages_to_install:
pkg_svc.install(list(plan.packages_to_install))
return
if action.phase == "dotfiles":
from flow.services.dotfiles import DotfilesService
from flow.app.dotfiles import DotfilesService
dot_svc = DotfilesService(self.ctx)
dot_svc.link(profile=dotfiles_profile)
return

View File

@@ -1,8 +1,7 @@
"""Shell completion support."""
"""Shell completion helpers."""
from __future__ import annotations
import argparse
import subprocess
from pathlib import Path
from typing import Sequence
@@ -28,27 +27,6 @@ TOP_LEVEL_COMMANDS = [
]
def register(subparsers):
parser = subparsers.add_parser("completion", help="Shell completion helpers")
sub = parser.add_subparsers(dest="completion_action")
zsh = sub.add_parser("zsh", help="Print the zsh completion script")
zsh.set_defaults(handler=_run_zsh_script)
install = sub.add_parser("install-zsh", help="Install zsh completion")
install.add_argument("--dir", default="~/.zsh/completions")
install.add_argument("--rc", default="~/.zshrc")
install.add_argument("--no-rc", action="store_true")
install.set_defaults(handler=_run_install_zsh)
hidden = sub.add_parser("_zsh_complete", help=argparse.SUPPRESS)
hidden.add_argument("--cword", type=int, required=True, help=argparse.SUPPRESS)
hidden.add_argument("words", nargs="*", help=argparse.SUPPRESS)
hidden.set_defaults(handler=_run_zsh_complete)
parser.set_defaults(handler=_run_zsh_script)
def complete(ctx: FlowContext, words: Sequence[str], cword: int) -> list[str]:
before, current = _split_words(words, cword)
@@ -365,11 +343,6 @@ def _complete_completion(before: Sequence[str], current: str) -> list[str]:
return []
def _run_zsh_complete(ctx, args):
for item in complete(ctx, args.words, args.cword):
print(item)
def _zsh_script_text() -> str:
return r'''#compdef flow
@@ -394,47 +367,15 @@ compdef _flow flow
'''
def _run_zsh_script(_ctx, _args):
print(_zsh_script_text())
def _run_install_zsh(_ctx, args):
completions_dir = Path(args.dir).expanduser()
completions_dir.mkdir(parents=True, exist_ok=True)
completion_file = completions_dir / "_flow"
completion_file.write_text(_zsh_script_text(), encoding="utf-8")
print(f"Installed completion script: {completion_file}")
if args.no_rc:
print("Skipped rc file update (--no-rc)")
return
rc_path = Path(args.rc).expanduser()
changed = _ensure_rc_snippet(rc_path, completions_dir)
if changed:
print(f"Updated shell rc: {rc_path}")
else:
print(f"Shell rc already configured: {rc_path}")
def _ensure_rc_snippet(rc_path: Path, completions_dir: Path) -> bool:
def render_zsh_rc_update(content: str, completions_dir: Path) -> str:
snippet = _zsh_rc_snippet(completions_dir)
content = rc_path.read_text(encoding="utf-8") if rc_path.exists() else ""
if ZSH_RC_START in content and ZSH_RC_END in content:
start = content.find(ZSH_RC_START)
end = content.find(ZSH_RC_END, start) + len(ZSH_RC_END)
updated = content[:start] + snippet.rstrip("\n") + content[end:]
if updated == content:
return False
rc_path.write_text(updated, encoding="utf-8")
return True
return content[:start] + snippet.rstrip("\n") + content[end:]
separator = "" if content.endswith("\n") or not content else "\n"
rc_path.parent.mkdir(parents=True, exist_ok=True)
rc_path.write_text(content + separator + snippet, encoding="utf-8")
return True
return content + separator + snippet
def _zsh_rc_snippet(completions_dir: Path) -> str:

View File

@@ -127,7 +127,20 @@ class ContainerService:
if not self.rt.container_exists(cname):
raise FlowError(f"Container does not exist: {cname}")
if not self.rt.container_running(cname):
self.rt.start(cname)
ActionExecutor(self.ctx).execute(
ActionPlan(
name=f"container.start.{cname}",
primitive_actions=(
PrimitiveAction(
id=f"container.{cname}.start",
type="container.start",
description=f"Start container {cname}",
payload={"name": cname},
rollback_policy=RollbackPolicy.NONE,
),
),
)
)
if not shutil.which("tmux"):
self.ctx.console.warn("tmux not found; falling back to direct exec")
@@ -138,13 +151,36 @@ class ContainerService:
image_ref = parse_image_ref(image_str)
if not self.tmux.has_session(cname):
self.tmux.new_session(
cname,
detached=True,
env={"DF_IMAGE": image_ref.label},
command=f"flow dev exec {name}",
ActionExecutor(self.ctx).execute(
ActionPlan(
name=f"tmux.session.{cname}",
primitive_actions=(
PrimitiveAction(
id=f"tmux.{cname}.new-session",
type="tmux.new_session",
description=f"Create tmux session {cname}",
payload={
"name": cname,
"detached": True,
"env": {"DF_IMAGE": image_ref.label},
"command": f"flow dev exec {name}",
},
rollback_policy=RollbackPolicy.NONE,
),
PrimitiveAction(
id=f"tmux.{cname}.default-command",
type="tmux.set_option",
description=f"Set default command for {cname}",
payload={
"session": cname,
"option": "default-command",
"value": f"flow dev exec {name}",
},
rollback_policy=RollbackPolicy.NONE,
),
),
)
)
self.tmux.set_option(cname, "default-command", f"flow dev exec {name}")
self.tmux.attach_or_switch(cname)
@@ -201,9 +237,24 @@ class ContainerService:
raise FlowError("tmux is required for respawn but was not found")
cname = container_name(name)
for pane in self.tmux.list_panes(cname):
panes = self.tmux.list_panes(cname)
for pane in panes:
self.ctx.console.info(f"Respawning {pane}...")
self.tmux.respawn_pane(pane)
ActionExecutor(self.ctx).execute(
ActionPlan(
name=f"tmux.respawn.{cname}",
primitive_actions=tuple(
PrimitiveAction(
id=f"tmux.{cname}.respawn.{index}",
type="tmux.respawn_pane",
description=f"Respawn tmux pane {pane}",
payload={"pane": pane},
rollback_policy=RollbackPolicy.NONE,
)
for index, pane in enumerate(panes)
),
)
)
def list(self) -> None:
"""List flow-managed containers."""

View File

@@ -203,29 +203,84 @@ class DotfilesService:
# Open editor
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR", "vi")
edit_dir = repo.path if repo.is_module else pkg.source_dir
result = self.ctx.runtime.runner.run(
[editor, str(edit_dir)], capture_output=False,
self._executor().execute(
ActionPlan(
name=f"dotfiles.edit.{package_name}",
primitive_actions=(
PrimitiveAction(
id=f"dotfiles.edit.{package_name}.editor",
type="process.argv",
description=f"Open editor for {package_name}",
payload={
"argv": (editor, str(edit_dir)),
"capture_output": False,
},
rollback_policy=RollbackPolicy.BARRIER,
),
),
)
)
if result.returncode != 0:
raise FlowError(f"Editor exited with code {result.returncode}")
if no_commit:
return
# Check for changes and auto-commit+push
status = self.ctx.runtime.git.run(
repo.path, "status", "--porcelain", check=True,
status_summary = self._executor().execute(
ActionPlan(
name=f"dotfiles.edit.{package_name}.status",
domain_actions=(
DomainAction(
id=f"repo.{repo.name}.status",
kind="repo",
action="status",
description=f"Check changes in {repo.name}",
payload={"repo": repo.path, "args": ("status", "--porcelain")},
rollback_policy=RollbackPolicy.NONE,
),
),
)
if not status.stdout.strip():
)
if not status_summary.results[0].stdout.strip():
self.ctx.console.info("No changes.")
return
self.ctx.runtime.git.run(repo.path, "add", str(edit_dir), check=True)
self.ctx.runtime.git.run(
repo.path, "commit", "-m", f"dotfiles: update {package_name}",
check=True,
self._executor().execute(
ActionPlan(
name=f"dotfiles.edit.{package_name}.commit",
primitive_actions=(
PrimitiveAction(
id=f"dotfiles.edit.{package_name}.add",
type="process.argv",
description=f"Stage changes for {package_name}",
payload={"argv": ("git", "-C", str(repo.path), "add", str(edit_dir))},
rollback_policy=RollbackPolicy.BARRIER,
),
PrimitiveAction(
id=f"dotfiles.edit.{package_name}.commit",
type="process.argv",
description=f"Commit changes for {package_name}",
payload={
"argv": (
"git",
"-C",
str(repo.path),
"commit",
"-m",
f"dotfiles: update {package_name}",
)
},
rollback_policy=RollbackPolicy.BARRIER,
),
PrimitiveAction(
id=f"dotfiles.edit.{package_name}.push",
type="git.push",
description=f"Push changes for {package_name}",
payload={"repo": repo.path, "args": ("push",)},
rollback_policy=RollbackPolicy.NONE,
),
),
)
)
self.ctx.runtime.git.run(repo.path, "push", check=True)
self.ctx.console.success(f"Changes to {package_name} committed and pushed.")
# ── Init ─────────────────────────────────────────────────────────────
@@ -291,10 +346,25 @@ class DotfilesService:
self.ctx.console.warn(f"{repo.name}: not cloned")
continue
self.ctx.console.info(f"[{repo.name}]")
result = self.ctx.runtime.git.run(
repo.path, "status", "--short", "--branch", check=True,
summary = self._executor().execute(
ActionPlan(
name=f"repo.status.{repo.name}",
domain_actions=(
DomainAction(
id=f"repo.{repo.name}.status",
kind="repo",
action="status",
description=f"Show git status for {repo.name}",
payload={
"repo": repo.path,
"args": ("status", "--short", "--branch"),
},
rollback_policy=RollbackPolicy.NONE,
),
),
)
output = result.stdout.strip()
)
output = summary.results[0].stdout.strip()
if output:
self.ctx.console.info(output)
else:
@@ -481,27 +551,6 @@ class DotfilesService:
primitive_actions=tuple(primitives),
)
def _pull_module_repo(self, repo: RepoInfo) -> None:
"""Pull a module repo, respecting its ref type."""
module = repo.module_ref
if module is None:
self.ctx.runtime.git.run(repo.path, "pull", "--ff-only", check=True)
return
self.ctx.runtime.git.run(repo.path, "fetch", "--all", check=True)
ref = _git_checkout_ref(module)
self.ctx.runtime.git.run(repo.path, "checkout", ref, check=True)
if module.ref_type == "branch":
self.ctx.runtime.git.run(repo.path, "pull", "--ff-only", check=True)
def _checkout_module_ref(self, repo: RepoInfo) -> None:
"""Checkout the correct ref after cloning a module repo."""
module = repo.module_ref
if module is None:
return
ref = _git_checkout_ref(module)
self.ctx.runtime.git.run(repo.path, "checkout", ref, check=True)
def _find_package_repo(self, package_name: str) -> tuple[Package, RepoInfo]:
"""Find a package and its owning repo."""
repos = self._discover_repos()

139
src/flow/app/projects.py Normal file
View File

@@ -0,0 +1,139 @@
"""ProjectService -- manages git project status."""
from __future__ import annotations
from pathlib import Path
from flow.actions import ActionExecutor, ActionPlan, DomainAction, RollbackPolicy
from flow.core.config import FlowContext
from flow.core.errors import FlowError
class ProjectService:
def __init__(self, ctx: FlowContext):
self.ctx = ctx
self.projects_dir = Path(self.ctx.config.projects_dir).expanduser()
def check(self, *, fetch: bool = False) -> None:
"""Check status of all git repos in projects dir."""
if not self.projects_dir.is_dir():
self.ctx.console.info(f"Projects directory not found: {self.projects_dir}")
return
repos = self._find_repos()
if not repos:
self.ctx.console.info("No git repositories found.")
return
if fetch:
self.ctx.console.info("Fetching all remotes...")
self._executor().execute(
self._repo_action_plan(
name="projects.fetch",
repos=repos,
action="fetch",
args=("fetch", "--all", "--quiet"),
description_prefix="Fetch remotes for",
)
)
rows = []
for repo in repos:
status = self._repo_status(repo)
rows.append([repo.name, status])
self.ctx.console.table(["REPO", "STATUS"], rows)
def summary(self) -> None:
"""Quick summary without fetch."""
self.check(fetch=False)
def fetch(self) -> None:
"""Fetch all remotes then show status."""
self.check(fetch=True)
def _find_repos(self) -> list[Path]:
"""Find all git repos in projects dir (immediate children only)."""
repos = []
for child in sorted(self.projects_dir.iterdir()):
if not child.is_dir():
continue
# Check for .git dir or .git file (worktree)
git_path = child / ".git"
if git_path.exists():
repos.append(child)
return repos
def _repo_status(self, repo: Path) -> str:
"""Get human-readable status for a repo."""
parts = []
status_result = self._executor().execute(
self._repo_action_plan(
name=f"projects.status.{repo.name}",
repos=[repo],
action="status",
args=("status", "--porcelain"),
description_prefix="Check working tree for",
)
).results[0]
if status_result.stdout.strip():
parts.append("uncommitted changes")
# Check ahead/behind
rev_result = self._executor().execute(
self._repo_action_plan(
name=f"projects.rev-list.{repo.name}",
repos=[repo],
action="status",
args=("rev-list", "--left-right", "--count", "HEAD...@{u}"),
description_prefix="Check git rev-list upstream divergence for",
)
).results[0]
counts = rev_result.stdout.strip().split()
if len(counts) != 2:
raise FlowError(
f"{repo.name}: unexpected git rev-list output: {rev_result.stdout.strip()!r}"
)
try:
ahead, behind = int(counts[0]), int(counts[1])
except ValueError as e:
raise FlowError(
f"{repo.name}: unexpected git rev-list output: {rev_result.stdout.strip()!r}"
) from e
if ahead > 0:
parts.append(f"{ahead} ahead")
if behind > 0:
parts.append(f"{behind} behind")
if not parts:
parts.append("clean")
return ", ".join(parts)
def _repo_action_plan(
self,
*,
name: str,
repos: list[Path],
action: str,
args: tuple[str, ...],
description_prefix: str,
) -> ActionPlan:
return ActionPlan(
name=name,
domain_actions=tuple(
DomainAction(
id=f"projects.{repo.name}.{action}",
kind="repo",
action=action,
description=f"{description_prefix} {repo.name}",
payload={"repo": repo, "args": args},
rollback_policy=RollbackPolicy.NONE,
)
for repo in repos
),
)
def _executor(self) -> ActionExecutor:
return ActionExecutor(self.ctx)

View File

@@ -17,12 +17,12 @@ from flow.core.console import Console
from flow.core.errors import FlowError
from flow.core.platform import detect_context, detect_platform
from flow.core.runtime import SystemRuntime
from flow.services.bootstrap import BootstrapService
from flow.services.containers import ContainerService
from flow.services.dotfiles import DotfilesService
from flow.services.packages import PackageService
from flow.services.projects import ProjectService
from flow.services.remote import RemoteService
from flow.app.bootstrap import BootstrapService
from flow.app.containers import ContainerService
from flow.app.dotfiles import DotfilesService
from flow.app.packages import PackageService
from flow.app.projects import ProjectService
from flow.app.remote import RemoteService
app = typer.Typer(
@@ -475,14 +475,14 @@ def sync_alias(
@completion_app.callback(invoke_without_command=True)
def completion_default(ctx: typer.Context) -> None:
if ctx.invoked_subcommand is None:
from flow.commands.completion import _zsh_script_text
from flow.app.completion import _zsh_script_text
typer.echo(_zsh_script_text(), nl=False)
@completion_app.command("zsh")
def completion_zsh() -> None:
from flow.commands.completion import _zsh_script_text
from flow.app.completion import _zsh_script_text
typer.echo(_zsh_script_text(), nl=False)
@@ -495,7 +495,7 @@ def completion_install_zsh(
no_rc: bool = typer.Option(False, "--no-rc"),
) -> None:
def _install(flow_ctx: FlowContext) -> None:
from flow.commands.completion import _zsh_rc_snippet, _zsh_script_text
from flow.app.completion import render_zsh_rc_update, _zsh_script_text
completions_dir = Path(directory).expanduser()
completion_file = completions_dir / "_flow"
@@ -510,16 +510,7 @@ def completion_install_zsh(
if not no_rc:
rc_path = Path(rc).expanduser()
content = rc_path.read_text(encoding="utf-8") if rc_path.exists() else ""
snippet = _zsh_rc_snippet(completions_dir)
start_marker = "# >>> flow completion >>>"
end_marker = "# <<< flow completion <<<"
if start_marker in content and end_marker in content:
start = content.find(start_marker)
end = content.find(end_marker, start) + len(end_marker)
updated = content[:start] + snippet.rstrip("\n") + content[end:]
else:
separator = "" if content.endswith("\n") or not content else "\n"
updated = content + separator + snippet
updated = render_zsh_rc_update(content, completions_dir)
primitives.append(
PrimitiveAction(
id="completion.zsh.write-rc",
@@ -553,7 +544,7 @@ def completion_zsh_complete(
cword: int = typer.Option(..., "--cword", help="Completion word index"),
words: Optional[list[str]] = typer.Argument(None),
) -> None:
from flow.commands.completion import complete
from flow.app.completion import complete
for item in complete(_ctx(ctx), words or [], cword):
typer.echo(item)

View File

@@ -1,91 +0,0 @@
"""Dev container commands."""
from __future__ import annotations
from flow.core.config import FlowContext
from flow.services.containers import ContainerService
def register(subparsers):
p = subparsers.add_parser("dev", help="Manage development containers")
sub = p.add_subparsers(dest="dev_action")
create = sub.add_parser("create", help="Create and start a development container")
create.add_argument("name", help="Container name")
create.add_argument("-i", "--image", required=True, help="Container image")
create.add_argument("-p", "--project", help="Project path to mount at /workspace")
create.add_argument("--dry-run", action="store_true")
create.set_defaults(handler=_create)
attach = sub.add_parser("attach", aliases=["connect"], help="Attach to the container tmux session")
attach.add_argument("name", help="Container name")
attach.set_defaults(handler=_attach)
exec_cmd = sub.add_parser("exec", help="Execute a command in a container")
exec_cmd.add_argument("name", help="Container name")
exec_cmd.add_argument("cmd", nargs="*", help="Command to run")
exec_cmd.set_defaults(handler=_exec)
enter = sub.add_parser("enter", help="Open an interactive shell in a container")
enter.add_argument("name", help="Container name")
enter.set_defaults(handler=_enter)
stop = sub.add_parser("stop", help="Stop a container")
stop.add_argument("name", help="Container name")
stop.add_argument("--kill", action="store_true", help="Kill instead of graceful stop")
stop.set_defaults(handler=_stop)
rm = sub.add_parser("remove", aliases=["rm"], help="Remove a container")
rm.add_argument("name", help="Container name")
rm.add_argument("-f", "--force", action="store_true", help="Force removal")
rm.set_defaults(handler=_remove)
respawn = sub.add_parser("respawn", help="Respawn tmux panes for a session")
respawn.add_argument("name", help="Container name")
respawn.set_defaults(handler=_respawn)
ls = sub.add_parser("list", help="List development containers")
ls.set_defaults(handler=_list)
p.set_defaults(handler=_default)
def _default(ctx: FlowContext, args):
_list(ctx, args)
def _create(ctx: FlowContext, args):
ContainerService(ctx).create(
args.name,
args.image,
project_path=args.project,
dry_run=args.dry_run,
)
def _attach(ctx: FlowContext, args):
ContainerService(ctx).connect(args.name)
def _exec(ctx: FlowContext, args):
ContainerService(ctx).exec(args.name, args.cmd or None)
def _enter(ctx: FlowContext, args):
ContainerService(ctx).exec(args.name)
def _stop(ctx: FlowContext, args):
ContainerService(ctx).stop(args.name, kill=args.kill)
def _remove(ctx: FlowContext, args):
ContainerService(ctx).remove(args.name, force=args.force)
def _respawn(ctx: FlowContext, args):
ContainerService(ctx).respawn(args.name)
def _list(ctx: FlowContext, args):
ContainerService(ctx).list()

View File

@@ -1,115 +0,0 @@
"""Dotfiles commands."""
from __future__ import annotations
from flow.core.config import FlowContext
from flow.services.dotfiles import DotfilesService
def register(subparsers):
p = subparsers.add_parser("dotfiles", aliases=["dot"], help="Manage dotfile symlinks")
sub = p.add_subparsers(dest="dotfiles_action")
init = sub.add_parser("init", help="Clone the dotfiles repository")
init.add_argument("--repo", help="Override the configured repository URL")
init.set_defaults(handler=_init)
link = sub.add_parser("link", help="Reconcile dotfile symlinks")
link.add_argument("--profile", help="Profile to include")
link.add_argument("--dry-run", "-n", action="store_true")
link.add_argument("--skip", nargs="*", default=[])
link.set_defaults(handler=_link)
unlink = sub.add_parser("unlink", help="Remove managed symlinks")
unlink.add_argument("packages", nargs="*", help="Packages to unlink (all if empty)")
unlink.add_argument("--dry-run", "-n", action="store_true")
unlink.set_defaults(handler=_unlink)
status = sub.add_parser("status", help="Show package and link status")
status.add_argument("packages", nargs="*", help="Filter by package name")
status.set_defaults(handler=_status)
edit = sub.add_parser("edit", help="Edit a package (pull -> editor -> commit+push)")
edit.add_argument("package", help="Package name")
edit.add_argument("--no-commit", action="store_true", help="Skip auto-commit/push")
edit.set_defaults(handler=_edit)
# repos subcommand group (unified: dotfiles repo + module repos)
repo = sub.add_parser("repos", aliases=["repo"], help="Manage dotfiles and module repos")
repo_sub = repo.add_subparsers(dest="dotfiles_repo_action")
repo_list = repo_sub.add_parser("list", help="List all managed repos")
repo_list.set_defaults(handler=_repos_list)
repo_status = repo_sub.add_parser("status", help="Show git status")
repo_status.add_argument("--repo", dest="repo_filter", help="Filter by repo name")
repo_status.set_defaults(handler=_repos_status)
repo_pull = repo_sub.add_parser("pull", help="Pull (or clone) repos")
repo_pull.add_argument("--repo", dest="repo_filter", help="Filter by repo name")
repo_pull.add_argument("--dry-run", "-n", action="store_true")
repo_pull.set_defaults(handler=_repos_pull)
repo_push = repo_sub.add_parser("push", help="Push repos")
repo_push.add_argument("--repo", dest="repo_filter", help="Filter by repo name")
repo_push.add_argument("--dry-run", "-n", action="store_true")
repo_push.set_defaults(handler=_repos_push)
repo.set_defaults(handler=_repos_list)
p.set_defaults(handler=_default)
def _default(ctx: FlowContext, args):
DotfilesService(ctx).status()
def _init(ctx: FlowContext, args):
DotfilesService(ctx).init(repo_url=args.repo)
def _link(ctx: FlowContext, args):
DotfilesService(ctx).link(
profile=args.profile,
dry_run=args.dry_run,
skip=set(args.skip) if args.skip else None,
)
def _unlink(ctx: FlowContext, args):
DotfilesService(ctx).unlink(
packages=args.packages if args.packages else None,
dry_run=args.dry_run,
)
def _status(ctx: FlowContext, args):
DotfilesService(ctx).status(
package_filter=args.packages if args.packages else None,
)
def _edit(ctx: FlowContext, args):
DotfilesService(ctx).edit(args.package, no_commit=args.no_commit)
def _repos_list(ctx: FlowContext, args):
DotfilesService(ctx).repos_list()
def _repos_status(ctx: FlowContext, args):
DotfilesService(ctx).repos_status(repo_filter=args.repo_filter)
def _repos_pull(ctx: FlowContext, args):
DotfilesService(ctx).repos_pull(
repo_filter=args.repo_filter,
dry_run=args.dry_run,
)
def _repos_push(ctx: FlowContext, args):
DotfilesService(ctx).repos_push(
repo_filter=args.repo_filter,
dry_run=args.dry_run,
)

View File

@@ -1,49 +0,0 @@
"""Package commands."""
from flow.core.config import FlowContext
from flow.services.packages import PackageService
def register(subparsers):
p = subparsers.add_parser("packages", aliases=["package", "pkg"], help="Manage packages")
sub = p.add_subparsers(dest="packages_action")
install = sub.add_parser("install", help="Install packages")
install.add_argument("packages", nargs="*")
install.add_argument("--profile", help="Install profile packages")
install.add_argument("--dry-run", "-n", action="store_true")
install.set_defaults(handler=_install)
remove = sub.add_parser("remove", help="Remove packages")
remove.add_argument("packages", nargs="+")
remove.add_argument("--dry-run", "-n", action="store_true")
remove.set_defaults(handler=_remove)
ls = sub.add_parser("list", help="List installed packages")
ls.add_argument("--all", action="store_true", help="List all known packages")
ls.set_defaults(handler=_list)
p.set_defaults(handler=_default)
def _default(ctx: FlowContext, args):
_list(ctx, args)
def _install(ctx: FlowContext, args):
svc = PackageService(ctx)
packages = svc.resolve_install_packages(
package_names=args.packages if args.packages else None,
profile=args.profile,
)
svc.install(packages, dry_run=args.dry_run)
def _remove(ctx: FlowContext, args):
svc = PackageService(ctx)
svc.remove(args.packages, dry_run=args.dry_run)
def _list(ctx: FlowContext, args):
svc = PackageService(ctx)
svc.list_packages(show_all=args.all)

View File

@@ -1,48 +0,0 @@
"""Projects commands."""
from flow.core.config import FlowContext
from flow.services.projects import ProjectService
def register(subparsers):
_register_projects_parser(subparsers, "projects", default_fetch=False, aliases=["project"])
_register_projects_parser(subparsers, "sync", default_fetch=True)
def _default(ctx: FlowContext, args):
_check(ctx, args)
def _check(ctx: FlowContext, args):
svc = ProjectService(ctx)
svc.check(fetch=args.fetch)
def _fetch(ctx: FlowContext, args):
svc = ProjectService(ctx)
svc.fetch()
def _summary(ctx: FlowContext, args):
svc = ProjectService(ctx)
svc.summary()
def _register_projects_parser(subparsers, name: str, *, default_fetch: bool, aliases=None):
parser = subparsers.add_parser(name, aliases=aliases or [], help="Manage git projects")
sub = parser.add_subparsers(dest=f"{name}_action")
check = sub.add_parser("check", help="Check project status")
check.add_argument("--fetch", dest="fetch", action="store_true", help="Fetch remotes first")
if default_fetch:
check.add_argument("--no-fetch", dest="fetch", action="store_false", help="Skip fetching remotes")
check.set_defaults(fetch=default_fetch)
check.set_defaults(handler=_check)
fetch = sub.add_parser("fetch", help="Fetch all project remotes")
fetch.set_defaults(handler=_fetch)
summary = sub.add_parser("summary", help="Show a summary without fetching")
summary.set_defaults(handler=_summary)
parser.set_defaults(handler=_default, fetch=default_fetch)

View File

@@ -1,56 +0,0 @@
"""Remote commands."""
from __future__ import annotations
from flow.core.config import FlowContext
from flow.services.remote import RemoteService
def register(subparsers):
p = subparsers.add_parser("remote", help="Manage remote targets")
sub = p.add_subparsers(dest="remote_action")
enter = sub.add_parser("enter", help="SSH into a target")
_add_enter_args(enter)
enter.set_defaults(handler=_enter)
ls = sub.add_parser("list", help="List configured targets")
ls.set_defaults(handler=_list)
p.set_defaults(handler=_default)
alias = subparsers.add_parser("enter", help="SSH into a target")
_add_enter_args(alias)
alias.set_defaults(handler=_enter)
def _default(ctx: FlowContext, args):
_list(ctx, args)
def _enter(ctx: FlowContext, args):
svc = RemoteService(ctx)
svc.enter(
args.target,
user=args.user,
namespace=args.namespace,
platform=args.platform,
session=args.session,
no_tmux=args.no_tmux,
dry_run=args.dry_run,
)
def _list(ctx: FlowContext, args):
svc = RemoteService(ctx)
svc.list()
def _add_enter_args(parser) -> None:
parser.add_argument("target", help="Target ([user@]namespace@platform)")
parser.add_argument("-u", "--user", help="SSH user override")
parser.add_argument("-n", "--namespace", help="Namespace override")
parser.add_argument("-p", "--platform", help="Platform override")
parser.add_argument("-s", "--session", help="tmux session name")
parser.add_argument("--no-tmux", action="store_true", help="Open plain SSH without tmux")
parser.add_argument("--dry-run", "-d", action="store_true")

View File

@@ -1,70 +0,0 @@
"""Setup/bootstrap commands."""
from __future__ import annotations
from flow.core.config import FlowContext
from flow.core.errors import FlowError
from flow.services.bootstrap import BootstrapService
def register(subparsers):
p = subparsers.add_parser(
"setup",
aliases=["bootstrap", "provision"],
help="Bootstrap a system profile",
)
sub = p.add_subparsers(dest="setup_action")
run = sub.add_parser("run", help="Run bootstrap for a profile")
run.add_argument("profile", nargs="?", help="Profile name")
run.add_argument("--profile", dest="profile_option", help="Profile name")
run.add_argument("--dry-run", "-n", action="store_true")
run.add_argument("--var", action="append", default=[], help="Set variable KEY=VALUE")
run.set_defaults(handler=_run)
show = sub.add_parser("show", help="Show bootstrap plan")
show.add_argument("profile", help="Profile name")
show.set_defaults(handler=_show)
ls = sub.add_parser("list", help="List available profiles")
ls.set_defaults(handler=_list)
p.set_defaults(handler=_default)
def _default(ctx: FlowContext, args):
_list(ctx, args)
def _run(ctx: FlowContext, args):
svc = BootstrapService(ctx)
env = _parse_vars(args.var)
svc.run(_profile_arg(args), dry_run=args.dry_run, env=env)
def _show(ctx: FlowContext, args):
svc = BootstrapService(ctx)
svc.show(args.profile)
def _list(ctx: FlowContext, args):
svc = BootstrapService(ctx)
svc.list_profiles()
def _profile_arg(args) -> str | None:
if args.profile and args.profile_option and args.profile != args.profile_option:
raise FlowError("Specify the profile only once.")
return args.profile or args.profile_option
def _parse_vars(items: list[str]) -> dict[str, str]:
values: dict[str, str] = {}
for item in items:
if "=" not in item:
raise FlowError(f"Invalid --var value '{item}'. Expected KEY=VALUE.")
key, value = item.split("=", 1)
if not key:
raise FlowError(f"Invalid --var value '{item}'. KEY cannot be empty.")
values[key] = value
return values

View File

@@ -13,8 +13,8 @@ from flow.adapters.download import DownloadClient
from flow.adapters.filesystem import FileSystem
from flow.adapters.git import GitClient
from flow.adapters.process import CommandRunner
from flow.core.containers import ContainerRuntime
from flow.core.tmux import TmuxClient
from flow.adapters.containers import ContainerRuntime
from flow.adapters.tmux import TmuxClient
@dataclass

View File

@@ -4,7 +4,7 @@ from typing import Optional
from flow.core.config import TargetConfig
from flow.core.errors import FlowError
from flow.core.tmux import build_new_session_argv
from flow.adapters.tmux import build_new_session_argv
from flow.domain.remote.models import SSHCommand, Target

View File

@@ -1,2 +0,0 @@
"""Domain services for CLI commands."""

View File

@@ -1,85 +0,0 @@
"""ProjectService -- manages git project status."""
from __future__ import annotations
from pathlib import Path
from flow.core.config import FlowContext
class ProjectService:
def __init__(self, ctx: FlowContext):
self.ctx = ctx
self.projects_dir = Path(self.ctx.config.projects_dir).expanduser()
def check(self, *, fetch: bool = False) -> None:
"""Check status of all git repos in projects dir."""
if not self.projects_dir.is_dir():
self.ctx.console.info(f"Projects directory not found: {self.projects_dir}")
return
repos = self._find_repos()
if not repos:
self.ctx.console.info("No git repositories found.")
return
if fetch:
self.ctx.console.info("Fetching all remotes...")
for repo in repos:
self.ctx.runtime.git.run(repo, "fetch", "--all", "--quiet")
rows = []
for repo in repos:
status = self._repo_status(repo)
rows.append([repo.name, status])
self.ctx.console.table(["REPO", "STATUS"], rows)
def summary(self) -> None:
"""Quick summary without fetch."""
self.check(fetch=False)
def fetch(self) -> None:
"""Fetch all remotes then show status."""
self.check(fetch=True)
def _find_repos(self) -> list[Path]:
"""Find all git repos in projects dir (immediate children only)."""
repos = []
for child in sorted(self.projects_dir.iterdir()):
if not child.is_dir():
continue
# Check for .git dir or .git file (worktree)
git_path = child / ".git"
if git_path.exists():
repos.append(child)
return repos
def _repo_status(self, repo: Path) -> str:
"""Get human-readable status for a repo."""
parts = []
# Check for uncommitted changes
result = self.ctx.runtime.git.run(
repo, "status", "--porcelain",
)
if result.stdout.strip():
parts.append("uncommitted changes")
# Check ahead/behind
result = self.ctx.runtime.git.run(
repo, "rev-list", "--left-right", "--count", "HEAD...@{u}",
)
if result.returncode == 0 and result.stdout.strip():
counts = result.stdout.strip().split()
if len(counts) == 2:
ahead, behind = int(counts[0]), int(counts[1])
if ahead > 0:
parts.append(f"{ahead} ahead")
if behind > 0:
parts.append(f"{behind} behind")
if not parts:
parts.append("clean")
return ", ".join(parts)

View File

@@ -22,7 +22,14 @@ IMAGE_TAG = "flow-e2e:test"
def _pick_runtime() -> str | None:
for binary in ("podman", "docker"):
if shutil.which(binary):
if not shutil.which(binary):
continue
result = subprocess.run(
[binary, "version"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return binary
return None

View File

@@ -0,0 +1,60 @@
"""Archive adapter safety tests."""
from __future__ import annotations
import io
import tarfile
import zipfile
import pytest
from flow.adapters.archive import ArchiveClient
from flow.adapters.filesystem import FileSystem
from flow.core.errors import FlowError
def test_extract_tar_uses_safe_member_paths(tmp_path):
archive = tmp_path / "ok.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
content = b"hello"
info = tarfile.TarInfo("pkg/bin/tool")
info.size = len(content)
tar.addfile(info, io.BytesIO(content))
target = tmp_path / "extract"
ArchiveClient(FileSystem()).extract(archive, target)
assert (target / "pkg" / "bin" / "tool").read_text() == "hello"
def test_rejects_tar_member_parent_traversal(tmp_path):
archive = tmp_path / "bad.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
content = b"bad"
info = tarfile.TarInfo("../escape")
info.size = len(content)
tar.addfile(info, io.BytesIO(content))
with pytest.raises(FlowError, match="escapes"):
ArchiveClient(FileSystem()).extract(archive, tmp_path / "extract")
def test_rejects_zip_member_parent_traversal(tmp_path):
archive = tmp_path / "bad.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("../escape", "bad")
with pytest.raises(FlowError, match="escapes"):
ArchiveClient(FileSystem()).extract(archive, tmp_path / "extract")
def test_rejects_tar_symlink_members(tmp_path):
archive = tmp_path / "bad-link.tar"
with tarfile.open(archive, "w") as tar:
info = tarfile.TarInfo("pkg/link")
info.type = tarfile.SYMTYPE
info.linkname = "/etc/passwd"
tar.addfile(info)
with pytest.raises(FlowError, match="member type"):
ArchiveClient(FileSystem()).extract(archive, tmp_path / "extract")

View File

@@ -2,7 +2,7 @@
import subprocess
from flow.commands.completion import complete
from flow.app.completion import complete
from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console
from flow.core.platform import PlatformInfo

View File

@@ -1,11 +1,11 @@
"""Tests for flow.core.containers."""
"""Tests for flow.adapters.containers."""
import subprocess
from pathlib import Path
import pytest
from flow.core.containers import ContainerRuntime
from flow.adapters.containers import ContainerRuntime
from flow.core.errors import FlowError
from tests.fakes import FakeRunner
@@ -53,7 +53,7 @@ class TestMode:
rootful.write_text("")
compat.write_text("")
monkeypatch.setattr(
"flow.core.containers.ContainerRuntime._socket_candidates",
"flow.adapters.containers.ContainerRuntime._socket_candidates",
lambda self: [rootful, rootless, compat],
)
rt = ContainerRuntime(FakeRunner(), mode="podman-rootful", binary="podman")
@@ -65,7 +65,7 @@ class TestMode:
rootless.write_text("")
rootful.write_text("")
monkeypatch.setattr(
"flow.core.containers.ContainerRuntime._socket_candidates",
"flow.adapters.containers.ContainerRuntime._socket_candidates",
lambda self: [rootless, rootful],
)
rt = ContainerRuntime(FakeRunner(), mode="podman", binary="podman")
@@ -77,7 +77,7 @@ class TestSocketPath:
sock = tmp_path / "docker.sock"
sock.write_text("")
monkeypatch.setattr(
"flow.core.containers.ContainerRuntime._socket_candidates",
"flow.adapters.containers.ContainerRuntime._socket_candidates",
lambda self: [sock],
)
rt = ContainerRuntime(FakeRunner(), binary="docker")
@@ -85,7 +85,7 @@ class TestSocketPath:
def test_docker_socket_missing(self, monkeypatch):
monkeypatch.setattr(
"flow.core.containers.ContainerRuntime._socket_candidates",
"flow.adapters.containers.ContainerRuntime._socket_candidates",
lambda self: [Path("/nonexistent/docker.sock")],
)
rt = ContainerRuntime(FakeRunner(), binary="docker")
@@ -97,7 +97,7 @@ class TestSocketPath:
rootless.write_text("")
rootful.write_text("")
monkeypatch.setattr(
"flow.core.containers.ContainerRuntime._socket_candidates",
"flow.adapters.containers.ContainerRuntime._socket_candidates",
lambda self: [rootless, rootful],
)
rt = ContainerRuntime(FakeRunner(), binary="podman")
@@ -107,7 +107,7 @@ class TestSocketPath:
rootful = tmp_path / "rootful.sock"
rootful.write_text("")
monkeypatch.setattr(
"flow.core.containers.ContainerRuntime._socket_candidates",
"flow.adapters.containers.ContainerRuntime._socket_candidates",
lambda self: [Path("/nonexistent"), rootful],
)
rt = ContainerRuntime(FakeRunner(), binary="podman")

View File

@@ -5,10 +5,10 @@ import os
import pytest
from flow.core.containers import ContainerRuntime
from flow.adapters.containers import ContainerRuntime
from flow.core.errors import FlowError
from flow.core.runtime import CommandRunner, FileSystem, GitClient, SystemRuntime
from flow.core.tmux import TmuxClient
from flow.adapters.tmux import TmuxClient
class TestFileSystem:

View File

@@ -1,8 +1,8 @@
"""Tests for flow.core.tmux."""
"""Tests for flow.adapters.tmux."""
import subprocess
from flow.core.tmux import TmuxClient, build_new_session_argv
from flow.adapters.tmux import TmuxClient, build_new_session_argv
from tests.fakes import FakeRunner

View File

@@ -7,7 +7,7 @@ from flow.core.console import Console
from flow.core.errors import FlowError
from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
from flow.services.bootstrap import BootstrapService
from flow.app.bootstrap import BootstrapService
def _make_ctx(manifest=None):
@@ -74,8 +74,8 @@ class TestBootstrapService:
def install(self, packages, *, dry_run=False):
captured["packages"] = packages
monkeypatch.setattr("flow.services.packages.PackageService", StubPackageService)
monkeypatch.setattr("flow.services.dotfiles.DotfilesService.link", lambda self, profile=None: None)
monkeypatch.setattr("flow.app.packages.PackageService", StubPackageService)
monkeypatch.setattr("flow.app.dotfiles.DotfilesService.link", lambda self, profile=None: None)
manifest = {
"profiles": {
@@ -116,7 +116,7 @@ class TestBootstrapService:
def test_run_uses_dotfiles_profile_override(self, monkeypatch):
captured = {}
monkeypatch.setattr("flow.services.packages.PackageService.install", lambda self, packages, dry_run=False: None)
monkeypatch.setattr("flow.app.packages.PackageService.install", lambda self, packages, dry_run=False: None)
class StubDotfilesService:
def __init__(self, ctx):
@@ -125,7 +125,7 @@ class TestBootstrapService:
def link(self, profile=None):
captured["profile"] = profile
monkeypatch.setattr("flow.services.dotfiles.DotfilesService", StubDotfilesService)
monkeypatch.setattr("flow.app.dotfiles.DotfilesService", StubDotfilesService)
manifest = {
"profiles": {

View File

@@ -4,11 +4,11 @@ import subprocess
from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console
from flow.core.containers import ContainerRuntime
from flow.adapters.containers import ContainerRuntime
from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
from flow.core import paths
from flow.services.containers import ContainerService
from flow.app.containers import ContainerService
from tests.fakes import FakeRunner

View File

@@ -13,7 +13,7 @@ from flow.core.errors import FlowError, PlanConflict
from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
from flow.core import paths
from flow.services.dotfiles import DotfilesService
from flow.app.dotfiles import DotfilesService
from tests.fakes import FakeRunner

View File

@@ -14,7 +14,7 @@ from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
from flow.core import paths
from flow.domain.packages.models import InstalledPackage, InstalledState, PackageDef
from flow.services.packages import PackageService
from flow.app.packages import PackageService
def _make_ctx(tmp_path, manifest=None):

View File

@@ -2,14 +2,18 @@
import subprocess
import pytest
from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console
from flow.core.errors import FlowError
from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
from flow.services.projects import ProjectService
from flow.app.projects import ProjectService
def _make_ctx(projects_dir):
def _make_ctx(projects_dir, monkeypatch):
monkeypatch.setattr("flow.actions.executor.paths.STATE_DIR", projects_dir / ".state")
return FlowContext(
config=AppConfig(projects_dir=str(projects_dir)),
manifest={},
@@ -19,25 +23,44 @@ def _make_ctx(projects_dir):
)
def _git(*args):
return subprocess.run(
["git", *[str(arg) for arg in args]],
capture_output=True,
text=True,
check=True,
)
def _init_repo(path, commit=True):
"""Create a git repo with an initial commit."""
path.mkdir(parents=True, exist_ok=True)
subprocess.run(["git", "init", str(path)], capture_output=True, check=True)
subprocess.run(["git", "-C", str(path), "config", "user.email", "test@test.com"], capture_output=True, check=True)
subprocess.run(["git", "-C", str(path), "config", "user.name", "Test"], capture_output=True, check=True)
_git("init", path)
_git("-C", path, "config", "user.email", "test@test.com")
_git("-C", path, "config", "user.name", "Test")
if commit:
(path / "README.md").write_text("# test")
subprocess.run(["git", "-C", str(path), "add", "."], capture_output=True, check=True)
subprocess.run(["git", "-C", str(path), "commit", "-m", "init"], capture_output=True, check=True)
_git("-C", path, "add", ".")
_git("-C", path, "commit", "-m", "init")
def _add_upstream(repo):
remote = repo.parent / f"{repo.name}.git"
_git("init", "--bare", remote)
_git("-C", repo, "remote", "add", "origin", remote)
branch = _git("-C", repo, "branch", "--show-current").stdout.strip()
_git("-C", repo, "push", "-u", "origin", branch)
class TestProjectService:
def test_check_clean_repo(self, tmp_path, capsys):
def test_check_clean_repo(self, tmp_path, monkeypatch, capsys):
projects = tmp_path / "projects"
projects.mkdir()
_init_repo(projects / "myrepo")
repo = projects / "myrepo"
_init_repo(repo)
_add_upstream(repo)
ctx = _make_ctx(projects)
ctx = _make_ctx(projects, monkeypatch)
svc = ProjectService(ctx)
svc.check(fetch=False)
@@ -45,33 +68,74 @@ class TestProjectService:
assert "myrepo" in output
assert "clean" in output
def test_check_uncommitted_changes(self, tmp_path, capsys):
def test_check_uncommitted_changes(self, tmp_path, monkeypatch, capsys):
projects = tmp_path / "projects"
projects.mkdir()
_init_repo(projects / "myrepo")
(projects / "myrepo" / "new_file.txt").write_text("changes")
repo = projects / "myrepo"
_init_repo(repo)
_add_upstream(repo)
(repo / "new_file.txt").write_text("changes")
ctx = _make_ctx(projects)
ctx = _make_ctx(projects, monkeypatch)
svc = ProjectService(ctx)
svc.check(fetch=False)
output = capsys.readouterr().out
assert "uncommitted" in output
def test_check_no_git_repos(self, tmp_path, capsys):
def test_check_no_git_repos(self, tmp_path, monkeypatch, capsys):
projects = tmp_path / "projects"
projects.mkdir()
(projects / "not-a-repo").mkdir()
ctx = _make_ctx(projects)
ctx = _make_ctx(projects, monkeypatch)
svc = ProjectService(ctx)
svc.check(fetch=False)
output = capsys.readouterr().out
assert "No git" in output
def test_missing_projects_dir(self, tmp_path, capsys):
ctx = _make_ctx(tmp_path / "nonexistent")
def test_missing_projects_dir(self, tmp_path, monkeypatch, capsys):
ctx = _make_ctx(tmp_path / "nonexistent", monkeypatch)
svc = ProjectService(ctx)
svc.check(fetch=False)
assert "not found" in capsys.readouterr().out
def test_fetch_failure_raises(self, tmp_path, monkeypatch):
projects = tmp_path / "projects"
projects.mkdir()
repo = projects / "myrepo"
_init_repo(repo)
_git("-C", repo, "remote", "add", "origin", tmp_path / "missing-origin")
ctx = _make_ctx(projects, monkeypatch)
svc = ProjectService(ctx)
with pytest.raises(FlowError, match="Fetch remotes for myrepo"):
svc.check(fetch=True)
def test_status_failure_raises(self, tmp_path, monkeypatch):
projects = tmp_path / "projects"
projects.mkdir()
repo = projects / "myrepo"
_init_repo(repo)
(repo / ".git" / "HEAD").unlink()
ctx = _make_ctx(projects, monkeypatch)
svc = ProjectService(ctx)
with pytest.raises(FlowError, match="Check working tree for myrepo"):
svc.check(fetch=False)
def test_missing_upstream_raises_instead_of_clean(self, tmp_path, monkeypatch, capsys):
projects = tmp_path / "projects"
projects.mkdir()
_init_repo(projects / "myrepo")
ctx = _make_ctx(projects, monkeypatch)
svc = ProjectService(ctx)
with pytest.raises(FlowError, match="rev-list"):
svc.check(fetch=False)
assert "clean" not in capsys.readouterr().out

View File

@@ -7,7 +7,7 @@ from flow.core.console import Console
from flow.core.errors import FlowError
from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
from flow.services.remote import RemoteService
from flow.app.remote import RemoteService
def _make_ctx(targets=None):

View File

@@ -12,6 +12,14 @@ MUTATING_PATTERNS = (
re.compile(r"runtime\.fs\.(create_symlink|remove_symlink|write_json|write_text|write_bytes|copy_file|copy_tree|remove_file|remove_tree)\("),
)
COMMAND_PATTERNS = (
re.compile(r"runtime\.runner\.(run|run_shell)\("),
re.compile(r"runtime\.git\.run\("),
re.compile(r"runtime\.containers\.(run_container|start|stop|kill|rm)\("),
re.compile(r"runtime\.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)\("),
)
ALLOWED_PREFIXES = (
Path("src/flow/adapters"),
Path("src/flow/actions"),
@@ -22,17 +30,12 @@ ALLOWED_FILES = {
Path("src/flow/core/paths.py"),
}
SKIPPED_LEGACY_COMMANDS = {
Path("src/flow/commands/completion.py"),
}
def test_no_direct_filesystem_mutation_outside_action_boundary():
root = Path(__file__).resolve().parents[1]
offenders: list[str] = []
for path in sorted((root / "src" / "flow").rglob("*.py")):
rel = path.relative_to(root)
if rel in ALLOWED_FILES or rel in SKIPPED_LEGACY_COMMANDS:
if rel in ALLOWED_FILES:
continue
if any(rel.is_relative_to(prefix) for prefix in ALLOWED_PREFIXES):
continue
@@ -43,3 +46,17 @@ def test_no_direct_filesystem_mutation_outside_action_boundary():
offenders.append(f"{rel}:{line_no}: {line.strip()}")
assert offenders == []
def test_no_direct_command_mutation_outside_action_boundary():
root = Path(__file__).resolve().parents[1]
offenders: list[str] = []
for path in sorted((root / "src" / "flow").rglob("*.py")):
rel = path.relative_to(root)
if any(rel.is_relative_to(prefix) for prefix in ALLOWED_PREFIXES):
continue
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if any(pattern.search(line) for pattern in COMMAND_PATTERNS):
offenders.append(f"{rel}:{line_no}: {line.strip()}")
assert offenders == []