Full rewrite spec covering: 4-layer architecture (core/domain/services/commands), plan-then-execute pattern, correct module path resolution, unified package domain shared by bootstrap, context-aware command surface, and setup modules abstraction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
32 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.services/receives all dependencies via constructor (runtime, config, console). No module-level singletons.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, SSHCommand
resolution.py # Target parsing, host template expansion
containers/
models.py # ContainerSpec, ContainerState
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
source_dir: Path # Absolute path in dotfiles repo
module: ModuleRef | None # If backed by external repo
@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/<name>
@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
def discover_packages(dotfiles_dir: Path, profile: str | None) -> list[Package]: ...
def resolve_package_targets(
package: Package,
home: Path,
skip: set[str],
) -> list[LinkTarget]:
"""Resolve all LinkTargets for a package, handling modules correctly."""
...
def resolve_all_targets(
packages: list[Package],
home: Path,
skip: set[str],
) -> list[LinkTarget]:
"""Resolve targets for all packages. Raises on conflicts."""
...
# domain/dotfiles/modules.py
def parse_module_yaml(path: Path) -> ModuleRef: ...
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_name: str, modules_base: Path) -> Path:
return modules_base / package_name.replace("/", "--")
def normalize_source(source: str) -> str:
"""github:org/repo -> https://github.com/org/repo.git"""
...
4.3 Planning
# domain/dotfiles/planning.py
def plan_link(
desired: list[LinkTarget],
current: LinkedState,
) -> LinkPlan:
"""Compare desired targets with current state, produce reconciliation plan.
- New targets: create_link ops
- Removed targets: remove_link ops
- Existing correct symlinks: unchanged (no op)
- Conflicts (target exists, not managed): listed in plan.conflicts
"""
...
def plan_unlink(
current: LinkedState,
packages: list[str] | None, # None = unlink all
) -> LinkPlan:
"""Plan removal of managed links."""
...
4.4 Conflict Detection
# domain/dotfiles/conflicts.py
def detect_conflicts(
desired: list[LinkTarget],
current: LinkedState,
filesystem_check: Callable[[Path], str | None],
# Returns "file", "dir", "symlink", or None
# This is the ONLY I/O dependency, injected from service layer
) -> list[str]:
"""Return human-readable conflict descriptions."""
...
The filesystem_check callback is the boundary between pure domain and I/O. The service injects it. Tests provide a fake.
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
modules: list[dict] # 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 BootstrapAction:
"""A single step in the bootstrap plan."""
type: str # "validate_env" | "install_packages" | "run_module"
# | "link_dotfiles" | "set_shell"
description: str
payload: Any # Type-specific data:
# install_packages -> PackagePlan
# run_module -> SetupModuleDef
# link_dotfiles -> profile name
# set_shell -> shell name
critical: bool # Stop on failure?
@dataclass(frozen=True)
class BootstrapPlan:
profile: str
actions: list[BootstrapAction]
summary: str
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 Target:
"""A resolved SSH target."""
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) -> None:
"""Clone dotfiles repo + discover and clone all module repos."""
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."""
def edit(self, target: str) -> None:
"""Pull relevant repo -> open editor -> commit + push."""
def repos_list(self) -> None: ...
def repos_status(self, repo_filter: str | None) -> None: ...
def repos_pull(self, repo_filter: str | None) -> None: ...
def repos_push(self, repo_filter: str | None) -> None: ...
Link flow detail
def link(self, ...):
packages = discover_packages(dotfiles_dir, profile) # domain
targets = resolve_all_targets(packages, home, skip) # domain
current = self._load_linked_state() # I/O
plan = plan_link(targets, current) # domain
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: ...
def show(self, profile: str) -> None: ...
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: ...
def exec(self, name: str, cmd: list[str] | None) -> None: ...
def list(self) -> None: ...
def stop(self, name: str, *, kill: bool) -> None: ...
def remove(self, name: str, *, force: bool) -> None: ...
def respawn(self, name: str) -> None: ...
9.6 ProjectService
class ProjectService:
def __init__(self, ctx: FlowContext): ...
def check(self, *, fetch: bool) -> None: ...
def fetch(self) -> None: ...
def summary(self) -> None: ...
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:
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]
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 }}"
packages:
- git
- fd
- binary/neovim
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)
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.