This commit is contained in:
2026-03-16 08:06:25 +02:00
parent 78d4064853
commit d0f8315cf1
35 changed files with 2493 additions and 624 deletions

View File

@@ -8,11 +8,11 @@ import sys
from typing import Optional from typing import Optional
from flow import __version__ from flow import __version__
from flow.core.config import AppConfig, FlowContext, load_config, load_manifest from flow.core.config import FlowContext, load_config, load_manifest
from flow.core.console import Console from flow.core.console import Console
from flow.core.errors import FlowError from flow.core.errors import FlowError
from flow.core import paths from flow.core import paths
from flow.core.platform import PlatformInfo, detect_context, detect_platform from flow.core.platform import detect_context, detect_platform
from flow.core.runtime import SystemRuntime from flow.core.runtime import SystemRuntime
@@ -33,35 +33,25 @@ def main(argv: Optional[list[str]] = None) -> None:
parser.print_help() parser.print_help()
return return
# Build context try:
console = Console(quiet=getattr(args, "quiet", False), color=None) console = Console(quiet=getattr(args, "quiet", False), color=None)
platform_info = detect_platform() platform_info = detect_platform()
context = detect_context() context = detect_context()
# Block remote commands inside VMs
cmd_name = getattr(args, "command", "") cmd_name = getattr(args, "command", "")
if context == "vm" and cmd_name in ("remote", "dev"):
console.error(f"Command '{cmd_name}' is not available inside a VM.") if context == "vm" and cmd_name == "remote":
sys.exit(1) raise FlowError("Command 'remote' is not available inside a VM.")
if context == "container" and cmd_name in {"remote", "dev", "projects"}:
raise FlowError(f"Command '{cmd_name}' is not available inside a container.")
paths.ensure_dirs() paths.ensure_dirs()
config = load_config(paths.CONFIG_DIR)
# Also try loading config from dotfiles
if paths.DOTFILES_FLOW_CONFIG.is_dir():
dotfiles_config = load_config(paths.DOTFILES_FLOW_CONFIG)
config = _merge_config(config, dotfiles_config)
manifest = load_manifest(paths.DOTFILES_FLOW_CONFIG)
ctx = FlowContext( ctx = FlowContext(
config=config, config=load_config(),
manifest=manifest, manifest=load_manifest(),
platform=platform_info, platform=platform_info,
console=console, console=console,
runtime=SystemRuntime(), runtime=SystemRuntime(),
) )
try:
args.handler(ctx, args) args.handler(ctx, args)
except FlowError as e: except FlowError as e:
console.error(str(e)) console.error(str(e))
@@ -71,25 +61,6 @@ def main(argv: Optional[list[str]] = None) -> None:
sys.exit(130) sys.exit(130)
def _merge_config(base: AppConfig, overlay: AppConfig) -> AppConfig:
"""Merge two configs: overlay's explicitly-set fields override base."""
def _pick(field: str) -> str:
if field in overlay._explicit:
return getattr(overlay, field)
return getattr(base, field)
return AppConfig(
dotfiles_url=_pick("dotfiles_url"),
dotfiles_branch=_pick("dotfiles_branch"),
projects_dir=_pick("projects_dir"),
container_registry=_pick("container_registry"),
container_tag=_pick("container_tag"),
tmux_session=_pick("tmux_session"),
targets=overlay.targets if "targets" in overlay._explicit else base.targets,
)
def _build_parser() -> argparse.ArgumentParser: def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="flow", prog="flow",

View File

@@ -1,99 +1,506 @@
"""Zsh completion for flow CLI.""" """Shell completion support."""
from __future__ import annotations
COMMANDS = { import argparse
"dotfiles": { import json
"subcommands": ["link", "unlink", "status", "sync"], import shutil
"flags": { import subprocess
"link": ["--profile", "--dry-run", "--skip"], from pathlib import Path
"unlink": ["--dry-run"], from typing import Sequence
"status": [],
"sync": [],
},
},
"packages": {
"subcommands": ["install", "remove", "list"],
"flags": {
"install": ["--profile", "--dry-run"],
"remove": ["--dry-run"],
"list": [],
},
},
"setup": {
"subcommands": ["run", "show", "list"],
"flags": {
"run": ["--dry-run"],
"show": [],
"list": [],
},
},
"remote": {
"subcommands": ["enter", "list"],
"flags": {
"enter": ["--dry-run"],
"list": [],
},
},
"dev": {
"subcommands": ["create", "enter", "stop", "remove", "list"],
"flags": {
"create": ["--namespace", "--dry-run"],
"enter": ["--shell"],
"stop": [],
"remove": [],
"list": [],
},
},
"projects": {
"subcommands": ["check"],
"flags": {
"check": ["--fetch"],
},
},
}
from flow.core.config import load_config, load_manifest
from flow.core import paths
from flow.domain.remote.resolution import HOST_TEMPLATES
def complete(comp_words: list[str], comp_cword: int) -> list[str]: ZSH_RC_START = "# >>> flow completion >>>"
"""Return completions for the current word.""" ZSH_RC_END = "# <<< flow completion <<<"
if comp_cword <= 1:
# Complete top-level commands
prefix = comp_words[1] if len(comp_words) > 1 else ""
return [c for c in COMMANDS if c.startswith(prefix)]
command = comp_words[1] if len(comp_words) > 1 else "" TOP_LEVEL_COMMANDS = [
if command not in COMMANDS: "enter",
return [] "remote",
"dev",
cmd_def = COMMANDS[command] "dotfiles",
"setup",
if comp_cword == 2: "packages",
# Complete subcommands "projects",
prefix = comp_words[2] if len(comp_words) > 2 else "" "completion",
return [s for s in cmd_def["subcommands"] if s.startswith(prefix)] ]
if comp_cword >= 3:
# Complete flags for the subcommand
subcommand = comp_words[2] if len(comp_words) > 2 else ""
flags = cmd_def["flags"].get(subcommand, [])
prefix = comp_words[comp_cword] if comp_cword < len(comp_words) else ""
return [f for f in flags if f.startswith(prefix)]
return []
def register(subparsers): def register(subparsers):
"""Register completion subcommand.""" parser = subparsers.add_parser("completion", help="Shell completion helpers")
p = subparsers.add_parser("completion", help="Shell completion") sub = parser.add_subparsers(dest="completion_action")
p.add_argument("--shell", default="zsh", choices=["zsh"])
p.set_defaults(handler=_handle) zsh = sub.add_parser("zsh", help="Print the zsh completion script")
zsh.set_defaults(handler=_run_zsh_script)
install = sub.add_parser("install-zsh", help="Install zsh completion")
install.add_argument("--dir", default="~/.zsh/completions")
install.add_argument("--rc", default="~/.zshrc")
install.add_argument("--no-rc", action="store_true")
install.set_defaults(handler=_run_install_zsh)
hidden = sub.add_parser("_zsh_complete", help=argparse.SUPPRESS)
hidden.add_argument("--cword", type=int, required=True, help=argparse.SUPPRESS)
hidden.add_argument("words", nargs="*", help=argparse.SUPPRESS)
hidden.set_defaults(handler=_run_zsh_complete)
parser.set_defaults(handler=_run_zsh_script)
def _handle(ctx, args): def complete(words: Sequence[str], cword: int) -> list[str]:
"""Output completion script.""" before, current = _split_words(words, cword)
import os
comp_words = os.environ.get("COMP_WORDS", "").split()
comp_cword = int(os.environ.get("COMP_CWORD", "0"))
completions = complete(comp_words, comp_cword) if not before:
for c in completions: return _filter(TOP_LEVEL_COMMANDS + ["-h", "--help", "--version"], current)
print(c)
command = _canonical_command(before[0])
if command in {"enter", "remote"}:
return _complete_remote(before, current)
if command == "dev":
return _complete_dev(before, current)
if command == "dotfiles":
return _complete_dotfiles(before, current)
if command == "bootstrap":
return _complete_bootstrap(before, current)
if command == "packages":
return _complete_packages(before, current)
if command == "projects":
return _complete_projects(before, current)
if command == "completion":
return _complete_completion(before, current)
return []
def _split_words(words: Sequence[str], cword: int) -> tuple[list[str], str]:
tokens = list(words)
if not tokens:
return [], ""
index = max(1, cword)
current = tokens[index] if index < len(tokens) else ""
before = tokens[1:index]
return before, current
def _canonical_command(command: str) -> str:
aliases = {
"dot": "dotfiles",
"bootstrap": "bootstrap",
"setup": "bootstrap",
"provision": "bootstrap",
"package": "packages",
"pkg": "packages",
"project": "projects",
"sync": "projects",
}
return aliases.get(command, command)
def _filter(candidates: Sequence[str], prefix: str) -> list[str]:
unique = sorted(set(candidates))
if not prefix:
return unique
return [candidate for candidate in unique if candidate.startswith(prefix)]
def _config():
return load_config()
def _manifest():
return load_manifest()
def _list_targets() -> list[str]:
cfg = _config()
return sorted({f"{target.namespace}@{target.platform}" for target in cfg.targets})
def _list_namespaces() -> list[str]:
cfg = _config()
return sorted({target.namespace for target in cfg.targets})
def _list_platforms() -> list[str]:
cfg = _config()
return sorted(set(HOST_TEMPLATES) | {target.platform for target in cfg.targets})
def _list_bootstrap_profiles() -> list[str]:
manifest = _manifest()
return sorted(manifest.get("profiles", {}).keys())
def _list_manifest_packages() -> list[str]:
manifest = _manifest()
packages = manifest.get("packages", [])
names: set[str] = set()
if isinstance(packages, list):
for package in packages:
if isinstance(package, dict) and isinstance(package.get("name"), str):
names.add(package["name"])
return sorted(names)
if isinstance(packages, dict):
for name, package in packages.items():
if isinstance(package, dict) and isinstance(package.get("name"), str):
names.add(package["name"])
elif isinstance(name, str):
names.add(name)
return sorted(names)
def _list_installed_packages() -> list[str]:
if not paths.INSTALLED_STATE.exists():
return []
with open(paths.INSTALLED_STATE, encoding="utf-8") as handle:
state = json.load(handle)
packages = state.get("packages", {}) if isinstance(state, dict) else {}
return sorted(packages.keys()) if isinstance(packages, dict) else []
def _list_dotfiles_profiles() -> list[str]:
if not paths.DOTFILES_DIR.is_dir():
return []
return sorted(
entry.name
for entry in paths.DOTFILES_DIR.iterdir()
if entry.is_dir() and not entry.name.startswith(".") and entry.name != "_shared"
)
def _list_dotfiles_packages(profile: str | None = None) -> list[str]:
if not paths.DOTFILES_DIR.is_dir():
return []
names: set[str] = set()
shared = paths.DOTFILES_DIR / "_shared"
if shared.is_dir():
names.update(
entry.name for entry in shared.iterdir()
if entry.is_dir() and not entry.name.startswith(".")
)
layers = []
if profile:
layers.append(paths.DOTFILES_DIR / profile)
else:
layers.extend(
entry
for entry in paths.DOTFILES_DIR.iterdir()
if entry.is_dir() and not entry.name.startswith(".") and entry.name != "_shared"
)
for layer in layers:
if not layer.is_dir():
continue
names.update(
entry.name for entry in layer.iterdir()
if entry.is_dir() and not entry.name.startswith(".")
)
return sorted(names)
def _list_container_names() -> list[str]:
runtime = None
for candidate in ("docker", "podman"):
if shutil.which(candidate):
runtime = candidate
break
if runtime is None:
return []
result = subprocess.run(
[
runtime,
"ps",
"-a",
"--filter",
"label=dev=true",
"--format",
'{{.Label "dev.name"}}',
],
capture_output=True,
text=True,
timeout=1,
check=False,
)
if result.returncode != 0:
return []
return sorted({line.strip() for line in result.stdout.splitlines() if line.strip()})
def _profile_from_before(before: Sequence[str]) -> str | None:
for i, token in enumerate(before):
if token == "--profile" and i + 1 < len(before):
return before[i + 1]
return None
def _complete_remote(before: Sequence[str], current: str) -> list[str]:
if before[0] == "remote":
if len(before) == 1:
return _filter(["enter", "list"], current)
if before[1] == "enter":
enter_tokens = ["enter", *before[2:]]
else:
return []
else:
enter_tokens = before
if enter_tokens and enter_tokens[-1] in {"-p", "--platform"}:
return _filter(_list_platforms(), current)
if enter_tokens and enter_tokens[-1] in {"-n", "--namespace"}:
return _filter(_list_namespaces(), current)
if current.startswith("-"):
return _filter(
[
"-u", "--user",
"-n", "--namespace",
"-p", "--platform",
"-s", "--session",
"--no-tmux",
"-d", "--dry-run",
],
current,
)
return _filter(_list_targets(), current)
def _complete_dev(before: Sequence[str], current: str) -> list[str]:
if len(before) <= 1:
return _filter(
["create", "attach", "connect", "exec", "enter", "list", "stop", "remove", "rm", "respawn"],
current,
)
subcommand = before[1]
if subcommand in {"attach", "connect", "exec", "enter", "stop", "remove", "rm", "respawn"}:
if current.startswith("-"):
options = {
"stop": ["--kill"],
"remove": ["-f", "--force"],
"rm": ["-f", "--force"],
}
return _filter(options.get(subcommand, []), current)
non_option = [token for token in before[2:] if not token.startswith("-")]
if not non_option:
return _filter(_list_container_names(), current)
return []
if subcommand == "create":
if current.startswith("-"):
return _filter(["-i", "--image", "-p", "--project", "--dry-run"], current)
return []
return []
def _complete_dotfiles(before: Sequence[str], current: str) -> list[str]:
if len(before) <= 1:
return _filter(
["init", "link", "relink", "unlink", "undo", "status", "clean", "sync", "modules", "repo", "repos", "edit"],
current,
)
subcommand = before[1]
if subcommand == "init":
return _filter(["--repo"], current) if current.startswith("-") else []
if subcommand in {"link", "relink"}:
if before and before[-1] == "--profile":
return _filter(_list_dotfiles_profiles(), current)
if current.startswith("-"):
return _filter(["--profile", "--dry-run", "--skip"], current)
return _filter(_list_dotfiles_packages(_profile_from_before(before)), current)
if subcommand == "unlink":
if current.startswith("-"):
return _filter(["--dry-run"], current)
return _filter(_list_dotfiles_packages(), current)
if subcommand == "sync":
if before and before[-1] == "--profile":
return _filter(_list_dotfiles_profiles(), current)
if current.startswith("-"):
return _filter(["--relink", "--profile"], current)
return []
if subcommand == "clean":
return _filter(["--dry-run"], current) if current.startswith("-") else []
if subcommand == "edit":
return _filter(_list_dotfiles_packages(), current) if not current.startswith("-") else []
if subcommand in {"repo", "repos"}:
if len(before) <= 2:
return _filter(["status", "pull", "push"], current)
repo_subcommand = before[2]
if repo_subcommand == "pull":
if before and before[-1] == "--profile":
return _filter(_list_dotfiles_profiles(), current)
if current.startswith("-"):
return _filter(["--rebase", "--no-rebase", "--relink", "--profile"], current)
return []
if subcommand == "modules":
if len(before) <= 2:
return _filter(["list", "sync"], current)
if before and before[-1] == "--profile":
return _filter(_list_dotfiles_profiles(), current)
if current.startswith("-"):
return _filter(["--profile"], current)
return _filter(_list_dotfiles_packages(_profile_from_before(before)), current)
return []
def _complete_bootstrap(before: Sequence[str], current: str) -> list[str]:
if len(before) <= 1:
return _filter(["run", "show", "list"], current)
subcommand = before[1]
if subcommand == "run":
if before and before[-1] == "--profile":
return _filter(_list_bootstrap_profiles(), current)
if current.startswith("-"):
return _filter(["--profile", "--dry-run", "--var"], current)
return _filter(_list_bootstrap_profiles(), current)
if subcommand == "show":
return _filter(_list_bootstrap_profiles(), current) if not current.startswith("-") else []
return []
def _complete_packages(before: Sequence[str], current: str) -> list[str]:
if len(before) <= 1:
return _filter(["install", "remove", "list"], current)
subcommand = before[1]
if subcommand == "install":
if before and before[-1] == "--profile":
return _filter(_list_bootstrap_profiles(), current)
if current.startswith("-"):
return _filter(["--profile", "--dry-run"], current)
return _filter(_list_manifest_packages(), current)
if subcommand == "remove":
if current.startswith("-"):
return []
return _filter(_list_installed_packages(), current)
if subcommand == "list":
return _filter(["--all"], current) if current.startswith("-") else []
return []
def _complete_projects(before: Sequence[str], current: str) -> list[str]:
if len(before) <= 1:
return _filter(["check", "fetch", "summary"], current)
if before[1] == "check" and current.startswith("-"):
return _filter(["--fetch", "--no-fetch"], current)
return []
def _complete_completion(before: Sequence[str], current: str) -> list[str]:
if len(before) <= 1:
return _filter(["zsh", "install-zsh"], current)
if before[1] == "install-zsh" and current.startswith("-"):
return _filter(["--dir", "--rc", "--no-rc"], current)
return []
def _run_zsh_complete(_ctx, args):
for item in complete(args.words, args.cword):
print(item)
def _zsh_script_text() -> str:
return r'''#compdef flow
_flow() {
local -a suggestions
suggestions=("${(@f)$(flow completion _zsh_complete --cword "$CURRENT" -- "${words[@]}" 2>/dev/null)}")
if (( ${#suggestions[@]} > 0 )); then
compadd -Q -- "${suggestions[@]}"
return 0
fi
if [[ "$words[CURRENT]" == */* || "$words[CURRENT]" == ./* || "$words[CURRENT]" == ~* ]]; then
_files
return 0
fi
return 1
}
compdef _flow flow
'''
def _run_zsh_script(_ctx, _args):
print(_zsh_script_text())
def _run_install_zsh(_ctx, args):
completions_dir = Path(args.dir).expanduser()
completions_dir.mkdir(parents=True, exist_ok=True)
completion_file = completions_dir / "_flow"
completion_file.write_text(_zsh_script_text(), encoding="utf-8")
print(f"Installed completion script: {completion_file}")
if args.no_rc:
print("Skipped rc file update (--no-rc)")
return
rc_path = Path(args.rc).expanduser()
changed = _ensure_rc_snippet(rc_path, completions_dir)
if changed:
print(f"Updated shell rc: {rc_path}")
else:
print(f"Shell rc already configured: {rc_path}")
def _ensure_rc_snippet(rc_path: Path, completions_dir: Path) -> bool:
snippet = _zsh_rc_snippet(completions_dir)
content = rc_path.read_text(encoding="utf-8") if rc_path.exists() else ""
if ZSH_RC_START in content and ZSH_RC_END in content:
start = content.find(ZSH_RC_START)
end = content.find(ZSH_RC_END, start) + len(ZSH_RC_END)
updated = content[:start] + snippet.rstrip("\n") + content[end:]
if updated == content:
return False
rc_path.write_text(updated, encoding="utf-8")
return True
separator = "" if content.endswith("\n") or not content else "\n"
rc_path.parent.mkdir(parents=True, exist_ok=True)
rc_path.write_text(content + separator + snippet, encoding="utf-8")
return True
def _zsh_rc_snippet(completions_dir: Path) -> str:
return (
f"{ZSH_RC_START}\n"
f"fpath=({_zsh_dir_for_rc(completions_dir)} $fpath)\n"
"autoload -Uz compinit && compinit\n"
f"{ZSH_RC_END}\n"
)
def _zsh_dir_for_rc(path: Path) -> str:
home = Path.home().resolve()
resolved = path.expanduser().resolve()
try:
rel = resolved.relative_to(home)
return f"~/{rel}" if str(rel) != "." else "~"
except ValueError:
return str(resolved)

