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:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
from flow.core import paths
|
||||
from flow.domain.containers.resolution import (
|
||||
build_container_spec,
|
||||
container_name,
|
||||
parse_image_ref,
|
||||
resolve_mounts,
|
||||
)
|
||||
label = f"{label_prefix}/{repo.split('/')[-1]}"
|
||||
|
||||
return full_ref, repo, tag, label
|
||||
|
||||
|
||||
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
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
67
src/flow/services/remote.py
Normal file
67
src/flow/services/remote.py
Normal 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}")
|
||||
67
tests/test_service_bootstrap.py
Normal file
67
tests/test_service_bootstrap.py
Normal 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
|
||||
73
tests/test_service_containers.py
Normal file
73
tests/test_service_containers.py
Normal 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)
|
||||
172
tests/test_service_dotfiles.py
Normal file
172
tests/test_service_dotfiles.py
Normal 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
|
||||
65
tests/test_service_packages.py
Normal file
65
tests/test_service_packages.py
Normal 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()
|
||||
78
tests/test_service_projects.py
Normal file
78
tests/test_service_projects.py
Normal 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
|
||||
62
tests/test_service_remote.py
Normal file
62
tests/test_service_remote.py
Normal 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
|
||||
Reference in New Issue
Block a user