fix: address all code review issues
1. _merge_config: track explicit fields instead of comparing to defaults 2. plan_install: let asset resolution errors propagate (fail loudly) 3. _install_binary/_install_appimage: use argv lists instead of shell strings 4. _find_module: narrow exception to OSError/YAMLError, raise ConfigError 5. _install_binary: use pkg.extract_dir to scope binary search 6. plan_install: raise FlowError when pkg type needs PM but none found 7. Frozen dataclasses: change mutable list fields to tuples throughout 8. Remove dead stream_shell method and unused Console import 9. Guard os.getuid() with hasattr for cross-platform safety 10. _parse_targets: raise ConfigError on malformed entries 11. Bootstrap modules: use shlex.quote on all interpolated values Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ from flow.core.runtime import SystemRuntime
|
|||||||
|
|
||||||
def main(argv: Optional[list[str]] = None) -> None:
|
def main(argv: Optional[list[str]] = None) -> None:
|
||||||
"""Main entry point."""
|
"""Main entry point."""
|
||||||
if os.getuid() == 0:
|
if hasattr(os, "getuid") and os.getuid() == 0:
|
||||||
print("Error: flow must not run as root", file=sys.stderr)
|
print("Error: flow must not run as root", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -72,15 +72,21 @@ def main(argv: Optional[list[str]] = None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _merge_config(base: AppConfig, overlay: AppConfig) -> AppConfig:
|
def _merge_config(base: AppConfig, overlay: AppConfig) -> AppConfig:
|
||||||
"""Merge two configs: overlay values override base when non-default."""
|
"""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(
|
return AppConfig(
|
||||||
dotfiles_url=overlay.dotfiles_url or base.dotfiles_url,
|
dotfiles_url=_pick("dotfiles_url"),
|
||||||
dotfiles_branch=overlay.dotfiles_branch if overlay.dotfiles_branch != "main" else base.dotfiles_branch,
|
dotfiles_branch=_pick("dotfiles_branch"),
|
||||||
projects_dir=overlay.projects_dir if overlay.projects_dir != "~/projects" else base.projects_dir,
|
projects_dir=_pick("projects_dir"),
|
||||||
container_registry=overlay.container_registry if overlay.container_registry != "registry.tomastm.com" else base.container_registry,
|
container_registry=_pick("container_registry"),
|
||||||
container_tag=overlay.container_tag if overlay.container_tag != "latest" else base.container_tag,
|
container_tag=_pick("container_tag"),
|
||||||
tmux_session=overlay.tmux_session if overlay.tmux_session != "default" else base.tmux_session,
|
tmux_session=_pick("tmux_session"),
|
||||||
targets=overlay.targets or base.targets,
|
targets=overlay.targets if "targets" in overlay._explicit else base.targets,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class AppConfig:
|
|||||||
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
|
||||||
@@ -58,16 +60,18 @@ def _load_yaml_file(path: Path) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _parse_targets(raw: Any) -> list[TargetConfig]:
|
def _parse_targets(raw: Any) -> list[TargetConfig]:
|
||||||
|
from flow.core.errors import ConfigError
|
||||||
|
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
targets: list[TargetConfig] = []
|
targets: list[TargetConfig] = []
|
||||||
for key, value in raw.items():
|
for key, value in raw.items():
|
||||||
if "@" not in key:
|
if "@" not in key:
|
||||||
continue
|
raise ConfigError(f"Invalid target key '{key}': expected 'namespace@platform'")
|
||||||
namespace, platform = key.split("@", 1)
|
namespace, platform = key.split("@", 1)
|
||||||
if not namespace or not platform:
|
if not namespace or not platform:
|
||||||
continue
|
raise ConfigError(f"Invalid target key '{key}': both namespace and platform required")
|
||||||
|
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
targets.append(TargetConfig(
|
targets.append(TargetConfig(
|
||||||
@@ -76,9 +80,9 @@ def _parse_targets(raw: Any) -> list[TargetConfig]:
|
|||||||
host=value,
|
host=value,
|
||||||
))
|
))
|
||||||
elif isinstance(value, dict):
|
elif isinstance(value, dict):
|
||||||
host = value.get("host", "")
|
host = value.get("host")
|
||||||
if not host:
|
if not host:
|
||||||
continue
|
raise ConfigError(f"Target '{key}': 'host' is required")
|
||||||
identity = value.get("identity")
|
identity = value.get("identity")
|
||||||
targets.append(TargetConfig(
|
targets.append(TargetConfig(
|
||||||
namespace=namespace,
|
namespace=namespace,
|
||||||
@@ -86,6 +90,8 @@ def _parse_targets(raw: Any) -> list[TargetConfig]:
|
|||||||
host=str(host),
|
host=str(host),
|
||||||
identity=str(identity) if identity is not None else None,
|
identity=str(identity) if identity is not None else None,
|
||||||
))
|
))
|
||||||
|
else:
|
||||||
|
raise ConfigError(f"Target '{key}': value must be a string or mapping")
|
||||||
|
|
||||||
return targets
|
return targets
|
||||||
|
|
||||||
@@ -99,35 +105,47 @@ def load_config(config_dir: Path) -> AppConfig:
|
|||||||
data = _load_yaml_file(config_file)
|
data = _load_yaml_file(config_file)
|
||||||
|
|
||||||
cfg = AppConfig()
|
cfg = AppConfig()
|
||||||
|
explicit: set[str] = set()
|
||||||
|
|
||||||
repository = data.get("repository")
|
repository = data.get("repository")
|
||||||
if isinstance(repository, dict):
|
if isinstance(repository, dict):
|
||||||
url = repository.get("url")
|
url = repository.get("url")
|
||||||
if url is not None:
|
if url is not None:
|
||||||
cfg.dotfiles_url = str(url)
|
cfg.dotfiles_url = str(url)
|
||||||
|
explicit.add("dotfiles_url")
|
||||||
branch = repository.get("branch")
|
branch = repository.get("branch")
|
||||||
if branch is not None:
|
if branch is not None:
|
||||||
cfg.dotfiles_branch = str(branch)
|
cfg.dotfiles_branch = str(branch)
|
||||||
|
explicit.add("dotfiles_branch")
|
||||||
|
|
||||||
paths_section = data.get("paths")
|
paths_section = data.get("paths")
|
||||||
if isinstance(paths_section, dict):
|
if isinstance(paths_section, dict):
|
||||||
projects = paths_section.get("projects")
|
projects = paths_section.get("projects")
|
||||||
if projects is not None:
|
if projects is not None:
|
||||||
cfg.projects_dir = str(projects)
|
cfg.projects_dir = str(projects)
|
||||||
|
explicit.add("projects_dir")
|
||||||
|
|
||||||
defaults = data.get("defaults")
|
defaults = data.get("defaults")
|
||||||
if isinstance(defaults, dict):
|
if isinstance(defaults, dict):
|
||||||
registry = defaults.get("container-registry")
|
registry = defaults.get("container-registry")
|
||||||
if registry is not None:
|
if registry is not None:
|
||||||
cfg.container_registry = str(registry)
|
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")
|
tmux = defaults.get("tmux-session")
|
||||||
if tmux is not None:
|
if tmux is not None:
|
||||||
cfg.tmux_session = str(tmux)
|
cfg.tmux_session = str(tmux)
|
||||||
|
explicit.add("tmux_session")
|
||||||
|
|
||||||
raw_targets = data.get("targets")
|
raw_targets = data.get("targets")
|
||||||
if raw_targets is not None:
|
if raw_targets is not None:
|
||||||
cfg.targets = _parse_targets(raw_targets)
|
cfg.targets = _parse_targets(raw_targets)
|
||||||
|
explicit.add("targets")
|
||||||
|
|
||||||
|
cfg._explicit = explicit
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable, Mapping, Optional, Sequence
|
from typing import Any, Iterable, Mapping, Optional, Sequence
|
||||||
|
|
||||||
from flow.core.console import Console
|
|
||||||
from flow.core.errors import FlowError
|
from flow.core.errors import FlowError
|
||||||
|
|
||||||
|
|
||||||
@@ -70,37 +69,6 @@ class CommandRunner:
|
|||||||
raise FlowError(msg)
|
raise FlowError(msg)
|
||||||
return completed
|
return completed
|
||||||
|
|
||||||
def stream_shell(
|
|
||||||
self,
|
|
||||||
command: str,
|
|
||||||
console: Console,
|
|
||||||
*,
|
|
||||||
check: bool = True,
|
|
||||||
) -> subprocess.CompletedProcess[str]:
|
|
||||||
process = subprocess.Popen(
|
|
||||||
command,
|
|
||||||
shell=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
)
|
|
||||||
lines: list[str] = []
|
|
||||||
assert process.stdout is not None
|
|
||||||
try:
|
|
||||||
for line in process.stdout:
|
|
||||||
stripped = line.rstrip()
|
|
||||||
if stripped:
|
|
||||||
lines.append(stripped)
|
|
||||||
finally:
|
|
||||||
process.stdout.close()
|
|
||||||
process.wait()
|
|
||||||
|
|
||||||
if check and process.returncode != 0:
|
|
||||||
raise FlowError(f"Command failed (exit {process.returncode}): {command}")
|
|
||||||
|
|
||||||
return subprocess.CompletedProcess(command, process.returncode, stdout="\n".join(lines), stderr="")
|
|
||||||
|
|
||||||
def require_binary(self, name: str) -> str:
|
def require_binary(self, name: str) -> str:
|
||||||
path = shutil.which(name)
|
path = shutil.which(name)
|
||||||
if path is None:
|
if path is None:
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ class Profile:
|
|||||||
hostname: Optional[str]
|
hostname: Optional[str]
|
||||||
locale: Optional[str]
|
locale: Optional[str]
|
||||||
shell: Optional[str]
|
shell: Optional[str]
|
||||||
ssh_keys: list[dict[str, str]]
|
ssh_keys: tuple[dict[str, str], ...]
|
||||||
runcmd: list[str]
|
runcmd: tuple[str, ...]
|
||||||
packages: list[Any] # Raw entries, resolved later
|
packages: tuple[Any, ...] # Raw entries, resolved later
|
||||||
env_required: list[str]
|
env_required: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -24,7 +24,7 @@ class BootstrapAction:
|
|||||||
"""A single action in a bootstrap plan."""
|
"""A single action in a bootstrap plan."""
|
||||||
phase: str # "validate" | "setup" | "packages" | "shell" | "dotfiles"
|
phase: str # "validate" | "setup" | "packages" | "shell" | "dotfiles"
|
||||||
description: str
|
description: str
|
||||||
commands: list[str]
|
commands: tuple[str, ...]
|
||||||
needs_sudo: bool = False
|
needs_sudo: bool = False
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
@@ -36,8 +36,8 @@ class BootstrapAction:
|
|||||||
class BootstrapPlan:
|
class BootstrapPlan:
|
||||||
"""Complete bootstrap plan."""
|
"""Complete bootstrap plan."""
|
||||||
profile: str
|
profile: str
|
||||||
actions: list[BootstrapAction]
|
actions: tuple[BootstrapAction, ...]
|
||||||
packages_to_install: list[Any] # PackageDef list
|
packages_to_install: tuple[Any, ...] # PackageDef tuple
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_steps(self) -> int:
|
def total_steps(self) -> int:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Bootstrap setup modules -- each produces shell commands."""
|
"""Bootstrap setup modules -- each produces shell commands."""
|
||||||
|
|
||||||
|
import shlex
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -21,9 +22,10 @@ class HostnameModule(SetupModule):
|
|||||||
return f"Set hostname to {self.hostname}"
|
return f"Set hostname to {self.hostname}"
|
||||||
|
|
||||||
def plan(self) -> list[str]:
|
def plan(self) -> list[str]:
|
||||||
|
h = shlex.quote(self.hostname)
|
||||||
return [
|
return [
|
||||||
f"sudo hostnamectl set-hostname {self.hostname}",
|
f"sudo hostnamectl set-hostname {h}",
|
||||||
f"echo '127.0.1.1 {self.hostname}' | sudo tee -a /etc/hosts",
|
f"echo '127.0.1.1 {h}' | sudo tee -a /etc/hosts",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -35,9 +37,10 @@ class LocaleModule(SetupModule):
|
|||||||
return f"Set locale to {self.locale}"
|
return f"Set locale to {self.locale}"
|
||||||
|
|
||||||
def plan(self) -> list[str]:
|
def plan(self) -> list[str]:
|
||||||
|
loc = shlex.quote(self.locale)
|
||||||
return [
|
return [
|
||||||
f"sudo locale-gen {self.locale}",
|
f"sudo locale-gen {loc}",
|
||||||
f"sudo update-locale LANG={self.locale}",
|
f"sudo update-locale LANG={loc}",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -49,7 +52,7 @@ class ShellModule(SetupModule):
|
|||||||
return f"Install and configure shell: {self.shell}"
|
return f"Install and configure shell: {self.shell}"
|
||||||
|
|
||||||
def plan(self) -> list[str]:
|
def plan(self) -> list[str]:
|
||||||
shell_path = f"/usr/bin/{self.shell}"
|
shell_path = shlex.quote(f"/usr/bin/{self.shell}")
|
||||||
return [
|
return [
|
||||||
f"sudo chsh -s {shell_path} $USER",
|
f"sudo chsh -s {shell_path} $USER",
|
||||||
]
|
]
|
||||||
@@ -65,12 +68,12 @@ class SSHKeygenModule(SetupModule):
|
|||||||
def plan(self) -> list[str]:
|
def plan(self) -> list[str]:
|
||||||
commands: list[str] = []
|
commands: list[str] = []
|
||||||
for key in self.keys:
|
for key in self.keys:
|
||||||
path = key.get("path", "~/.ssh/id_ed25519")
|
path = shlex.quote(key.get("path", "~/.ssh/id_ed25519"))
|
||||||
key_type = key.get("type", "ed25519")
|
key_type = shlex.quote(key.get("type", "ed25519"))
|
||||||
comment = key.get("comment", "")
|
comment = key.get("comment", "")
|
||||||
cmd = f'ssh-keygen -t {key_type} -f {path} -N ""'
|
cmd = f'ssh-keygen -t {key_type} -f {path} -N ""'
|
||||||
if comment:
|
if comment:
|
||||||
cmd += f' -C "{comment}"'
|
cmd += f" -C {shlex.quote(comment)}"
|
||||||
commands.append(cmd)
|
commands.append(cmd)
|
||||||
return commands
|
return commands
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ 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=raw.get("ssh-keys") or raw.get("ssh_keys") or [],
|
ssh_keys=tuple(raw.get("ssh-keys") or raw.get("ssh_keys") or []),
|
||||||
runcmd=raw.get("runcmd") or [],
|
runcmd=tuple(raw.get("runcmd") or []),
|
||||||
packages=raw.get("packages") or [],
|
packages=tuple(raw.get("packages") or []),
|
||||||
env_required=raw.get("env-required") or raw.get("env_required") or [],
|
env_required=tuple(raw.get("env-required") or raw.get("env_required") or []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -53,21 +53,21 @@ def plan_bootstrap(
|
|||||||
m = HostnameModule(hostname=profile.hostname)
|
m = HostnameModule(hostname=profile.hostname)
|
||||||
actions.append(BootstrapAction(
|
actions.append(BootstrapAction(
|
||||||
phase="setup", description=m.describe(),
|
phase="setup", description=m.describe(),
|
||||||
commands=m.plan(), needs_sudo=True,
|
commands=tuple(m.plan()), needs_sudo=True,
|
||||||
))
|
))
|
||||||
|
|
||||||
if profile.locale:
|
if profile.locale:
|
||||||
m = LocaleModule(locale=profile.locale)
|
m = LocaleModule(locale=profile.locale)
|
||||||
actions.append(BootstrapAction(
|
actions.append(BootstrapAction(
|
||||||
phase="setup", description=m.describe(),
|
phase="setup", description=m.describe(),
|
||||||
commands=m.plan(), needs_sudo=True,
|
commands=tuple(m.plan()), needs_sudo=True,
|
||||||
))
|
))
|
||||||
|
|
||||||
if profile.ssh_keys:
|
if profile.ssh_keys:
|
||||||
m = SSHKeygenModule(keys=profile.ssh_keys)
|
m = SSHKeygenModule(keys=list(profile.ssh_keys))
|
||||||
actions.append(BootstrapAction(
|
actions.append(BootstrapAction(
|
||||||
phase="setup", description=m.describe(),
|
phase="setup", description=m.describe(),
|
||||||
commands=m.plan(),
|
commands=tuple(m.plan()),
|
||||||
))
|
))
|
||||||
|
|
||||||
# Phase 3: Packages
|
# Phase 3: Packages
|
||||||
@@ -83,7 +83,7 @@ def plan_bootstrap(
|
|||||||
actions.append(BootstrapAction(
|
actions.append(BootstrapAction(
|
||||||
phase="packages",
|
phase="packages",
|
||||||
description=f"Install {len(packages_to_install)} package(s): {', '.join(pkg_names[:5])}{'...' if len(pkg_names) > 5 else ''}",
|
description=f"Install {len(packages_to_install)} package(s): {', '.join(pkg_names[:5])}{'...' if len(pkg_names) > 5 else ''}",
|
||||||
commands=[], # Executed by PackageService
|
commands=(), # Executed by PackageService
|
||||||
))
|
))
|
||||||
|
|
||||||
# Phase 4: Shell
|
# Phase 4: Shell
|
||||||
@@ -91,26 +91,26 @@ def plan_bootstrap(
|
|||||||
m = ShellModule(shell=profile.shell)
|
m = ShellModule(shell=profile.shell)
|
||||||
actions.append(BootstrapAction(
|
actions.append(BootstrapAction(
|
||||||
phase="shell", description=m.describe(),
|
phase="shell", description=m.describe(),
|
||||||
commands=m.plan(), needs_sudo=True,
|
commands=tuple(m.plan()), needs_sudo=True,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Phase 5: Custom commands
|
# Phase 5: Custom commands
|
||||||
if profile.runcmd:
|
if profile.runcmd:
|
||||||
m = RuncmdModule(commands=profile.runcmd)
|
m = RuncmdModule(commands=list(profile.runcmd))
|
||||||
actions.append(BootstrapAction(
|
actions.append(BootstrapAction(
|
||||||
phase="setup", description=m.describe(),
|
phase="setup", description=m.describe(),
|
||||||
commands=m.plan(),
|
commands=tuple(m.plan()),
|
||||||
))
|
))
|
||||||
|
|
||||||
# Phase 6: Dotfiles link
|
# Phase 6: Dotfiles link
|
||||||
actions.append(BootstrapAction(
|
actions.append(BootstrapAction(
|
||||||
phase="dotfiles",
|
phase="dotfiles",
|
||||||
description="Link dotfiles",
|
description="Link dotfiles",
|
||||||
commands=[], # Executed by DotfilesService
|
commands=(), # Executed by DotfilesService
|
||||||
))
|
))
|
||||||
|
|
||||||
return BootstrapPlan(
|
return BootstrapPlan(
|
||||||
profile=profile.name,
|
profile=profile.name,
|
||||||
actions=actions,
|
actions=tuple(actions),
|
||||||
packages_to_install=packages_to_install,
|
packages_to_install=tuple(packages_to_install),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ class PlanSummary:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LinkPlan:
|
class LinkPlan:
|
||||||
"""Complete reconciliation plan."""
|
"""Complete reconciliation plan."""
|
||||||
operations: list[LinkOp]
|
operations: tuple[LinkOp, ...]
|
||||||
conflicts: list[str]
|
conflicts: tuple[str, ...]
|
||||||
summary: PlanSummary
|
summary: PlanSummary
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ def plan_link(
|
|||||||
from_modules += 1
|
from_modules += 1
|
||||||
|
|
||||||
return LinkPlan(
|
return LinkPlan(
|
||||||
operations=ops,
|
operations=tuple(ops),
|
||||||
conflicts=conflicts,
|
conflicts=tuple(conflicts),
|
||||||
summary=PlanSummary(
|
summary=PlanSummary(
|
||||||
added=added, removed=removed,
|
added=added, removed=removed,
|
||||||
unchanged=unchanged, from_modules=from_modules,
|
unchanged=unchanged, from_modules=from_modules,
|
||||||
@@ -113,7 +113,7 @@ def plan_unlink(
|
|||||||
))
|
))
|
||||||
|
|
||||||
return LinkPlan(
|
return LinkPlan(
|
||||||
operations=ops,
|
operations=tuple(ops),
|
||||||
conflicts=[],
|
conflicts=(),
|
||||||
summary=PlanSummary(added=0, removed=len(ops), unchanged=0, from_modules=0),
|
summary=PlanSummary(added=0, removed=len(ops), unchanged=0, from_modules=0),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class PkgRemoveOp:
|
|||||||
"""A single package remove operation."""
|
"""A single package remove operation."""
|
||||||
name: str
|
name: str
|
||||||
type: str
|
type: str
|
||||||
files: list[Path]
|
files: tuple[Path, ...]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"REMOVE: {self.name} ({len(self.files)} file(s))"
|
return f"REMOVE: {self.name} ({len(self.files)} file(s))"
|
||||||
@@ -59,8 +59,8 @@ class PkgRemoveOp:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PackagePlan:
|
class PackagePlan:
|
||||||
"""Complete package install/remove plan."""
|
"""Complete package install/remove plan."""
|
||||||
install_ops: list[PkgInstallOp]
|
install_ops: tuple[PkgInstallOp, ...]
|
||||||
remove_ops: list[PkgRemoveOp]
|
remove_ops: tuple[PkgRemoveOp, ...]
|
||||||
pm_update_needed: bool
|
pm_update_needed: bool
|
||||||
pm_command: Optional[str]
|
pm_command: Optional[str]
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from flow.core.errors import FlowError
|
||||||
from flow.domain.packages.models import (
|
from flow.domain.packages.models import (
|
||||||
InstalledState,
|
InstalledState,
|
||||||
PackageDef,
|
PackageDef,
|
||||||
@@ -36,6 +37,11 @@ def plan_install(
|
|||||||
continue # Already installed
|
continue # Already installed
|
||||||
|
|
||||||
if pkg.type == "pkg":
|
if pkg.type == "pkg":
|
||||||
|
if pm is None:
|
||||||
|
raise FlowError(
|
||||||
|
f"No supported package manager found to install '{pkg.name}'. "
|
||||||
|
"Install apt, dnf, or brew."
|
||||||
|
)
|
||||||
source_name = resolve_source_name(pkg, pm)
|
source_name = resolve_source_name(pkg, pm)
|
||||||
pm_packages.append(source_name)
|
pm_packages.append(source_name)
|
||||||
install_ops.append(PkgInstallOp(
|
install_ops.append(PkgInstallOp(
|
||||||
@@ -43,12 +49,8 @@ def plan_install(
|
|||||||
source_name=source_name, download_url=None,
|
source_name=source_name, download_url=None,
|
||||||
))
|
))
|
||||||
elif pkg.type in ("binary", "appimage"):
|
elif pkg.type in ("binary", "appimage"):
|
||||||
try:
|
|
||||||
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)
|
||||||
except Exception:
|
|
||||||
asset = pkg.name
|
|
||||||
url = None
|
|
||||||
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,
|
||||||
@@ -63,8 +65,8 @@ def plan_install(
|
|||||||
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=install_ops,
|
install_ops=tuple(install_ops),
|
||||||
remove_ops=[],
|
remove_ops=(),
|
||||||
pm_update_needed=bool(pm_packages),
|
pm_update_needed=bool(pm_packages),
|
||||||
pm_command=pm_cmd,
|
pm_command=pm_cmd,
|
||||||
)
|
)
|
||||||
@@ -81,12 +83,12 @@ def plan_remove(
|
|||||||
if name in installed.packages:
|
if name in installed.packages:
|
||||||
pkg = installed.packages[name]
|
pkg = installed.packages[name]
|
||||||
remove_ops.append(PkgRemoveOp(
|
remove_ops.append(PkgRemoveOp(
|
||||||
name=name, type=pkg.type, files=pkg.files,
|
name=name, type=pkg.type, files=tuple(pkg.files),
|
||||||
))
|
))
|
||||||
|
|
||||||
return PackagePlan(
|
return PackagePlan(
|
||||||
install_ops=[],
|
install_ops=(),
|
||||||
remove_ops=remove_ops,
|
remove_ops=tuple(remove_ops),
|
||||||
pm_update_needed=False,
|
pm_update_needed=False,
|
||||||
pm_command=None,
|
pm_command=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -269,10 +269,11 @@ class DotfilesService:
|
|||||||
"""Find and parse _module.yaml in a package directory."""
|
"""Find and parse _module.yaml in a package directory."""
|
||||||
for module_yaml in pkg_dir.rglob(MODULE_FILE):
|
for module_yaml in pkg_dir.rglob(MODULE_FILE):
|
||||||
try:
|
try:
|
||||||
with open(module_yaml) as f:
|
with open(module_yaml, encoding="utf-8") as f:
|
||||||
raw = yaml.safe_load(f) or {}
|
raw = yaml.safe_load(f) or {}
|
||||||
except Exception:
|
except (OSError, yaml.YAMLError) as e:
|
||||||
continue
|
from flow.core.errors import ConfigError
|
||||||
|
raise ConfigError(f"Failed to read {module_yaml}: {e}") from e
|
||||||
|
|
||||||
mount_path = compute_mount_path(module_yaml, pkg_dir)
|
mount_path = compute_mount_path(module_yaml, pkg_dir)
|
||||||
|
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ class PackageService:
|
|||||||
self.ctx.runtime.fs.ensure_dir(tmp_dir)
|
self.ctx.runtime.fs.ensure_dir(tmp_dir)
|
||||||
archive = tmp_dir / asset
|
archive = tmp_dir / asset
|
||||||
|
|
||||||
self.ctx.runtime.runner.run_shell(
|
self.ctx.runtime.runner.run(
|
||||||
f"curl -fSL -o {archive} '{url}'", check=True,
|
["curl", "-fSL", "-o", str(archive), url], check=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
bin_dir = Path.home() / ".local" / "bin"
|
bin_dir = Path.home() / ".local" / "bin"
|
||||||
@@ -128,15 +128,15 @@ class PackageService:
|
|||||||
if asset.endswith((".tar.gz", ".tar.xz", ".tar.bz2", ".tgz")):
|
if asset.endswith((".tar.gz", ".tar.xz", ".tar.bz2", ".tgz")):
|
||||||
extract_dir = tmp_dir / f"{pkg.name}-extract"
|
extract_dir = tmp_dir / f"{pkg.name}-extract"
|
||||||
self.ctx.runtime.fs.ensure_dir(extract_dir)
|
self.ctx.runtime.fs.ensure_dir(extract_dir)
|
||||||
self.ctx.runtime.runner.run_shell(
|
self.ctx.runtime.runner.run(
|
||||||
f"tar -xf {archive} -C {extract_dir}", check=True,
|
["tar", "-xf", str(archive), "-C", str(extract_dir)], check=True,
|
||||||
)
|
)
|
||||||
# Find and install binaries
|
# Find and install binaries
|
||||||
install_cfg = pkg.install or {}
|
install_cfg = pkg.install or {}
|
||||||
binary_name = install_cfg.get("binary", pkg.name)
|
binary_name = install_cfg.get("binary", pkg.name)
|
||||||
src_dir = pkg.extract_dir or ""
|
search_root = extract_dir / pkg.extract_dir if pkg.extract_dir else extract_dir
|
||||||
|
|
||||||
for candidate in extract_dir.rglob(binary_name):
|
for candidate in search_root.rglob(binary_name):
|
||||||
if candidate.is_file():
|
if candidate.is_file():
|
||||||
target = bin_dir / binary_name
|
target = bin_dir / binary_name
|
||||||
self.ctx.runtime.fs.copy_file(candidate, target)
|
self.ctx.runtime.fs.copy_file(candidate, target)
|
||||||
@@ -169,8 +169,8 @@ class PackageService:
|
|||||||
target = bin_dir / pkg.name
|
target = bin_dir / pkg.name
|
||||||
|
|
||||||
self.ctx.console.info(f"Downloading {pkg.name} AppImage...")
|
self.ctx.console.info(f"Downloading {pkg.name} AppImage...")
|
||||||
self.ctx.runtime.runner.run_shell(
|
self.ctx.runtime.runner.run(
|
||||||
f"curl -fSL -o {target} '{url}'", check=True,
|
["curl", "-fSL", "-o", str(target), url], check=True,
|
||||||
)
|
)
|
||||||
target.chmod(0o755)
|
target.chmod(0o755)
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class TestParseProfile:
|
|||||||
profile = parse_profile("minimal", {})
|
profile = parse_profile("minimal", {})
|
||||||
assert profile.os == "linux"
|
assert profile.os == "linux"
|
||||||
assert profile.hostname is None
|
assert profile.hostname is None
|
||||||
assert profile.packages == []
|
assert profile.packages == ()
|
||||||
|
|
||||||
def test_ssh_keys(self):
|
def test_ssh_keys(self):
|
||||||
raw = {"ssh-keys": [{"path": "~/.ssh/id_ed25519", "type": "ed25519"}]}
|
raw = {"ssh-keys": [{"path": "~/.ssh/id_ed25519", "type": "ed25519"}]}
|
||||||
|
|||||||
Reference in New Issue
Block a user