View File

@@ -1,5 +1,7 @@
"""Dev container commands.""" """Dev container commands."""
from __future__ import annotations
from flow.core.config import FlowContext from flow.core.config import FlowContext
from flow.services.containers import ContainerService from flow.services.containers import ContainerService
@@ -8,26 +10,41 @@ def register(subparsers):
p = subparsers.add_parser("dev", help="Manage development containers") p = subparsers.add_parser("dev", help="Manage development containers")
sub = p.add_subparsers(dest="dev_action") sub = p.add_subparsers(dest="dev_action")
create = sub.add_parser("create", help="Create a container") create = sub.add_parser("create", help="Create and start a development container")
create.add_argument("image", help="Container image") create.add_argument("name", help="Container name")
create.add_argument("--namespace", "-n", default="default") create.add_argument("-i", "--image", required=True, help="Container image")
create.add_argument("-p", "--project", help="Project path to mount at /workspace")
create.add_argument("--dry-run", action="store_true") create.add_argument("--dry-run", action="store_true")
create.set_defaults(handler=_create) create.set_defaults(handler=_create)
enter = sub.add_parser("enter", help="Enter a running container") attach = sub.add_parser("attach", aliases=["connect"], help="Attach to the container tmux session")
attach.add_argument("name", help="Container name")
attach.set_defaults(handler=_attach)
exec_cmd = sub.add_parser("exec", help="Execute a command in a container")
exec_cmd.add_argument("name", help="Container name")
exec_cmd.add_argument("cmd", nargs="*", help="Command to run")
exec_cmd.set_defaults(handler=_exec)
enter = sub.add_parser("enter", help="Open an interactive shell in a container")
enter.add_argument("name", help="Container name") enter.add_argument("name", help="Container name")
enter.add_argument("--shell", default="/bin/bash")
enter.set_defaults(handler=_enter) enter.set_defaults(handler=_enter)
stop = sub.add_parser("stop", help="Stop a container") stop = sub.add_parser("stop", help="Stop a container")
stop.add_argument("name", help="Container name") stop.add_argument("name", help="Container name")
stop.add_argument("--kill", action="store_true", help="Kill instead of graceful stop")
stop.set_defaults(handler=_stop) stop.set_defaults(handler=_stop)
rm = sub.add_parser("remove", help="Remove a container") rm = sub.add_parser("remove", aliases=["rm"], help="Remove a container")
rm.add_argument("name", help="Container name") rm.add_argument("name", help="Container name")
rm.add_argument("-f", "--force", action="store_true", help="Force removal")
rm.set_defaults(handler=_remove) rm.set_defaults(handler=_remove)
ls = sub.add_parser("list", help="List flow containers") respawn = sub.add_parser("respawn", help="Respawn tmux panes for a session")
respawn.add_argument("name", help="Container name")
respawn.set_defaults(handler=_respawn)
ls = sub.add_parser("list", help="List development containers")
ls.set_defaults(handler=_list) ls.set_defaults(handler=_list)
p.set_defaults(handler=_default) p.set_defaults(handler=_default)
@@ -38,25 +55,37 @@ def _default(ctx: FlowContext, args):
def _create(ctx: FlowContext, args): def _create(ctx: FlowContext, args):
svc = ContainerService(ctx) ContainerService(ctx).create(
svc.create(args.image, args.namespace, dry_run=args.dry_run) args.name,
args.image,
project_path=args.project,
dry_run=args.dry_run,
)
def _attach(ctx: FlowContext, args):
ContainerService(ctx).connect(args.name)
def _exec(ctx: FlowContext, args):
ContainerService(ctx).exec(args.name, args.cmd or None)
def _enter(ctx: FlowContext, args): def _enter(ctx: FlowContext, args):
svc = ContainerService(ctx) ContainerService(ctx).exec(args.name)
svc.enter(args.name, shell=args.shell)
def _stop(ctx: FlowContext, args): def _stop(ctx: FlowContext, args):
svc = ContainerService(ctx) ContainerService(ctx).stop(args.name, kill=args.kill)
svc.stop(args.name)
def _remove(ctx: FlowContext, args): def _remove(ctx: FlowContext, args):
svc = ContainerService(ctx) ContainerService(ctx).remove(args.name, force=args.force)
svc.remove(args.name)
def _respawn(ctx: FlowContext, args):
ContainerService(ctx).respawn(args.name)
def _list(ctx: FlowContext, args): def _list(ctx: FlowContext, args):
svc = ContainerService(ctx) ContainerService(ctx).list()
svc.list()

View File

@@ -1,30 +1,85 @@
"""Dotfiles commands.""" """Dotfiles commands."""
from __future__ import annotations
from flow.core.config import FlowContext from flow.core.config import FlowContext
from flow.services.dotfiles import DotfilesService from flow.services.dotfiles import DotfilesService
def register(subparsers): def register(subparsers):
p = subparsers.add_parser("dotfiles", help="Manage dotfile symlinks") p = subparsers.add_parser("dotfiles", aliases=["dot"], help="Manage dotfile symlinks")
sub = p.add_subparsers(dest="dotfiles_action") sub = p.add_subparsers(dest="dotfiles_action")
init = sub.add_parser("init", help="Clone the dotfiles repository")
init.add_argument("--repo", help="Override the configured repository URL")
init.set_defaults(handler=_init)
link = sub.add_parser("link", help="Link dotfiles to home") link = sub.add_parser("link", help="Link dotfiles to home")
link.add_argument("--profile", help="Profile to include") link.add_argument("--profile", help="Profile to include")
link.add_argument("--dry-run", "-n", action="store_true") link.add_argument("--dry-run", "-n", action="store_true")
link.add_argument("--skip", nargs="*", default=[]) link.add_argument("--skip", nargs="*", default=[])
link.set_defaults(handler=_link) link.set_defaults(handler=_link)
relink = sub.add_parser("relink", help="Refresh managed symlinks")
relink.add_argument("--profile", help="Profile to include")
relink.set_defaults(handler=_relink)
unlink = sub.add_parser("unlink", help="Remove managed symlinks") unlink = sub.add_parser("unlink", help="Remove managed symlinks")
unlink.add_argument("packages", nargs="*", help="Packages to unlink (all if empty)") unlink.add_argument("packages", nargs="*", help="Packages to unlink (all if empty)")
unlink.add_argument("--dry-run", "-n", action="store_true") unlink.add_argument("--dry-run", "-n", action="store_true")
unlink.set_defaults(handler=_unlink) unlink.set_defaults(handler=_unlink)
undo = sub.add_parser("undo", help="Restore the previous linked state")
undo.set_defaults(handler=_undo)
status = sub.add_parser("status", help="Show link status") status = sub.add_parser("status", help="Show link status")
status.set_defaults(handler=_status) status.set_defaults(handler=_status)
clean = sub.add_parser("clean", help="Remove broken symlinks")
clean.add_argument("--dry-run", action="store_true")
clean.set_defaults(handler=_clean)
sync = sub.add_parser("sync", help="Pull dotfiles and sync modules") sync = sub.add_parser("sync", help="Pull dotfiles and sync modules")
sync.add_argument("--relink", action="store_true")
sync.add_argument("--profile", help="Profile to relink after syncing")
sync.set_defaults(handler=_sync) sync.set_defaults(handler=_sync)
modules = sub.add_parser("modules", help="Inspect and refresh external modules")
modules_sub = modules.add_subparsers(dest="dotfiles_modules_action")
modules_list = modules_sub.add_parser("list", help="List module packages")
modules_list.add_argument("--profile", help="Limit to shared + one profile")
modules_list.set_defaults(handler=_modules_list)
modules_sync = modules_sub.add_parser("sync", help="Refresh module repositories")
modules_sync.add_argument("--profile", help="Limit to shared + one profile")
modules_sync.set_defaults(handler=_modules_sync)
modules.set_defaults(handler=_modules_list)
repo = sub.add_parser("repo", aliases=["repos"], help="Manage the dotfiles repository")
repo_sub = repo.add_subparsers(dest="dotfiles_repo_action")
repo_status = repo_sub.add_parser("status", help="Show git status")
repo_status.set_defaults(handler=_repo_status)
repo_pull = repo_sub.add_parser("pull", help="Pull latest changes")
repo_pull.add_argument("--rebase", dest="rebase", action="store_true")
repo_pull.add_argument("--no-rebase", dest="rebase", action="store_false")
repo_pull.add_argument("--relink", action="store_true")
repo_pull.add_argument("--profile", help="Profile to relink after pulling")
repo_pull.set_defaults(rebase=True)
repo_pull.set_defaults(handler=_repo_pull)
repo_push = repo_sub.add_parser("push", help="Push local changes")
repo_push.set_defaults(handler=_repo_push)
repo.set_defaults(handler=_repo_status)
edit = sub.add_parser("edit", help="Show the package directory")
edit.add_argument("package", help="Package name")
edit.set_defaults(handler=_edit)
p.set_defaults(handler=_default) p.set_defaults(handler=_default)
@@ -32,28 +87,68 @@ def _default(ctx: FlowContext, args):
_status(ctx, args) _status(ctx, args)
def _init(ctx: FlowContext, args):
DotfilesService(ctx).init(repo_url=args.repo)
def _link(ctx: FlowContext, args): def _link(ctx: FlowContext, args):
svc = DotfilesService(ctx) DotfilesService(ctx).link(
svc.link(
profile=args.profile, profile=args.profile,
dry_run=args.dry_run, dry_run=args.dry_run,
skip=set(args.skip) if args.skip else None, skip=set(args.skip) if args.skip else None,
) )
def _relink(ctx: FlowContext, args):
DotfilesService(ctx).relink(profile=args.profile)
def _unlink(ctx: FlowContext, args): def _unlink(ctx: FlowContext, args):
svc = DotfilesService(ctx) DotfilesService(ctx).unlink(
svc.unlink(
packages=args.packages if args.packages else None, packages=args.packages if args.packages else None,
dry_run=args.dry_run, dry_run=args.dry_run,
) )
def _undo(ctx: FlowContext, args):
DotfilesService(ctx).undo()
def _status(ctx: FlowContext, args): def _status(ctx: FlowContext, args):
svc = DotfilesService(ctx) DotfilesService(ctx).status()
svc.status()
def _clean(ctx: FlowContext, args):
DotfilesService(ctx).clean(dry_run=args.dry_run)
def _sync(ctx: FlowContext, args): def _sync(ctx: FlowContext, args):
svc = DotfilesService(ctx) DotfilesService(ctx).sync(profile=args.profile, relink=args.relink)
svc.sync()
def _modules_list(ctx: FlowContext, args):
DotfilesService(ctx).list_modules(profile=getattr(args, "profile", None))
def _modules_sync(ctx: FlowContext, args):
DotfilesService(ctx).sync_modules(profile=args.profile)
def _repo_status(ctx: FlowContext, args):
DotfilesService(ctx).repo_status()
def _repo_pull(ctx: FlowContext, args):
DotfilesService(ctx).repo_pull(
profile=args.profile,
relink=args.relink,
rebase=args.rebase,
)
def _repo_push(ctx: FlowContext, args):
DotfilesService(ctx).repo_push()
def _edit(ctx: FlowContext, args):
DotfilesService(ctx).edit(args.package)

View File

@@ -5,7 +5,7 @@ from flow.services.packages import PackageService
def register(subparsers): def register(subparsers):
p = subparsers.add_parser("packages", help="Manage packages") p = subparsers.add_parser("packages", aliases=["package", "pkg"], help="Manage packages")
sub = p.add_subparsers(dest="packages_action") sub = p.add_subparsers(dest="packages_action")
install = sub.add_parser("install", help="Install packages") install = sub.add_parser("install", help="Install packages")
@@ -20,6 +20,7 @@ def register(subparsers):
remove.set_defaults(handler=_remove) remove.set_defaults(handler=_remove)
ls = sub.add_parser("list", help="List installed packages") ls = sub.add_parser("list", help="List installed packages")
ls.add_argument("--all", action="store_true", help="List all known packages")
ls.set_defaults(handler=_list) ls.set_defaults(handler=_list)
p.set_defaults(handler=_default) p.set_defaults(handler=_default)
@@ -31,11 +32,11 @@ def _default(ctx: FlowContext, args):
def _install(ctx: FlowContext, args): def _install(ctx: FlowContext, args):
svc = PackageService(ctx) svc = PackageService(ctx)
svc.install( packages = svc.resolve_install_packages(
package_names=args.packages if args.packages else None, package_names=args.packages if args.packages else None,
profile=args.profile, profile=args.profile,
dry_run=args.dry_run,
) )
svc.install(packages, dry_run=args.dry_run)
def _remove(ctx: FlowContext, args): def _remove(ctx: FlowContext, args):
@@ -45,4 +46,4 @@ def _remove(ctx: FlowContext, args):
def _list(ctx: FlowContext, args): def _list(ctx: FlowContext, args):
svc = PackageService(ctx) svc = PackageService(ctx)
svc.list_packages() svc.list_packages(show_all=args.all)

View File

@@ -5,14 +5,8 @@ from flow.services.projects import ProjectService
def register(subparsers): def register(subparsers):
p = subparsers.add_parser("projects", help="Manage git projects") _register_projects_parser(subparsers, "projects", default_fetch=False, aliases=["project"])
sub = p.add_subparsers(dest="projects_action") _register_projects_parser(subparsers, "sync", default_fetch=True)
check = sub.add_parser("check", help="Check project status")
check.add_argument("--fetch", "-f", action="store_true", help="Fetch remotes first")
check.set_defaults(handler=_check)
p.set_defaults(handler=_default)
def _default(ctx: FlowContext, args): def _default(ctx: FlowContext, args):
@@ -22,3 +16,33 @@ def _default(ctx: FlowContext, args):
def _check(ctx: FlowContext, args): def _check(ctx: FlowContext, args):
svc = ProjectService(ctx) svc = ProjectService(ctx)
svc.check(fetch=getattr(args, "fetch", False)) svc.check(fetch=getattr(args, "fetch", False))
def _fetch(ctx: FlowContext, args):
svc = ProjectService(ctx)
svc.fetch()
def _summary(ctx: FlowContext, args):
svc = ProjectService(ctx)
svc.summary()
def _register_projects_parser(subparsers, name: str, *, default_fetch: bool, aliases=None):
parser = subparsers.add_parser(name, aliases=aliases or [], help="Manage git projects")
sub = parser.add_subparsers(dest=f"{name}_action")
check = sub.add_parser("check", help="Check project status")
check.add_argument("--fetch", dest="fetch", action="store_true", help="Fetch remotes first")
if default_fetch:
check.add_argument("--no-fetch", dest="fetch", action="store_false", help="Skip fetching remotes")
check.set_defaults(fetch=default_fetch)
check.set_defaults(handler=_check)
fetch = sub.add_parser("fetch", help="Fetch all project remotes")
fetch.set_defaults(handler=_fetch)
summary = sub.add_parser("summary", help="Show a summary without fetching")
summary.set_defaults(handler=_summary)
parser.set_defaults(handler=_default)

View File

@@ -1,5 +1,7 @@
"""Remote commands.""" """Remote commands."""
from __future__ import annotations
from flow.core.config import FlowContext from flow.core.config import FlowContext
from flow.services.remote import RemoteService from flow.services.remote import RemoteService
@@ -9,8 +11,7 @@ def register(subparsers):
sub = p.add_subparsers(dest="remote_action") sub = p.add_subparsers(dest="remote_action")
enter = sub.add_parser("enter", help="SSH into a target") enter = sub.add_parser("enter", help="SSH into a target")
enter.add_argument("target", help="Target (namespace@platform)") _add_enter_args(enter)
enter.add_argument("--dry-run", "-n", action="store_true")
enter.set_defaults(handler=_enter) enter.set_defaults(handler=_enter)
ls = sub.add_parser("list", help="List configured targets") ls = sub.add_parser("list", help="List configured targets")
@@ -18,6 +19,10 @@ def register(subparsers):
p.set_defaults(handler=_default) p.set_defaults(handler=_default)
alias = subparsers.add_parser("enter", help="SSH into a target")
_add_enter_args(alias)
alias.set_defaults(handler=_enter)
def _default(ctx: FlowContext, args): def _default(ctx: FlowContext, args):
_list(ctx, args) _list(ctx, args)
@@ -25,9 +30,27 @@ def _default(ctx: FlowContext, args):
def _enter(ctx: FlowContext, args): def _enter(ctx: FlowContext, args):
svc = RemoteService(ctx) svc = RemoteService(ctx)
svc.enter(args.target, dry_run=args.dry_run) svc.enter(
args.target,
user=args.user,
namespace=args.namespace,
platform=args.platform,
session=args.session,
no_tmux=args.no_tmux,
dry_run=args.dry_run,
)
def _list(ctx: FlowContext, args): def _list(ctx: FlowContext, args):
svc = RemoteService(ctx) svc = RemoteService(ctx)
svc.list() svc.list()
def _add_enter_args(parser) -> None:
parser.add_argument("target", help="Target ([user@]namespace@platform)")
parser.add_argument("-u", "--user", help="SSH user override")
parser.add_argument("-n", "--namespace", help="Namespace override")
parser.add_argument("-p", "--platform", help="Platform override")
parser.add_argument("-s", "--session", help="tmux session name")
parser.add_argument("--no-tmux", action="store_true", help="Open plain SSH without tmux")
parser.add_argument("--dry-run", "-d", action="store_true")

