refactor
This commit is contained in:
@@ -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
|
|
||||||
console = Console(quiet=getattr(args, "quiet", False), color=None)
|
|
||||||
platform_info = detect_platform()
|
|
||||||
context = detect_context()
|
|
||||||
|
|
||||||
# Block remote commands inside VMs
|
|
||||||
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.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
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(
|
|
||||||
config=config,
|
|
||||||
manifest=manifest,
|
|
||||||
platform=platform_info,
|
|
||||||
console=console,
|
|
||||||
runtime=SystemRuntime(),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
console = Console(quiet=getattr(args, "quiet", False), color=None)
|
||||||
|
platform_info = detect_platform()
|
||||||
|
context = detect_context()
|
||||||
|
cmd_name = getattr(args, "command", "")
|
||||||
|
|
||||||
|
if context == "vm" and cmd_name == "remote":
|
||||||
|
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()
|
||||||
|
ctx = FlowContext(
|
||||||
|
config=load_config(),
|
||||||
|
manifest=load_manifest(),
|
||||||
|
platform=platform_info,
|
||||||
|
console=console,
|
||||||
|
runtime=SystemRuntime(),
|
||||||
|
)
|
||||||
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",
|
||||||
|
|||||||
@@ -1,99 +1,506 @@
|
|||||||
"""Zsh completion for flow CLI."""
|
"""Shell completion support."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from flow.core.config import load_config, load_manifest
|
||||||
|
from flow.core import paths
|
||||||
|
from flow.domain.remote.resolution import HOST_TEMPLATES
|
||||||
|
|
||||||
|
ZSH_RC_START = "# >>> flow completion >>>"
|
||||||
|
ZSH_RC_END = "# <<< flow completion <<<"
|
||||||
|
|
||||||
|
TOP_LEVEL_COMMANDS = [
|
||||||
|
"enter",
|
||||||
|
"remote",
|
||||||
|
"dev",
|
||||||
|
"dotfiles",
|
||||||
|
"setup",
|
||||||
|
"packages",
|
||||||
|
"projects",
|
||||||
|
"completion",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
COMMANDS = {
|
def register(subparsers):
|
||||||
"dotfiles": {
|
parser = subparsers.add_parser("completion", help="Shell completion helpers")
|
||||||
"subcommands": ["link", "unlink", "status", "sync"],
|
sub = parser.add_subparsers(dest="completion_action")
|
||||||
"flags": {
|
|
||||||
"link": ["--profile", "--dry-run", "--skip"],
|
zsh = sub.add_parser("zsh", help="Print the zsh completion script")
|
||||||
"unlink": ["--dry-run"],
|
zsh.set_defaults(handler=_run_zsh_script)
|
||||||
"status": [],
|
|
||||||
"sync": [],
|
install = sub.add_parser("install-zsh", help="Install zsh completion")
|
||||||
},
|
install.add_argument("--dir", default="~/.zsh/completions")
|
||||||
},
|
install.add_argument("--rc", default="~/.zshrc")
|
||||||
"packages": {
|
install.add_argument("--no-rc", action="store_true")
|
||||||
"subcommands": ["install", "remove", "list"],
|
install.set_defaults(handler=_run_install_zsh)
|
||||||
"flags": {
|
|
||||||
"install": ["--profile", "--dry-run"],
|
hidden = sub.add_parser("_zsh_complete", help=argparse.SUPPRESS)
|
||||||
"remove": ["--dry-run"],
|
hidden.add_argument("--cword", type=int, required=True, help=argparse.SUPPRESS)
|
||||||
"list": [],
|
hidden.add_argument("words", nargs="*", help=argparse.SUPPRESS)
|
||||||
},
|
hidden.set_defaults(handler=_run_zsh_complete)
|
||||||
},
|
|
||||||
"setup": {
|
parser.set_defaults(handler=_run_zsh_script)
|
||||||
"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"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def complete(comp_words: list[str], comp_cword: int) -> list[str]:
|
def complete(words: Sequence[str], cword: int) -> list[str]:
|
||||||
"""Return completions for the current word."""
|
before, current = _split_words(words, cword)
|
||||||
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 ""
|
if not before:
|
||||||
if command not in COMMANDS:
|
return _filter(TOP_LEVEL_COMMANDS + ["-h", "--help", "--version"], current)
|
||||||
return []
|
|
||||||
|
|
||||||
cmd_def = COMMANDS[command]
|
command = _canonical_command(before[0])
|
||||||
|
|
||||||
if comp_cword == 2:
|
if command in {"enter", "remote"}:
|
||||||
# Complete subcommands
|
return _complete_remote(before, current)
|
||||||
prefix = comp_words[2] if len(comp_words) > 2 else ""
|
if command == "dev":
|
||||||
return [s for s in cmd_def["subcommands"] if s.startswith(prefix)]
|
return _complete_dev(before, current)
|
||||||
|
if command == "dotfiles":
|
||||||
if comp_cword >= 3:
|
return _complete_dotfiles(before, current)
|
||||||
# Complete flags for the subcommand
|
if command == "bootstrap":
|
||||||
subcommand = comp_words[2] if len(comp_words) > 2 else ""
|
return _complete_bootstrap(before, current)
|
||||||
flags = cmd_def["flags"].get(subcommand, [])
|
if command == "packages":
|
||||||
prefix = comp_words[comp_cword] if comp_cword < len(comp_words) else ""
|
return _complete_packages(before, current)
|
||||||
return [f for f in flags if f.startswith(prefix)]
|
if command == "projects":
|
||||||
|
return _complete_projects(before, current)
|
||||||
|
if command == "completion":
|
||||||
|
return _complete_completion(before, current)
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def register(subparsers):
|
def _split_words(words: Sequence[str], cword: int) -> tuple[list[str], str]:
|
||||||
"""Register completion subcommand."""
|
tokens = list(words)
|
||||||
p = subparsers.add_parser("completion", help="Shell completion")
|
if not tokens:
|
||||||
p.add_argument("--shell", default="zsh", choices=["zsh"])
|
return [], ""
|
||||||
p.set_defaults(handler=_handle)
|
index = max(1, cword)
|
||||||
|
current = tokens[index] if index < len(tokens) else ""
|
||||||
|
before = tokens[1:index]
|
||||||
|
return before, current
|
||||||
|
|
||||||
|
|
||||||
def _handle(ctx, args):
|
def _canonical_command(command: str) -> str:
|
||||||
"""Output completion script."""
|
aliases = {
|
||||||
import os
|
"dot": "dotfiles",
|
||||||
comp_words = os.environ.get("COMP_WORDS", "").split()
|
"bootstrap": "bootstrap",
|
||||||
comp_cword = int(os.environ.get("COMP_CWORD", "0"))
|
"setup": "bootstrap",
|
||||||
|
"provision": "bootstrap",
|
||||||
|
"package": "packages",
|
||||||
|
"pkg": "packages",
|
||||||
|
"project": "projects",
|
||||||
|
"sync": "projects",
|
||||||
|
}
|
||||||
|
return aliases.get(command, command)
|
||||||
|
|
||||||
completions = complete(comp_words, comp_cword)
|
|
||||||
for c in completions:
|
def _filter(candidates: Sequence[str], prefix: str) -> list[str]:
|
||||||
print(c)
|
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)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 isinstance(value, str):
|
if len(parts) < 2:
|
||||||
targets.append(TargetConfig(
|
raise ConfigError(
|
||||||
namespace=namespace,
|
f"Invalid target value for '{key}': expected 'platform host [identity]'"
|
||||||
platform=platform,
|
)
|
||||||
host=value,
|
|
||||||
))
|
return TargetConfig(
|
||||||
elif isinstance(value, dict):
|
namespace=key,
|
||||||
host = value.get("host")
|
platform=parts[0],
|
||||||
if not host:
|
host=parts[1],
|
||||||
raise ConfigError(f"Target '{key}': 'host' is required")
|
identity=parts[2] if len(parts) > 2 else None,
|
||||||
identity = value.get("identity")
|
)
|
||||||
targets.append(TargetConfig(
|
|
||||||
namespace=namespace,
|
|
||||||
platform=platform,
|
def _parse_targets(raw: Any) -> list[TargetConfig]:
|
||||||
host=str(host),
|
targets: list[TargetConfig] = []
|
||||||
identity=str(identity) if identity is not None else None,
|
|
||||||
))
|
if raw is None:
|
||||||
else:
|
return targets
|
||||||
raise ConfigError(f"Target '{key}': value must be a string or mapping")
|
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
for key, value in raw.items():
|
||||||
|
if isinstance(value, str):
|
||||||
|
targets.append(_parse_target_shorthand(key, value))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ConfigError(f"Target '{key}': value must be a string or mapping")
|
||||||
|
|
||||||
|
if "@" in key:
|
||||||
|
namespace, platform = key.split("@", 1)
|
||||||
|
else:
|
||||||
|
namespace = key
|
||||||
|
platform = value.get("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),
|
||||||
|
identity=str(identity) if identity is not None else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
registry = default_registry
|
||||||
if ":" in image:
|
tag = default_tag
|
||||||
base, tag = image.rsplit(":", 1)
|
repo = image
|
||||||
else:
|
|
||||||
base = image
|
|
||||||
tag = default_tag
|
|
||||||
|
|
||||||
if "/" in base:
|
if image.startswith("docker/"):
|
||||||
# Has registry
|
registry = "docker.io"
|
||||||
parts = base.split("/", 1)
|
repo = f"library/{image.split('/', 1)[1]}"
|
||||||
registry = parts[0]
|
elif image.startswith("tm0/"):
|
||||||
name = parts[1]
|
repo = image.split("/", 1)[1]
|
||||||
else:
|
elif "/" in image:
|
||||||
registry = default_registry
|
prefix, remainder = image.split("/", 1)
|
||||||
name = base
|
if "." in prefix or ":" in prefix or prefix == "localhost":
|
||||||
|
registry = prefix
|
||||||
|
repo = remainder
|
||||||
|
|
||||||
return ImageRef(registry=registry, name=name, tag=tag)
|
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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)}"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
raise FlowError(f"Unknown target: {spec}. Check flow config targets section.")
|
if resolved_platform not in HOST_TEMPLATES:
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
image,
|
|
||||||
default_registry=self.ctx.config.container_registry,
|
|
||||||
default_tag=self.ctx.config.container_tag,
|
|
||||||
)
|
|
||||||
|
|
||||||
mounts = resolve_mounts(
|
|
||||||
paths.HOME,
|
|
||||||
self.ctx.config.projects_dir,
|
|
||||||
dotfiles_dir=paths.DOTFILES_DIR,
|
|
||||||
)
|
|
||||||
|
|
||||||
spec = build_container_spec(
|
spec = build_container_spec(
|
||||||
namespace, image_ref, mounts,
|
name,
|
||||||
env=extra_env,
|
parse_image_ref(
|
||||||
|
image,
|
||||||
|
default_registry=self.ctx.config.container_registry,
|
||||||
|
default_tag=self.ctx.config.container_tag,
|
||||||
|
),
|
||||||
|
resolve_mounts(
|
||||||
|
paths.HOME,
|
||||||
|
project_path=project_path,
|
||||||
|
dotfiles_dir=paths.DOTFILES_DIR,
|
||||||
|
),
|
||||||
|
project_path=project_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self._container_exists(rt, spec.name):
|
||||||
|
raise FlowError(f"Container already exists: {spec.name}")
|
||||||
|
|
||||||
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,
|
check=True,
|
||||||
)
|
)
|
||||||
|
image_ref = parse_image_ref(inspect.stdout.strip())
|
||||||
|
|
||||||
def stop(self, name: str) -> None:
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
if os.environ.get("TMUX"):
|
||||||
|
os.execvp("tmux", ["tmux", "switch-client", "-t", cname])
|
||||||
|
os.execvp("tmux", ["tmux", "attach", "-t", cname])
|
||||||
|
|
||||||
|
def stop(self, name: str, *, kill: bool = False) -> None:
|
||||||
"""Stop a running container."""
|
"""Stop a running container."""
|
||||||
self.ctx.runtime.runner.run(
|
rt = runtime()
|
||||||
["docker", "stop", name], check=True,
|
cname = container_name(name)
|
||||||
)
|
if not self._container_exists(rt, cname):
|
||||||
self.ctx.console.success(f"Container {name} stopped.")
|
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) -> None:
|
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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
"""Clone the dotfiles repository."""
|
||||||
|
remote = repo_url or self.ctx.config.dotfiles_url
|
||||||
|
if not remote:
|
||||||
|
raise FlowError("No dotfiles URL configured")
|
||||||
|
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.dotfiles_dir.parent,
|
||||||
|
"clone",
|
||||||
|
"-b",
|
||||||
|
self.ctx.config.dotfiles_branch,
|
||||||
|
"--recurse-submodules",
|
||||||
|
remote,
|
||||||
|
str(self.dotfiles_dir),
|
||||||
|
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."""
|
"""Pull latest dotfiles and sync modules."""
|
||||||
if not self.dotfiles_dir.is_dir():
|
if not self.dotfiles_dir.is_dir():
|
||||||
if not self.ctx.config.dotfiles_url:
|
self.init()
|
||||||
raise FlowError("No dotfiles URL configured")
|
|
||||||
self.ctx.console.info(f"Cloning dotfiles from {self.ctx.config.dotfiles_url}")
|
|
||||||
self.ctx.runtime.git.run(
|
|
||||||
self.dotfiles_dir.parent,
|
|
||||||
"clone", self.ctx.config.dotfiles_url, str(self.dotfiles_dir),
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
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
|
||||||
|
|||||||
@@ -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,36 +39,44 @@ 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)
|
||||||
|
|
||||||
@@ -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")
|
||||||
archive = tmp_dir / asset
|
|
||||||
|
|
||||||
self.ctx.runtime.runner.run(
|
context = self._binary_context(pkg)
|
||||||
["curl", "-fSL", "-o", str(archive), url], check=True,
|
with tempfile.TemporaryDirectory(prefix=f"flow-{pkg.name}-") as tmp:
|
||||||
)
|
tmp_dir = Path(tmp)
|
||||||
|
archive = tmp_dir / asset
|
||||||
|
extracted = tmp_dir / "extract"
|
||||||
|
|
||||||
bin_dir = Path.home() / ".local" / "bin"
|
self.ctx.console.info(f"Downloading {pkg.name}...")
|
||||||
self.ctx.runtime.fs.ensure_dir(bin_dir)
|
with urllib.request.urlopen(url, timeout=60) as response:
|
||||||
|
self.ctx.runtime.fs.write_bytes(archive, response.read())
|
||||||
|
|
||||||
installed_files: list[Path] = []
|
self.ctx.runtime.fs.ensure_dir(extracted)
|
||||||
if asset.endswith((".tar.gz", ".tar.xz", ".tar.bz2", ".tgz")):
|
try:
|
||||||
extract_dir = tmp_dir / f"{pkg.name}-extract"
|
shutil.unpack_archive(str(archive), str(extracted))
|
||||||
self.ctx.runtime.fs.ensure_dir(extract_dir)
|
except (shutil.ReadError, ValueError) as e:
|
||||||
self.ctx.runtime.runner.run(
|
raise FlowError(f"Could not extract archive for '{pkg.name}': {e}") from e
|
||||||
["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):
|
extract_dir = resolve_extract_dir(pkg, self.ctx.platform.platform)
|
||||||
if candidate.is_file():
|
source_root = extracted if extract_dir is None else extracted / extract_dir
|
||||||
target = bin_dir / binary_name
|
if not source_root.exists():
|
||||||
self.ctx.runtime.fs.copy_file(candidate, target)
|
raise FlowError(f"extract-dir '{extract_dir}' not found for package '{pkg.name}'")
|
||||||
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
|
source_root_resolved = source_root.resolve(strict=False)
|
||||||
self.ctx.runtime.fs.remove_tree(tmp_dir)
|
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),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not installed_paths:
|
||||||
|
raise FlowError(f"Binary package '{pkg.name}' installed no files")
|
||||||
|
|
||||||
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:
|
||||||
self.ctx.runtime.fs.remove_file(f, missing_ok=True)
|
if f.is_dir():
|
||||||
|
self.ctx.runtime.fs.remove_tree(f)
|
||||||
|
else:
|
||||||
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
self.ctx.console.info(f" {cmd}")
|
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}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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]
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user