Plan fixes: - detect_platform raises FlowError not RuntimeError - TargetConfig lives in core/config.py only (remote domain imports it) - plan_link handles source changes (remove_link + create_link) - resolve_package_targets skips local files when mount_path is root - LinkedState.from_dict guards on version mismatch - Added missing test for parse_module_ref with absent ref - Task 12 now has full tests and serialization format - Task 13 uses spec signatures as truth, old code as reference - Task 15 includes describe() examples and tests - Task 24 has detailed test cases for ProjectService - Note that conflicts.py is intentionally merged into planning.py - Spec Section 12 example fixed to include filesystem_check arg Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
40 KiB
Flow CLI Architecture Redesign
Overview
Full rewrite of the flow CLI: a personal dev environment tool managing the host -> VM -> container workflow stack. Single Python binary (PyInstaller), context-aware (host/vm/container), no backward compatibility constraints.
Goals
- Correct abstractions with pure domain logic separated from I/O
- Plan-then-execute pattern for all mutating operations (native dry-run)
- No code duplication across domains
- Every domain unit independently testable without mocks
- Module path resolution works correctly for
_module.yaml-backed packages
1. Layer Architecture
Four layers with strict dependency direction (each layer only imports from layers below):
commands/ Layer 4: CLI parsing + dispatch (no logic)
|
services/ Layer 3: Orchestration + side effects (thin)
|
domain/ Layer 2: Pure logic, models, planning (no I/O)
|
core/ Layer 1: Runtime primitives + config loading
Rules
domain/never importscore/runtime,services/, orcommands/. It only importscore/template.py,core/errors.py,core/paths.py(constants only), and stdlib. Domain functions that need filesystem information (directory listings, file existence) receive it as pre-built data or injected callbacks -- never by reading the filesystem directly.services/receives all dependencies via constructor (runtime, config, console). No module-level singletons. Cross-service calls use the sharedFlowContext(e.g.BootstrapServiceconstructsDotfilesService(self.ctx)internally). This is the intended pattern since all services share the same runtime context.commands/are trivial: parse args into a typed namespace, call one service method.- Every plan is a frozen dataclass returned by domain functions. Services decide whether to execute or dry-run print them.
File Layout
src/flow/
__init__.py
__main__.py
cli.py # argparse, context creation, error handling
core/
runtime.py # CommandRunner, FileSystem, GitClient, SystemRuntime
config.py # YAML loading, AppConfig, FlowContext
platform.py # OS/arch detection, execution context (host/vm/container)
paths.py # XDG path constants
console.py # Output formatting with TTY detection, --no-color, --quiet
errors.py # FlowError hierarchy
template.py # Variable/template substitution (pure)
domain/
dotfiles/
models.py # Package, ModuleRef, LinkTarget, LinkedState
resolution.py # Path resolution: package -> home-relative targets
modules.py # Module source normalization, cache path computation
planning.py # Compute LinkPlan from desired vs current state
conflicts.py # Conflict detection
packages/
models.py # PackageDef, ProfilePackageRef, InstalledState, InstalledPackage
catalog.py # Manifest parsing, profile entry normalization, spec resolution
resolution.py # Source name resolution, platform mapping, URL building
planning.py # Compute PackagePlan (install/remove operations)
bootstrap/
models.py # Profile, SetupModuleDef, BootstrapPlan
planning.py # Profile -> ordered action list (packages + modules + dotfiles)
modules.py # Built-in setup module definitions (hostname, locale, shell, ssh-keygen, runcmd)
remote/
models.py # Target, TargetConfig, SSHCommand
resolution.py # Target parsing, host template expansion
containers/
models.py # ContainerSpec, ContainerInfo, ImageRef
resolution.py # Image ref parsing, name normalization, mount computation
services/
dotfiles.py # DotfilesService
packages.py # PackageService
bootstrap.py # BootstrapService
remote.py # RemoteService
containers.py # ContainerService
projects.py # ProjectService
commands/
__init__.py
remote.py
dev.py
dotfiles.py
setup.py
packages.py
projects.py
completion.py
2. Command Surface
Context awareness
Flow detects execution context via environment variables (DF_NAMESPACE, DF_PLATFORM) and restricts commands accordingly:
| Context | Detection | Available commands |
|---|---|---|
| Host | No DF_NAMESPACE, no DF_PLATFORM |
remote, dotfiles, setup, packages |
| VM | DF_NAMESPACE set, no container indicators |
dev, projects, dotfiles, setup, packages |
| Container | Inside container (e.g., /.dockerenv exists) |
dotfiles, setup, packages |
Unavailable commands print a clear message (e.g., "flow remote is only available on the host machine").
Commands
Canonical names are plural where they manage collections. Short aliases in parentheses.
flow remote enter <target> # Host only
flow remote list
flow dev create <name> -i <image> # VM only
flow dev attach <name>
flow dev exec <name> [cmd...]
flow dev list
flow dev stop <name>
flow dev rm <name>
flow dev respawn <name>
flow dotfiles init --repo <url> # (dot) Everywhere
flow dotfiles link [--profile p]
flow dotfiles unlink [packages...]
flow dotfiles status [packages...]
flow dotfiles edit <package>
flow dotfiles repos list # (repo)
flow dotfiles repos status [--repo=x]
flow dotfiles repos pull [--repo=x]
flow dotfiles repos push [--repo=x]
flow setup run [--profile p] # (bootstrap) Everywhere
flow setup list
flow setup show <profile>
flow packages install <name...> # (package, pkg) Everywhere
flow packages list [--all]
flow packages remove <name...>
flow projects check [--fetch] # (project) VM only
flow projects fetch
flow projects summary
Global flags
flow --version
flow --quiet # Suppress info messages
flow --no-color # Disable ANSI colors
flow --dry-run # Available on all mutating commands (passed through to service)
--dry-run as a global flag means the plan-then-execute pattern works uniformly: every service method that mutates state accepts dry_run: bool and either executes the plan or prints it.
3. Core Layer
3.1 Errors
Small hierarchy. Every domain function raises FlowError subtypes. cli.py catches FlowError at the top level.
class FlowError(Exception):
"""Base for all user-facing errors."""
class ConfigError(FlowError):
"""Invalid config/manifest YAML."""
class PlanConflict(FlowError):
"""Conflicts detected during planning."""
def __init__(self, message: str, conflicts: list[str]):
super().__init__(message)
self.conflicts = conflicts
class ExecutionError(FlowError):
"""A plan step failed during execution."""
No bare RuntimeError anywhere in the codebase.
3.2 Console
class Console:
def __init__(self, *, quiet: bool = False, color: bool | None = None):
# color=None means auto-detect via os.isatty(1)
def info(self, msg: str) -> None: ...
def warn(self, msg: str) -> None: ...
def error(self, msg: str) -> None: ...
def success(self, msg: str) -> None: ...
def table(self, headers: list[str], rows: list[list[str]]) -> None: ...
def print_plan(self, operations: list[Any], *, verb: str = "execute") -> None: ...
3.3 Runtime
class CommandRunner:
def run(self, argv, *, cwd, env, capture_output, check, timeout) -> CompletedProcess: ...
def run_shell(self, command, *, cwd, env, capture_output, check) -> CompletedProcess: ...
def stream_shell(self, command, console, *, check) -> CompletedProcess: ...
def require_binary(self, name) -> str: ...
class FileSystem:
def ensure_dir(self, path, *, sudo, runner, mode) -> None: ...
def remove_file(self, path, *, sudo, runner, missing_ok) -> None: ...
def remove_tree(self, path) -> None: ...
def copy_file(self, source, target, *, sudo, runner) -> None: ...
def copy_tree(self, source, target) -> None: ...
def create_symlink(self, source, target, *, sudo, runner) -> None: ...
def same_symlink(self, target, source) -> bool: ...
def read_text(self, path, *, default) -> str: ...
def write_text(self, path, content) -> None: ...
def read_json(self, path, *, default) -> Any: ...
def write_json(self, path, data) -> None: ...
class GitClient:
def __init__(self, runner: CommandRunner): ...
def run(self, repo_dir, *args, capture_output, check) -> CompletedProcess: ...
@dataclass
class SystemRuntime:
runner: CommandRunner
fs: FileSystem
git: GitClient
3.4 FlowContext
@dataclass
class FlowContext:
config: AppConfig
manifest: dict[str, Any]
platform: PlatformInfo
console: Console
runtime: SystemRuntime
Created once in cli.py, passed to services via constructor. Never accessed as a global.
3.5 Template
Pure functions. No I/O.
def substitute(text: str, variables: dict[str, str]) -> str:
"""Replace $VAR and ${VAR} with values."""
def substitute_template(text: str, context: dict[str, Any]) -> str:
"""Replace {{expr}} placeholders."""
4. Dotfiles Domain
4.1 Models
@dataclass(frozen=True)
class Package:
"""A dotfiles package: a named set of files mapping to home-relative targets."""
name: str # e.g. "zsh", "nvim"
layer: str # "_shared" or profile name
package_id: str # Qualified: "layer/name" (e.g. "_shared/nvim")
source_dir: Path # Absolute path in dotfiles repo
module: ModuleRef | None # If backed by external repo
local_files: list[tuple[Path, Path]] # (absolute_source, relative_to_package_root)
# Pre-walked by the service layer (no I/O in domain)
@dataclass(frozen=True)
class ModuleRef:
"""An external git repo providing content for a package subtree."""
source: str # Normalized git URL
ref_type: str # "branch" | "tag" | "commit"
ref_value: str # e.g. "main", "v1.0", "abc123"
mount_path: Path # Relative path within package to _module.yaml parent
# e.g. Path(".config/nvim")
cache_dir: Path # ~/.local/share/flow/modules/<package_id>
module_files: list[tuple[Path, Path]] # (absolute_source, relative_to_cache_root)
# Pre-walked by the service layer
@dataclass(frozen=True)
class LinkTarget:
"""A single file that should be linked into the filesystem."""
source: Path # Where file lives (dotfiles repo or module cache)
target: Path # Where symlink goes (e.g. ~/.config/nvim/init.lua)
package: str # Owning package (e.g. "_shared/nvim")
from_module: bool # Whether source is from a module repo
needs_sudo: bool # True for _root/ targets outside $HOME
@dataclass(frozen=True)
class LinkOp:
"""A single operation in a link plan."""
type: str # "create_link" | "remove_link" | "create_dir"
target: Path
source: Path | None
package: str
needs_sudo: bool
@dataclass(frozen=True)
class PlanSummary:
added: int
removed: int
unchanged: int
from_modules: int
@dataclass(frozen=True)
class LinkPlan:
"""Complete reconciliation plan."""
operations: list[LinkOp]
conflicts: list[str]
summary: PlanSummary
@dataclass
class LinkedState:
"""Persisted to ~/.local/state/flow/linked.json."""
links: dict[Path, LinkTarget]
def as_dict(self) -> dict: ...
@classmethod
def from_dict(cls, data: dict) -> LinkedState: ...
@dataclass(frozen=True)
class RepoInfo:
"""A managed git repo (dotfiles or module)."""
name: str # "dotfiles" or module package name
path: Path # Local clone path
source: str # Remote URL
is_module: bool
4.2 Module Path Resolution
The core algorithm that fixes the current bug. Given a dotfiles repo layout:
_shared/
nvim/ # Package: "nvim", layer: "_shared"
.config/nvim/
_module.yaml # Module definition
.local/bin/nvim-wrapper # Normal local file
Resolution steps:
- Discover packages: scan
_shared/and profile dirs for first-level subdirectories. - For each package, walk its directory tree:
- Find
_module.yamlif present. Its parent relative to the package root is themount_path. - For
_shared/nvim/.config/nvim/_module.yaml: mount_path =.config/nvim
- Find
- Resolve files to LinkTargets:
- Files outside mount_path come from the dotfiles repo directly.
.local/bin/nvim-wrapper->LinkTarget(source=<dotfiles>/_shared/nvim/.local/bin/nvim-wrapper, target=~/.local/bin/nvim-wrapper)
- Files inside mount_path come from the module cache.
- The module repo is cloned to
~/.local/share/flow/modules/nvim. - Every file in the clone maps to
~/<mount_path>/<relative>. modules/nvim/init.lua->LinkTarget(source=<modules>/nvim/init.lua, target=~/.config/nvim/init.lua)
- The module repo is cloned to
- The
_module.yamlfile itself is never linked.
- Files outside mount_path come from the dotfiles repo directly.
# domain/dotfiles/resolution.py
# All functions are PURE: they operate on pre-built Package data (including
# pre-walked file lists), never touching the filesystem directly.
def resolve_package_targets(
package: Package,
home: Path,
skip: set[str],
) -> list[LinkTarget]:
"""Resolve all LinkTargets for a package, handling modules correctly.
Uses package.local_files and package.module.module_files (pre-walked
by service layer) to compute targets. No filesystem I/O.
"""
...
def resolve_all_targets(
packages: list[Package],
home: Path,
skip: set[str],
) -> list[LinkTarget]:
"""Resolve targets for all packages. Raises PlanConflict on duplicate targets
across packages (e.g. _shared/zsh and linux-work/zsh both targeting ~/.zshrc).
This is a pure cross-package collision check, not a filesystem conflict check.
"""
...
# domain/dotfiles/modules.py
# Pure functions for module metadata. I/O (reading YAML, walking dirs) is done
# by the service layer, which passes parsed data into these functions.
def parse_module_ref(
raw: dict, # Pre-loaded YAML content (dict, not file path)
package_id: str,
mount_path: Path,
modules_base: Path,
) -> ModuleRef:
"""Build a ModuleRef from parsed _module.yaml content.
Args:
raw: The parsed YAML dict (service reads the file and passes content in).
package_id: Qualified name like "_shared/nvim".
mount_path: Relative path from package root to _module.yaml parent.
modules_base: Base directory for module caches (from core/paths.py).
"""
...
def compute_mount_path(module_yaml: Path, package_dir: Path) -> Path:
"""The key function: relative path from package root to _module.yaml parent."""
return module_yaml.parent.relative_to(package_dir)
def module_cache_dir(package_id: str, modules_base: Path) -> Path:
"""Compute cache dir for a module. Uses package_id (e.g. '_shared/nvim')
with '/' replaced by '--' to avoid collisions between same-named packages
in different layers."""
return modules_base / package_id.replace("/", "--")
def normalize_source(source: str) -> str:
"""github:org/repo -> https://github.com/org/repo.git"""
...
I/O boundary note: The service layer (DotfilesService) is responsible for:
- Walking the dotfiles dir to find packages and
_module.yamlfiles - Reading
_module.yamlfiles and passing parsed dicts toparse_module_ref - Walking module cache dirs to build file lists
- Constructing
Packageobjects with pre-populatedlocal_filesandModuleRef.module_files - Passing these fully-built objects to pure domain functions
4.3 Planning and Conflict Detection
Conflict detection and planning are integrated into a single flow. There are two kinds of conflicts:
- Cross-package collisions (pure): two packages want the same target path. Detected by
resolve_all_targetswhich raisesPlanConflict. - Filesystem conflicts (requires I/O): a target path already exists on disk and is not managed by flow. Detected by
plan_linkvia an injected callback.
# domain/dotfiles/planning.py
def plan_link(
desired: list[LinkTarget],
current: LinkedState,
filesystem_check: Callable[[Path], str | None],
# ^^ Injected by service layer. Returns "file", "dir", "symlink", or None.
# This is the ONLY I/O dependency in the planning layer.
# Tests provide a fake (e.g., lambda p: None).
) -> LinkPlan:
"""Compare desired targets with current state, produce reconciliation plan.
- New targets: create_link ops
- Removed targets (in current but not desired): remove_link ops
- Existing correct symlinks: unchanged (no op)
- Filesystem conflicts: listed in plan.conflicts (target exists, not managed)
- Directory conflicts: listed in plan.conflicts (target is a dir, cannot overwrite)
The service checks plan.conflicts and raises PlanConflict if non-empty and
--force was not passed.
"""
...
def plan_unlink(
current: LinkedState,
packages: list[str] | None, # None = unlink all
) -> LinkPlan:
"""Plan removal of managed links."""
...
5. Packages Domain
Shared by flow packages and flow setup. Single source of truth.
5.1 Models
@dataclass(frozen=True)
class PackageDef:
"""A package as defined in the manifest."""
name: str
type: str # "pkg" | "binary" | "cask"
sources: dict[str, str] # {"apt": "fd-find", "brew": "fd"}
source: str | None # Binary: "github:neovim/neovim"
version: str | None # Binary: "0.10.4"
asset_pattern: str | None
platform_map: dict[str, dict]
extract_dir: str | None
install: dict[str, list[str]] # {"bin": ["bin/nvim"], "share": [...]}
post_install: str | None
allow_sudo: bool
@dataclass(frozen=True)
class ProfilePackageRef:
"""A package reference from a profile's package list."""
name: str
type: str | None
allow_sudo: bool
post_install: str | None
@dataclass(frozen=True)
class PkgInstallOp:
type: str # "pm_update" | "pm_install" | "binary_install" | "run_hook"
package: str | None
command: str | None # Shell command for pm_update/pm_install/run_hook
download_url: str | None # For binary_install
install_map: dict[str, list[str]] | None
description: str # Human-readable for dry-run
@dataclass(frozen=True)
class PkgRemoveOp:
package: str
files: list[Path] # Files to delete
@dataclass(frozen=True)
class PackagePlan:
operations: list[PkgInstallOp | PkgRemoveOp]
summary: str
@dataclass(frozen=True)
class InstalledPackage:
name: str
version: str
type: str
files: list[Path] # Track installed files for real removal
@dataclass
class InstalledState:
packages: dict[str, InstalledPackage]
def as_dict(self) -> dict: ...
@classmethod
def from_dict(cls, data: dict) -> InstalledState: ...
5.2 Pure Functions
# domain/packages/catalog.py
def parse_catalog(raw: Any) -> dict[str, PackageDef]: ...
def normalize_profile_entry(entry: Any) -> ProfilePackageRef: ...
def resolve_spec(catalog: dict[str, PackageDef], ref: ProfilePackageRef) -> PackageDef: ...
# domain/packages/resolution.py
def resolve_source_name(spec: PackageDef, pm: str) -> str: ...
def resolve_binary_asset(spec: PackageDef, platform: PlatformInfo) -> str: ...
def resolve_download_url(spec: PackageDef, asset: str, template_ctx: dict) -> str: ...
def detect_package_manager(os_name: str) -> str | None: ...
def pm_update_command(pm: str) -> str: ...
def pm_install_command(pm: str, packages: list[str], pkg_type: str) -> str: ...
# domain/packages/planning.py
def plan_install(
specs: list[PackageDef],
pm: str,
platform: PlatformInfo,
template_ctx: dict,
) -> PackagePlan: ...
def plan_remove(
names: list[str],
installed: InstalledState,
) -> PackagePlan: ...
6. Bootstrap Domain
Bootstrap is an orchestrator. It does not have unique domain logic -- it composes packages, dotfiles, and setup modules into an ordered plan.
6.1 Models
@dataclass(frozen=True)
class Profile:
"""A bootstrap profile from the manifest."""
name: str
os: str # "linux" | "macos"
package_manager: str | None # None = auto-detect
packages: list[Any] # Raw entries, normalized via packages domain
setup_modules: list[SetupModuleDef] # Parsed setup module definitions
requires: list[str] # Required env vars
shell: str | None # e.g. "zsh"
dotfiles_profile: str | None # Profile name for dotfiles linking
@dataclass(frozen=True)
class SetupModuleDef:
"""Configuration for a single setup module step, parsed from YAML.
Examples:
SetupModuleDef(type="hostname", config={"value": "my-host"})
SetupModuleDef(type="ssh-keygen", config={"keys": [{"type": "ed25519", ...}]})
SetupModuleDef(type="runcmd", config={"commands": ["sudo groupadd docker || true"]})
"""
type: str # "hostname" | "locale" | "shell" | "ssh-keygen" | "runcmd"
config: dict # Type-specific configuration from YAML
@dataclass(frozen=True)
class BootstrapAction:
"""A single step in the bootstrap plan."""
type: str # "install_packages" | "run_setup_module"
# | "link_dotfiles" | "set_shell"
description: str
payload: Any # Type-specific data:
# install_packages -> PackagePlan
# run_setup_module -> tuple[SetupModuleDef, list[str]]
# (the module def + pre-computed shell commands)
# link_dotfiles -> profile name (str)
# set_shell -> shell name (str)
critical: bool # Stop on failure?
@dataclass(frozen=True)
class BootstrapPlan:
profile: str
actions: list[BootstrapAction]
summary: str
Note on env var validation: plan_bootstrap validates required env vars eagerly and raises ConfigError if any are missing. This is a pure check (comparing profile.requires against the provided env dict) and happens before any actions are generated. It is not an action in the plan -- missing env vars are a precondition failure that prevents plan creation.
6.2 Setup Modules
Each module type is a small class with a plan method (pure) that returns shell commands to execute.
class SetupModule(Protocol):
def plan(self, config: dict, template_ctx: dict) -> list[str]:
"""Return shell commands to execute. Pure -- no side effects."""
...
def describe(self) -> str:
"""Human-readable description for dry-run output."""
...
Built-in modules in domain/bootstrap/modules.py:
HostnameModule-- sets hostname via hostnamectl (linux) or scutil (macos)LocaleModule-- sets locale via locale-gen + update-locale (linux only)ShellModule-- installs shell if missing, adds to /etc/shells, runs chshSSHKeygenModule-- generates SSH keys per specRuncmdModule-- runs arbitrary shell commands from profile
6.3 Planning
# domain/bootstrap/planning.py
def parse_profile(name: str, raw: dict) -> Profile: ...
def plan_bootstrap(
profile: Profile,
catalog: dict[str, PackageDef],
platform: PlatformInfo,
env: dict[str, str],
template_ctx: dict,
) -> BootstrapPlan:
"""Build the full ordered action list:
1. Validate required env vars
2. Setup modules (hostname, locale, etc.)
3. Install packages (delegates to packages domain)
4. Set shell
5. SSH keygen
6. Runcmd
7. Link dotfiles
"""
...
6.4 Config Format
profiles:
linux-work:
os: linux
shell: zsh
dotfiles-profile: linux-work
requires: [USER_EMAIL]
modules:
- type: hostname
value: "{{ env.HOSTNAME }}"
- type: locale
value: en_US.UTF-8
- type: ssh-keygen
keys:
- type: ed25519
comment: "{{ env.USER_EMAIL }}"
- type: runcmd
commands:
- "sudo groupadd docker || true"
- "sudo usermod -aG docker $USER"
packages:
- git
- fd
- binary/neovim
7. Remote Domain
7.1 Models
@dataclass(frozen=True)
class TargetConfig:
"""A target as defined in config YAML. Parsed from the 'targets' section.
Supports two YAML formats:
personal@orb: personal.orb # shorthand: key is namespace@platform, value is host
work@ec2: # dict form
host: work.ec2.internal
identity: ~/.ssh/id_work
"""
namespace: str
platform: str
host: str
identity: str | None = None
@dataclass(frozen=True)
class Target:
"""A fully resolved SSH target (after merging CLI args + config + templates)."""
user: str
namespace: str
platform: str
host: str
identity: str | None
@dataclass(frozen=True)
class SSHCommand:
"""A fully resolved SSH command ready to exec."""
argv: list[str]
destination: str
tmux_session: str | None
env_vars: dict[str, str] # DF_NAMESPACE, DF_PLATFORM
7.2 Resolution
# domain/remote/resolution.py
HOST_TEMPLATES = {
"orb": "<namespace>.orb",
"utm": "<namespace>.utm.local",
"core": "<namespace>.core.lan",
}
def parse_target(target: str) -> tuple[str | None, str | None, str | None]:
"""Parse [user@]namespace@platform."""
...
def resolve_target(
parsed: tuple,
config_targets: list[TargetConfig],
default_user: str,
) -> Target: ...
def build_ssh_command(
target: Target,
*,
tmux_session: str | None,
no_tmux: bool,
) -> SSHCommand: ...
def terminfo_fix_command(term: str | None, destination: str) -> str | None: ...
8. Containers Domain
8.1 Models
@dataclass(frozen=True)
class ImageRef:
"""A resolved container image reference."""
full_ref: str
registry: str
repo: str
tag: str
label: str
@dataclass(frozen=True)
class ContainerSpec:
"""Everything needed to create a container."""
name: str # dev-<name>
image: ImageRef
project_path: Path | None
mounts: list[tuple[str, str]]
labels: dict[str, str]
network: str # "host"
@dataclass(frozen=True)
class ContainerInfo:
"""Runtime state of an existing container."""
name: str
image: str
project: str
status: str
8.2 Resolution
# domain/containers/resolution.py
def parse_image_ref(
image: str,
*,
default_registry: str,
default_tag: str,
) -> ImageRef: ...
def container_name(name: str) -> str:
"""Ensure dev- prefix."""
...
def resolve_mounts(home: str) -> list[tuple[str, str]]:
"""Standard host mounts: .ssh (ro), .npmrc (ro), .npm, docker socket."""
...
def build_container_spec(
name: str,
image: ImageRef,
project_path: Path | None,
home: str,
) -> ContainerSpec: ...
9. Services Layer
Each service is a class receiving dependencies via constructor. All mutating methods accept dry_run: bool.
9.1 DotfilesService
class DotfilesService:
def __init__(self, ctx: FlowContext): ...
def init(self, repo_url: str, *, dry_run: bool) -> None:
"""Clone dotfiles repo + discover and clone all module repos.
dry_run: prints repos that would be cloned without cloning."""
def link(self, *, profile: str | None, packages: list[str] | None,
force: bool, dry_run: bool) -> None:
"""Reconcile links: discover -> resolve -> plan -> execute."""
def unlink(self, packages: list[str] | None, *, dry_run: bool) -> None:
"""Remove managed links."""
def status(self, packages: list[str] | None) -> None:
"""Show package list, link health, module info. Read-only."""
def edit(self, target: str, *, no_commit: bool) -> None:
"""Pull relevant repo -> open editor -> commit + push.
Interactive command -- dry_run not applicable (editor is the point).
no_commit: skip the auto-commit/push after editing."""
def repos_list(self) -> None:
"""List all managed repos. Read-only."""
def repos_status(self, repo_filter: str | None) -> None:
"""Git status for repos. Read-only."""
def repos_pull(self, repo_filter: str | None, *, dry_run: bool) -> None:
"""Pull one or all repos. dry_run: shows what would be pulled."""
def repos_push(self, repo_filter: str | None, *, dry_run: bool) -> None:
"""Push one or all repos. dry_run: shows what would be pushed."""
Link flow detail
The service layer performs all I/O (directory walking, YAML reading, filesystem checks) and passes pre-built data structures to pure domain functions:
def link(self, ...):
# I/O: walk dirs, read _module.yaml files, build Package objects
packages = self._discover_packages(profile)
# Pure: resolve file paths to LinkTargets
targets = resolve_all_targets(packages, home, skip)
# I/O: load persisted state
current = self._load_linked_state()
# Pure (with injected callback): build plan with filesystem checks
plan = plan_link(targets, current,
filesystem_check=self._check_path_on_disk)
if plan.conflicts and not force:
raise PlanConflict(...)
if dry_run:
self.console.print_plan(plan.operations)
return
self._execute_link_plan(plan) # I/O
self._save_linked_state(...) # I/O
9.2 PackageService
class PackageService:
def __init__(self, ctx: FlowContext): ...
def install(self, names: list[str], *, dry_run: bool) -> None: ...
def list(self, *, show_all: bool) -> None: ...
def remove(self, names: list[str], *, dry_run: bool) -> None: ...
9.3 BootstrapService
class BootstrapService:
def __init__(self, ctx: FlowContext): ...
def run(self, *, profile: str | None, variables: dict[str, str],
dry_run: bool) -> None:
"""Parse profile -> build plan -> execute actions sequentially."""
def list(self) -> None:
"""List available profiles with summary info. Read-only."""
def show(self, profile: str, *, dry_run: bool) -> None:
"""Show profile details. When dry_run=True (or always, effectively),
builds and prints the full BootstrapPlan without executing.
'setup show' is conceptually 'setup run --dry-run' scoped to one profile."""
Run flow detail
def run(self, ...):
profile = parse_profile(name, raw) # domain
catalog = parse_catalog(manifest["packages"]) # domain/packages
plan = plan_bootstrap(profile, catalog, platform, ...) # domain
if dry_run:
self.console.print_plan(plan.actions)
return
for action in plan.actions:
match action.type:
case "install_packages":
self._execute_package_plan(action.payload)
case "run_module":
self._execute_setup_module(action.payload)
case "link_dotfiles":
DotfilesService(self.ctx).link(profile=action.payload, dry_run=False)
case "set_shell":
self._set_shell(action.payload)
9.4 RemoteService
class RemoteService:
def __init__(self, ctx: FlowContext): ...
def enter(self, target_str: str, *, user: str | None, namespace: str | None,
platform: str | None, session: str, no_tmux: bool,
dry_run: bool) -> None: ...
def list(self) -> None: ...
9.5 ContainerService
class ContainerService:
def __init__(self, ctx: FlowContext): ...
def create(self, name: str, image: str, project: str | None, *,
dry_run: bool) -> None: ...
def attach(self, name: str) -> None:
"""Interactive -- attach to tmux session. No dry_run."""
def exec(self, name: str, cmd: list[str] | None) -> None:
"""Interactive -- exec into container. No dry_run."""
def list(self) -> None:
"""Read-only."""
def stop(self, name: str, *, kill: bool, dry_run: bool) -> None: ...
def remove(self, name: str, *, force: bool, dry_run: bool) -> None: ...
def respawn(self, name: str, *, dry_run: bool) -> None: ...
9.6 ProjectService
class ProjectService:
def __init__(self, ctx: FlowContext): ...
def check(self, *, fetch: bool) -> None:
"""Read-only (fetch is network I/O but does not mutate local state
beyond FETCH_HEAD -- acceptable for a check command)."""
def fetch(self) -> None:
"""Fetch all remotes. Network I/O only, no local mutations beyond
remote tracking refs. dry_run not applicable."""
def summary(self) -> None:
"""Read-only."""
10. CLI Entry Point
# cli.py
def main():
parser = build_parser()
args = parser.parse_args()
console = Console(
quiet=args.quiet,
color=not args.no_color if args.no_color else None,
)
context_type = detect_context()
validate_command_context(args.command, context_type, console)
ensure_non_root(console)
platform = detect_platform()
config = load_config()
manifest = load_manifest()
runtime = SystemRuntime()
ctx = FlowContext(config, manifest, platform, console, runtime)
try:
args.handler(ctx, args)
except PlanConflict as e:
for conflict in e.conflicts:
console.error(conflict)
console.error(str(e))
sys.exit(1)
except FlowError as e:
console.error(str(e))
sys.exit(1)
Command modules are trivial:
# commands/dotfiles.py
def register(subparsers):
p = subparsers.add_parser("dotfiles", aliases=["dot"], help="Manage dotfiles")
sub = p.add_subparsers(dest="dotfiles_command")
link = sub.add_parser("link", help="Reconcile dotfile symlinks")
link.add_argument("--profile")
link.add_argument("packages", nargs="*")
link.add_argument("--force", action="store_true")
link.set_defaults(handler=_run_link)
# ...
def _run_link(ctx, args):
DotfilesService(ctx).link(
profile=args.profile,
packages=args.packages or None,
force=args.force,
dry_run=args.dry_run,
)
11. Config Format
Single unified YAML format, loaded from ~/.config/flow/ or self-hosted from dotfiles repo.
repository:
url: git@github.com:user/dotfiles.git
branch: main
paths:
projects: ~/projects
defaults:
container-registry: registry.tomastm.com
container-tag: latest
tmux-session: default
# Targets: two formats supported
# Shorthand: "namespace@platform: host [identity]"
# Dict form: "namespace@platform: {host: ..., identity: ...}"
targets:
personal@orb: personal.orb
work@ec2:
host: work.ec2.internal
identity: ~/.ssh/id_work
packages:
- name: fd
type: pkg
sources:
apt: fd-find
brew: fd
- name: neovim
type: binary
source: github:neovim/neovim
version: "0.10.4"
asset-pattern: "nvim-{{os}}-{{arch}}.tar.gz"
platform-map:
linux-x64: { os: linux, arch: x64 }
darwin-arm64: { os: macos, arch: arm64 }
extract-dir: "nvim-{{os}}64"
install:
bin: [bin/nvim]
share: [share/nvim]
- name: docker
type: pkg
sources:
apt: docker-ce
allow-sudo: true # Allow sudo in post-install hook
post-install: | # Shell script, runs after install
sudo groupadd docker || true
sudo usermod -aG docker $USER
profiles:
linux-work:
os: linux
shell: zsh
dotfiles-profile: linux-work
requires: [USER_EMAIL]
modules:
- type: hostname
value: "{{ env.HOSTNAME }}"
- type: locale
value: en_US.UTF-8
- type: ssh-keygen
keys:
- type: ed25519
comment: "{{ env.USER_EMAIL }}"
- type: runcmd
commands:
- "sudo groupadd docker || true"
packages:
- git
- fd
- binary/neovim
- name: docker
allow-sudo: true
Path constants (core/paths.py)
All XDG-compliant, hardcoded in core/paths.py:
CONFIG_DIR = XDG_CONFIG_HOME / "flow" # ~/.config/flow/
DATA_DIR = XDG_DATA_HOME / "flow" # ~/.local/share/flow/
STATE_DIR = XDG_STATE_HOME / "flow" # ~/.local/state/flow/
DOTFILES_DIR = DATA_DIR / "dotfiles" # Cloned dotfiles repo
MODULES_DIR = DATA_DIR / "modules" # Module cache (each module cloned here)
PACKAGES_DIR = DATA_DIR / "packages" # Binary package scratch
LINKED_STATE = STATE_DIR / "linked.json" # Current link state
INSTALLED_STATE = STATE_DIR / "installed.json" # Installed packages state
# Self-hosted config (from dotfiles repo, takes priority over CONFIG_DIR)
DOTFILES_FLOW_CONFIG = DOTFILES_DIR / "_shared" / "flow" / ".config" / "flow"
MODULES_DIR is the modules_base passed to module_cache_dir() and other domain functions.
Target config normalization
The config parser in core/config.py normalizes both target formats into TargetConfig objects:
# Shorthand: "personal@orb: personal.orb"
# -> TargetConfig(namespace="personal", platform="orb", host="personal.orb", identity=None)
# Dict: "work@ec2: {host: ..., identity: ...}"
# -> TargetConfig(namespace="work", platform="ec2", host="work.ec2.internal", identity="~/.ssh/id_work")
12. Testing Strategy
Domain tests (majority of tests)
Pure function tests. No mocks, no filesystem, no subprocess. Fast.
def test_module_mount_path():
mount = compute_mount_path(
module_yaml=Path("/dots/_shared/nvim/.config/nvim/_module.yaml"),
package_dir=Path("/dots/_shared/nvim"),
)
assert mount == Path(".config/nvim")
def test_plan_link_creates_ops_for_new_targets():
desired = [LinkTarget(source=Path("/a"), target=Path("/home/x/.zshrc"),
package="_shared/zsh", from_module=False, needs_sudo=False)]
current = LinkedState(links={})
plan = plan_link(desired, current, filesystem_check=lambda p: None)
assert len(plan.operations) == 1
assert plan.operations[0].type == "create_link"
def test_resolve_binary_url():
spec = PackageDef(name="nvim", type="binary",
source="github:neovim/neovim", version="0.10.4", ...)
url = resolve_download_url(spec, "nvim-linux-x64.tar.gz", {...})
assert url == "https://github.com/neovim/neovim/releases/download/v0.10.4/nvim-linux-x64.tar.gz"
Service tests (integration)
Use tmp_path fixtures with real filesystem but fake CommandRunner that records calls.
class FakeRunner:
def __init__(self):
self.calls: list[list[str]] = []
def run(self, argv, **kwargs):
self.calls.append(list(argv))
return CompletedProcess(argv, 0, stdout="", stderr="")
def test_dotfiles_link_creates_symlinks(tmp_path):
# Set up real dotfiles dir, real home dir
# Inject FakeRunner for git calls
# Call DotfilesService.link()
# Assert symlinks exist on disk
E2E tests (opt-in, container-based)
Same pattern as current test_dotfiles_e2e_container.py. Run with FLOW_RUN_E2E=1.
13. Migration Notes
This is a full rewrite. The approach is:
- Build
domain/layer first with comprehensive tests - Build
services/layer on top - Build
commands/+cli.pylast - Delete all old code
No incremental migration. The old code serves as reference but is not preserved.