View File

@@ -1,16 +1,25 @@
"""Setup/bootstrap commands.""" """Setup/bootstrap commands."""
from __future__ import annotations
from flow.core.config import FlowContext from flow.core.config import FlowContext
from flow.core.errors import FlowError
from flow.services.bootstrap import BootstrapService from flow.services.bootstrap import BootstrapService
def register(subparsers): def register(subparsers):
p = subparsers.add_parser("setup", help="Bootstrap a system profile") p = subparsers.add_parser(
"setup",
aliases=["bootstrap", "provision"],
help="Bootstrap a system profile",
)
sub = p.add_subparsers(dest="setup_action") sub = p.add_subparsers(dest="setup_action")
run = sub.add_parser("run", help="Run bootstrap for a profile") run = sub.add_parser("run", help="Run bootstrap for a profile")
run.add_argument("profile", help="Profile name") run.add_argument("profile", nargs="?", help="Profile name")
run.add_argument("--profile", dest="profile_option", help="Profile name")
run.add_argument("--dry-run", "-n", action="store_true") run.add_argument("--dry-run", "-n", action="store_true")
run.add_argument("--var", action="append", default=[], help="Set variable KEY=VALUE")
run.set_defaults(handler=_run) run.set_defaults(handler=_run)
show = sub.add_parser("show", help="Show bootstrap plan") show = sub.add_parser("show", help="Show bootstrap plan")
@@ -29,7 +38,8 @@ def _default(ctx: FlowContext, args):
def _run(ctx: FlowContext, args): def _run(ctx: FlowContext, args):
svc = BootstrapService(ctx) svc = BootstrapService(ctx)
svc.run(args.profile, dry_run=args.dry_run) env = _parse_vars(args.var)
svc.run(_profile_arg(args), dry_run=args.dry_run, env=env)
def _show(ctx: FlowContext, args): def _show(ctx: FlowContext, args):
@@ -40,3 +50,21 @@ def _show(ctx: FlowContext, args):
def _list(ctx: FlowContext, args): def _list(ctx: FlowContext, args):
svc = BootstrapService(ctx) svc = BootstrapService(ctx)
svc.list_profiles() svc.list_profiles()
def _profile_arg(args) -> str | None:
if args.profile and args.profile_option and args.profile != args.profile_option:
raise FlowError("Specify the profile only once.")
return args.profile or args.profile_option
def _parse_vars(items: list[str]) -> dict[str, str]:
values: dict[str, str] = {}
for item in items:
if "=" not in item:
raise FlowError(f"Invalid --var value '{item}'. Expected KEY=VALUE.")
key, value = item.split("=", 1)
if not key:
raise FlowError(f"Invalid --var value '{item}'. KEY cannot be empty.")
values[key] = value
return values

View File

@@ -8,7 +8,9 @@ from typing import Any, Optional
import yaml import yaml
from flow.core import paths
from flow.core.console import Console from flow.core.console import Console
from flow.core.errors import ConfigError
from flow.core.platform import PlatformInfo from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime from flow.core.runtime import SystemRuntime
@@ -25,13 +27,12 @@ class TargetConfig:
class AppConfig: class AppConfig:
dotfiles_url: str = "" dotfiles_url: str = ""
dotfiles_branch: str = "main" dotfiles_branch: str = "main"
dotfiles_pull_before_edit: bool = True
projects_dir: str = "~/projects" projects_dir: str = "~/projects"
container_registry: str = "registry.tomastm.com" container_registry: str = "registry.tomastm.com"
container_tag: str = "latest" container_tag: str = "latest"
tmux_session: str = "default" tmux_session: str = "default"
targets: list[TargetConfig] = field(default_factory=list) targets: list[TargetConfig] = field(default_factory=list)
# Tracks which fields were explicitly set in config (not defaults)
_explicit: set[str] = field(default_factory=set, repr=False, compare=False)
@dataclass @dataclass
@@ -48,122 +49,281 @@ def _load_yaml_file(path: Path) -> dict[str, Any]:
with open(path, "r", encoding="utf-8") as handle: with open(path, "r", encoding="utf-8") as handle:
data = yaml.safe_load(handle) data = yaml.safe_load(handle)
except yaml.YAMLError as e: except yaml.YAMLError as e:
raise RuntimeError(f"Invalid YAML in {path}: {e}") from e raise ConfigError(f"Invalid YAML in {path}: {e}") from e
if data is None: if data is None:
return {} return {}
if not isinstance(data, dict): if not isinstance(data, dict):
raise RuntimeError(f"YAML file must contain a mapping at root: {path}") raise ConfigError(f"YAML file must contain a mapping at root: {path}")
return data return data
def _parse_targets(raw: Any) -> list[TargetConfig]: def _merge_yaml_values(base: Any, overlay: Any) -> Any:
from flow.core.errors import ConfigError if isinstance(base, dict) and isinstance(overlay, dict):
merged = dict(base)
for key, value in overlay.items():
if key in merged:
merged[key] = _merge_yaml_values(merged[key], value)
else:
merged[key] = value
return merged
if not isinstance(raw, dict): if isinstance(base, list) and isinstance(overlay, list):
return [*base, *overlay]
return overlay
def _list_yaml_files(directory: Path) -> list[Path]:
if not directory.exists() or not directory.is_dir():
return [] return []
targets: list[TargetConfig] = [] return sorted(
for key, value in raw.items(): (
if "@" not in key: child for child in directory.iterdir()
raise ConfigError(f"Invalid target key '{key}': expected 'namespace@platform'") if child.is_file() and child.suffix.lower() in {".yaml", ".yml"}
),
key=lambda child: child.name,
)
def _load_yaml_source(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
if path.is_file():
return _load_yaml_file(path)
merged: dict[str, Any] = {}
for file_path in _list_yaml_files(path):
merged = _merge_yaml_values(merged, _load_yaml_file(file_path))
return merged
def _load_yaml_documents(path: Path) -> list[dict[str, Any]]:
if not path.exists():
return []
if path.is_file():
return [_load_yaml_file(path)]
return [_load_yaml_file(file_path) for file_path in _list_yaml_files(path)]
def _load_yaml_sources(*source_paths: Path) -> dict[str, Any]:
merged: dict[str, Any] = {}
for path in source_paths:
merged = _merge_yaml_values(merged, _load_yaml_source(path))
return merged
def _as_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "y", "on"}:
return True
if normalized in {"0", "false", "no", "n", "off"}:
return False
raise ConfigError(f"Expected boolean value, got {value!r}")
def _parse_target_shorthand(key: str, value: str) -> TargetConfig:
parts = value.split()
if not parts:
raise ConfigError(f"Target '{key}' must define a host")
if "@" in key:
namespace, platform = key.split("@", 1) namespace, platform = key.split("@", 1)
if not namespace or not platform: if not namespace or not platform:
raise ConfigError(f"Invalid target key '{key}': both namespace and platform required") raise ConfigError(f"Invalid target key '{key}'")
return TargetConfig(
namespace=namespace,
platform=platform,
host=parts[0],
identity=parts[1] if len(parts) > 1 else None,
)
if len(parts) < 2:
raise ConfigError(
f"Invalid target value for '{key}': expected 'platform host [identity]'"
)
return TargetConfig(
namespace=key,
platform=parts[0],
host=parts[1],
identity=parts[2] if len(parts) > 2 else None,
)
def _parse_targets(raw: Any) -> list[TargetConfig]:
targets: list[TargetConfig] = []
if raw is None:
return targets
if isinstance(raw, dict):
for key, value in raw.items():
if isinstance(value, str): if isinstance(value, str):
targets.append(TargetConfig( targets.append(_parse_target_shorthand(key, value))
namespace=namespace, continue
platform=platform,
host=value, if not isinstance(value, dict):
)) raise ConfigError(f"Target '{key}': value must be a string or mapping")
elif isinstance(value, dict):
host = value.get("host") if "@" in key:
if not host: namespace, platform = key.split("@", 1)
raise ConfigError(f"Target '{key}': 'host' is required") else:
identity = value.get("identity") namespace = key
targets.append(TargetConfig( platform = value.get("platform")
namespace=namespace,
platform=platform, host = value.get("host", value.get("ssh-host", value.get("ssh_host")))
if not namespace or not platform or not host:
raise ConfigError(
f"Target '{key}' must define namespace, platform, and host"
)
identity = value.get("identity", value.get("ssh-identity", value.get("ssh_identity")))
targets.append(
TargetConfig(
namespace=str(namespace),
platform=str(platform),
host=str(host), host=str(host),
identity=str(identity) if identity is not None else None, identity=str(identity) if identity is not None else None,
)) )
else: )
raise ConfigError(f"Target '{key}': value must be a string or mapping") return targets
if isinstance(raw, list):
for item in raw:
if not isinstance(item, dict):
raise ConfigError("Target list entries must be mappings")
namespace = item.get("namespace")
platform = item.get("platform")
host = item.get("host", item.get("ssh-host", item.get("ssh_host")))
if not namespace or not platform or not host:
raise ConfigError(
"Target list entries must define namespace, platform, and host"
)
identity = item.get("identity", item.get("ssh-identity", item.get("ssh_identity")))
targets.append(
TargetConfig(
namespace=str(namespace),
platform=str(platform),
host=str(host),
identity=str(identity) if identity is not None else None,
)
)
return targets
raise ConfigError("Targets must be a mapping or list of mappings")
return targets return targets
def load_config(config_dir: Path) -> AppConfig: def load_config(
"""Load config.yaml from the given directory into AppConfig.""" config_dir: Optional[Path] = None,
config_file = config_dir / "config.yaml" overlay_dir: Optional[Path] = None,
if not config_file.exists(): ) -> AppConfig:
return AppConfig() """Load config into AppConfig."""
if config_dir is None and overlay_dir is None:
source_paths = (paths.CONFIG_DIR, paths.DOTFILES_FLOW_CONFIG)
elif overlay_dir is None:
source_paths = (config_dir,)
elif config_dir is None:
source_paths = (paths.CONFIG_DIR, overlay_dir)
else:
source_paths = (config_dir, overlay_dir)
data = _load_yaml_file(config_file) loaded_sources = [
source
cfg = AppConfig() for path in source_paths
explicit: set[str] = set() for source in _load_yaml_documents(path)
]
data: dict[str, Any] = {}
for source in loaded_sources:
data = _merge_yaml_values(data, source)
repository = data.get("repository") repository = data.get("repository")
if isinstance(repository, dict):
url = repository.get("url")
if url is not None:
cfg.dotfiles_url = str(url)
explicit.add("dotfiles_url")
branch = repository.get("branch")
if branch is not None:
cfg.dotfiles_branch = str(branch)
explicit.add("dotfiles_branch")
paths_section = data.get("paths") paths_section = data.get("paths")
if isinstance(paths_section, dict):
projects = paths_section.get("projects")
if projects is not None:
cfg.projects_dir = str(projects)
explicit.add("projects_dir")
defaults = data.get("defaults") defaults = data.get("defaults")
if isinstance(defaults, dict):
registry = defaults.get("container-registry")
if registry is not None:
cfg.container_registry = str(registry)
explicit.add("container_registry")
tag = defaults.get("container-tag")
if tag is not None:
cfg.container_tag = str(tag)
explicit.add("container_tag")
tmux = defaults.get("tmux-session")
if tmux is not None:
cfg.tmux_session = str(tmux)
explicit.add("tmux_session")
raw_targets = data.get("targets") return AppConfig(
if raw_targets is not None: dotfiles_url=(
cfg.targets = _parse_targets(raw_targets) str(repository["url"])
explicit.add("targets") if isinstance(repository, dict) and "url" in repository
else str(data["dotfiles_url"]) if "dotfiles_url" in data
cfg._explicit = explicit else ""
return cfg ),
dotfiles_branch=(
str(repository["branch"])
def load_manifest(manifest_dir: Path) -> dict[str, Any]: if isinstance(repository, dict) and "branch" in repository
"""Load manifest.yaml or merge all *.yaml files from the directory.""" else str(data["dotfiles_branch"]) if "dotfiles_branch" in data
if not manifest_dir.exists(): else "main"
return {} ),
dotfiles_pull_before_edit=(
manifest_file = manifest_dir / "manifest.yaml" _as_bool(repository["pull-before-edit"])
if manifest_file.exists(): if isinstance(repository, dict) and "pull-before-edit" in repository
return _load_yaml_file(manifest_file) else _as_bool(repository["pull_before_edit"])
if isinstance(repository, dict) and "pull_before_edit" in repository
merged: dict[str, Any] = {} else _as_bool(data["dotfiles_pull_before_edit"])
yaml_files = sorted( if "dotfiles_pull_before_edit" in data
(f for f in manifest_dir.iterdir() if f.is_file() and f.suffix in {".yaml", ".yml"}), else True
key=lambda p: p.name, ),
projects_dir=(
str(paths_section["projects"])
if isinstance(paths_section, dict) and "projects" in paths_section
else str(paths_section["projects_dir"])
if isinstance(paths_section, dict) and "projects_dir" in paths_section
else str(data["projects_dir"]) if "projects_dir" in data
else "~/projects"
),
container_registry=(
str(defaults["container-registry"])
if isinstance(defaults, dict) and "container-registry" in defaults
else str(defaults["container_registry"])
if isinstance(defaults, dict) and "container_registry" in defaults
else str(data["container_registry"]) if "container_registry" in data
else "registry.tomastm.com"
),
container_tag=(
str(defaults["container-tag"])
if isinstance(defaults, dict) and "container-tag" in defaults
else str(defaults["container_tag"])
if isinstance(defaults, dict) and "container_tag" in defaults
else str(data["container_tag"]) if "container_tag" in data
else "latest"
),
tmux_session=(
str(defaults["tmux-session"])
if isinstance(defaults, dict) and "tmux-session" in defaults
else str(defaults["tmux_session"])
if isinstance(defaults, dict) and "tmux_session" in defaults
else str(data["tmux_session"]) if "tmux_session" in data
else "default"
),
targets=[
target
for source in loaded_sources
if "targets" in source
for target in _parse_targets(source["targets"])
] if any("targets" in source for source in loaded_sources) else [],
) )
for path in yaml_files:
merged.update(_load_yaml_file(path))
return merged
def load_manifest(
config_dir: Optional[Path] = None,
overlay_dir: Optional[Path] = None,
) -> dict[str, Any]:
"""Load merged manifest YAML."""
if config_dir is None and overlay_dir is None:
source_paths = (paths.CONFIG_DIR, paths.DOTFILES_FLOW_CONFIG)
elif overlay_dir is None:
source_paths = (config_dir,)
elif config_dir is None:
source_paths = (paths.CONFIG_DIR, overlay_dir)
else:
source_paths = (config_dir, overlay_dir)
return _load_yaml_sources(*source_paths)

View File

