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
|
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
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
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