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 from __future__ import annotations
import os import os
import shutil
from typing import Optional from typing import Optional
from flow.core.config import FlowContext from flow.core.config import FlowContext
from flow.core.errors import FlowError from flow.core.errors import FlowError
from flow.core import paths
DEFAULT_REGISTRY = "registry.tomastm.com" from flow.domain.containers.resolution import (
DEFAULT_TAG = "latest" build_container_spec,
CONTAINER_HOME = "/home/dev" container_name,
parse_image_ref,
resolve_mounts,
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
class ContainerService: class ContainerService:
"""Own all container-runtime interactions."""
def __init__(self, ctx: FlowContext): def __init__(self, ctx: FlowContext):
self.ctx = ctx self.ctx = ctx
self.runner = ctx.runtime.runner
def container_exists(self, rt: str, name: str) -> bool: def create(
result = self.runner.run( self,
[rt, "container", "ls", "-a", "--format", "{{.Names}}"], image: str,
capture_output=True, namespace: str = "default",
) *,
return name in result.stdout.strip().splitlines() dry_run: bool = False,
extra_env: Optional[dict[str, str]] = None,
def container_running(self, rt: str, name: str) -> bool: ) -> None:
result = self.runner.run( """Create and start a container."""
[rt, "container", "ls", "--format", "{{.Names}}"], image_ref = parse_image_ref(
capture_output=True, image,
)
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,
default_registry=self.ctx.config.container_registry, default_registry=self.ctx.config.container_registry,
default_tag=self.ctx.config.container_tag, default_tag=self.ctx.config.container_tag,
) )
cmd = [ mounts = resolve_mounts(
rt, paths.HOME,
"run", self.ctx.config.projects_dir,
"-d", dotfiles_dir=paths.DOTFILES_DIR,
"--name", )
cname,
"--label",
"dev=true",
"--label",
f"dev.name={args.name}",
"--label",
f"dev.image_ref={full_ref}",
"--network",
"host",
"--init",
]
if project_path: spec = build_container_spec(
cmd.extend(["-v", f"{project_path}:/workspace"]) namespace, image_ref, mounts,
cmd.extend(["--label", f"dev.project_path={project_path}"]) env=extra_env,
)
docker_sock = "/var/run/docker.sock" self.ctx.console.info(f"Creating container: {spec.name}")
if os.path.exists(docker_sock): self.ctx.console.info(f" Image: {spec.image.full}")
cmd.extend(["-v", f"{docker_sock}:{docker_sock}"]) self.ctx.console.info(f" Mounts: {len(spec.mounts)}")
home = os.path.expanduser("~") if dry_run:
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)
return return
result = self.runner.run( # Build docker run command
[rt, "container", "inspect", cname, "--format", "{{ .Config.Image }}"] argv = ["docker", "run", "-d", "--name", spec.name]
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,
) )
image_ref = result.stdout.strip()
_, _, _, image_label = parse_image_ref(image_ref)
check = self.runner.run(["tmux", "has-session", "-t", cname], check=False) def stop(self, name: str) -> None:
if check.returncode != 0: """Stop a running container."""
ns = os.environ.get("DF_NAMESPACE", "") self.ctx.runtime.runner.run(
plat = os.environ.get("DF_PLATFORM", "") ["docker", "stop", name], check=True,
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,
)
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}}',
]
) )
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 = [] rows = []
for line in result.stdout.strip().splitlines(): for line in result.stdout.strip().splitlines():
if not line: parts = line.split("\t")
continue if len(parts) >= 3:
name, image, project, status = (line.split("|") + ["", "", "", ""])[:4] rows.append(parts[:3])
home = os.path.expanduser("~")
if project.startswith(home):
project = "~" + project[len(home) :]
rows.append([name, image, project, status])
if not rows: self.ctx.console.table(["NAME", "IMAGE", "STATUS"], 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)

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 __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Optional
from flow.core.config import FlowContext from flow.core.config import FlowContext
from flow.core.system import JsonStateStore from flow.core.errors import FlowError
from flow.services.package_defs import BinaryInstaller, get_package_catalog 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: class PackageService:
def __init__(self, ctx: FlowContext, *, installed_state: Path): def __init__(self, ctx: FlowContext):
self.ctx = ctx self.ctx = ctx
self.installed = JsonStateStore(installed_state, ctx.runtime.fs, dict)
self.binary_installer = BinaryInstaller(ctx)
def load_installed(self) -> dict: def install(
state = self.installed.load() self,
return state if isinstance(state, dict) else {} 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: # Resolve packages to install
self.installed.save(state) packages: list[PackageDef] = []
if package_names:
def definitions(self): for name in package_names:
return get_package_catalog(self.ctx) ref = normalize_profile_entry(name)
pkg = resolve_spec(ref, catalog)
def install(self, args) -> None: packages.append(pkg)
definitions = self.definitions() elif profile:
installed = self.load_installed() profiles = self.ctx.manifest.get("profiles", {})
had_error = False if profile not in profiles:
raise FlowError(f"Unknown profile: {profile}")
for package_name in args.packages: profile_data = profiles[profile]
package_def = definitions.get(package_name) for entry in profile_data.get("packages", []):
if not package_def: ref = normalize_profile_entry(entry)
self.ctx.console.error(f"Package not found in manifest: {package_name}") pkg = resolve_spec(ref, catalog)
had_error = True packages.append(pkg)
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 "-",
]
)
else: else:
if not installed: raise FlowError("Specify package names or --profile")
self.ctx.console.info("No packages 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.table(["PACKAGE", "TYPE", "INSTALLED", "AVAILABLE"], rows) plan = plan_install(packages, installed, self.ctx.platform.platform, pm)
def remove(self, args) -> None: if not plan.install_ops:
installed = self.load_installed() self.ctx.console.info("All packages already installed.")
for package_name in args.packages: return
if package_name not in installed:
self.ctx.console.warn(f"Package not installed: {package_name}") self.ctx.console.print_plan(
continue [str(op) for op in plan.install_ops], verb="install"
del installed[package_name] )
self.ctx.console.success(f"Removed {package_name} from installed packages")
self.ctx.console.warn( if dry_run:
"Note: installed files were not automatically deleted. Remove manually if needed." 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,
) )
self.save_installed(installed)
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",
)
# 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)
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,
)
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 from __future__ import annotations
import os from pathlib import Path
import subprocess from typing import Optional
from flow.core.config import FlowContext from flow.core.config import FlowContext
from flow.core.errors import FlowError from flow.core.errors import FlowError
class ProjectSyncService: class ProjectService:
"""Inspect and synchronize git repositories under the projects directory."""
def __init__(self, ctx: FlowContext): def __init__(self, ctx: FlowContext):
self.ctx = ctx 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]: def check(self, *, fetch: bool = False) -> None:
return self.runner.run( """Check status of all git repos in projects dir."""
["git", "-C", repo, *cmd], if not self.projects_dir.is_dir():
capture_output=capture, self.ctx.console.info(f"Projects directory not found: {self.projects_dir}")
) return
def is_git_repo(self, repo_path: str) -> bool: repos = self._find_repos()
git_dir = os.path.join(repo_path, ".git") if not repos:
return os.path.isdir(git_dir) or os.path.isfile(git_dir) 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]: if fetch:
name = os.path.basename(repo_path) self.ctx.console.info("Fetching all remotes...")
if not self.is_git_repo(repo_path): for repo in repos:
return name, None self.ctx.runtime.git.run(repo, "fetch", "--all", "--quiet")
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()
rows = [] rows = []
needs_action = [] for repo in repos:
not_git = [] status = self._repo_status(repo)
checked = 0 rows.append([repo.name, status])
for entry in sorted(os.listdir(projects_dir)): self.ctx.console.table(["REPO", "STATUS"], rows)
repo_path = os.path.join(projects_dir, entry)
if not os.path.isdir(repo_path): 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 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) def _repo_status(self, repo: Path) -> str:
if issues is None: """Get human-readable status for a repo."""
not_git.append(name) parts = []
continue
checked += 1
if issues:
needs_action.append(name)
rows.append([name, "; ".join(issues)])
else:
rows.append([name, "clean and synced"])
if checked == 0: # Check for uncommitted changes
self.ctx.console.info("No git repositories found in projects directory.") result = self.ctx.runtime.git.run(
if not_git: repo, "status", "--porcelain",
self.ctx.console.info(f"Skipped non-git directories: {', '.join(sorted(not_git))}") )
return 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: if not parts:
self.ctx.console.warn(f"Projects needing action: {', '.join(sorted(needs_action))}") parts.append("clean")
else:
self.ctx.console.success("All repositories clean and synced.")
if not_git: return ", ".join(parts)
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)

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