@@ -17,6 +17,8 @@ class Profile:
runcmd: tuple[str, ...] runcmd: tuple[str, ...]
packages: tuple[Any, ...] # Raw entries, resolved later packages: tuple[Any, ...] # Raw entries, resolved later
env_required: tuple[str, ...] env_required: tuple[str, ...]
dotfiles_profile: Optional[str] = None
post_link: Optional[str] = None
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@@ -1,7 +1,7 @@
"""Bootstrap planning -- builds ordered action list.""" """Bootstrap planning -- builds ordered action list."""
import os import os
from typing import Any, Optional from typing import Any, Mapping, Optional
from flow.core.errors import ConfigError from flow.core.errors import ConfigError
from flow.domain.bootstrap.models import BootstrapAction, BootstrapPlan, Profile from flow.domain.bootstrap.models import BootstrapAction, BootstrapPlan, Profile
@@ -17,8 +17,35 @@ from flow.domain.packages.models import PackageDef
from flow.domain.packages.resolution import resolve_spec from flow.domain.packages.resolution import resolve_spec
def _normalize_ssh_keys(raw: Any) -> tuple[dict[str, str], ...]:
if not raw:
return ()
if not isinstance(raw, list):
raise ConfigError("Profile SSH key definitions must be a list")
keys: list[dict[str, str]] = []
for entry in raw:
if not isinstance(entry, dict):
raise ConfigError("SSH key entries must be mappings")
normalized = dict(entry)
if "path" not in normalized:
filename = normalized.get("filename")
key_type = normalized.get("type", "ed25519")
normalized["path"] = f"~/.ssh/{filename or f'id_{key_type}'}"
keys.append(normalized)
return tuple(keys)
def parse_profile(name: str, raw: dict[str, Any]) -> Profile: def parse_profile(name: str, raw: dict[str, Any]) -> Profile:
"""Parse a profile definition from manifest.""" """Parse a profile definition from manifest."""
ssh_keys = raw.get("ssh-keys") or raw.get("ssh_keys")
if ssh_keys is None:
ssh_keys = raw.get("ssh-keygen") or raw.get("ssh_keygen")
env_required = raw.get("env-required") or raw.get("env_required")
if env_required is None:
env_required = raw.get("requires")
return Profile( return Profile(
name=name, name=name,
os=raw.get("os", "linux"), os=raw.get("os", "linux"),
@@ -26,22 +53,27 @@ def parse_profile(name: str, raw: dict[str, Any]) -> Profile:
hostname=raw.get("hostname"), hostname=raw.get("hostname"),
locale=raw.get("locale"), locale=raw.get("locale"),
shell=raw.get("shell"), shell=raw.get("shell"),
ssh_keys=tuple(raw.get("ssh-keys") or raw.get("ssh_keys") or []), ssh_keys=_normalize_ssh_keys(ssh_keys),
runcmd=tuple(raw.get("runcmd") or []), runcmd=tuple(raw.get("runcmd") or []),
packages=tuple(raw.get("packages") or []), packages=tuple(raw.get("packages") or []),
env_required=tuple(raw.get("env-required") or raw.get("env_required") or []), env_required=tuple(env_required or []),
dotfiles_profile=raw.get("dotfiles-profile") or raw.get("dotfiles_profile"),
post_link=raw.get("post-link") or raw.get("post_link"),
) )
def plan_bootstrap( def plan_bootstrap(
profile: Profile, profile: Profile,
manifest: dict[str, Any], manifest: dict[str, Any],
*,
env: Optional[Mapping[str, str]] = None,
) -> BootstrapPlan: ) -> BootstrapPlan:
"""Build a complete bootstrap plan from a profile.""" """Build a complete bootstrap plan from a profile."""
actions: list[BootstrapAction] = [] actions: list[BootstrapAction] = []
environment = env or os.environ
# Phase 1: Validate required env vars # Phase 1: Validate required env vars
missing = [v for v in profile.env_required if not os.environ.get(v)] missing = [v for v in profile.env_required if not environment.get(v)]
if missing: if missing:
raise ConfigError( raise ConfigError(
f"Missing required environment variables for profile '{profile.name}': " f"Missing required environment variables for profile '{profile.name}': "
@@ -109,6 +141,13 @@ def plan_bootstrap(
commands=(), # Executed by DotfilesService commands=(), # Executed by DotfilesService
)) ))
if profile.post_link:
actions.append(BootstrapAction(
phase="post-link",
description="Run post-link commands",
commands=(profile.post_link,),
))
return BootstrapPlan( return BootstrapPlan(
profile=profile.name, profile=profile.name,
actions=tuple(actions), actions=tuple(actions),

View File

@@ -9,12 +9,17 @@ from typing import Optional
class ImageRef: class ImageRef:
"""A container image reference.""" """A container image reference."""
registry: str registry: str
name: str repo: str
tag: str tag: str
label: str
@property @property
def full(self) -> str: def full(self) -> str:
return f"{self.registry}/{self.name}:{self.tag}" return f"{self.registry}/{self.repo}:{self.tag}"
@property
def name(self) -> str:
return self.repo
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -35,6 +40,6 @@ class ContainerSpec:
name: str name: str
image: ImageRef image: ImageRef
mounts: tuple[Mount, ...] mounts: tuple[Mount, ...]
env: dict[str, str] = field(default_factory=dict) project_path: Optional[Path] = None
extra_flags: tuple[str, ...] = () labels: dict[str, str] = field(default_factory=dict)
command: Optional[str] = None network: str = "host"

View File

@@ -3,7 +3,6 @@
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from flow.core.errors import FlowError
from flow.domain.containers.models import ContainerSpec, ImageRef, Mount from flow.domain.containers.models import ContainerSpec, ImageRef, Mount
@@ -12,89 +11,97 @@ def parse_image_ref(
default_registry: str = "registry.tomastm.com", default_registry: str = "registry.tomastm.com",
default_tag: str = "latest", default_tag: str = "latest",
) -> ImageRef: ) -> ImageRef:
"""Parse image string into ImageRef.""" """Parse an image string into registry, repo, tag, and display label."""
# Handle full registry/name:tag
if ":" in image:
base, tag = image.rsplit(":", 1)
else:
base = image
tag = default_tag
if "/" in base:
# Has registry
parts = base.split("/", 1)
registry = parts[0]
name = parts[1]
else:
registry = default_registry registry = default_registry
name = base tag = default_tag
repo = image
return ImageRef(registry=registry, name=name, tag=tag) if image.startswith("docker/"):
registry = "docker.io"
repo = f"library/{image.split('/', 1)[1]}"
elif image.startswith("tm0/"):
repo = image.split("/", 1)[1]
elif "/" in image:
prefix, remainder = image.split("/", 1)
if "." in prefix or ":" in prefix or prefix == "localhost":
registry = prefix
repo = remainder
if ":" in repo.split("/")[-1]:
repo, tag = repo.rsplit(":", 1)
label_prefix = (
registry.rsplit(".", 1)[0].rsplit(".", 1)[-1] if "." in registry else registry
)
label = f"{label_prefix}/{repo.split('/')[-1]}"
return ImageRef(registry=registry, repo=repo, tag=tag, label=label)
def container_name(namespace: str, image_name: str) -> str: def container_name(name: str) -> str:
"""Compute container name from namespace and image.""" """Normalize the flow container name."""
return f"flow-{namespace}-{image_name}" return name if name.startswith("dev-") else f"dev-{name}"
def resolve_mounts( def resolve_mounts(
home: Path, home: Path,
projects_dir: str, *,
project_path: Optional[str] = None,
dotfiles_dir: Optional[Path] = None, dotfiles_dir: Optional[Path] = None,
extra_mounts: Optional[list[dict]] = None,
) -> list[Mount]: ) -> list[Mount]:
"""Resolve standard container mounts.""" """Resolve standard container mounts."""
mounts: list[Mount] = [] mounts: list[Mount] = []
# Projects dir if project_path:
projects = Path(projects_dir).expanduser() project = Path(project_path).expanduser().resolve()
if projects.exists(): mounts.append(Mount(source=project, target="/workspace"))
mounts.append(Mount(source=projects, target="/home/user/projects"))
# SSH agent standard_mounts = [
ssh_auth = Path.home() / ".ssh" (home / ".ssh", "/home/dev/.ssh", True),
if ssh_auth.exists(): (home / ".npmrc", "/home/dev/.npmrc", True),
mounts.append(Mount(source=ssh_auth, target="/home/user/.ssh", readonly=True)) (home / ".npm", "/home/dev/.npm", False),
]
for source, target, readonly in standard_mounts:
if source.exists():
mounts.append(Mount(source=source, target=target, readonly=readonly))
docker_sock = Path("/var/run/docker.sock")
if docker_sock.exists():
mounts.append(Mount(source=docker_sock, target="/var/run/docker.sock"))
# Dotfiles
if dotfiles_dir and dotfiles_dir.exists(): if dotfiles_dir and dotfiles_dir.exists():
mounts.append(Mount(source=dotfiles_dir, target="/home/user/.local/share/flow/dotfiles", readonly=True)) mounts.append(
Mount(
# Extra mounts from config source=dotfiles_dir,
if extra_mounts: target="/home/dev/.local/share/flow/dotfiles",
for m in extra_mounts: readonly=True,
source = Path(str(m.get("source", ""))).expanduser() )
target = str(m.get("target", "")) )
if source and target:
mounts.append(Mount(
source=source,
target=target,
readonly=bool(m.get("readonly", False)),
))
return mounts return mounts
def build_container_spec( def build_container_spec(
namespace: str, name: str,
image_ref: ImageRef, image_ref: ImageRef,
mounts: list[Mount], mounts: list[Mount],
env: Optional[dict[str, str]] = None, *,
command: Optional[str] = None, project_path: Optional[str] = None,
) -> ContainerSpec: ) -> ContainerSpec:
"""Build a complete container run specification.""" """Build a complete container run specification."""
name = container_name(namespace, image_ref.name) labels = {
container_env = { "dev": "true",
"DF_NAMESPACE": namespace, "dev.name": name,
"DF_PLATFORM": "container", "dev.image_ref": image_ref.full,
} }
if env: if project_path:
container_env.update(env) labels["dev.project_path"] = str(Path(project_path).expanduser().resolve())
return ContainerSpec( return ContainerSpec(
name=name, name=container_name(name),
image=image_ref, image=image_ref,
mounts=tuple(mounts), mounts=tuple(mounts),
env=container_env, project_path=Path(project_path).expanduser().resolve() if project_path else None,
command=command, labels=labels,
network="host",
) )

View File

@@ -59,7 +59,7 @@ def _parse_package_entry(entry: dict[str, Any]) -> PackageDef:
extract_dir=entry.get("extract-dir") or entry.get("extract_dir"), extract_dir=entry.get("extract-dir") or entry.get("extract_dir"),
install=entry.get("install") or {}, install=entry.get("install") or {},
post_install=entry.get("post-install") or entry.get("post_install"), post_install=entry.get("post-install") or entry.get("post_install"),
allow_sudo=bool(entry.get("allow-sudo", False)), allow_sudo=bool(entry.get("allow-sudo", entry.get("allow_sudo", False))),
) )
@@ -69,8 +69,30 @@ def normalize_profile_entry(entry: Any) -> ProfilePackageRef:
# Could be "binary/neovim" or just "neovim" # Could be "binary/neovim" or just "neovim"
if "/" in entry: if "/" in entry:
pkg_type, name = entry.split("/", 1) pkg_type, name = entry.split("/", 1)
return ProfilePackageRef(name=name, type=pkg_type, source=None, version=None, asset_pattern=None) return ProfilePackageRef(
return ProfilePackageRef(name=entry, type=None, source=None, version=None, asset_pattern=None) name=name,
type=pkg_type,
source=None,
version=None,
asset_pattern=None,
platform_map=None,
extract_dir=None,
install=None,
post_install=None,
allow_sudo=None,
)
return ProfilePackageRef(
name=entry,
type=None,
source=None,
version=None,
asset_pattern=None,
platform_map=None,
extract_dir=None,
install=None,
post_install=None,
allow_sudo=None,
)
if isinstance(entry, dict): if isinstance(entry, dict):
name = entry.get("name", "") name = entry.get("name", "")
@@ -80,6 +102,11 @@ def normalize_profile_entry(entry: Any) -> ProfilePackageRef:
source=entry.get("source"), source=entry.get("source"),
version=entry.get("version"), version=entry.get("version"),
asset_pattern=entry.get("asset-pattern") or entry.get("asset_pattern"), asset_pattern=entry.get("asset-pattern") or entry.get("asset_pattern"),
platform_map=entry.get("platform-map") or entry.get("platform_map"),
extract_dir=entry.get("extract-dir") or entry.get("extract_dir"),
install=entry.get("install"),
post_install=entry.get("post-install") or entry.get("post_install"),
allow_sudo=entry.get("allow-sudo", entry.get("allow_sudo")),
) )
raise ConfigError(f"Invalid profile package entry: {entry}") raise ConfigError(f"Invalid profile package entry: {entry}")

View File

@@ -9,12 +9,12 @@ from typing import Any, Optional
class PackageDef: class PackageDef:
"""A package definition from the manifest.""" """A package definition from the manifest."""
name: str name: str
type: str # "pkg" | "binary" | "appimage" | "script" type: str # "pkg" | "binary" | "appimage" | "cask"
sources: dict[str, str] # pm_name -> package_name sources: dict[str, str] # pm_name -> package_name
source: Optional[str] # direct URL or github shorthand source: Optional[str] # direct URL or github shorthand
version: Optional[str] version: Optional[str]
asset_pattern: Optional[str] asset_pattern: Optional[str]
platform_map: dict[str, str] # platform -> asset suffix platform_map: dict[str, Any] # platform -> asset suffix or template context overrides
extract_dir: Optional[str] extract_dir: Optional[str]
install: dict[str, Any] # install config overrides install: dict[str, Any] # install config overrides
post_install: Optional[str] post_install: Optional[str]
@@ -29,13 +29,18 @@ class ProfilePackageRef:
source: Optional[str] source: Optional[str]
version: Optional[str] version: Optional[str]
asset_pattern: Optional[str] asset_pattern: Optional[str]
platform_map: Optional[dict[str, Any]] = None
extract_dir: Optional[str] = None
install: Optional[dict[str, Any]] = None
post_install: Optional[str] = None
allow_sudo: Optional[bool] = None
@dataclass(frozen=True) @dataclass(frozen=True)
class PkgInstallOp: class PkgInstallOp:
"""A single package install operation.""" """A single package install operation."""
package: PackageDef package: PackageDef
method: str # "pm" | "binary" | "appimage" | "script" method: str # "pm" | "binary" | "appimage" | "cask"
source_name: str # resolved pm package name or URL source_name: str # resolved pm package name or URL
download_url: Optional[str] download_url: Optional[str]

View File

@@ -48,26 +48,32 @@ def plan_install(
package=pkg, method="pm", package=pkg, method="pm",
source_name=source_name, download_url=None, source_name=source_name, download_url=None,
)) ))
elif pkg.type == "cask":
if pm != "brew":
raise FlowError(f"Cask package '{pkg.name}' requires Homebrew")
source_name = resolve_source_name(pkg, pm)
install_ops.append(PkgInstallOp(
package=pkg,
method="cask",
source_name=source_name,
download_url=None,
))
elif pkg.type in ("binary", "appimage"): elif pkg.type in ("binary", "appimage"):
asset = resolve_binary_asset(pkg, platform_str) asset = resolve_binary_asset(pkg, platform_str)
url = resolve_download_url(pkg, asset) url = resolve_download_url(pkg, asset, platform_str)
install_ops.append(PkgInstallOp( install_ops.append(PkgInstallOp(
package=pkg, method=pkg.type, package=pkg, method=pkg.type,
source_name=asset, download_url=url, source_name=asset, download_url=url,
)) ))
elif pkg.type == "script": else:
install_ops.append(PkgInstallOp( raise FlowError(f"Unsupported package type: {pkg.type}")
package=pkg, method="script",
source_name=pkg.source or pkg.name,
download_url=pkg.source,
))
pm_cmd = pm_install_command(pm, pm_packages) if pm and pm_packages else None pm_cmd = pm_install_command(pm, pm_packages) if pm and pm_packages else None
return PackagePlan( return PackagePlan(
install_ops=tuple(install_ops), install_ops=tuple(install_ops),
remove_ops=(), remove_ops=(),
pm_update_needed=bool(pm_packages), pm_update_needed=bool(pm_packages or any(op.method == "cask" for op in install_ops)),
pm_command=pm_cmd, pm_command=pm_cmd,
) )

View File

@@ -1,8 +1,9 @@
"""Package resolution: resolving what to install and how.""" """Package resolution: resolving what to install and how."""
import shutil import shutil
from typing import Optional from typing import Any, Optional
from flow.core.template import substitute_template
from flow.core.errors import FlowError from flow.core.errors import FlowError
from flow.domain.packages.models import PackageDef, ProfilePackageRef from flow.domain.packages.models import PackageDef, ProfilePackageRef
@@ -22,11 +23,11 @@ def resolve_spec(
source=ref.source, source=ref.source,
version=ref.version, version=ref.version,
asset_pattern=ref.asset_pattern, asset_pattern=ref.asset_pattern,
platform_map={}, platform_map=ref.platform_map or {},
extract_dir=None, extract_dir=ref.extract_dir,
install={}, install=ref.install or {},
post_install=None, post_install=ref.post_install,
allow_sudo=False, allow_sudo=bool(ref.allow_sudo),
) )
# Merge: profile overrides catalog # Merge: profile overrides catalog
@@ -37,11 +38,11 @@ def resolve_spec(
source=ref.source or base.source, source=ref.source or base.source,
version=ref.version or base.version, version=ref.version or base.version,
asset_pattern=ref.asset_pattern or base.asset_pattern, asset_pattern=ref.asset_pattern or base.asset_pattern,
platform_map=base.platform_map, platform_map=ref.platform_map or base.platform_map,
extract_dir=base.extract_dir, extract_dir=ref.extract_dir or base.extract_dir,
install=base.install, install=ref.install or base.install,
post_install=base.post_install, post_install=ref.post_install or base.post_install,
allow_sudo=base.allow_sudo, allow_sudo=ref.allow_sudo if ref.allow_sudo is not None else base.allow_sudo,
) )
@@ -52,35 +53,93 @@ def resolve_source_name(pkg: PackageDef, pm: Optional[str]) -> str:
return pkg.name return pkg.name
def binary_template_context(pkg: PackageDef, platform_str: str) -> dict[str, str]:
os_name, arch = platform_str.split("-", 1)
context = {"os": os_name, "arch": arch}
for key in platform_lookup_keys(platform_str):
if key not in pkg.platform_map:
continue
mapping = pkg.platform_map[key]
if isinstance(mapping, dict):
for map_key, map_value in mapping.items():
if isinstance(map_value, str):
context[map_key] = map_value
break
return context
def _render_template_value(template: str, context: dict[str, str]) -> str:
rendered = substitute_template(template, context)
for key, value in context.items():
rendered = rendered.replace(f"{{{key}}}", value)
return rendered
def resolve_binary_asset(pkg: PackageDef, platform_str: str) -> str: def resolve_binary_asset(pkg: PackageDef, platform_str: str) -> str:
"""Resolve the binary asset filename for a platform.""" """Resolve the binary asset filename for a platform."""
if platform_str in pkg.platform_map: for key in platform_lookup_keys(platform_str):
return pkg.platform_map[platform_str] if key not in pkg.platform_map:
continue
mapping = pkg.platform_map[key]
if isinstance(mapping, str):
return mapping
if isinstance(mapping, dict):
break
if pkg.asset_pattern: if pkg.asset_pattern:
os_name, arch = platform_str.split("-", 1) return _render_template_value(pkg.asset_pattern, binary_template_context(pkg, platform_str))
return pkg.asset_pattern.replace("{os}", os_name).replace("{arch}", arch)
raise FlowError(f"No asset mapping for {pkg.name} on {platform_str}") raise FlowError(f"No asset mapping for {pkg.name} on {platform_str}")
def resolve_download_url(pkg: PackageDef, asset: str) -> str: def resolve_extract_dir(pkg: PackageDef, platform_str: str) -> Optional[str]:
if not pkg.extract_dir:
return None
return _render_template_value(pkg.extract_dir, binary_template_context(pkg, platform_str))
def resolve_download_url(
pkg: PackageDef,
asset: str,
platform_str: Optional[str] = None,
) -> str:
"""Build download URL from source + asset.""" """Build download URL from source + asset."""
source = pkg.source source = pkg.source
if not source: if not source:
raise FlowError(f"No source URL for {pkg.name}") raise FlowError(f"No source URL for {pkg.name}")
if source.startswith("github:"): context = binary_template_context(pkg, platform_str) if platform_str is not None else {}
repo = source.split(":", 1)[1] rendered_source = _render_template_value(source, context)
if rendered_source.startswith("github:"):
repo = rendered_source.split(":", 1)[1]
version = pkg.version or "latest" version = pkg.version or "latest"
if version == "latest": if version == "latest":
return f"https://github.com/{repo}/releases/latest/download/{asset}" return f"https://github.com/{repo}/releases/latest/download/{asset}"
return f"https://github.com/{repo}/releases/download/{version}/{asset}" release = version if version.startswith("v") else f"v{version}"
return f"https://github.com/{repo}/releases/download/{release}/{asset}"
if source.startswith(("http://", "https://")): if rendered_source.startswith(("http://", "https://")):
if source.endswith("/"): if rendered_source.endswith(asset):
return f"{source}{asset}" return rendered_source
return source if rendered_source.endswith("/"):
return f"{rendered_source}{asset}"
return f"{rendered_source}/{asset}"
return source return rendered_source
def platform_lookup_keys(platform_str: str) -> list[str]:
os_name, arch = platform_str.split("-", 1)
keys = [platform_str]
if os_name == "macos":
keys.append(f"darwin-{arch}")
if arch == "x64":
keys.append(f"{os_name}-amd64")
if os_name == "macos":
keys.append("darwin-amd64")
return keys
def detect_package_manager() -> Optional[str]: def detect_package_manager() -> Optional[str]:
@@ -117,3 +176,9 @@ def pm_install_command(pm: str, packages: list[str]) -> str:
if pm not in commands: if pm not in commands:
raise FlowError(f"Unsupported package manager: {pm}") raise FlowError(f"Unsupported package manager: {pm}")
return commands[pm] return commands[pm]
def pm_cask_install_command(pm: str, packages: list[str]) -> str:
if pm != "brew":
raise FlowError(f"Package manager '{pm}' does not support casks")
return f"brew install --cask {' '.join(packages)}"

