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:
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