diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b633a18..53fc41b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/README.md b/README.md index 3896600..be12f5f 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/refactor-plan.md b/docs/refactor-plan.md index 2ee209c..9132e1c 100644 --- a/docs/refactor-plan.md +++ b/docs/refactor-plan.md @@ -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 # Host only. SSH+tmux into VM. @@ -40,16 +40,16 @@ flow dotfiles edit # Pull -> $EDITOR -> commit+push. flow dotfiles repos list # List ALL managed repos (dotfiles + modules). flow dotfiles repos status [--repo=x] # Git status for one or all repos. -flow dotfiles repos pull [--repo=x] # 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 # Show profile plan. -flow packages install # Install packages from manifest. +flow packages install [name...] [--profile p] [--dry-run] flow packages list [--all] # List packages. -flow packages remove # Remove packages. +flow packages remove [--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) diff --git a/src/flow/actions/executor.py b/src/flow/actions/executor.py index 5e91689..952ec8a 100644 --- a/src/flow/actions/executor.py +++ b/src/flow/actions/executor.py @@ -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( diff --git a/src/flow/actions/models.py b/src/flow/actions/models.py index 2ef3946..e1bcb4c 100644 --- a/src/flow/actions/models.py +++ b/src/flow/actions/models.py @@ -54,6 +54,9 @@ class ActionResult: action_type: str status: str message: str = "" + stdout: str = "" + stderr: str = "" + returncode: int = 0 @dataclass(frozen=True) diff --git a/src/flow/adapters/archive.py b/src/flow/adapters/archive.py index b895ade..3d86af5 100644 --- a/src/flow/adapters/archive.py +++ b/src/flow/adapters/archive.py @@ -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}") diff --git a/src/flow/core/containers.py b/src/flow/adapters/containers.py similarity index 100% rename from src/flow/core/containers.py rename to src/flow/adapters/containers.py diff --git a/src/flow/core/tmux.py b/src/flow/adapters/tmux.py similarity index 100% rename from src/flow/core/tmux.py rename to src/flow/adapters/tmux.py diff --git a/src/flow/app/__init__.py b/src/flow/app/__init__.py new file mode 100644 index 0000000..4df34c0 --- /dev/null +++ b/src/flow/app/__init__.py @@ -0,0 +1,2 @@ +"""Application use-cases for CLI commands.""" + diff --git a/src/flow/services/bootstrap.py b/src/flow/app/bootstrap.py similarity index 96% rename from src/flow/services/bootstrap.py rename to src/flow/app/bootstrap.py index adb0cea..5a15224 100644 --- a/src/flow/services/bootstrap.py +++ b/src/flow/app/bootstrap.py @@ -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 diff --git a/src/flow/commands/completion.py b/src/flow/app/completion.py similarity index 84% rename from src/flow/commands/completion.py rename to src/flow/app/completion.py index f24ff96..b2c0308 100644 --- a/src/flow/commands/completion.py +++ b/src/flow/app/completion.py @@ -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: diff --git a/src/flow/services/containers.py b/src/flow/app/containers.py similarity index 75% rename from src/flow/services/containers.py rename to src/flow/app/containers.py index 0bece3d..314fdfb 100644 --- a/src/flow/services/containers.py +++ b/src/flow/app/containers.py @@ -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.""" diff --git a/src/flow/services/dotfiles.py b/src/flow/app/dotfiles.py similarity index 88% rename from src/flow/services/dotfiles.py rename to src/flow/app/dotfiles.py index aad4064..2572ae4 100644 --- a/src/flow/services/dotfiles.py +++ b/src/flow/app/dotfiles.py @@ -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() diff --git a/src/flow/services/packages.py b/src/flow/app/packages.py similarity index 100% rename from src/flow/services/packages.py rename to src/flow/app/packages.py diff --git a/src/flow/app/projects.py b/src/flow/app/projects.py new file mode 100644 index 0000000..60c092c --- /dev/null +++ b/src/flow/app/projects.py @@ -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) diff --git a/src/flow/services/remote.py b/src/flow/app/remote.py similarity index 100% rename from src/flow/services/remote.py rename to src/flow/app/remote.py diff --git a/src/flow/cli.py b/src/flow/cli.py index 8472c54..d77b496 100644 --- a/src/flow/cli.py +++ b/src/flow/cli.py @@ -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) diff --git a/src/flow/commands/__init__.py b/src/flow/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/flow/commands/dev.py b/src/flow/commands/dev.py deleted file mode 100644 index 4d78e26..0000000 --- a/src/flow/commands/dev.py +++ /dev/null @@ -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() diff --git a/src/flow/commands/dotfiles.py b/src/flow/commands/dotfiles.py deleted file mode 100644 index a993050..0000000 --- a/src/flow/commands/dotfiles.py +++ /dev/null @@ -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, - ) diff --git a/src/flow/commands/packages.py b/src/flow/commands/packages.py deleted file mode 100644 index 2d87407..0000000 --- a/src/flow/commands/packages.py +++ /dev/null @@ -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) diff --git a/src/flow/commands/projects.py b/src/flow/commands/projects.py deleted file mode 100644 index 4209c5a..0000000 --- a/src/flow/commands/projects.py +++ /dev/null @@ -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) diff --git a/src/flow/commands/remote.py b/src/flow/commands/remote.py deleted file mode 100644 index 409bc2d..0000000 --- a/src/flow/commands/remote.py +++ /dev/null @@ -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") diff --git a/src/flow/commands/setup.py b/src/flow/commands/setup.py deleted file mode 100644 index 60789e3..0000000 --- a/src/flow/commands/setup.py +++ /dev/null @@ -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 diff --git a/src/flow/core/runtime.py b/src/flow/core/runtime.py index 04e68ab..474d3a6 100644 --- a/src/flow/core/runtime.py +++ b/src/flow/core/runtime.py @@ -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 diff --git a/src/flow/domain/remote/resolution.py b/src/flow/domain/remote/resolution.py index b3cbdbb..8b6a6c2 100644 --- a/src/flow/domain/remote/resolution.py +++ b/src/flow/domain/remote/resolution.py @@ -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 diff --git a/src/flow/services/__init__.py b/src/flow/services/__init__.py deleted file mode 100644 index 5310a5f..0000000 --- a/src/flow/services/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Domain services for CLI commands.""" - diff --git a/src/flow/services/projects.py b/src/flow/services/projects.py deleted file mode 100644 index 271e6a4..0000000 --- a/src/flow/services/projects.py +++ /dev/null @@ -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) diff --git a/tests/e2e/test_dotfiles_e2e.py b/tests/e2e/test_dotfiles_e2e.py index ce5195c..3b1b0ae 100644 --- a/tests/e2e/test_dotfiles_e2e.py +++ b/tests/e2e/test_dotfiles_e2e.py @@ -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 diff --git a/tests/test_adapters_archive.py b/tests/test_adapters_archive.py new file mode 100644 index 0000000..2d0ebd5 --- /dev/null +++ b/tests/test_adapters_archive.py @@ -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") diff --git a/tests/test_completion.py b/tests/test_completion.py index b7c01ec..2161c2a 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -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 diff --git a/tests/test_core_containers.py b/tests/test_core_containers.py index 9b068ac..adbe39b 100644 --- a/tests/test_core_containers.py +++ b/tests/test_core_containers.py @@ -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") diff --git a/tests/test_core_runtime.py b/tests/test_core_runtime.py index 87c659f..a0b5290 100644 --- a/tests/test_core_runtime.py +++ b/tests/test_core_runtime.py @@ -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: diff --git a/tests/test_core_tmux.py b/tests/test_core_tmux.py index 0038651..2ccf14d 100644 --- a/tests/test_core_tmux.py +++ b/tests/test_core_tmux.py @@ -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 diff --git a/tests/test_service_bootstrap.py b/tests/test_service_bootstrap.py index 25ce8f6..9bb5712 100644 --- a/tests/test_service_bootstrap.py +++ b/tests/test_service_bootstrap.py @@ -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": { diff --git a/tests/test_service_containers.py b/tests/test_service_containers.py index 0eea04a..0fec02c 100644 --- a/tests/test_service_containers.py +++ b/tests/test_service_containers.py @@ -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 diff --git a/tests/test_service_dotfiles.py b/tests/test_service_dotfiles.py index b2f7f68..72c40b7 100644 --- a/tests/test_service_dotfiles.py +++ b/tests/test_service_dotfiles.py @@ -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 diff --git a/tests/test_service_packages.py b/tests/test_service_packages.py index c3fa462..e197b8b 100644 --- a/tests/test_service_packages.py +++ b/tests/test_service_packages.py @@ -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): diff --git a/tests/test_service_projects.py b/tests/test_service_projects.py index 56757d8..043bdb6 100644 --- a/tests/test_service_projects.py +++ b/tests/test_service_projects.py @@ -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 diff --git a/tests/test_service_remote.py b/tests/test_service_remote.py index a357527..8eae198 100644 --- a/tests/test_service_remote.py +++ b/tests/test_service_remote.py @@ -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): diff --git a/tests/test_static_mutation_guard.py b/tests/test_static_mutation_guard.py index 1554a53..49191ef 100644 --- a/tests/test_static_mutation_guard.py +++ b/tests/test_static_mutation_guard.py @@ -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 == []