View File

@@ -11,6 +11,7 @@ class Target:
platform: str platform: str
host: str host: str
identity: Optional[str] = None identity: Optional[str] = None
user: str = ""
@property @property
def label(self) -> str: def label(self) -> str:
@@ -21,4 +22,6 @@ class Target:
class SSHCommand: class SSHCommand:
"""A constructed SSH command.""" """A constructed SSH command."""
argv: tuple[str, ...] argv: tuple[str, ...]
destination: str
tmux_session: Optional[str]
env: dict[str, str] = field(default_factory=dict) env: dict[str, str] = field(default_factory=dict)

View File

@@ -7,78 +7,147 @@ from flow.core.errors import FlowError
from flow.domain.remote.models import SSHCommand, Target from flow.domain.remote.models import SSHCommand, Target
def parse_target(spec: str) -> tuple[str, str]: HOST_TEMPLATES = {
"""Parse 'namespace@platform' into (namespace, platform).""" "orb": "<namespace>.orb",
if "@" not in spec: "utm": "<namespace>.utm.local",
raise FlowError(f"Invalid target format: {spec!r}. Expected 'namespace@platform'") "core": "<namespace>.core.lan",
namespace, platform = spec.split("@", 1) }
def parse_target(spec: str) -> tuple[Optional[str], str, str]:
"""Parse [user@]namespace@platform."""
parts = spec.split("@")
if len(parts) == 2:
namespace, platform = parts
user = None
elif len(parts) == 3:
user, namespace, platform = parts
else:
raise FlowError(
f"Invalid target format: {spec!r}. Expected '[user@]namespace@platform'"
)
if not namespace or not platform: if not namespace or not platform:
raise FlowError(f"Invalid target format: {spec!r}. Both namespace and platform required") raise FlowError(
return namespace, platform f"Invalid target format: {spec!r}. Both namespace and platform required"
)
return user, namespace, platform
def resolve_target( def resolve_target(
spec: str, spec: str,
targets: list[TargetConfig], targets: list[TargetConfig],
*,
default_user: str = "",
user: Optional[str] = None,
namespace: Optional[str] = None,
platform: Optional[str] = None,
) -> Target: ) -> Target:
"""Resolve a target spec against configured targets.""" """Resolve a target spec against configured targets."""
namespace, platform = parse_target(spec) parsed_user, parsed_namespace, parsed_platform = parse_target(spec)
resolved_user = user or parsed_user or default_user
resolved_namespace = namespace or parsed_namespace
resolved_platform = platform or parsed_platform
for t in targets: for t in targets:
if t.namespace == namespace and t.platform == platform: if t.namespace == resolved_namespace and t.platform == resolved_platform:
return Target( return Target(
user=resolved_user,
namespace=t.namespace, namespace=t.namespace,
platform=t.platform, platform=t.platform,
host=t.host, host=t.host,
identity=t.identity, identity=t.identity,
) )
if resolved_platform not in HOST_TEMPLATES:
raise FlowError(f"Unknown target: {spec}. Check flow config targets section.") raise FlowError(f"Unknown target: {spec}. Check flow config targets section.")
return Target(
user=resolved_user,
namespace=resolved_namespace,
platform=resolved_platform,
host=HOST_TEMPLATES[resolved_platform].replace("<namespace>", resolved_namespace),
identity=None,
)
def build_ssh_command( def build_ssh_command(
target: Target, target: Target,
*, *,
extra_args: Optional[list[str]] = None, tmux_session: str = "default",
remote_command: Optional[str] = None, no_tmux: bool = False,
) -> SSHCommand: ) -> SSHCommand:
"""Build SSH command for a target.""" """Build SSH command for a target."""
argv: list[str] = ["ssh"] argv: list[str] = ["ssh"]
if not no_tmux:
argv.append("-tt")
if target.identity: if target.identity:
argv.extend(["-i", target.identity]) argv.extend(["-i", target.identity])
# Standard SSH options
argv.extend(["-o", "StrictHostKeyChecking=accept-new"]) argv.extend(["-o", "StrictHostKeyChecking=accept-new"])
destination = _build_destination(target.user, target.host)
if extra_args: argv.append(destination)
argv.extend(extra_args)
argv.append(target.host)
if remote_command:
argv.append(remote_command)
env = { env = {
"DF_NAMESPACE": target.namespace, "DF_NAMESPACE": target.namespace,
"DF_PLATFORM": target.platform, "DF_PLATFORM": target.platform,
} }
return SSHCommand(argv=tuple(argv), env=env) if not no_tmux:
argv.extend([
"tmux",
"new-session",
"-As",
tmux_session,
"-e",
f"DF_NAMESPACE={target.namespace}",
"-e",
f"DF_PLATFORM={target.platform}",
])
return SSHCommand(
argv=tuple(argv),
destination=destination,
tmux_session=None if no_tmux else tmux_session,
env=env,
)
def terminfo_fix_command(term: str = "xterm-256color") -> list[str]: def _build_destination(user: str, host: str) -> str:
"""Commands to fix terminfo on remote host.""" if "@" in host:
return [ return host
f"infocmp -x {term} > /tmp/{term}.terminfo", if not user:
f"ssh TARGET tic -x /tmp/{term}.terminfo", return host
] return f"{user}@{host}"
def terminfo_fix_command(
term: Optional[str] = "xterm-256color",
destination: str = "TARGET",
) -> Optional[str]:
normalized_term = (term or "").strip().lower()
if normalized_term == "xterm-ghostty":
return f"infocmp -x xterm-ghostty | ssh {destination} -- tic -x -"
if normalized_term == "wezterm":
return (
f"ssh {destination} -- sh -lc "
"'tempfile=$(mktemp) && curl -fsSL -o \"$tempfile\" "
"https://raw.githubusercontent.com/wezterm/wezterm/main/termwiz/data/wezterm.terminfo "
"&& tic -x -o ~/.terminfo \"$tempfile\" && rm \"$tempfile\"'"
)
fallback_term = normalized_term or "xterm-256color"
return f"infocmp -x {fallback_term} | ssh {destination} -- tic -x -"
def list_targets(targets: list[TargetConfig]) -> list[Target]: def list_targets(targets: list[TargetConfig]) -> list[Target]:
"""Convert config targets to domain targets.""" """Convert config targets to domain targets."""
return [ return [
Target( Target(
user="",
namespace=t.namespace, namespace=t.namespace,
platform=t.platform, platform=t.platform,
host=t.host, host=t.host,

View File

@@ -3,7 +3,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional import os
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
@@ -16,17 +17,32 @@ class BootstrapService:
def run( def run(
self, self,
profile_name: str, profile_name: Optional[str],
*, *,
dry_run: bool = False, dry_run: bool = False,
env: Optional[dict[str, str]] = None,
) -> None: ) -> None:
"""Run bootstrap for a profile.""" """Run bootstrap for a profile."""
profiles = self.ctx.manifest.get("profiles", {}) profiles = self.ctx.manifest.get("profiles", {})
if profile_name is None:
if len(profiles) == 1:
profile_name = next(iter(profiles))
else:
raise FlowError(
"Multiple profiles available. Specify one with --profile."
)
if profile_name not in profiles: if profile_name not in profiles:
raise FlowError(f"Unknown profile: {profile_name}") raise FlowError(f"Unknown profile: {profile_name}")
profile = parse_profile(profile_name, profiles[profile_name]) profile = parse_profile(profile_name, profiles[profile_name])
plan = plan_bootstrap(profile, self.ctx.manifest) if profile.os != self.ctx.platform.os:
raise FlowError(
f"Profile '{profile_name}' targets '{profile.os}', current OS is '{self.ctx.platform.os}'"
)
runtime_env = dict(os.environ)
if env:
runtime_env.update(env)
plan = plan_bootstrap(profile, self.ctx.manifest, env=runtime_env)
self.ctx.console.info(f"Bootstrap profile: {profile_name}") self.ctx.console.info(f"Bootstrap profile: {profile_name}")
self.ctx.console.print_plan(plan.actions, verb="bootstrap") self.ctx.console.print_plan(plan.actions, verb="bootstrap")
@@ -41,16 +57,15 @@ class BootstrapService:
# Delegate to PackageService # Delegate to PackageService
from flow.services.packages import PackageService from flow.services.packages import PackageService
pkg_svc = PackageService(self.ctx) pkg_svc = PackageService(self.ctx)
pkg_names = [p.name for p in plan.packages_to_install] if plan.packages_to_install:
if pkg_names: pkg_svc.install(list(plan.packages_to_install))
pkg_svc.install(pkg_names)
continue continue
if action.phase == "dotfiles": if action.phase == "dotfiles":
# Delegate to DotfilesService # Delegate to DotfilesService
from flow.services.dotfiles import DotfilesService from flow.services.dotfiles import DotfilesService
dot_svc = DotfilesService(self.ctx) dot_svc = DotfilesService(self.ctx)
dot_svc.link(profile=profile_name) dot_svc.link(profile=profile.dotfiles_profile or profile_name)
continue continue
# Execute shell commands # Execute shell commands

View File

@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from typing import Optional import shutil
from flow.core.config import FlowContext from flow.core.config import FlowContext
from flow.core.errors import FlowError from flow.core.errors import FlowError
@@ -16,102 +16,227 @@ from flow.domain.containers.resolution import (
) )
def runtime() -> str:
for name in ("docker", "podman"):
if shutil.which(name):
return name
raise FlowError("No container runtime found (docker or podman)")
class ContainerService: class ContainerService:
def __init__(self, ctx: FlowContext): def __init__(self, ctx: FlowContext):
self.ctx = ctx self.ctx = ctx
self.runner = ctx.runtime.runner
def create( def create(
self, self,
name: str,
image: str, image: str,
namespace: str = "default",
*, *,
project_path: str | None = None,
dry_run: bool = False, dry_run: bool = False,
extra_env: Optional[dict[str, str]] = None,
) -> None: ) -> None:
"""Create and start a container.""" """Create and start a development container."""
image_ref = parse_image_ref( rt = runtime()
spec = build_container_spec(
name,
parse_image_ref(
image, 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,
) ),
resolve_mounts(
mounts = resolve_mounts(
paths.HOME, paths.HOME,
self.ctx.config.projects_dir, project_path=project_path,
dotfiles_dir=paths.DOTFILES_DIR, dotfiles_dir=paths.DOTFILES_DIR,
),
project_path=project_path,
) )
spec = build_container_spec( if self._container_exists(rt, spec.name):
namespace, image_ref, mounts, raise FlowError(f"Container already exists: {spec.name}")
env=extra_env,
)
self.ctx.console.info(f"Creating container: {spec.name}") self.ctx.console.info(f"Creating container: {spec.name}")
self.ctx.console.info(f" Image: {spec.image.full}") self.ctx.console.info(f" Image: {spec.image.full}")
self.ctx.console.info(f" Mounts: {len(spec.mounts)}")
if dry_run: if dry_run:
return return
# Build docker run command cmd = [
argv = ["docker", "run", "-d", "--name", spec.name] rt,
"run",
"-d",
"--name",
spec.name,
"--network",
spec.network,
"--init",
]
for key, value in spec.labels.items():
cmd.extend(["--label", f"{key}={value}"])
for mount in spec.mounts:
cmd.extend(["-v", f"{mount.source}:{mount.target}{':ro' if mount.readonly else ''}"])
cmd.extend([spec.image.full, "sleep", "infinity"])
for m in spec.mounts: self.runner.run(cmd, capture_output=False, check=True)
argv.extend(["-v", f"{m.source}:{m.target}{':ro' if m.readonly else ''}"]) self.ctx.console.success(f"Created and started container: {spec.name}")
for k, v in spec.env.items(): def exec(self, name: str, command: list[str] | None = None) -> None:
argv.extend(["-e", f"{k}={v}"]) """Run a command or interactive shell inside a container."""
rt = runtime()
cname = container_name(name)
if not self._container_running(rt, cname):
raise FlowError(f"Container {cname} not running")
argv.append(spec.image.full) if command:
argv = [rt, "exec"]
if os.isatty(0):
argv.extend(["-it"])
argv.append(cname)
argv.extend(command)
result = self.runner.run(argv, capture_output=False)
raise SystemExit(result.returncode)
if spec.command: for shell in (["zsh", "-l"], ["bash", "-l"], ["sh"]):
argv.extend(spec.command.split()) argv = [rt, "exec", "--detach-keys", "ctrl-q,ctrl-p", "-it", cname, *shell]
result = self.runner.run(argv, capture_output=False)
if result.returncode not in (126, 127):
raise SystemExit(result.returncode)
self.ctx.runtime.runner.run(argv, check=True, capture_output=False) raise FlowError(f"Unable to start an interactive shell in {cname}")
self.ctx.console.success(f"Container {spec.name} created.")
def enter( def connect(self, name: str) -> None:
self, """Attach to the container tmux session."""
name: str, rt = runtime()
*, cname = container_name(name)
shell: str = "/bin/bash",
) -> None: if not self._container_exists(rt, cname):
"""Exec into a running container.""" raise FlowError(f"Container does not exist: {cname}")
self.ctx.console.info(f"Entering container: {name}") if not self._container_running(rt, cname):
self.ctx.runtime.runner.run( self.runner.run([rt, "start", cname], capture_output=True, check=True)
["docker", "exec", "-it", name, shell],
capture_output=False, if not shutil.which("tmux"):
self.ctx.console.warn("tmux not found; falling back to direct exec")
self.exec(name)
return
inspect = self.runner.run(
[rt, "container", "inspect", cname, "--format", "{{ .Config.Image }}"],
check=True,
)
image_ref = parse_image_ref(inspect.stdout.strip())
has_session = self.runner.run(["tmux", "has-session", "-t", cname], check=False)
if has_session.returncode != 0:
self.runner.run(
[
"tmux",
"new-session",
"-ds",
cname,
"-e",
f"DF_IMAGE={image_ref.label}",
f"flow dev exec {name}",
],
capture_output=True,
check=True,
)
self.runner.run(
["tmux", "set-option", "-t", cname, "default-command", f"flow dev exec {name}"],
capture_output=True,
check=True, check=True,
) )
def stop(self, name: str) -> None: if os.environ.get("TMUX"):
"""Stop a running container.""" os.execvp("tmux", ["tmux", "switch-client", "-t", cname])
self.ctx.runtime.runner.run( os.execvp("tmux", ["tmux", "attach", "-t", cname])
["docker", "stop", name], check=True,
)
self.ctx.console.success(f"Container {name} stopped.")
def remove(self, name: str) -> None: def stop(self, name: str, *, kill: bool = False) -> None:
"""Stop a running container."""
rt = runtime()
cname = container_name(name)
if not self._container_exists(rt, cname):
raise FlowError(f"Container {cname} does not exist")
argv = [rt, "kill" if kill else "stop", cname]
self.runner.run(argv, capture_output=False, check=True)
self.ctx.console.success(f"Container {cname} stopped.")
def remove(self, name: str, *, force: bool = False) -> None:
"""Remove a container.""" """Remove a container."""
self.ctx.runtime.runner.run( rt = runtime()
["docker", "rm", "-f", name], check=True, cname = container_name(name)
if not self._container_exists(rt, cname):
raise FlowError(f"Container {cname} does not exist")
argv = [rt, "rm"]
if force:
argv.append("-f")
argv.append(cname)
self.runner.run(argv, capture_output=False, check=True)
self.ctx.console.success(f"Container {cname} removed.")
def respawn(self, name: str) -> None:
"""Respawn all tmux panes for a session."""
if not shutil.which("tmux"):
raise FlowError("tmux is required for respawn but was not found")
cname = container_name(name)
panes = self.runner.run(
[
"tmux",
"list-panes",
"-t",
cname,
"-s",
"-F",
"#{session_name}:#{window_index}.#{pane_index}",
],
check=True,
) )
self.ctx.console.success(f"Container {name} removed.") for pane in panes.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, check=True)
def list(self) -> None: def list(self) -> None:
"""List flow-managed containers.""" """List flow-managed containers."""
result = self.ctx.runtime.runner.run( rt = runtime()
["docker", "ps", "-a", "--filter", "name=flow-", "--format", result = self.runner.run(
"{{.Names}}\t{{.Image}}\t{{.Status}}"], [
rt,
"ps",
"-a",
"--filter",
"label=dev=true",
"--format",
'{{.Label "dev.name"}}\t{{.Image}}\t{{.Label "dev.project_path"}}\t{{.Status}}',
],
check=True,
) )
if not result.stdout.strip(): if not result.stdout.strip():
self.ctx.console.info("No flow containers found.") self.ctx.console.info("No flow containers found.")
return return
rows = [] rows = []
home = str(paths.HOME)
for line in result.stdout.strip().splitlines(): for line in result.stdout.strip().splitlines():
parts = line.split("\t") name, image, project, status = (line.split("\t") + ["", "", "", ""])[:4]
if len(parts) >= 3: if project.startswith(home):
rows.append(parts[:3]) project = "~" + project[len(home):]
rows.append([name, image, project or "-", status])
self.ctx.console.table(["NAME", "IMAGE", "STATUS"], rows) self.ctx.console.table(["NAME", "IMAGE", "PROJECT", "STATUS"], rows)
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()

View File

@@ -76,26 +76,8 @@ class DotfilesService:
if dry_run: if dry_run:
return return
# Execute self._save_backup(current)
new_state = LinkedState(links=dict(current.links)) new_state = self._apply_plan(plan, targets, current)
for op in plan.operations:
if op.type == "create_link":
assert op.source is not None
self.ctx.runtime.fs.create_symlink(
op.source, op.target,
sudo=op.needs_sudo,
runner=self.ctx.runtime.runner if op.needs_sudo else None,
)
# Find the matching LinkTarget
lt = next(t for t in targets if t.target == op.target)
new_state.links[op.target] = lt
elif op.type == "remove_link":
self.ctx.runtime.fs.remove_file(
op.target,
sudo=op.needs_sudo,
runner=self.ctx.runtime.runner if op.needs_sudo else None,
)
new_state.links.pop(op.target, None)
self._save_state(new_state) self._save_state(new_state)
self.ctx.console.success( self.ctx.console.success(
@@ -127,6 +109,7 @@ class DotfilesService:
if dry_run: if dry_run:
return return
self._save_backup(current)
new_state = LinkedState(links=dict(current.links)) new_state = LinkedState(links=dict(current.links))
for op in plan.operations: for op in plan.operations:
self.ctx.runtime.fs.remove_file( self.ctx.runtime.fs.remove_file(
@@ -167,29 +150,172 @@ class DotfilesService:
self.ctx.console.info(f"Package directory: {pkg_dir}") self.ctx.console.info(f"Package directory: {pkg_dir}")
def sync(self) -> None: def init(self, repo_url: Optional[str] = None) -> None:
"""Pull latest dotfiles and sync modules.""" """Clone the dotfiles repository."""
if not self.dotfiles_dir.is_dir(): remote = repo_url or self.ctx.config.dotfiles_url
if not self.ctx.config.dotfiles_url: if not remote:
raise FlowError("No dotfiles URL configured") raise FlowError("No dotfiles URL configured")
self.ctx.console.info(f"Cloning dotfiles from {self.ctx.config.dotfiles_url}") if self.dotfiles_dir.exists():
self.ctx.console.warn(f"Dotfiles directory already exists: {self.dotfiles_dir}")
return
self.ctx.console.info(
f"Cloning {remote} (branch: {self.ctx.config.dotfiles_branch})..."
)
self.ctx.runtime.git.run( self.ctx.runtime.git.run(
self.dotfiles_dir.parent, self.dotfiles_dir.parent,
"clone", self.ctx.config.dotfiles_url, str(self.dotfiles_dir), "clone",
"-b",
self.ctx.config.dotfiles_branch,
"--recurse-submodules",
remote,
str(self.dotfiles_dir),
check=True, check=True,
) )
self.sync_modules()
self.ctx.console.success(f"Dotfiles cloned to {self.dotfiles_dir}")
def sync(self, *, profile: Optional[str] = None, relink: bool = False) -> None:
"""Pull latest dotfiles and sync modules."""
if not self.dotfiles_dir.is_dir():
self.init()
else: else:
self.ctx.console.info("Pulling latest dotfiles...") self.ctx.console.info("Pulling latest dotfiles...")
self.ctx.runtime.git.run( self.ctx.runtime.git.run(
self.dotfiles_dir, "pull", "--ff-only", check=True, self.dotfiles_dir, "pull", "--ff-only", check=True,
) )
# Sync modules self.sync_modules(profile=profile)
packages = self._discover_packages(profile=None) if relink:
self.relink(profile=profile)
def list_modules(self, *, profile: Optional[str] = None) -> None:
"""List detected module packages."""
packages = self._discover_packages(
profile=profile,
include_all_layers=profile is None,
)
module_packages = [pkg for pkg in packages if pkg.module is not None]
if not module_packages:
self.ctx.console.info("No module packages found.")
return
rows = []
for pkg in module_packages:
assert pkg.module is not None
status = "ready" if pkg.module.cache_dir.exists() else "missing"
rows.append([
pkg.package_id,
f"{pkg.module.ref_type}:{pkg.module.ref_value}",
pkg.module.source,
status,
])
self.ctx.console.table(["PACKAGE", "REF", "SOURCE", "STATUS"], rows)
def sync_modules(self, *, profile: Optional[str] = None) -> None:
"""Clone or update module repositories."""
packages = self._discover_packages(
profile=profile,
include_all_layers=profile is None,
)
for pkg in packages: for pkg in packages:
if pkg.module: if pkg.module:
self._sync_module(pkg) self._sync_module(pkg)
def repo_status(self) -> None:
"""Show git status for the dotfiles repository."""
if not self.dotfiles_dir.is_dir():
raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}")
result = self.ctx.runtime.git.run(
self.dotfiles_dir,
"status",
"--short",
"--branch",
check=True,
)
output = result.stdout.strip()
if output:
print(output)
return
self.ctx.console.info("Dotfiles repository is clean.")
def repo_pull(
self,
*,
profile: Optional[str] = None,
relink: bool = False,
rebase: bool = True,
) -> None:
"""Pull the dotfiles repository and refresh modules."""
if not self.dotfiles_dir.is_dir():
raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}")
argv = ["pull"]
argv.append("--rebase" if rebase else "--ff-only")
self.ctx.runtime.git.run(self.dotfiles_dir, *argv, check=True)
self.sync_modules(profile=profile)
if relink:
self.relink(profile=profile)
def repo_push(self) -> None:
"""Push the dotfiles repository."""
if not self.dotfiles_dir.is_dir():
raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}")
self.ctx.runtime.git.run(self.dotfiles_dir, "push", check=True)
self.ctx.console.success("Dotfiles pushed.")
def relink(self, *, profile: Optional[str] = None) -> None:
"""Refresh symlinks for the selected profile."""
self.link(profile=profile)
def clean(self, *, dry_run: bool = False) -> None:
"""Remove broken symlinks from managed state."""
current = self._load_state()
broken = [
target for target in sorted(current.links)
if target.is_symlink() and not target.exists()
]
if not broken:
self.ctx.console.info("No broken symlinks found.")
return
if dry_run:
for target in broken:
self.ctx.console.info(f"Would remove broken symlink: {target}")
return
self._save_backup(current)
for target in broken:
link = current.links[target]
self.ctx.runtime.fs.remove_file(
target,
sudo=link.needs_sudo,
runner=self.ctx.runtime.runner if link.needs_sudo else None,
missing_ok=True,
)
current.links.pop(target, None)
self._save_state(current)
self.ctx.console.success(f"Cleaned {len(broken)} broken symlink(s).")
def undo(self) -> None:
"""Restore the previous linked state."""
previous = self._load_backup()
if previous is None:
self.ctx.console.info("No dotfiles link transaction to undo.")
return
current = self._load_state()
desired = list(previous.links.values())
plan = plan_link(desired, current, self._filesystem_check)
if not plan.operations:
self.ctx.console.info("Nothing to undo.")
return
self.ctx.console.print_plan(plan.operations, verb="undo")
self._save_backup(current)
restored = self._apply_plan(plan, desired, current)
self._save_state(restored)
self.ctx.console.success("Dotfiles state restored.")
def _sync_module(self, pkg: Package) -> None: def _sync_module(self, pkg: Package) -> None:
"""Clone or update a module.""" """Clone or update a module."""
module = pkg.module module = pkg.module
@@ -226,7 +352,12 @@ class DotfilesService:
ref = f"tags/{ref}" ref = f"tags/{ref}"
self.ctx.runtime.git.run(cache_dir, "checkout", ref, check=True) self.ctx.runtime.git.run(cache_dir, "checkout", ref, check=True)
def _discover_packages(self, profile: Optional[str]) -> list[Package]: def _discover_packages(
self,
profile: Optional[str],
*,
include_all_layers: bool = False,
) -> list[Package]:
"""Walk dotfiles dir and build Package objects.""" """Walk dotfiles dir and build Package objects."""
packages: list[Package] = [] packages: list[Package] = []
@@ -234,7 +365,17 @@ class DotfilesService:
return packages return packages
layers = ["_shared"] layers = ["_shared"]
if profile: if include_all_layers:
layers.extend(
sorted(
layer.name
for layer in self.dotfiles_dir.iterdir()
if layer.is_dir()
and not layer.name.startswith(".")
and layer.name != "_shared"
)
)
elif profile:
layers.append(profile) layers.append(profile)
for layer in layers: for layer in layers:
@@ -327,8 +468,58 @@ class DotfilesService:
data = self.ctx.runtime.fs.read_json(paths.LINKED_STATE, default={}) data = self.ctx.runtime.fs.read_json(paths.LINKED_STATE, default={})
if data is None: if data is None:
data = {} data = {}
return LinkedState.from_dict(data) state = LinkedState.from_dict(data)
reconciled = LinkedState(
links={
target: link
for target, link in state.links.items()
if self.ctx.runtime.fs.same_symlink(target, link.source)
}
)
if reconciled.links != state.links:
self._save_state(reconciled)
return reconciled
def _save_state(self, state: LinkedState) -> None: def _save_state(self, state: LinkedState) -> None:
"""Save linked state to disk.""" """Save linked state to disk."""
self.ctx.runtime.fs.write_json(paths.LINKED_STATE, state.as_dict()) self.ctx.runtime.fs.write_json(paths.LINKED_STATE, state.as_dict())
def _save_backup(self, state: LinkedState) -> None:
self.ctx.runtime.fs.write_json(self._backup_path(), state.as_dict())
def _load_backup(self) -> Optional[LinkedState]:
data = self.ctx.runtime.fs.read_json(self._backup_path(), default=None)
if data is None:
return None
return LinkedState.from_dict(data)
def _backup_path(self) -> Path:
return paths.LINKED_STATE.with_name("linked.previous.json")
def _apply_plan(
self,
plan,
targets: list[LinkTarget],
current: LinkedState,
) -> LinkedState:
new_state = LinkedState(links=dict(current.links))
for op in plan.operations:
if op.type == "create_link":
assert op.source is not None
self.ctx.runtime.fs.create_symlink(
op.source,
op.target,
sudo=op.needs_sudo,
runner=self.ctx.runtime.runner if op.needs_sudo else None,
)
link_target = next(target for target in targets if target.target == op.target)
new_state.links[op.target] = link_target
elif op.type == "remove_link":
self.ctx.runtime.fs.remove_file(
op.target,
sudo=op.needs_sudo,
runner=self.ctx.runtime.runner if op.needs_sudo else None,
missing_ok=True,
)
new_state.links.pop(op.target, None)
return new_state

View File

@@ -1,13 +1,17 @@
# src/flow/services/packages.py
"""PackageService -- orchestrates package installation.""" """PackageService -- orchestrates package installation."""
from __future__ import annotations from __future__ import annotations
import os
import shutil
import tempfile
import urllib.request
from pathlib import Path from pathlib import Path
from typing import Any, 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.template import substitute_template
from flow.core import paths from flow.core import paths
from flow.domain.packages.catalog import normalize_profile_entry, parse_catalog from flow.domain.packages.catalog import normalize_profile_entry, parse_catalog
from flow.domain.packages.models import ( from flow.domain.packages.models import (
@@ -18,10 +22,13 @@ from flow.domain.packages.models import (
) )
from flow.domain.packages.planning import plan_install, plan_remove from flow.domain.packages.planning import plan_install, plan_remove
from flow.domain.packages.resolution import ( from flow.domain.packages.resolution import (
binary_template_context,
detect_package_manager, detect_package_manager,
pm_cask_install_command,
pm_install_command, pm_install_command,
pm_update_command, pm_update_command,
resolve_binary_asset, resolve_binary_asset,
resolve_extract_dir,
resolve_download_url, resolve_download_url,
resolve_source_name, resolve_source_name,
resolve_spec, resolve_spec,
@@ -32,37 +39,45 @@ class PackageService:
def __init__(self, ctx: FlowContext): def __init__(self, ctx: FlowContext):
self.ctx = ctx self.ctx = ctx
def install( def resolve_install_packages(
self, self,
package_names: Optional[list[str]] = None,
*, *,
package_names: Optional[list[str]] = None,
profile: Optional[str] = None, profile: Optional[str] = None,
dry_run: bool = False, ) -> list[PackageDef]:
) -> None: """Resolve package definitions from names or a profile."""
"""Install packages from profile or by name."""
catalog = parse_catalog(self.ctx.manifest) catalog = parse_catalog(self.ctx.manifest)
installed = self._load_state()
pm = detect_package_manager()
# Resolve packages to install
packages: list[PackageDef] = [] packages: list[PackageDef] = []
if package_names: if package_names:
for name in package_names: for name in package_names:
ref = normalize_profile_entry(name) ref = normalize_profile_entry(name)
pkg = resolve_spec(ref, catalog) packages.append(resolve_spec(ref, catalog))
packages.append(pkg) return packages
elif profile:
if profile:
profiles = self.ctx.manifest.get("profiles", {}) profiles = self.ctx.manifest.get("profiles", {})
if profile not in profiles: if profile not in profiles:
raise FlowError(f"Unknown profile: {profile}") raise FlowError(f"Unknown profile: {profile}")
profile_data = profiles[profile] profile_data = profiles[profile]
for entry in profile_data.get("packages", []): for entry in profile_data.get("packages", []):
ref = normalize_profile_entry(entry) ref = normalize_profile_entry(entry)
pkg = resolve_spec(ref, catalog) packages.append(resolve_spec(ref, catalog))
packages.append(pkg) return packages
else:
raise FlowError("Specify package names or --profile") raise FlowError("Specify package names or --profile")
def install(
self,
packages: Optional[list[PackageDef]] = None,
*,
dry_run: bool = False,
) -> None:
"""Install the resolved package definitions."""
if not packages:
raise FlowError("Specify packages to install")
installed = self._load_state()
pm = detect_package_manager()
plan = plan_install(packages, installed, self.ctx.platform.platform, pm) plan = plan_install(packages, installed, self.ctx.platform.platform, pm)
if not plan.install_ops: if not plan.install_ops:
@@ -83,9 +98,7 @@ class PackageService:
pm_update_command(pm), check=True, pm_update_command(pm), check=True,
) )
pm_names = [ pm_names = [op.source_name for op in plan.install_ops if op.method == "pm"]
op.source_name for op in plan.install_ops if op.method == "pm"
]
if pm_names and pm: if pm_names and pm:
cmd = pm_install_command(pm, pm_names) cmd = pm_install_command(pm, pm_names)
self.ctx.console.info(f"Installing: {', '.join(pm_names)}") self.ctx.console.info(f"Installing: {', '.join(pm_names)}")
@@ -97,13 +110,29 @@ class PackageService:
version=op.package.version or "system", version=op.package.version or "system",
type="pkg", type="pkg",
) )
self._run_post_install(op.package)
cask_names = [op.source_name for op in plan.install_ops if op.method == "cask"]
if cask_names and pm:
cmd = pm_cask_install_command(pm, cask_names)
self.ctx.console.info(f"Installing casks: {', '.join(cask_names)}")
self.ctx.runtime.runner.run_shell(cmd, check=True)
for op in plan.install_ops:
if op.method == "cask":
installed.packages[op.package.name] = InstalledPackage(
name=op.package.name,
version=op.package.version or "system",
type="cask",
)
self._run_post_install(op.package)
# Execute binary packages
for op in plan.install_ops: for op in plan.install_ops:
if op.method == "binary" and op.download_url: if op.method == "binary" and op.download_url:
self._install_binary(op.package, op.download_url, op.source_name, installed) self._install_binary(op.package, op.download_url, op.source_name, installed)
self._run_post_install(op.package)
elif op.method == "appimage" and op.download_url: elif op.method == "appimage" and op.download_url:
self._install_appimage(op.package, op.download_url, installed) self._install_appimage(op.package, op.download_url, installed)
self._run_post_install(op.package)
self._save_state(installed) self._save_state(installed)
self.ctx.console.success(f"Installed {len(plan.install_ops)} package(s).") self.ctx.console.success(f"Installed {len(plan.install_ops)} package(s).")
@@ -112,52 +141,64 @@ class PackageService:
self, pkg: PackageDef, url: str, asset: str, state: InstalledState, self, pkg: PackageDef, url: str, asset: str, state: InstalledState,
) -> None: ) -> None:
"""Download and install a binary package.""" """Download and install a binary package."""
self.ctx.console.info(f"Downloading {pkg.name}...") install_map = pkg.install
tmp_dir = paths.DATA_DIR / "tmp" if not install_map:
self.ctx.runtime.fs.ensure_dir(tmp_dir) raise FlowError(f"Binary package '{pkg.name}' must define install paths")
context = self._binary_context(pkg)
with tempfile.TemporaryDirectory(prefix=f"flow-{pkg.name}-") as tmp:
tmp_dir = Path(tmp)
archive = tmp_dir / asset archive = tmp_dir / asset
extracted = tmp_dir / "extract"
self.ctx.runtime.runner.run( self.ctx.console.info(f"Downloading {pkg.name}...")
["curl", "-fSL", "-o", str(archive), url], check=True, with urllib.request.urlopen(url, timeout=60) as response:
self.ctx.runtime.fs.write_bytes(archive, response.read())
self.ctx.runtime.fs.ensure_dir(extracted)
try:
shutil.unpack_archive(str(archive), str(extracted))
except (shutil.ReadError, ValueError) as e:
raise FlowError(f"Could not extract archive for '{pkg.name}': {e}") from e
extract_dir = resolve_extract_dir(pkg, self.ctx.platform.platform)
source_root = extracted if extract_dir is None else extracted / extract_dir
if not source_root.exists():
raise FlowError(f"extract-dir '{extract_dir}' not found for package '{pkg.name}'")
source_root_resolved = source_root.resolve(strict=False)
installed_paths: list[Path] = []
for section in ("bin", "share", "man", "lib"):
if section not in install_map:
continue
items = install_map[section]
if not isinstance(items, list):
raise FlowError(
f"Install section '{section}' for '{pkg.name}' must be a list"
)
for item in items:
if not isinstance(item, str):
raise FlowError(
f"Install paths for '{pkg.name}' must be strings"
)
installed_paths.append(
self._copy_install_item(
pkg.name,
source_root,
source_root_resolved,
section,
substitute_template(item, context),
)
) )
bin_dir = Path.home() / ".local" / "bin" if not installed_paths:
self.ctx.runtime.fs.ensure_dir(bin_dir) raise FlowError(f"Binary package '{pkg.name}' installed no files")
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(
["tar", "-xf", str(archive), "-C", str(extract_dir)], check=True,
)
# Find and install binaries
install_cfg = pkg.install or {}
binary_name = install_cfg.get("binary", pkg.name)
search_root = extract_dir / pkg.extract_dir if pkg.extract_dir else extract_dir
for candidate in search_root.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( state.packages[pkg.name] = InstalledPackage(
name=pkg.name, name=pkg.name,
version=pkg.version or "latest", version=pkg.version or "latest",
type="binary", type="binary",
files=installed_files, files=installed_paths,
) )
def _install_appimage( def _install_appimage(
@@ -204,15 +245,35 @@ class PackageService:
for op in plan.remove_ops: for op in plan.remove_ops:
for f in op.files: for f in op.files:
if f.is_dir():
self.ctx.runtime.fs.remove_tree(f)
else:
self.ctx.runtime.fs.remove_file(f, missing_ok=True) self.ctx.runtime.fs.remove_file(f, missing_ok=True)
installed.packages.pop(op.name, None) installed.packages.pop(op.name, None)
self._save_state(installed) self._save_state(installed)
self.ctx.console.success(f"Removed {len(plan.remove_ops)} package(s).") self.ctx.console.success(f"Removed {len(plan.remove_ops)} package(s).")
def list_packages(self) -> None: def list_packages(self, *, show_all: bool = False) -> None:
"""List installed packages.""" """List installed packages."""
catalog = parse_catalog(self.ctx.manifest)
installed = self._load_state() installed = self._load_state()
if show_all:
if not catalog:
self.ctx.console.info("No packages defined in manifest.")
return
rows = []
for name, package in sorted(catalog.items()):
installed_pkg = installed.packages.get(name)
rows.append([
name,
package.type,
installed_pkg.version if installed_pkg else "-",
package.version or "-",
])
self.ctx.console.table(["NAME", "TYPE", "INSTALLED", "AVAILABLE"], rows)
return
if not installed.packages: if not installed.packages:
self.ctx.console.info("No packages installed by flow.") self.ctx.console.info("No packages installed by flow.")
return return
@@ -231,3 +292,102 @@ class PackageService:
def _save_state(self, state: InstalledState) -> None: def _save_state(self, state: InstalledState) -> None:
self.ctx.runtime.fs.write_json(paths.INSTALLED_STATE, state.as_dict()) self.ctx.runtime.fs.write_json(paths.INSTALLED_STATE, state.as_dict())
def _binary_context(self, pkg: PackageDef) -> dict[str, str]:
return {
"env": dict(os.environ),
"name": pkg.name,
"version": pkg.version or "",
**binary_template_context(pkg, self.ctx.platform.platform),
}
def _copy_install_item(
self,
package_name: str,
source_root: Path,
source_root_resolved: Path,
section: str,
raw_path: str,
) -> Path:
declared_path = Path(raw_path)
self._validate_install_path(package_name, declared_path)
source = (source_root / declared_path).resolve(strict=False)
if not str(source).startswith(str(source_root_resolved)):
raise FlowError(
f"Install path escapes extract-dir for '{package_name}': {declared_path}"
)
if not source.exists():
raise FlowError(
f"Install path not found for '{package_name}': {declared_path}"
)
destination_root = self._install_destination(section)
stripped_path = self._strip_prefix(declared_path, self._install_strip_prefix(section))
destination = destination_root / stripped_path
if source.is_dir():
self.ctx.runtime.fs.copy_tree(source, destination)
else:
self.ctx.runtime.fs.copy_file(source, destination)
if section == "bin":
destination.chmod(destination.stat().st_mode | 0o111)
return destination
def _run_post_install(self, pkg: PackageDef) -> None:
if not pkg.post_install:
return
script = substitute_template(pkg.post_install, self._binary_context(pkg))
if not pkg.allow_sudo and self._script_uses_sudo(script):
raise FlowError(
f"Package '{pkg.name}' post-install uses sudo but allow-sudo is false"
)
self.ctx.runtime.runner.run_shell(script, check=True)
def _install_destination(self, section: str) -> Path:
home = Path.home()
destinations = {
"bin": home / ".local" / "bin",
"share": home / ".local" / "share",
"man": home / ".local" / "share" / "man",
"lib": home / ".local" / "lib",
}
if section not in destinations:
raise FlowError(f"Unsupported install section: {section}")
return destinations[section]
def _install_strip_prefix(self, section: str) -> Path:
prefixes = {
"bin": Path("bin"),
"share": Path("share"),
"man": Path("share") / "man",
"lib": Path("lib"),
}
if section not in prefixes:
raise FlowError(f"Unsupported install section: {section}")
return prefixes[section]
def _strip_prefix(self, path: Path, prefix: Path) -> Path:
try:
return path.relative_to(prefix)
except ValueError:
return path
def _validate_install_path(self, package_name: str, declared_path: Path) -> None:
if declared_path.is_absolute():
raise FlowError(
f"Install path for '{package_name}' must be relative: {declared_path}"
)
if any(part == ".." for part in declared_path.parts):
raise FlowError(
f"Install path for '{package_name}' must not include parent traversal: {declared_path}"
)
def _script_uses_sudo(self, script: str) -> bool:
for line in script.splitlines():
stripped = line.strip()
if stripped.startswith("sudo "):
return True
return False

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import getpass
import os import os
from typing import Optional from typing import Optional
@@ -23,11 +24,27 @@ class RemoteService:
self, self,
target_spec: str, target_spec: str,
*, *,
user: Optional[str] = None,
namespace: Optional[str] = None,
platform: Optional[str] = None,
session: Optional[str] = None,
no_tmux: bool = False,
dry_run: bool = False, dry_run: bool = False,
) -> None: ) -> None:
"""SSH into a target.""" """SSH into a target."""
target = resolve_target(target_spec, self.ctx.config.targets) target = resolve_target(
cmd = build_ssh_command(target) target_spec,
self.ctx.config.targets,
default_user=os.environ.get("USER") or getpass.getuser(),
user=user,
namespace=namespace,
platform=platform,
)
cmd = build_ssh_command(
target,
tmux_session=session or self.ctx.config.tmux_session,
no_tmux=no_tmux,
)
self.ctx.console.info(f"Connecting to {target.label} ({target.host})") self.ctx.console.info(f"Connecting to {target.label} ({target.host})")
@@ -35,13 +52,8 @@ class RemoteService:
self.ctx.console.info(f"Would run: {' '.join(cmd.argv)}") self.ctx.console.info(f"Would run: {' '.join(cmd.argv)}")
return return
# Set env vars for the SSH session
env = dict(os.environ)
env.update(cmd.env)
self.ctx.runtime.runner.run( self.ctx.runtime.runner.run(
cmd.argv, cmd.argv,
env=env,
capture_output=False, capture_output=False,
check=True, check=True,
) )
@@ -61,7 +73,15 @@ class RemoteService:
def fix_terminfo(self, target_spec: str) -> None: def fix_terminfo(self, target_spec: str) -> None:
"""Show terminfo fix commands.""" """Show terminfo fix commands."""
cmds = terminfo_fix_command() target = resolve_target(
self.ctx.console.info("Run these commands to fix terminfo:") target_spec,
for cmd in cmds: self.ctx.config.targets,
default_user=os.environ.get("USER") or getpass.getuser(),
)
destination = f"{target.user}@{target.host}" if target.user else target.host
cmd = terminfo_fix_command(os.environ.get("TERM"), destination)
if cmd is None:
self.ctx.console.info("No terminfo workaround needed for the current TERM.")
return
self.ctx.console.info("Run this command to fix terminfo:")
self.ctx.console.info(f" {cmd}") self.ctx.console.info(f" {cmd}")

