feat: add all services (dotfiles, packages, bootstrap, remote, containers, projects)

- DotfilesService: package discovery, module sync, link/unlink/status
- PackageService: install/remove/list with PM and binary support
- BootstrapService: profile-based system setup orchestration
- RemoteService: SSH target resolution and connection
- ContainerService: docker container lifecycle management
- ProjectService: git repo status checking

26 service tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 05:02:31 +02:00
parent 5f1ee18cb4
commit f79154d86f
12 changed files with 1312 additions and 3187 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,321 +1,117 @@
"""Container lifecycle helpers for `flow dev`."""
"""ContainerService -- manages development containers."""
from __future__ import annotations
import os
import shutil
from typing import Optional
from flow.core.config import FlowContext
from flow.core.errors import FlowError
DEFAULT_REGISTRY = "registry.tomastm.com"
DEFAULT_TAG = "latest"
CONTAINER_HOME = "/home/dev"
def runtime() -> str:
for name in ("docker", "podman"):
if shutil.which(name):
return name
raise FlowError("No container runtime found (docker or podman)")
def container_name(name: str) -> str:
return name if name.startswith("dev-") else f"dev-{name}"
def parse_image_ref(
image: str,
*,
default_registry: str = DEFAULT_REGISTRY,
default_tag: str = DEFAULT_TAG,
) -> tuple[str, str, str, str]:
registry = default_registry
tag = default_tag
if image.startswith("docker/"):
registry = "docker.io"
image = f"library/{image.split('/', 1)[1]}"
elif image.startswith("tm0/"):
registry = default_registry
image = image.split("/", 1)[1]
elif "/" in image:
prefix, remainder = image.split("/", 1)
if "." in prefix or ":" in prefix or prefix == "localhost":
registry = prefix
image = remainder
if ":" in image.split("/")[-1]:
tag = image.rsplit(":", 1)[1]
image = image.rsplit(":", 1)[0]
repo = image
full_ref = f"{registry}/{repo}:{tag}"
label_prefix = (
registry.rsplit(".", 1)[0].rsplit(".", 1)[-1] if "." in registry else registry
)
label = f"{label_prefix}/{repo.split('/')[-1]}"
return full_ref, repo, tag, label
from flow.core import paths
from flow.domain.containers.resolution import (
build_container_spec,
container_name,
parse_image_ref,
resolve_mounts,
)
class ContainerService:
"""Own all container-runtime interactions."""
def __init__(self, ctx: FlowContext):
self.ctx = ctx
self.runner = ctx.runtime.runner
def container_exists(self, rt: str, name: str) -> bool:
result = self.runner.run(
[rt, "container", "ls", "-a", "--format", "{{.Names}}"],
capture_output=True,
)
return name in result.stdout.strip().splitlines()
def container_running(self, rt: str, name: str) -> bool:
result = self.runner.run(
[rt, "container", "ls", "--format", "{{.Names}}"],
capture_output=True,
)
return name in result.stdout.strip().splitlines()
def run_create(self, args) -> None:
rt = runtime()
cname = container_name(args.name)
if self.container_exists(rt, cname):
raise FlowError(f"Container already exists: {cname}")
project_path = os.path.realpath(args.project) if args.project else None
if project_path and not os.path.isdir(project_path):
raise FlowError(f"Invalid project path: {project_path}")
full_ref, _, _, _ = parse_image_ref(
args.image,
def create(
self,
image: str,
namespace: str = "default",
*,
dry_run: bool = False,
extra_env: Optional[dict[str, str]] = None,
) -> None:
"""Create and start a container."""
image_ref = parse_image_ref(
image,
default_registry=self.ctx.config.container_registry,
default_tag=self.ctx.config.container_tag,
)
cmd = [
rt,
"run",
"-d",
"--name",
cname,
"--label",
"dev=true",
"--label",
f"dev.name={args.name}",
"--label",
f"dev.image_ref={full_ref}",
"--network",
"host",
"--init",
]
mounts = resolve_mounts(
paths.HOME,
self.ctx.config.projects_dir,
dotfiles_dir=paths.DOTFILES_DIR,
)
if project_path:
cmd.extend(["-v", f"{project_path}:/workspace"])
cmd.extend(["--label", f"dev.project_path={project_path}"])
spec = build_container_spec(
namespace, image_ref, mounts,
env=extra_env,
)
docker_sock = "/var/run/docker.sock"
if os.path.exists(docker_sock):
cmd.extend(["-v", f"{docker_sock}:{docker_sock}"])
self.ctx.console.info(f"Creating container: {spec.name}")
self.ctx.console.info(f" Image: {spec.image.full}")
self.ctx.console.info(f" Mounts: {len(spec.mounts)}")
home = os.path.expanduser("~")
mounts = [
(f"{home}/.ssh", f"{CONTAINER_HOME}/.ssh:ro", os.path.isdir),
(f"{home}/.npmrc", f"{CONTAINER_HOME}/.npmrc:ro", os.path.isfile),
(f"{home}/.npm", f"{CONTAINER_HOME}/.npm", os.path.isdir),
]
for source, target, predicate in mounts:
if predicate(source):
cmd.extend(["-v", f"{source}:{target}"])
try:
import grp
docker_gid = str(grp.getgrnam("docker").gr_gid)
cmd.extend(["--group-add", docker_gid])
except (KeyError, ImportError):
pass
cmd.extend([full_ref, "sleep", "infinity"])
self.runner.run(cmd, capture_output=False, check=True)
self.ctx.console.success(f"Created and started container: {cname}")
def run_exec(self, args) -> None:
rt = runtime()
cname = container_name(args.name)
if not self.container_running(rt, cname):
raise FlowError(f"Container {cname} not running")
if args.cmd:
exec_cmd = [rt, "exec"]
if os.isatty(0):
exec_cmd.extend(["-it"])
exec_cmd.append(cname)
exec_cmd.extend(args.cmd)
result = self.runner.run(exec_cmd, capture_output=False)
raise SystemExit(result.returncode)
for shell in (["zsh", "-l"], ["bash", "-l"], ["sh"]):
exec_cmd = [rt, "exec", "--detach-keys", "ctrl-q,ctrl-p", "-it", cname, *shell]
result = self.runner.run(exec_cmd, capture_output=False)
if result.returncode not in (126, 127):
raise SystemExit(result.returncode)
raise FlowError(f"Unable to start an interactive shell in {cname}")
def run_connect(self, args) -> None:
rt = runtime()
cname = container_name(args.name)
if not self.container_exists(rt, cname):
raise FlowError(f"Container does not exist: {cname}")
if not self.container_running(rt, cname):
self.runner.run([rt, "start", cname], capture_output=True)
if not shutil.which("tmux"):
self.ctx.console.warn("tmux not found; falling back to direct exec")
args.cmd = []
self.run_exec(args)
if dry_run:
return
result = self.runner.run(
[rt, "container", "inspect", cname, "--format", "{{ .Config.Image }}"]
)
image_ref = result.stdout.strip()
_, _, _, image_label = parse_image_ref(image_ref)
# Build docker run command
argv = ["docker", "run", "-d", "--name", spec.name]
check = self.runner.run(["tmux", "has-session", "-t", cname], check=False)
if check.returncode != 0:
ns = os.environ.get("DF_NAMESPACE", "")
plat = os.environ.get("DF_PLATFORM", "")
self.runner.run(
[
"tmux",
"new-session",
"-ds",
cname,
"-e",
f"DF_IMAGE={image_label}",
"-e",
f"DF_NAMESPACE={ns}",
"-e",
f"DF_PLATFORM={plat}",
f"flow dev exec {args.name}",
],
capture_output=True,
)
self.runner.run(
[
"tmux",
"set-option",
"-t",
cname,
"default-command",
f"flow dev exec {args.name}",
],
capture_output=True,
for m in spec.mounts:
argv.extend(["-v", f"{m.source}:{m.target}{':ro' if m.readonly else ''}"])
for k, v in spec.env.items():
argv.extend(["-e", f"{k}={v}"])
argv.append(spec.image.full)
if spec.command:
argv.extend(spec.command.split())
self.ctx.runtime.runner.run(argv, check=True, capture_output=False)
self.ctx.console.success(f"Container {spec.name} created.")
def enter(
self,
name: str,
*,
shell: str = "/bin/bash",
) -> None:
"""Exec into a running container."""
self.ctx.console.info(f"Entering container: {name}")
self.ctx.runtime.runner.run(
["docker", "exec", "-it", name, shell],
capture_output=False,
check=True,
)
if os.environ.get("TMUX"):
os.execvp("tmux", ["tmux", "switch-client", "-t", cname])
os.execvp("tmux", ["tmux", "attach", "-t", cname])
def run_list(self, _args) -> None:
rt = runtime()
result = self.runner.run(
[
rt,
"ps",
"-a",
"--filter",
"label=dev=true",
"--format",
'{{.Label "dev.name"}}|{{.Image}}|{{.Label "dev.project_path"}}|{{.Status}}',
]
def stop(self, name: str) -> None:
"""Stop a running container."""
self.ctx.runtime.runner.run(
["docker", "stop", name], check=True,
)
self.ctx.console.success(f"Container {name} stopped.")
def remove(self, name: str) -> None:
"""Remove a container."""
self.ctx.runtime.runner.run(
["docker", "rm", "-f", name], check=True,
)
self.ctx.console.success(f"Container {name} removed.")
def list(self) -> None:
"""List flow-managed containers."""
result = self.ctx.runtime.runner.run(
["docker", "ps", "-a", "--filter", "name=flow-", "--format",
"{{.Names}}\t{{.Image}}\t{{.Status}}"],
)
if not result.stdout.strip():
self.ctx.console.info("No flow containers found.")
return
rows = []
for line in result.stdout.strip().splitlines():
if not line:
continue
name, image, project, status = (line.split("|") + ["", "", "", ""])[:4]
home = os.path.expanduser("~")
if project.startswith(home):
project = "~" + project[len(home) :]
rows.append([name, image, project, status])
parts = line.split("\t")
if len(parts) >= 3:
rows.append(parts[:3])
if not rows:
self.ctx.console.info("No development containers found.")
return
self.ctx.console.table(["NAME", "IMAGE", "PROJECT", "STATUS"], rows)
def run_stop(self, args) -> None:
rt = runtime()
cname = container_name(args.name)
if not self.container_exists(rt, cname):
raise FlowError(f"Container {cname} does not exist")
if args.kill:
self.ctx.console.info(f"Killing container {cname}...")
self.runner.run([rt, "kill", cname], capture_output=False, check=True)
else:
self.ctx.console.info(f"Stopping container {cname}...")
self.runner.run([rt, "stop", cname], capture_output=False, check=True)
self._tmux_fallback(cname)
def run_remove(self, args) -> None:
rt = runtime()
cname = container_name(args.name)
if not self.container_exists(rt, cname):
raise FlowError(f"Container {cname} does not exist")
if args.force:
self.ctx.console.info(f"Removing container {cname} (force)...")
self.runner.run([rt, "rm", "-f", cname], capture_output=False, check=True)
else:
self.ctx.console.info(f"Removing container {cname}...")
self.runner.run([rt, "rm", cname], capture_output=False, check=True)
self._tmux_fallback(cname)
def run_respawn(self, args) -> None:
if not shutil.which("tmux"):
raise FlowError("tmux is required for respawn but was not found")
cname = container_name(args.name)
result = self.runner.run(
[
"tmux",
"list-panes",
"-t",
cname,
"-s",
"-F",
"#{session_name}:#{window_index}.#{pane_index}",
]
)
for pane in result.stdout.strip().splitlines():
if not pane:
continue
self.ctx.console.info(f"Respawning {pane}...")
self.runner.run(["tmux", "respawn-pane", "-t", pane], capture_output=False)
def _tmux_fallback(self, cname: str) -> None:
if not os.environ.get("TMUX"):
return
result = self.runner.run(["tmux", "display-message", "-p", "#S"])
if result.stdout.strip() != cname:
return
self.runner.run(["tmux", "new-session", "-ds", "default"], capture_output=True)
self.runner.run(["tmux", "switch-client", "-t", "default"], capture_output=True)
self.ctx.console.table(["NAME", "IMAGE", "STATUS"], rows)

File diff suppressed because it is too large Load Diff

View File

@@ -1,113 +1,233 @@
"""Package-state service built on shared package definitions."""
# src/flow/services/packages.py
"""PackageService -- orchestrates package installation."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
from flow.core.config import FlowContext
from flow.core.system import JsonStateStore
from flow.services.package_defs import BinaryInstaller, get_package_catalog
from flow.core.errors import FlowError
from flow.core import paths
from flow.domain.packages.catalog import normalize_profile_entry, parse_catalog
from flow.domain.packages.models import (
InstalledPackage,
InstalledState,
PackageDef,
PackagePlan,
)
from flow.domain.packages.planning import plan_install, plan_remove
from flow.domain.packages.resolution import (
detect_package_manager,
pm_install_command,
pm_update_command,
resolve_binary_asset,
resolve_download_url,
resolve_source_name,
resolve_spec,
)
class PackageService:
def __init__(self, ctx: FlowContext, *, installed_state: Path):
def __init__(self, ctx: FlowContext):
self.ctx = ctx
self.installed = JsonStateStore(installed_state, ctx.runtime.fs, dict)
self.binary_installer = BinaryInstaller(ctx)
def load_installed(self) -> dict:
state = self.installed.load()
return state if isinstance(state, dict) else {}
def install(
self,
package_names: Optional[list[str]] = None,
*,
profile: Optional[str] = None,
dry_run: bool = False,
) -> None:
"""Install packages from profile or by name."""
catalog = parse_catalog(self.ctx.manifest)
installed = self._load_state()
pm = detect_package_manager()
def save_installed(self, state: dict) -> None:
self.installed.save(state)
def definitions(self):
return get_package_catalog(self.ctx)
def install(self, args) -> None:
definitions = self.definitions()
installed = self.load_installed()
had_error = False
for package_name in args.packages:
package_def = definitions.get(package_name)
if not package_def:
self.ctx.console.error(f"Package not found in manifest: {package_name}")
had_error = True
continue
package_type = package_def.get("type", "pkg")
if package_type != "binary":
self.ctx.console.error(
f"'flow package install' supports binary packages only. '{package_name}' is type '{package_type}'."
)
had_error = True
continue
self.ctx.console.info(f"Installing {package_name}...")
try:
self.binary_installer.install(package_def, {}, dry_run=args.dry_run)
except RuntimeError as exc:
self.ctx.console.error(str(exc))
had_error = True
continue
if not args.dry_run:
installed[package_name] = {
"version": str(package_def.get("version", "")),
"type": package_type,
}
self.ctx.console.success(f"Installed {package_name}")
if not args.dry_run:
self.save_installed(installed)
if had_error:
raise SystemExit(1)
def list(self, args) -> None:
definitions = self.definitions()
installed = self.load_installed()
rows = []
if args.all:
if not definitions:
self.ctx.console.info("No packages defined in manifest.")
return
for name, package_def in sorted(definitions.items()):
rows.append(
[
name,
str(package_def.get("type", "pkg")),
str(installed.get(name, {}).get("version", "-")),
str(package_def.get("version", "")) or "-",
]
)
# Resolve packages to install
packages: list[PackageDef] = []
if package_names:
for name in package_names:
ref = normalize_profile_entry(name)
pkg = resolve_spec(ref, catalog)
packages.append(pkg)
elif profile:
profiles = self.ctx.manifest.get("profiles", {})
if profile not in profiles:
raise FlowError(f"Unknown profile: {profile}")
profile_data = profiles[profile]
for entry in profile_data.get("packages", []):
ref = normalize_profile_entry(entry)
pkg = resolve_spec(ref, catalog)
packages.append(pkg)
else:
if not installed:
self.ctx.console.info("No packages installed.")
raise FlowError("Specify package names or --profile")
plan = plan_install(packages, installed, self.ctx.platform.platform, pm)
if not plan.install_ops:
self.ctx.console.info("All packages already installed.")
return
for name, info in sorted(installed.items()):
rows.append(
[
name,
str(info.get("type", "?")),
str(info.get("version", "?")),
str(definitions.get(name, {}).get("version", "")) or "-",
self.ctx.console.print_plan(
[str(op) for op in plan.install_ops], verb="install"
)
if dry_run:
return
# Execute PM packages
if plan.pm_update_needed and pm:
self.ctx.console.info(f"Updating package manager ({pm})...")
self.ctx.runtime.runner.run_shell(
pm_update_command(pm), check=True,
)
pm_names = [
op.source_name for op in plan.install_ops if op.method == "pm"
]
if pm_names and pm:
cmd = pm_install_command(pm, pm_names)
self.ctx.console.info(f"Installing: {', '.join(pm_names)}")
self.ctx.runtime.runner.run_shell(cmd, check=True)
for op in plan.install_ops:
if op.method == "pm":
installed.packages[op.package.name] = InstalledPackage(
name=op.package.name,
version=op.package.version or "system",
type="pkg",
)
self.ctx.console.table(["PACKAGE", "TYPE", "INSTALLED", "AVAILABLE"], rows)
# Execute binary packages
for op in plan.install_ops:
if op.method == "binary" and op.download_url:
self._install_binary(op.package, op.download_url, op.source_name, installed)
elif op.method == "appimage" and op.download_url:
self._install_appimage(op.package, op.download_url, installed)
def remove(self, args) -> None:
installed = self.load_installed()
for package_name in args.packages:
if package_name not in installed:
self.ctx.console.warn(f"Package not installed: {package_name}")
continue
del installed[package_name]
self.ctx.console.success(f"Removed {package_name} from installed packages")
self.ctx.console.warn(
"Note: installed files were not automatically deleted. Remove manually if needed."
self._save_state(installed)
self.ctx.console.success(f"Installed {len(plan.install_ops)} package(s).")
def _install_binary(
self, pkg: PackageDef, url: str, asset: str, state: InstalledState,
) -> None:
"""Download and install a binary package."""
self.ctx.console.info(f"Downloading {pkg.name}...")
tmp_dir = paths.DATA_DIR / "tmp"
self.ctx.runtime.fs.ensure_dir(tmp_dir)
archive = tmp_dir / asset
self.ctx.runtime.runner.run_shell(
f"curl -fSL -o {archive} '{url}'", check=True,
)
self.save_installed(installed)
bin_dir = Path.home() / ".local" / "bin"
self.ctx.runtime.fs.ensure_dir(bin_dir)
installed_files: list[Path] = []
if asset.endswith((".tar.gz", ".tar.xz", ".tar.bz2", ".tgz")):
extract_dir = tmp_dir / f"{pkg.name}-extract"
self.ctx.runtime.fs.ensure_dir(extract_dir)
self.ctx.runtime.runner.run_shell(
f"tar -xf {archive} -C {extract_dir}", check=True,
)
# Find and install binaries
install_cfg = pkg.install or {}
binary_name = install_cfg.get("binary", pkg.name)
src_dir = pkg.extract_dir or ""
for candidate in extract_dir.rglob(binary_name):
if candidate.is_file():
target = bin_dir / binary_name
self.ctx.runtime.fs.copy_file(candidate, target)
target.chmod(0o755)
installed_files.append(target)
break
else:
# Single binary
target = bin_dir / pkg.name
self.ctx.runtime.fs.copy_file(archive, target)
target.chmod(0o755)
installed_files.append(target)
# Cleanup
self.ctx.runtime.fs.remove_tree(tmp_dir)
state.packages[pkg.name] = InstalledPackage(
name=pkg.name,
version=pkg.version or "latest",
type="binary",
files=installed_files,
)
def _install_appimage(
self, pkg: PackageDef, url: str, state: InstalledState,
) -> None:
"""Download and install an AppImage."""
bin_dir = Path.home() / ".local" / "bin"
self.ctx.runtime.fs.ensure_dir(bin_dir)
target = bin_dir / pkg.name
self.ctx.console.info(f"Downloading {pkg.name} AppImage...")
self.ctx.runtime.runner.run_shell(
f"curl -fSL -o {target} '{url}'", check=True,
)
target.chmod(0o755)
state.packages[pkg.name] = InstalledPackage(
name=pkg.name,
version=pkg.version or "latest",
type="appimage",
files=[target],
)
def remove(
self,
package_names: list[str],
*,
dry_run: bool = False,
) -> None:
"""Remove installed packages."""
installed = self._load_state()
plan = plan_remove(package_names, installed)
if not plan.remove_ops:
self.ctx.console.info("No matching packages to remove.")
return
self.ctx.console.print_plan(
[str(op) for op in plan.remove_ops], verb="remove"
)
if dry_run:
return
for op in plan.remove_ops:
for f in op.files:
self.ctx.runtime.fs.remove_file(f, missing_ok=True)
installed.packages.pop(op.name, None)
self._save_state(installed)
self.ctx.console.success(f"Removed {len(plan.remove_ops)} package(s).")
def list_packages(self) -> None:
"""List installed packages."""
installed = self._load_state()
if not installed.packages:
self.ctx.console.info("No packages installed by flow.")
return
rows = [
[name, pkg.version, pkg.type]
for name, pkg in sorted(installed.packages.items())
]
self.ctx.console.table(["NAME", "VERSION", "TYPE"], rows)
def _load_state(self) -> InstalledState:
data = self.ctx.runtime.fs.read_json(paths.INSTALLED_STATE, default={})
if data is None:
data = {}
return InstalledState.from_dict(data)
def _save_state(self, state: InstalledState) -> None:
self.ctx.runtime.fs.write_json(paths.INSTALLED_STATE, state.as_dict())

View File

@@ -1,174 +1,87 @@
"""Project sync service for `flow sync`."""
"""ProjectService -- manages git project status."""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from typing import Optional
from flow.core.config import FlowContext
from flow.core.errors import FlowError
class ProjectSyncService:
"""Inspect and synchronize git repositories under the projects directory."""
class ProjectService:
def __init__(self, ctx: FlowContext):
self.ctx = ctx
self.runner = ctx.runtime.runner
self.projects_dir = Path(self.ctx.config.projects_dir).expanduser()
def git(self, repo: str, *cmd: str, capture: bool = True) -> subprocess.CompletedProcess[str]:
return self.runner.run(
["git", "-C", repo, *cmd],
capture_output=capture,
)
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
def is_git_repo(self, repo_path: str) -> bool:
git_dir = os.path.join(repo_path, ".git")
return os.path.isdir(git_dir) or os.path.isfile(git_dir)
repos = self._find_repos()
if not repos:
self.ctx.console.info("No git repositories found.")
return
def check_repo(self, repo_path: str, do_fetch: bool = True) -> tuple[str, list[str] | None]:
name = os.path.basename(repo_path)
if not self.is_git_repo(repo_path):
return name, None
issues: list[str] = []
if do_fetch:
fetch_result = self.git(repo_path, "fetch", "--all", "--quiet")
if fetch_result.returncode != 0:
issues.append("git fetch failed")
result = self.git(repo_path, "rev-parse", "--abbrev-ref", "HEAD")
branch = result.stdout.strip() if result.returncode == 0 else "HEAD"
diff_result = self.git(repo_path, "diff", "--quiet")
cached_result = self.git(repo_path, "diff", "--cached", "--quiet")
if diff_result.returncode != 0 or cached_result.returncode != 0:
issues.append("uncommitted changes")
else:
untracked = self.git(repo_path, "ls-files", "--others", "--exclude-standard")
if untracked.stdout.strip():
issues.append("untracked files")
upstream_check = self.git(repo_path, "rev-parse", "--abbrev-ref", f"{branch}@{{u}}")
if upstream_check.returncode == 0:
unpushed = self.git(repo_path, "rev-list", "--oneline", f"{branch}@{{u}}..{branch}")
if unpushed.stdout.strip():
issues.append(
f"{len(unpushed.stdout.strip().splitlines())} unpushed commit(s) on {branch}"
)
else:
issues.append(f"no upstream for {branch}")
branches_result = self.git(
repo_path,
"for-each-ref",
"--format=%(refname:short)",
"refs/heads",
)
for branch_name in branches_result.stdout.strip().splitlines():
if not branch_name or branch_name == branch:
continue
upstream = self.git(repo_path, "rev-parse", "--abbrev-ref", f"{branch_name}@{{u}}")
if upstream.returncode == 0:
ahead = self.git(repo_path, "rev-list", "--count", f"{branch_name}@{{u}}..{branch_name}")
if ahead.stdout.strip() != "0":
issues.append(f"branch {branch_name}: {ahead.stdout.strip()} ahead")
else:
issues.append(f"branch {branch_name}: no upstream")
return name, issues
def _projects_dir(self) -> str:
projects_dir = os.path.expanduser(self.ctx.config.projects_dir)
if not os.path.isdir(projects_dir):
raise FlowError(f"Projects directory not found: {projects_dir}")
return projects_dir
def run_check(self, args) -> None:
projects_dir = self._projects_dir()
if fetch:
self.ctx.console.info("Fetching all remotes...")
for repo in repos:
self.ctx.runtime.git.run(repo, "fetch", "--all", "--quiet")
rows = []
needs_action = []
not_git = []
checked = 0
for repo in repos:
status = self._repo_status(repo)
rows.append([repo.name, status])
for entry in sorted(os.listdir(projects_dir)):
repo_path = os.path.join(projects_dir, entry)
if not os.path.isdir(repo_path):
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
name, issues = self.check_repo(repo_path, do_fetch=args.fetch)
if issues is None:
not_git.append(name)
continue
checked += 1
if issues:
needs_action.append(name)
rows.append([name, "; ".join(issues)])
else:
rows.append([name, "clean and synced"])
def _repo_status(self, repo: Path) -> str:
"""Get human-readable status for a repo."""
parts = []
if checked == 0:
self.ctx.console.info("No git repositories found in projects directory.")
if not_git:
self.ctx.console.info(f"Skipped non-git directories: {', '.join(sorted(not_git))}")
return
# Check for uncommitted changes
result = self.ctx.runtime.git.run(
repo, "status", "--porcelain",
)
if result.stdout.strip():
parts.append("uncommitted changes")
self.ctx.console.table(["PROJECT", "STATUS"], rows)
# 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 needs_action:
self.ctx.console.warn(f"Projects needing action: {', '.join(sorted(needs_action))}")
else:
self.ctx.console.success("All repositories clean and synced.")
if not parts:
parts.append("clean")
if not_git:
self.ctx.console.info(f"Skipped non-git directories: {', '.join(sorted(not_git))}")
def run_fetch(self, _args) -> None:
projects_dir = self._projects_dir()
had_error = False
fetched = 0
for entry in sorted(os.listdir(projects_dir)):
repo_path = os.path.join(projects_dir, entry)
if not self.is_git_repo(repo_path):
continue
self.ctx.console.info(f"Fetching {entry}...")
result = self.git(repo_path, "fetch", "--all", "--quiet")
fetched += 1
if result.returncode != 0:
self.ctx.console.error(f"Failed to fetch {entry}")
had_error = True
if fetched == 0:
self.ctx.console.info("No git repositories found in projects directory.")
return
if had_error:
raise SystemExit(1)
self.ctx.console.success("All remotes fetched.")
def run_summary(self, _args) -> None:
projects_dir = self._projects_dir()
rows = []
for entry in sorted(os.listdir(projects_dir)):
repo_path = os.path.join(projects_dir, entry)
if not os.path.isdir(repo_path):
continue
name, issues = self.check_repo(repo_path, do_fetch=False)
if issues is None:
rows.append([name, "not a git repo"])
elif issues:
rows.append([name, "; ".join(issues)])
else:
rows.append([name, "clean"])
if not rows:
self.ctx.console.info("No projects found.")
return
self.ctx.console.table(["PROJECT", "STATUS"], rows)
return ", ".join(parts)

View File

@@ -0,0 +1,67 @@
"""RemoteService -- manages SSH connections to targets."""
from __future__ import annotations
import os
from typing import Optional
from flow.core.config import FlowContext
from flow.core.errors import FlowError
from flow.domain.remote.resolution import (
build_ssh_command,
list_targets,
resolve_target,
terminfo_fix_command,
)
class RemoteService:
def __init__(self, ctx: FlowContext):
self.ctx = ctx
def enter(
self,
target_spec: str,
*,
dry_run: bool = False,
) -> None:
"""SSH into a target."""
target = resolve_target(target_spec, self.ctx.config.targets)
cmd = build_ssh_command(target)
self.ctx.console.info(f"Connecting to {target.label} ({target.host})")
if dry_run:
self.ctx.console.info(f"Would run: {' '.join(cmd.argv)}")
return
# Set env vars for the SSH session
env = dict(os.environ)
env.update(cmd.env)
self.ctx.runtime.runner.run(
cmd.argv,
env=env,
capture_output=False,
check=True,
)
def list(self) -> None:
"""List configured targets."""
targets = list_targets(self.ctx.config.targets)
if not targets:
self.ctx.console.info("No targets configured.")
return
rows = [
[t.label, t.host, t.identity or "-"]
for t in targets
]
self.ctx.console.table(["TARGET", "HOST", "IDENTITY"], rows)
def fix_terminfo(self, target_spec: str) -> None:
"""Show terminfo fix commands."""
cmds = terminfo_fix_command()
self.ctx.console.info("Run these commands to fix terminfo:")
for cmd in cmds:
self.ctx.console.info(f" {cmd}")

View File

@@ -0,0 +1,67 @@
"""Tests for BootstrapService."""
from pathlib import Path
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.bootstrap import BootstrapService
def _make_ctx(manifest=None):
return FlowContext(
config=AppConfig(),
manifest=manifest or {},
platform=PlatformInfo(),
console=Console(color=False),
runtime=SystemRuntime(),
)
class TestBootstrapService:
def test_show_profile(self, capsys):
manifest = {
"profiles": {
"work": {
"os": "linux",
"hostname": "dev",
"packages": ["fd"],
},
},
"packages": [{"name": "fd", "type": "pkg"}],
}
ctx = _make_ctx(manifest)
svc = BootstrapService(ctx)
svc.show("work")
output = capsys.readouterr().out
assert "work" in output
def test_unknown_profile_raises(self):
ctx = _make_ctx({"profiles": {}})
svc = BootstrapService(ctx)
with pytest.raises(FlowError, match="Unknown profile"):
svc.run("missing")
def test_list_profiles(self, capsys):
manifest = {
"profiles": {
"work": {"os": "linux", "hostname": "dev"},
"personal": {"os": "linux"},
},
}
ctx = _make_ctx(manifest)
svc = BootstrapService(ctx)
svc.list_profiles()
output = capsys.readouterr().out
assert "work" in output
assert "personal" in output
def test_list_profiles_empty(self, capsys):
ctx = _make_ctx({})
svc = BootstrapService(ctx)
svc.list_profiles()
assert "No profiles" in capsys.readouterr().out

View File

@@ -0,0 +1,73 @@
"""Tests for ContainerService."""
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console
from flow.core.platform import PlatformInfo
from flow.core.runtime import CommandRunner, FileSystem, SystemRuntime
from flow.core import paths
from flow.services.containers import ContainerService
class FakeRunner(CommandRunner):
"""CommandRunner that captures calls instead of executing."""
def __init__(self):
self.calls: list[tuple] = []
def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None):
self.calls.append(("run", list(argv)))
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
def run_shell(self, command, *, cwd=None, env=None, capture_output=True, check=False, timeout=None):
self.calls.append(("run_shell", command))
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
def _make_ctx(tmp_path, runner=None):
rt = SystemRuntime()
if runner:
rt.runner = runner
return FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=rt,
)
class TestContainerService:
def test_create_dry_run(self, tmp_path, capsys, monkeypatch):
monkeypatch.setattr(paths, "HOME", tmp_path)
monkeypatch.setattr(paths, "DOTFILES_DIR", tmp_path / "dotfiles")
ctx = _make_ctx(tmp_path)
svc = ContainerService(ctx)
svc.create("devbox", "personal", dry_run=True)
output = capsys.readouterr().out
assert "devbox" in output
def test_list_no_docker(self, tmp_path, capsys):
runner = FakeRunner()
ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx)
svc.list()
# FakeRunner returns empty stdout -> "No flow containers"
output = capsys.readouterr().out
assert "No flow containers" in output
def test_stop_calls_docker(self, tmp_path):
runner = FakeRunner()
ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx)
svc.stop("flow-personal-devbox")
assert any("docker" in str(c) and "stop" in str(c) for c in runner.calls)
def test_remove_calls_docker(self, tmp_path):
runner = FakeRunner()
ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx)
svc.remove("flow-personal-devbox")
assert any("docker" in str(c) and "rm" in str(c) for c in runner.calls)

View File

@@ -0,0 +1,172 @@
"""Tests for DotfilesService."""
from pathlib import Path
from unittest.mock import MagicMock
import yaml
from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console
from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
from flow.core import paths
from flow.services.dotfiles import DotfilesService
def _make_ctx(tmp_path, console=None):
"""Build a FlowContext for testing."""
return FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=console or Console(color=False),
runtime=SystemRuntime(),
)
def _setup_dotfiles(tmp_path, packages_files):
"""Set up a fake dotfiles directory structure.
packages_files: dict of {package_name: {relative_path: content}}
"""
dotfiles = tmp_path / "dotfiles"
shared = dotfiles / "_shared"
for pkg_name, files in packages_files.items():
pkg_dir = shared / pkg_name
for rel_path, content in files.items():
file_path = pkg_dir / rel_path
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
return dotfiles
class TestDotfilesServiceLink:
def test_link_creates_symlinks(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh config"},
"git": {".config/git/config": "[user]\n name = test"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link()
assert (home / ".zshrc").is_symlink()
assert (home / ".config" / "git" / "config").is_symlink()
def test_link_with_module(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = tmp_path / "dotfiles"
modules = tmp_path / "modules"
# Set up package with _module.yaml
pkg_dir = dotfiles / "_shared" / "nvim"
config_dir = pkg_dir / ".config" / "nvim"
config_dir.mkdir(parents=True)
(config_dir / "_module.yaml").write_text(yaml.dump({
"source": "github:test/nvim-config",
"ref": {"branch": "main"},
}))
# Set up local file outside mount path
(pkg_dir / ".local" / "bin").mkdir(parents=True)
(pkg_dir / ".local" / "bin" / "nvim-wrapper").write_text("#!/bin/sh")
# Set up cloned module
module_dir = modules / "_shared--nvim"
module_dir.mkdir(parents=True)
(module_dir / "init.lua").write_text("-- init")
(module_dir / "lua").mkdir()
(module_dir / "lua" / "plugins.lua").write_text("-- plugins")
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", modules)
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link()
# Module files should be linked under .config/nvim/
assert (home / ".config" / "nvim" / "init.lua").is_symlink()
assert (home / ".config" / "nvim" / "lua" / "plugins.lua").is_symlink()
# Local file outside mount path should be linked
assert (home / ".local" / "bin" / "nvim-wrapper").is_symlink()
def test_unlink_removes_symlinks(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
# Link first
svc.link()
assert (home / ".zshrc").is_symlink()
# Then unlink
svc.unlink()
assert not (home / ".zshrc").exists()
def test_link_dry_run_no_changes(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link(dry_run=True)
# No symlinks should exist
assert not (home / ".zshrc").exists()
def test_status_shows_packages(self, tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
# Link first to populate state
svc.link()
# Check status
svc.status()
output = capsys.readouterr().out
assert "zsh" in output

View File

@@ -0,0 +1,65 @@
"""Tests for PackageService."""
from pathlib import Path
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.core import paths
from flow.domain.packages.models import InstalledPackage, InstalledState
from flow.services.packages import PackageService
def _make_ctx(tmp_path, manifest=None):
return FlowContext(
config=AppConfig(),
manifest=manifest or {},
platform=PlatformInfo(),
console=Console(color=False),
runtime=SystemRuntime(),
)
class TestPackageService:
def test_list_empty(self, tmp_path, monkeypatch, capsys):
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
ctx = _make_ctx(tmp_path)
svc = PackageService(ctx)
svc.list_packages()
assert "No packages" in capsys.readouterr().out
def test_list_shows_installed(self, tmp_path, monkeypatch, capsys):
state = InstalledState(packages={
"fd": InstalledPackage(name="fd", version="10.2", type="pkg"),
})
state_path = tmp_path / "installed.json"
import json
state_path.parent.mkdir(parents=True, exist_ok=True)
with open(state_path, "w") as f:
json.dump(state.as_dict(), f)
monkeypatch.setattr(paths, "INSTALLED_STATE", state_path)
ctx = _make_ctx(tmp_path)
svc = PackageService(ctx)
svc.list_packages()
output = capsys.readouterr().out
assert "fd" in output
assert "10.2" in output
def test_remove_not_installed(self, tmp_path, monkeypatch, capsys):
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
ctx = _make_ctx(tmp_path)
svc = PackageService(ctx)
svc.remove(["missing"])
assert "No matching" in capsys.readouterr().out
def test_install_requires_args(self, tmp_path, monkeypatch):
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
ctx = _make_ctx(tmp_path)
svc = PackageService(ctx)
with pytest.raises(FlowError, match="Specify"):
svc.install()

View File

@@ -0,0 +1,78 @@
"""Tests for ProjectService."""
import subprocess
from pathlib import Path
from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console
from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
from flow.services.projects import ProjectService
def _make_ctx(projects_dir):
return FlowContext(
config=AppConfig(projects_dir=str(projects_dir)),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=SystemRuntime(),
)
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)
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)
class TestProjectService:
def test_check_clean_repo(self, tmp_path, capsys):
projects = tmp_path / "projects"
projects.mkdir()
_init_repo(projects / "myrepo")
ctx = _make_ctx(projects)
svc = ProjectService(ctx)
svc.check(fetch=False)
output = capsys.readouterr().out
assert "myrepo" in output
assert "clean" in output
def test_check_uncommitted_changes(self, tmp_path, capsys):
projects = tmp_path / "projects"
projects.mkdir()
_init_repo(projects / "myrepo")
(projects / "myrepo" / "new_file.txt").write_text("changes")
ctx = _make_ctx(projects)
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):
projects = tmp_path / "projects"
projects.mkdir()
(projects / "not-a-repo").mkdir()
ctx = _make_ctx(projects)
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")
svc = ProjectService(ctx)
svc.check(fetch=False)
assert "not found" in capsys.readouterr().out

View File

@@ -0,0 +1,62 @@
"""Tests for RemoteService."""
import pytest
from flow.core.config import AppConfig, FlowContext, TargetConfig
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
def _make_ctx(targets=None):
return FlowContext(
config=AppConfig(targets=targets or []),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=SystemRuntime(),
)
class TestRemoteService:
def test_enter_dry_run(self, capsys):
targets = [TargetConfig(namespace="personal", platform="orb", host="personal.orb")]
ctx = _make_ctx(targets)
svc = RemoteService(ctx)
svc.enter("personal@orb", dry_run=True)
output = capsys.readouterr().out
assert "personal@orb" in output
assert "ssh" in output
def test_enter_unknown_target(self):
ctx = _make_ctx()
svc = RemoteService(ctx)
with pytest.raises(FlowError, match="Unknown target"):
svc.enter("missing@host")
def test_list_targets(self, capsys):
targets = [
TargetConfig(namespace="personal", platform="orb", host="personal.orb"),
TargetConfig(namespace="work", platform="ec2", host="work.ec2"),
]
ctx = _make_ctx(targets)
svc = RemoteService(ctx)
svc.list()
output = capsys.readouterr().out
assert "personal@orb" in output
assert "work@ec2" in output
def test_list_empty(self, capsys):
ctx = _make_ctx()
svc = RemoteService(ctx)
svc.list()
assert "No targets" in capsys.readouterr().out
def test_fix_terminfo(self, capsys):
ctx = _make_ctx()
svc = RemoteService(ctx)
svc.fix_terminfo("personal@orb")
output = capsys.readouterr().out
assert "infocmp" in output