View File

@@ -1,10 +1,8 @@
"""Tests for CLI.""" """Tests for CLI."""
import os
import subprocess import subprocess
import sys import sys
from unittest.mock import patch
import pytest
def test_version_flag(): def test_version_flag():
@@ -46,3 +44,43 @@ def test_packages_help():
) )
assert result.returncode == 0 assert result.returncode == 0
assert "install" in result.stdout assert "install" in result.stdout
def test_invalid_config_is_reported_without_traceback(tmp_path):
config_root = tmp_path / "config"
(config_root / "flow").mkdir(parents=True)
(config_root / "flow" / "config.yaml").write_text(":\n bad\n")
env = dict(os.environ)
env["XDG_CONFIG_HOME"] = str(config_root)
result = subprocess.run(
[sys.executable, "-m", "flow", "completion"],
capture_output=True,
text=True,
env=env,
)
assert result.returncode == 1
assert "Invalid YAML" in result.stderr
assert "Traceback" not in result.stderr
def test_local_manifest_is_loaded(tmp_path):
config_root = tmp_path / "config"
flow_dir = config_root / "flow"
flow_dir.mkdir(parents=True)
(flow_dir / "manifest.yaml").write_text(
"profiles:\n"
" demo:\n"
" os: linux\n"
)
env = dict(os.environ)
env["XDG_CONFIG_HOME"] = str(config_root)
result = subprocess.run(
[sys.executable, "-m", "flow", "setup", "list"],
capture_output=True,
text=True,
env=env,
)
assert result.returncode == 0
assert "demo" in result.stdout

View File

@@ -75,3 +75,59 @@ def test_load_manifest_merges_files(tmp_path):
data = load_manifest(tmp_path) data = load_manifest(tmp_path)
assert "packages" in data assert "packages" in data
assert "profiles" in data assert "profiles" in data
def test_load_config_merges_local_and_overlay(tmp_path):
local = tmp_path / "local"
overlay = tmp_path / "overlay"
local.mkdir()
overlay.mkdir()
(local / "config.yaml").write_text(
"repository:\n"
" url: git@github.com:user/dots.git\n"
"targets:\n"
" personal@orb: personal.orb\n"
)
(overlay / "config.yaml").write_text(
"repository:\n"
" branch: dev\n"
"defaults:\n"
" tmux-session: main\n"
)
cfg = load_config(local, overlay)
assert cfg.dotfiles_url == "git@github.com:user/dots.git"
assert cfg.dotfiles_branch == "dev"
assert cfg.tmux_session == "main"
assert cfg.targets[0].host == "personal.orb"
def test_load_config_parses_legacy_targets(tmp_path):
(tmp_path / "01-targets.yaml").write_text(
"targets:\n"
" personal: orb personal.orb ~/.ssh/id_personal\n"
)
(tmp_path / "02-targets.yaml").write_text(
"targets:\n"
" - namespace: work\n"
" platform: ec2\n"
" host: work.ec2.internal\n"
" identity: ~/.ssh/id_work\n"
)
cfg = load_config(tmp_path)
assert len(cfg.targets) == 2
assert cfg.targets[0].platform == "orb"
assert cfg.targets[0].identity == "~/.ssh/id_personal"
assert cfg.targets[1].namespace == "work"
def test_load_manifest_merges_local_and_overlay(tmp_path):
local = tmp_path / "local"
overlay = tmp_path / "overlay"
local.mkdir()
overlay.mkdir()
(local / "manifest.yaml").write_text("profiles:\n local:\n os: linux\n")
(overlay / "packages.yaml").write_text("packages:\n - name: fd\n type: pkg\n")
data = load_manifest(local, overlay)
assert "profiles" in data
assert "packages" in data

View File

@@ -33,6 +33,20 @@ class TestParseProfile:
profile = parse_profile("test", raw) profile = parse_profile("test", raw)
assert len(profile.ssh_keys) == 1 assert len(profile.ssh_keys) == 1
def test_ssh_keygen_alias(self):
raw = {"ssh-keygen": [{"filename": "id_work", "type": "ed25519"}]}
profile = parse_profile("test", raw)
assert profile.ssh_keys[0]["path"] == "~/.ssh/id_work"
def test_requires_alias(self):
profile = parse_profile("test", {"requires": ["USER_EMAIL"]})
assert profile.env_required == ("USER_EMAIL",)
def test_post_link_and_dotfiles_profile(self):
profile = parse_profile("test", {"dotfiles-profile": "linux-work", "post-link": "echo done"})
assert profile.dotfiles_profile == "linux-work"
assert profile.post_link == "echo done"
class TestPlanBootstrap: class TestPlanBootstrap:
def test_basic_plan(self): def test_basic_plan(self):
@@ -73,6 +87,16 @@ class TestPlanBootstrap:
runcmd_actions = [a for a in plan.actions if "custom command" in a.description.lower()] runcmd_actions = [a for a in plan.actions if "custom command" in a.description.lower()]
assert len(runcmd_actions) == 1 assert len(runcmd_actions) == 1
def test_post_link_produces_action(self):
profile = Profile(
name="test", os="linux", arch=None,
hostname=None, locale=None, shell=None,
ssh_keys=[], runcmd=[], packages=[], env_required=[],
post_link="echo done",
)
plan = plan_bootstrap(profile, {})
assert any(action.phase == "post-link" for action in plan.actions)
def test_ssh_keys_action(self): def test_ssh_keys_action(self):
profile = Profile( profile = Profile(
name="test", os="linux", arch=None, name="test", os="linux", arch=None,

View File

@@ -15,7 +15,7 @@ class TestParseImageRef:
def test_simple_name(self): def test_simple_name(self):
ref = parse_image_ref("devbox") ref = parse_image_ref("devbox")
assert ref.registry == "registry.tomastm.com" assert ref.registry == "registry.tomastm.com"
assert ref.name == "devbox" assert ref.repo == "devbox"
assert ref.tag == "latest" assert ref.tag == "latest"
def test_with_tag(self): def test_with_tag(self):
@@ -25,7 +25,7 @@ class TestParseImageRef:
def test_full_ref(self): def test_full_ref(self):
ref = parse_image_ref("ghcr.io/user/image:main") ref = parse_image_ref("ghcr.io/user/image:main")
assert ref.registry == "ghcr.io" assert ref.registry == "ghcr.io"
assert ref.name == "user/image" assert ref.repo == "user/image"
assert ref.tag == "main" assert ref.tag == "main"
def test_full_image_string(self): def test_full_image_string(self):
@@ -35,38 +35,35 @@ class TestParseImageRef:
class TestContainerName: class TestContainerName:
def test_basic(self): def test_basic(self):
assert container_name("personal", "devbox") == "flow-personal-devbox" assert container_name("devbox") == "dev-devbox"
class TestResolveMounts: class TestResolveMounts:
def test_projects_mount(self, tmp_path): def test_projects_mount(self, tmp_path):
projects = tmp_path / "projects" projects = tmp_path / "projects"
projects.mkdir() projects.mkdir()
mounts = resolve_mounts(tmp_path, str(projects)) mounts = resolve_mounts(tmp_path, project_path=str(projects))
project_mounts = [m for m in mounts if m.target == "/home/user/projects"] project_mounts = [m for m in mounts if m.target == "/workspace"]
assert len(project_mounts) == 1 assert len(project_mounts) == 1
def test_extra_mounts(self, tmp_path): def test_dotfiles_mount(self, tmp_path):
mounts = resolve_mounts( dotfiles = tmp_path / "dotfiles"
tmp_path, str(tmp_path), dotfiles.mkdir()
extra_mounts=[{"source": str(tmp_path), "target": "/data"}], mounts = resolve_mounts(tmp_path, dotfiles_dir=dotfiles)
) assert any(m.target.endswith("/flow/dotfiles") for m in mounts)
extra = [m for m in mounts if m.target == "/data"]
assert len(extra) == 1
class TestBuildContainerSpec: class TestBuildContainerSpec:
def test_basic(self): def test_basic(self):
image = ImageRef(registry="reg", name="img", tag="v1") image = ImageRef(registry="reg", repo="img", tag="v1", label="reg/img")
spec = build_container_spec("personal", image, []) spec = build_container_spec("api", image, [])
assert spec.name == "flow-personal-img" assert spec.name == "dev-api"
assert spec.env["DF_NAMESPACE"] == "personal" assert spec.labels["dev.name"] == "api"
assert spec.env["DF_PLATFORM"] == "container"
def test_with_mounts(self): def test_with_mounts(self):
image = ImageRef(registry="reg", name="img", tag="v1") image = ImageRef(registry="reg", repo="img", tag="v1", label="reg/img")
mounts = [Mount(source=Path("/a"), target="/b")] mounts = [Mount(source=Path("/a"), target="/b")]
spec = build_container_spec("ns", image, mounts) spec = build_container_spec("api", image, mounts)
assert len(spec.mounts) == 1 assert len(spec.mounts) == 1

View File

@@ -4,16 +4,20 @@ import pytest
from flow.core.errors import ConfigError, FlowError from flow.core.errors import ConfigError, FlowError
from flow.domain.packages.catalog import normalize_profile_entry, parse_catalog from flow.domain.packages.catalog import normalize_profile_entry, parse_catalog
from flow.domain.packages.planning import plan_install
from flow.domain.packages.resolution import ( from flow.domain.packages.resolution import (
binary_template_context,
detect_package_manager, detect_package_manager,
pm_cask_install_command,
pm_install_command, pm_install_command,
pm_update_command, pm_update_command,
resolve_binary_asset, resolve_binary_asset,
resolve_download_url, resolve_download_url,
resolve_extract_dir,
resolve_source_name, resolve_source_name,
resolve_spec, resolve_spec,
) )
from flow.domain.packages.models import PackageDef, ProfilePackageRef from flow.domain.packages.models import InstalledState, PackageDef, ProfilePackageRef
class TestParseCatalog: class TestParseCatalog:
@@ -77,6 +81,26 @@ class TestResolveSpec:
assert result.name == "unknown" assert result.name == "unknown"
assert result.type == "binary" assert result.type == "binary"
def test_profile_object_overrides_catalog(self):
catalog = {"docker": PackageDef(
name="docker", type="pkg", sources={"apt": "docker-ce"},
source=None, version=None, asset_pattern=None,
platform_map={}, extract_dir=None, install={},
post_install=None, allow_sudo=False,
)}
ref = ProfilePackageRef(
name="docker",
type=None,
source=None,
version=None,
asset_pattern=None,
post_install="sudo groupadd docker || true",
allow_sudo=True,
)
result = resolve_spec(ref, catalog)
assert result.post_install == "sudo groupadd docker || true"
assert result.allow_sudo is True
class TestResolveSourceName: class TestResolveSourceName:
def test_with_pm_mapping(self): def test_with_pm_mapping(self):
@@ -125,6 +149,19 @@ class TestResolveBinaryAsset:
assert "x64" in result assert "x64" in result
assert "linux" in result assert "linux" in result
def test_double_brace_pattern_uses_platform_map_context(self):
pkg = PackageDef(
name="nvim", type="binary", sources={},
source="github:neovim/neovim",
version="0.10.4",
asset_pattern="nvim-{{os}}-{{arch}}.tar.gz",
platform_map={"linux-x64": {"os": "linux", "arch": "x86_64"}},
extract_dir="nvim-{{os}}64", install={},
post_install=None, allow_sudo=False,
)
assert resolve_binary_asset(pkg, "linux-x64") == "nvim-linux-x86_64.tar.gz"
assert resolve_extract_dir(pkg, "linux-x64") == "nvim-linux64"
class TestResolveDownloadUrl: class TestResolveDownloadUrl:
def test_github_shorthand_with_version(self): def test_github_shorthand_with_version(self):
@@ -140,6 +177,18 @@ class TestResolveDownloadUrl:
assert "github.com/neovim/neovim" in url assert "github.com/neovim/neovim" in url
assert "v0.10.4" in url assert "v0.10.4" in url
def test_github_shorthand_prefixes_v(self):
pkg = PackageDef(
name="nvim", type="binary", sources={},
source="github:neovim/neovim",
version="0.10.4",
asset_pattern=None, platform_map={},
extract_dir=None, install={},
post_install=None, allow_sudo=False,
)
url = resolve_download_url(pkg, "nvim.tar.gz", "linux-x64")
assert "/download/v0.10.4/" in url
def test_github_latest(self): def test_github_latest(self):
pkg = PackageDef( pkg = PackageDef(
name="nvim", type="binary", sources={}, name="nvim", type="binary", sources={},
@@ -181,7 +230,24 @@ class TestPmCommands:
cmd = pm_install_command("apt", ["fd-find"]) cmd = pm_install_command("apt", ["fd-find"])
assert "apt-get install" in cmd assert "apt-get install" in cmd
def test_brew_cask_install(self):
cmd = pm_cask_install_command("brew", ["wezterm"])
assert "--cask" in cmd
assert "wezterm" in cmd
def test_detect_package_manager_returns_something(self): def test_detect_package_manager_returns_something(self):
# Just verify it doesn't error # Just verify it doesn't error
result = detect_package_manager() result = detect_package_manager()
assert result is None or result in ("apt", "dnf", "brew") assert result is None or result in ("apt", "dnf", "brew")
class TestPlanning:
def test_cask_package_is_planned(self):
pkg = PackageDef(
name="wezterm", type="cask", sources={"brew": "wezterm"},
source=None, version=None, asset_pattern=None,
platform_map={}, extract_dir=None, install={},
post_install=None, allow_sudo=False,
)
plan = plan_install([pkg], InstalledState(), "macos-arm64", "brew")
assert plan.install_ops[0].method == "cask"

View File

@@ -16,7 +16,14 @@ from flow.domain.remote.resolution import (
class TestParseTarget: class TestParseTarget:
def test_valid_spec(self): def test_valid_spec(self):
ns, plat = parse_target("personal@orb") user, ns, plat = parse_target("personal@orb")
assert user is None
assert ns == "personal"
assert plat == "orb"
def test_valid_spec_with_user(self):
user, ns, plat = parse_target("alice@personal@orb")
assert user == "alice"
assert ns == "personal" assert ns == "personal"
assert plat == "orb" assert plat == "orb"
@@ -32,33 +39,40 @@ class TestParseTarget:
class TestResolveTarget: class TestResolveTarget:
def test_found(self): def test_found(self):
targets = [TargetConfig(namespace="personal", platform="orb", host="personal.orb")] targets = [TargetConfig(namespace="personal", platform="orb", host="personal.orb")]
result = resolve_target("personal@orb", targets) result = resolve_target("personal@orb", targets, default_user="tomas")
assert result.host == "personal.orb" assert result.host == "personal.orb"
assert result.label == "personal@orb" assert result.label == "personal@orb"
assert result.user == "tomas"
def test_not_found(self): def test_not_found(self):
with pytest.raises(FlowError, match="Unknown target"): with pytest.raises(FlowError, match="Unknown target"):
resolve_target("missing@host", []) resolve_target("missing@host", [], default_user="tomas")
def test_falls_back_to_host_template(self):
result = resolve_target("personal@orb", [], default_user="tomas")
assert result.host == "personal.orb"
class TestBuildSSHCommand: class TestBuildSSHCommand:
def test_basic(self): def test_basic(self):
target = Target(namespace="personal", platform="orb", host="personal.orb") target = Target(namespace="personal", platform="orb", host="personal.orb", user="tomas")
cmd = build_ssh_command(target) cmd = build_ssh_command(target)
assert "ssh" in cmd.argv assert "ssh" in cmd.argv
assert "personal.orb" in cmd.argv assert cmd.destination == "tomas@personal.orb"
assert cmd.env["DF_NAMESPACE"] == "personal" assert cmd.env["DF_NAMESPACE"] == "personal"
assert "tmux" in cmd.argv
def test_with_identity(self): def test_with_identity(self):
target = Target(namespace="work", platform="ec2", host="work.ec2", identity="~/.ssh/id_work") target = Target(namespace="work", platform="ec2", host="work.ec2", identity="~/.ssh/id_work", user="tomas")
cmd = build_ssh_command(target) cmd = build_ssh_command(target)
assert "-i" in cmd.argv assert "-i" in cmd.argv
assert "~/.ssh/id_work" in cmd.argv assert "~/.ssh/id_work" in cmd.argv
def test_with_remote_command(self): def test_without_tmux(self):
target = Target(namespace="p", platform="o", host="h") target = Target(namespace="p", platform="o", host="h", user="tomas")
cmd = build_ssh_command(target, remote_command="ls -la") cmd = build_ssh_command(target, no_tmux=True)
assert cmd.argv[-1] == "ls -la" assert "tmux" not in cmd.argv
assert cmd.destination == "tomas@h"
class TestListTargets: class TestListTargets:
@@ -73,7 +87,6 @@ class TestListTargets:
class TestTerminfoFix: class TestTerminfoFix:
def test_returns_commands(self): def test_returns_command(self):
cmds = terminfo_fix_command() cmd = terminfo_fix_command()
assert len(cmds) == 2 assert "infocmp" in cmd
assert "infocmp" in cmds[0]

View File

@@ -1,7 +1,5 @@
"""Tests for BootstrapService.""" """Tests for BootstrapService."""
from pathlib import Path
import pytest import pytest
from flow.core.config import AppConfig, FlowContext from flow.core.config import AppConfig, FlowContext
@@ -65,3 +63,61 @@ class TestBootstrapService:
svc = BootstrapService(ctx) svc = BootstrapService(ctx)
svc.list_profiles() svc.list_profiles()
assert "No profiles" in capsys.readouterr().out assert "No profiles" in capsys.readouterr().out
def test_run_preserves_profile_package_overrides(self, monkeypatch):
captured = {}
class StubPackageService:
def __init__(self, ctx):
pass
def install(self, packages, *, dry_run=False):
captured["packages"] = packages
monkeypatch.setattr("flow.services.packages.PackageService", StubPackageService)
monkeypatch.setattr("flow.services.dotfiles.DotfilesService.link", lambda self, profile=None: None)
manifest = {
"profiles": {
"linux-auto": {
"os": "linux",
"packages": [{
"name": "docker",
"allow-sudo": True,
"post-install": "sudo groupadd docker || true",
}],
},
},
"packages": [{"name": "docker", "type": "pkg", "sources": {"apt": "docker-ce"}}],
}
ctx = _make_ctx(manifest)
BootstrapService(ctx).run("linux-auto")
assert captured["packages"][0].allow_sudo is True
assert captured["packages"][0].post_install == "sudo groupadd docker || true"
def test_run_uses_dotfiles_profile_override(self, monkeypatch):
captured = {}
monkeypatch.setattr("flow.services.packages.PackageService.install", lambda self, packages, dry_run=False: None)
class StubDotfilesService:
def __init__(self, ctx):
pass
def link(self, profile=None):
captured["profile"] = profile
monkeypatch.setattr("flow.services.dotfiles.DotfilesService", StubDotfilesService)
manifest = {
"profiles": {
"linux-auto": {
"os": "linux",
"dotfiles-profile": "linux-work",
},
},
}
ctx = _make_ctx(manifest)
BootstrapService(ctx).run("linux-auto")
assert captured["profile"] == "linux-work"

View File

@@ -1,13 +1,11 @@
"""Tests for ContainerService.""" """Tests for ContainerService."""
import subprocess import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
from flow.core.config import AppConfig, FlowContext from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console from flow.core.console import Console
from flow.core.platform import PlatformInfo from flow.core.platform import PlatformInfo
from flow.core.runtime import CommandRunner, FileSystem, SystemRuntime from flow.core.runtime import CommandRunner, SystemRuntime
from flow.core import paths from flow.core import paths
from flow.services.containers import ContainerService from flow.services.containers import ContainerService
@@ -19,6 +17,11 @@ class FakeRunner(CommandRunner):
def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None): def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None):
self.calls.append(("run", list(argv))) self.calls.append(("run", list(argv)))
command = list(argv)
if command[:4] == ["docker", "container", "ls", "-a"]:
return subprocess.CompletedProcess(argv, 0, stdout="dev-api\n", stderr="")
if command[:3] == ["docker", "container", "ls"]:
return subprocess.CompletedProcess(argv, 0, stdout="dev-api\n", stderr="")
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
def run_shell(self, command, *, cwd=None, env=None, capture_output=True, check=False, timeout=None): def run_shell(self, command, *, cwd=None, env=None, capture_output=True, check=False, timeout=None):
@@ -43,31 +46,35 @@ class TestContainerService:
def test_create_dry_run(self, tmp_path, capsys, monkeypatch): def test_create_dry_run(self, tmp_path, capsys, monkeypatch):
monkeypatch.setattr(paths, "HOME", tmp_path) monkeypatch.setattr(paths, "HOME", tmp_path)
monkeypatch.setattr(paths, "DOTFILES_DIR", tmp_path / "dotfiles") monkeypatch.setattr(paths, "DOTFILES_DIR", tmp_path / "dotfiles")
monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker")
ctx = _make_ctx(tmp_path) ctx = _make_ctx(tmp_path)
svc = ContainerService(ctx) svc = ContainerService(ctx)
svc.create("devbox", "personal", dry_run=True) svc.create("api", "tm0/node", dry_run=True)
output = capsys.readouterr().out output = capsys.readouterr().out
assert "devbox" in output assert "dev-api" in output
def test_list_no_docker(self, tmp_path, capsys): def test_list_no_containers(self, tmp_path, capsys, monkeypatch):
runner = FakeRunner() runner = FakeRunner()
monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker")
runner.run = lambda argv, **kwargs: subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
ctx = _make_ctx(tmp_path, runner=runner) ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx) svc = ContainerService(ctx)
svc.list() svc.list()
# FakeRunner returns empty stdout -> "No flow containers"
output = capsys.readouterr().out output = capsys.readouterr().out
assert "No flow containers" in output assert "No flow containers" in output
def test_stop_calls_docker(self, tmp_path): def test_stop_calls_docker(self, tmp_path, monkeypatch):
runner = FakeRunner() runner = FakeRunner()
monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker")
ctx = _make_ctx(tmp_path, runner=runner) ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx) svc = ContainerService(ctx)
svc.stop("flow-personal-devbox") svc.stop("api")
assert any("docker" in str(c) and "stop" in str(c) for c in runner.calls) assert any("docker" in str(c) and "stop" in str(c) for c in runner.calls)
def test_remove_calls_docker(self, tmp_path): def test_remove_calls_docker(self, tmp_path, monkeypatch):
runner = FakeRunner() runner = FakeRunner()
monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker")
ctx = _make_ctx(tmp_path, runner=runner) ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx) svc = ContainerService(ctx)
svc.remove("flow-personal-devbox") svc.remove("api")
assert any("docker" in str(c) and "rm" in str(c) for c in runner.calls) assert any("docker" in str(c) and "rm" in str(c) for c in runner.calls)

View File

@@ -1,18 +1,28 @@
"""Tests for DotfilesService.""" """Tests for DotfilesService."""
import subprocess
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock
import yaml import yaml
from flow.core.config import AppConfig, FlowContext from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console from flow.core.console import Console
from flow.core.platform import PlatformInfo from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime from flow.core.runtime import CommandRunner, SystemRuntime
from flow.core import paths from flow.core import paths
from flow.services.dotfiles import DotfilesService from flow.services.dotfiles import DotfilesService
class FakeRunner(CommandRunner):
def __init__(self):
self.calls: list[list[str]] = []
def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None):
command = [str(part) for part in argv]
self.calls.append(command)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
def _make_ctx(tmp_path, console=None): def _make_ctx(tmp_path, console=None):
"""Build a FlowContext for testing.""" """Build a FlowContext for testing."""
return FlowContext( return FlowContext(
@@ -170,3 +180,59 @@ class TestDotfilesServiceLink:
svc.status() svc.status()
output = capsys.readouterr().out output = capsys.readouterr().out
assert "zsh" in output assert "zsh" in output
def test_relink_does_not_remove_unmanaged_file(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()
target = home / ".zshrc"
target.unlink()
target.write_text("user managed file")
svc.link()
assert target.read_text() == "user managed file"
assert not target.is_symlink()
def test_sync_modules_includes_profile_layers(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = tmp_path / "dotfiles"
profile_pkg = dotfiles / "linux-work" / "nvim" / ".config" / "nvim"
profile_pkg.mkdir(parents=True)
(profile_pkg / "_module.yaml").write_text(yaml.dump({
"source": "github:test/nvim-config",
"ref": {"branch": "main"},
}))
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")
runtime = SystemRuntime()
runner = FakeRunner()
runtime.runner = runner
runtime.git.runner = runner
ctx = FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=runtime,
)
DotfilesService(ctx).sync_modules()
assert any("linux-work--nvim" in " ".join(call) for call in runner.calls)

View File

@@ -1,5 +1,7 @@
"""Tests for PackageService.""" """Tests for PackageService."""
import io
import tarfile
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -63,3 +65,68 @@ class TestPackageService:
svc = PackageService(ctx) svc = PackageService(ctx)
with pytest.raises(FlowError, match="Specify"): with pytest.raises(FlowError, match="Specify"):
svc.install() svc.install()
def test_list_all_known_packages(self, tmp_path, monkeypatch, capsys):
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
manifest = {"packages": [{"name": "fd", "type": "pkg"}]}
ctx = _make_ctx(tmp_path, manifest)
svc = PackageService(ctx)
svc.list_packages(show_all=True)
assert "fd" in capsys.readouterr().out
def test_install_binary_honors_declared_install_map(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setattr(paths, "DATA_DIR", tmp_path / "data")
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
archive = io.BytesIO()
with tarfile.open(fileobj=archive, mode="w:gz") as tar:
files = {
"nvim-linux64/bin/nvim": b"#!/bin/sh\n",
"nvim-linux64/share/nvim/runtime.txt": b"runtime\n",
"nvim-linux64/share/man/man1/nvim.1": b"manpage\n",
}
for name, content in files.items():
info = tarfile.TarInfo(name=name)
info.size = len(content)
tar.addfile(info, io.BytesIO(content))
archive_bytes = archive.getvalue()
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return archive_bytes
monkeypatch.setattr("flow.services.packages.urllib.request.urlopen", lambda *args, **kwargs: FakeResponse())
manifest = {
"packages": [{
"name": "neovim",
"type": "binary",
"source": "github:neovim/neovim",
"version": "0.10.4",
"asset-pattern": "nvim-{{os}}-{{arch}}.tar.gz",
"platform-map": {"linux-x64": {"os": "linux", "arch": "x64"}},
"extract-dir": "nvim-{{os}}64",
"install": {
"bin": ["bin/nvim"],
"share": ["share/nvim"],
"man": ["share/man/man1/nvim.1"],
},
}],
}
ctx = _make_ctx(tmp_path, manifest)
svc = PackageService(ctx)
packages = svc.resolve_install_packages(package_names=["neovim"])
svc.install(packages)
assert (home / ".local" / "bin" / "nvim").exists()
assert (home / ".local" / "share" / "nvim" / "runtime.txt").exists()
assert (home / ".local" / "share" / "man" / "man1" / "nvim.1").exists()

View File

@@ -29,6 +29,8 @@ class TestRemoteService:
output = capsys.readouterr().out output = capsys.readouterr().out
assert "personal@orb" in output assert "personal@orb" in output
assert "ssh" in output assert "ssh" in output
assert "tmux" in output
assert "DF_NAMESPACE=personal" in output
def test_enter_unknown_target(self): def test_enter_unknown_target(self):
ctx = _make_ctx() ctx = _make_ctx()