refactor: fail loud, tighten types, remove speculative abstraction
Fail loud at the boundary:
- substitute_template raises ConfigError on unresolved {{...}}; no more
silent literal placeholders in download URLs.
- parse_profile raises ConfigError when 'os' is missing -- no
raw.get("os", "linux") default that silently masks typos.
- urllib download failures wrapped to FlowError.
- bootstrap _execute_action dispatches phases explicitly and raises
on unhandled phase; no more "anything else runs as shell".
Direct access over defensive wrapping:
- plan_bootstrap requires env; plan_install requires pm. Drop the
dead `or os.environ` / `or detect_package_manager()` fallbacks.
- InstalledState.from_dict raises ConfigError on missing fields
rather than .get(..., default).
- Replace `x or {}` chains with explicit `x if x is not None else {}`
in package resolution; catalog validates type/platform-map/install
shapes at parse.
One canonical form / direct access:
- Path.home() replaced with paths.HOME in services/packages.py and
commands/completion.py. paths.HOME is the single source now.
- Use Path.is_relative_to for install-path containment instead of
str.startswith.
Domain purity:
- domain/containers/resolution.resolve_mounts takes a filesystem_check
predicate; service passes the probe in. Domain no longer touches
the filesystem directly.
No speculative abstraction:
- Drop the `allow_sudo` field entirely. The _script_uses_sudo check
it gated was bypassable (substring match) and gave false confidence;
the manifest is fully user-trusted anyway.
- Delete dead terminfo_fix_command + RemoteService.fix_terminfo
(no command surface exposes them).
- FileSystem.remove_tree no longer swallows errors via ignore_errors;
callers opt into missing_ok if needed.
Typed enums:
- PackageDef.type, AppConfig.container_runtime as Literal[...].
container_runtime values validated at config parse.
Completion bypasses runtime no longer:
- complete(ctx, ...) threads context; ContainerRuntime and state-file
reads go through ctx.runtime instead of constructing primitives.
Tests added for: template raise, missing os raise, env/pm required,
unknown phase raise, no allow_sudo gate, URL download failure, install
path escape, corrupt installed.json, container_runtime Literal,
filesystem_check controls mounts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,16 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
from flow.core.config import load_config, load_manifest
|
from flow.core.config import FlowContext, load_config, load_manifest
|
||||||
from flow.core import paths
|
from flow.core import paths
|
||||||
from flow.core.containers import ContainerRuntime
|
|
||||||
from flow.core.errors import FlowError
|
from flow.core.errors import FlowError
|
||||||
from flow.core.runtime import CommandRunner
|
|
||||||
from flow.domain.remote.resolution import HOST_TEMPLATES
|
from flow.domain.remote.resolution import HOST_TEMPLATES
|
||||||
|
|
||||||
ZSH_RC_START = "# >>> flow completion >>>"
|
ZSH_RC_START = "# >>> flow completion >>>"
|
||||||
@@ -52,27 +49,28 @@ def register(subparsers):
|
|||||||
parser.set_defaults(handler=_run_zsh_script)
|
parser.set_defaults(handler=_run_zsh_script)
|
||||||
|
|
||||||
|
|
||||||
def complete(words: Sequence[str], cword: int) -> list[str]:
|
def complete(ctx: FlowContext, words: Sequence[str], cword: int) -> list[str]:
|
||||||
before, current = _split_words(words, cword)
|
before, current = _split_words(words, cword)
|
||||||
|
|
||||||
if not before:
|
if not before:
|
||||||
return _filter(TOP_LEVEL_COMMANDS + ["-h", "--help", "--version"], current)
|
return _filter(TOP_LEVEL_COMMANDS + ["-h", "--help", "--version"], current)
|
||||||
|
|
||||||
command = _canonical_command(before[0])
|
command = _canonical_command(before[0])
|
||||||
completers = {
|
if command == "enter" or command == "remote":
|
||||||
"enter": _complete_remote,
|
return _complete_remote(before, current)
|
||||||
"remote": _complete_remote,
|
if command == "dev":
|
||||||
"dev": _complete_dev,
|
return _complete_dev(ctx, before, current)
|
||||||
"dotfiles": _complete_dotfiles,
|
if command == "dotfiles":
|
||||||
"setup": _complete_setup,
|
return _complete_dotfiles(before, current)
|
||||||
"packages": _complete_packages,
|
if command == "setup":
|
||||||
"projects": _complete_projects,
|
return _complete_setup(before, current)
|
||||||
"completion": _complete_completion,
|
if command == "packages":
|
||||||
}
|
return _complete_packages(ctx, before, current)
|
||||||
handler = completers.get(command)
|
if command == "projects":
|
||||||
if handler is None:
|
return _complete_projects(before, current)
|
||||||
return []
|
if command == "completion":
|
||||||
return handler(before, current)
|
return _complete_completion(before, current)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _split_words(words: Sequence[str], cword: int) -> tuple[list[str], str]:
|
def _split_words(words: Sequence[str], cword: int) -> tuple[list[str], str]:
|
||||||
@@ -152,11 +150,8 @@ def _list_manifest_packages() -> list[str]:
|
|||||||
return sorted(names)
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
def _list_installed_packages() -> list[str]:
|
def _list_installed_packages(ctx: FlowContext) -> list[str]:
|
||||||
if not paths.INSTALLED_STATE.exists():
|
state = ctx.runtime.fs.read_json(paths.INSTALLED_STATE, default={})
|
||||||
return []
|
|
||||||
with open(paths.INSTALLED_STATE, encoding="utf-8") as handle:
|
|
||||||
state = json.load(handle)
|
|
||||||
packages = state.get("packages", {}) if isinstance(state, dict) else {}
|
packages = state.get("packages", {}) if isinstance(state, dict) else {}
|
||||||
return sorted(packages.keys()) if isinstance(packages, dict) else []
|
return sorted(packages.keys()) if isinstance(packages, dict) else []
|
||||||
|
|
||||||
@@ -204,11 +199,9 @@ def _list_dotfiles_packages(profile: str | None = None) -> list[str]:
|
|||||||
return sorted(names)
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
def _list_container_names() -> list[str]:
|
def _list_container_names(ctx: FlowContext) -> list[str]:
|
||||||
try:
|
try:
|
||||||
config = _config()
|
output = ctx.runtime.containers.ps(
|
||||||
rt = ContainerRuntime(CommandRunner(), mode=config.container_runtime)
|
|
||||||
output = rt.ps(
|
|
||||||
all=True,
|
all=True,
|
||||||
filter="label=dev=true",
|
filter="label=dev=true",
|
||||||
format='{{.Label "dev.name"}}',
|
format='{{.Label "dev.name"}}',
|
||||||
@@ -256,7 +249,7 @@ def _complete_remote(before: Sequence[str], current: str) -> list[str]:
|
|||||||
return _filter(_list_targets(), current)
|
return _filter(_list_targets(), current)
|
||||||
|
|
||||||
|
|
||||||
def _complete_dev(before: Sequence[str], current: str) -> list[str]:
|
def _complete_dev(ctx: FlowContext, before: Sequence[str], current: str) -> list[str]:
|
||||||
if len(before) <= 1:
|
if len(before) <= 1:
|
||||||
return _filter(
|
return _filter(
|
||||||
["create", "attach", "connect", "exec", "enter", "list", "stop", "remove", "rm", "respawn"],
|
["create", "attach", "connect", "exec", "enter", "list", "stop", "remove", "rm", "respawn"],
|
||||||
@@ -274,7 +267,7 @@ def _complete_dev(before: Sequence[str], current: str) -> list[str]:
|
|||||||
return _filter(options.get(subcommand, []), current)
|
return _filter(options.get(subcommand, []), current)
|
||||||
non_option = [token for token in before[2:] if not token.startswith("-")]
|
non_option = [token for token in before[2:] if not token.startswith("-")]
|
||||||
if not non_option:
|
if not non_option:
|
||||||
return _filter(_list_container_names(), current)
|
return _filter(_list_container_names(ctx), current)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if subcommand == "create":
|
if subcommand == "create":
|
||||||
@@ -346,7 +339,7 @@ def _complete_setup(before: Sequence[str], current: str) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _complete_packages(before: Sequence[str], current: str) -> list[str]:
|
def _complete_packages(ctx: FlowContext, before: Sequence[str], current: str) -> list[str]:
|
||||||
if len(before) <= 1:
|
if len(before) <= 1:
|
||||||
return _filter(["install", "remove", "list"], current)
|
return _filter(["install", "remove", "list"], current)
|
||||||
|
|
||||||
@@ -361,7 +354,7 @@ def _complete_packages(before: Sequence[str], current: str) -> list[str]:
|
|||||||
if subcommand == "remove":
|
if subcommand == "remove":
|
||||||
if current.startswith("-"):
|
if current.startswith("-"):
|
||||||
return []
|
return []
|
||||||
return _filter(_list_installed_packages(), current)
|
return _filter(_list_installed_packages(ctx), current)
|
||||||
|
|
||||||
if subcommand == "list":
|
if subcommand == "list":
|
||||||
return _filter(["--all"], current) if current.startswith("-") else []
|
return _filter(["--all"], current) if current.startswith("-") else []
|
||||||
@@ -385,8 +378,8 @@ def _complete_completion(before: Sequence[str], current: str) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _run_zsh_complete(_ctx, args):
|
def _run_zsh_complete(ctx, args):
|
||||||
for item in complete(args.words, args.cword):
|
for item in complete(ctx, args.words, args.cword):
|
||||||
print(item)
|
print(item)
|
||||||
|
|
||||||
|
|
||||||
@@ -467,7 +460,7 @@ def _zsh_rc_snippet(completions_dir: Path) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _zsh_dir_for_rc(path: Path) -> str:
|
def _zsh_dir_for_rc(path: Path) -> str:
|
||||||
home = Path.home().resolve()
|
home = paths.HOME.resolve()
|
||||||
resolved = path.expanduser().resolve()
|
resolved = path.expanduser().resolve()
|
||||||
try:
|
try:
|
||||||
rel = resolved.relative_to(home)
|
rel = resolved.relative_to(home)
|
||||||
|
|||||||
@@ -4,14 +4,21 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from flow.core import paths
|
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
|
||||||
|
|
||||||
|
|
||||||
|
ContainerRuntimeMode = Literal["auto", "docker", "podman", "podman-rootful"]
|
||||||
|
_VALID_CONTAINER_RUNTIMES: tuple[ContainerRuntimeMode, ...] = (
|
||||||
|
"auto", "docker", "podman", "podman-rootful",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TargetConfig:
|
class TargetConfig:
|
||||||
namespace: str
|
namespace: str
|
||||||
@@ -26,7 +33,7 @@ class AppConfig:
|
|||||||
dotfiles_branch: str = "main"
|
dotfiles_branch: str = "main"
|
||||||
dotfiles_pull_before_edit: bool = True
|
dotfiles_pull_before_edit: bool = True
|
||||||
projects_dir: str = "~/projects"
|
projects_dir: str = "~/projects"
|
||||||
container_runtime: str = "auto"
|
container_runtime: ContainerRuntimeMode = "auto"
|
||||||
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"
|
||||||
@@ -85,12 +92,19 @@ def load_config(
|
|||||||
|
|
||||||
pull_raw = _section_get(data, "repository", "pull-before-edit")
|
pull_raw = _section_get(data, "repository", "pull-before-edit")
|
||||||
|
|
||||||
|
container_runtime_raw = str(_section_get(data, "defaults", "container-runtime", "auto"))
|
||||||
|
if container_runtime_raw not in _VALID_CONTAINER_RUNTIMES:
|
||||||
|
raise ConfigError(
|
||||||
|
f"Invalid container-runtime {container_runtime_raw!r}. "
|
||||||
|
f"Expected one of: {', '.join(_VALID_CONTAINER_RUNTIMES)}"
|
||||||
|
)
|
||||||
|
|
||||||
return AppConfig(
|
return AppConfig(
|
||||||
dotfiles_url=str(_section_get(data, "repository", "url", "")),
|
dotfiles_url=str(_section_get(data, "repository", "url", "")),
|
||||||
dotfiles_branch=str(_section_get(data, "repository", "branch", "main")),
|
dotfiles_branch=str(_section_get(data, "repository", "branch", "main")),
|
||||||
dotfiles_pull_before_edit=as_bool(pull_raw) if pull_raw is not None else True,
|
dotfiles_pull_before_edit=as_bool(pull_raw) if pull_raw is not None else True,
|
||||||
projects_dir=str(_section_get(data, "paths", "projects", "~/projects")),
|
projects_dir=str(_section_get(data, "paths", "projects", "~/projects")),
|
||||||
container_runtime=str(_section_get(data, "defaults", "container-runtime", "auto")),
|
container_runtime=container_runtime_raw, # type: ignore[arg-type]
|
||||||
container_registry=str(_section_get(data, "defaults", "container-registry", "registry.tomastm.com")),
|
container_registry=str(_section_get(data, "defaults", "container-registry", "registry.tomastm.com")),
|
||||||
container_tag=str(_section_get(data, "defaults", "container-tag", "latest")),
|
container_tag=str(_section_get(data, "defaults", "container-tag", "latest")),
|
||||||
tmux_session=str(_section_get(data, "defaults", "tmux-session", "default")),
|
tmux_session=str(_section_get(data, "defaults", "tmux-session", "default")),
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import os
|
|||||||
import re
|
import re
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from flow.core.errors import ConfigError
|
||||||
|
|
||||||
|
_UNRESOLVED = object()
|
||||||
|
|
||||||
|
|
||||||
def substitute(text: Any, variables: Dict[str, str]) -> Any:
|
def substitute(text: Any, variables: Dict[str, str]) -> Any:
|
||||||
"""Replace $VAR and ${VAR} with values from variables dict or env."""
|
"""Replace $VAR and ${VAR} with values from variables dict or env."""
|
||||||
@@ -29,7 +33,9 @@ def _resolve_template_value(expr: str, context: Dict[str, Any]) -> Any:
|
|||||||
env_ctx = context.get("env", {})
|
env_ctx = context.get("env", {})
|
||||||
if isinstance(env_ctx, dict) and env_key in env_ctx:
|
if isinstance(env_ctx, dict) and env_key in env_ctx:
|
||||||
return env_ctx[env_key]
|
return env_ctx[env_key]
|
||||||
return os.environ.get(env_key)
|
if env_key in os.environ:
|
||||||
|
return os.environ[env_key]
|
||||||
|
return _UNRESOLVED
|
||||||
|
|
||||||
if expr in context:
|
if expr in context:
|
||||||
return context[expr]
|
return context[expr]
|
||||||
@@ -37,22 +43,27 @@ def _resolve_template_value(expr: str, context: Dict[str, Any]) -> Any:
|
|||||||
current: Any = context
|
current: Any = context
|
||||||
for part in expr.split("."):
|
for part in expr.split("."):
|
||||||
if not isinstance(current, dict) or part not in current:
|
if not isinstance(current, dict) or part not in current:
|
||||||
return None
|
return _UNRESOLVED
|
||||||
current = current[part]
|
current = current[part]
|
||||||
|
|
||||||
return current
|
return current
|
||||||
|
|
||||||
|
|
||||||
def substitute_template(text: Any, context: Dict[str, Any]) -> Any:
|
def substitute_template(text: Any, context: Dict[str, Any]) -> Any:
|
||||||
"""Replace {{expr}} placeholders with values from context dict."""
|
"""Replace {{expr}} placeholders with values from context dict.
|
||||||
|
|
||||||
|
Raises ConfigError if a placeholder cannot be resolved.
|
||||||
|
"""
|
||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def _replace(match: re.Match[str]) -> str:
|
def _replace(match: re.Match[str]) -> str:
|
||||||
key = match.group(1).strip()
|
key = match.group(1).strip()
|
||||||
value = _resolve_template_value(key, context)
|
value = _resolve_template_value(key, context)
|
||||||
if value is None:
|
if value is _UNRESOLVED:
|
||||||
return match.group(0)
|
raise ConfigError(
|
||||||
|
f"Unresolved template variable: {match.group(0)}"
|
||||||
|
)
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
return re.sub(r"\{\{\s*([^{}]+?)\s*\}\}", _replace, text)
|
return re.sub(r"\{\{\s*([^{}]+?)\s*\}\}", _replace, text)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Bootstrap planning -- builds ordered action list."""
|
"""Bootstrap planning -- builds ordered action list."""
|
||||||
|
|
||||||
import os
|
from typing import Any, Mapping
|
||||||
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
|
||||||
@@ -38,11 +37,14 @@ def _normalize_ssh_keys(raw: Any) -> tuple[dict[str, str], ...]:
|
|||||||
|
|
||||||
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."""
|
||||||
|
if "os" not in raw:
|
||||||
|
raise ConfigError(f"Profile {name!r}: required field 'os' is missing")
|
||||||
|
|
||||||
ssh_keys = raw.get("ssh-keys")
|
ssh_keys = raw.get("ssh-keys")
|
||||||
|
|
||||||
return Profile(
|
return Profile(
|
||||||
name=name,
|
name=name,
|
||||||
os=raw.get("os", "linux"),
|
os=raw["os"],
|
||||||
arch=raw.get("arch"),
|
arch=raw.get("arch"),
|
||||||
hostname=raw.get("hostname"),
|
hostname=raw.get("hostname"),
|
||||||
locale=raw.get("locale"),
|
locale=raw.get("locale"),
|
||||||
@@ -60,14 +62,13 @@ def plan_bootstrap(
|
|||||||
profile: Profile,
|
profile: Profile,
|
||||||
manifest: dict[str, Any],
|
manifest: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
env: Optional[Mapping[str, str]] = None,
|
env: Mapping[str, str],
|
||||||
) -> 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 environment.get(v)]
|
missing = [v for v in profile.env_required if not env.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}': "
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Container resolution -- pure functions."""
|
"""Container resolution -- pure functions."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from flow.domain.containers.models import ContainerSpec, ImageRef, Mount
|
from flow.domain.containers.models import ContainerSpec, ImageRef, Mount
|
||||||
|
|
||||||
@@ -46,11 +46,16 @@ def container_name(name: str) -> str:
|
|||||||
def resolve_mounts(
|
def resolve_mounts(
|
||||||
home: Path,
|
home: Path,
|
||||||
*,
|
*,
|
||||||
|
filesystem_check: Callable[[Path], bool],
|
||||||
project_path: Optional[str] = None,
|
project_path: Optional[str] = None,
|
||||||
dotfiles_dir: Optional[Path] = None,
|
dotfiles_dir: Optional[Path] = None,
|
||||||
socket_path: Optional[Path] = None,
|
socket_path: Optional[Path] = None,
|
||||||
) -> list[Mount]:
|
) -> list[Mount]:
|
||||||
"""Resolve standard container mounts."""
|
"""Resolve standard container mounts.
|
||||||
|
|
||||||
|
``filesystem_check`` is a callable returning whether a given path
|
||||||
|
exists -- injected so this function is pure.
|
||||||
|
"""
|
||||||
mounts: list[Mount] = []
|
mounts: list[Mount] = []
|
||||||
|
|
||||||
if project_path:
|
if project_path:
|
||||||
@@ -63,13 +68,13 @@ def resolve_mounts(
|
|||||||
(home / ".npm", "/home/dev/.npm", False),
|
(home / ".npm", "/home/dev/.npm", False),
|
||||||
]
|
]
|
||||||
for source, target, readonly in standard_mounts:
|
for source, target, readonly in standard_mounts:
|
||||||
if source.exists():
|
if filesystem_check(source):
|
||||||
mounts.append(Mount(source=source, target=target, readonly=readonly))
|
mounts.append(Mount(source=source, target=target, readonly=readonly))
|
||||||
|
|
||||||
if socket_path:
|
if socket_path:
|
||||||
mounts.append(Mount(source=socket_path, target="/var/run/docker.sock"))
|
mounts.append(Mount(source=socket_path, target="/var/run/docker.sock"))
|
||||||
|
|
||||||
if dotfiles_dir and dotfiles_dir.exists():
|
if dotfiles_dir and filesystem_check(dotfiles_dir):
|
||||||
mounts.append(
|
mounts.append(
|
||||||
Mount(
|
Mount(
|
||||||
source=dotfiles_dir,
|
source=dotfiles_dir,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def parse_catalog(manifest: dict[str, Any]) -> dict[str, PackageDef]:
|
|||||||
name=name, type="pkg", sources={},
|
name=name, type="pkg", sources={},
|
||||||
source=None, version=None, asset_pattern=None,
|
source=None, version=None, asset_pattern=None,
|
||||||
platform_map={}, extract_dir=None, install={},
|
platform_map={}, extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)
|
)
|
||||||
catalog[pkg.name] = pkg
|
catalog[pkg.name] = pkg
|
||||||
|
|
||||||
@@ -38,6 +38,11 @@ def _parse_package_entry(entry: dict[str, Any]) -> PackageDef:
|
|||||||
raise ConfigError("Package entry missing 'name'")
|
raise ConfigError("Package entry missing 'name'")
|
||||||
|
|
||||||
pkg_type = entry.get("type", "pkg")
|
pkg_type = entry.get("type", "pkg")
|
||||||
|
if pkg_type not in ("pkg", "binary", "appimage", "cask"):
|
||||||
|
raise ConfigError(
|
||||||
|
f"Package {name!r}: invalid type {pkg_type!r}. "
|
||||||
|
f"Expected one of: pkg, binary, appimage, cask"
|
||||||
|
)
|
||||||
sources: dict[str, str] = {}
|
sources: dict[str, str] = {}
|
||||||
|
|
||||||
# Parse sources from various keys
|
# Parse sources from various keys
|
||||||
@@ -48,6 +53,18 @@ def _parse_package_entry(entry: dict[str, Any]) -> PackageDef:
|
|||||||
if "sources" in entry and isinstance(entry["sources"], dict):
|
if "sources" in entry and isinstance(entry["sources"], dict):
|
||||||
sources.update(entry["sources"])
|
sources.update(entry["sources"])
|
||||||
|
|
||||||
|
platform_map = entry.get("platform-map")
|
||||||
|
if platform_map is not None and not isinstance(platform_map, dict):
|
||||||
|
raise ConfigError(
|
||||||
|
f"Package {name!r}: 'platform-map' must be a mapping, got {type(platform_map).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
install = entry.get("install")
|
||||||
|
if install is not None and not isinstance(install, dict):
|
||||||
|
raise ConfigError(
|
||||||
|
f"Package {name!r}: 'install' must be a mapping, got {type(install).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
return PackageDef(
|
return PackageDef(
|
||||||
name=name,
|
name=name,
|
||||||
type=pkg_type,
|
type=pkg_type,
|
||||||
@@ -55,11 +72,10 @@ def _parse_package_entry(entry: dict[str, Any]) -> PackageDef:
|
|||||||
source=entry.get("source"),
|
source=entry.get("source"),
|
||||||
version=entry.get("version"),
|
version=entry.get("version"),
|
||||||
asset_pattern=entry.get("asset-pattern"),
|
asset_pattern=entry.get("asset-pattern"),
|
||||||
platform_map=entry.get("platform-map") or {},
|
platform_map=platform_map if platform_map is not None else {},
|
||||||
extract_dir=entry.get("extract-dir"),
|
extract_dir=entry.get("extract-dir"),
|
||||||
install=entry.get("install") or {},
|
install=install if install is not None else {},
|
||||||
post_install=entry.get("post-install"),
|
post_install=entry.get("post-install"),
|
||||||
allow_sudo=bool(entry.get("allow-sudo", False)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -79,7 +95,6 @@ def normalize_profile_entry(entry: Any) -> ProfilePackageRef:
|
|||||||
extract_dir=None,
|
extract_dir=None,
|
||||||
install=None,
|
install=None,
|
||||||
post_install=None,
|
post_install=None,
|
||||||
allow_sudo=None,
|
|
||||||
)
|
)
|
||||||
return ProfilePackageRef(
|
return ProfilePackageRef(
|
||||||
name=entry,
|
name=entry,
|
||||||
@@ -91,7 +106,6 @@ def normalize_profile_entry(entry: Any) -> ProfilePackageRef:
|
|||||||
extract_dir=None,
|
extract_dir=None,
|
||||||
install=None,
|
install=None,
|
||||||
post_install=None,
|
post_install=None,
|
||||||
allow_sudo=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(entry, dict):
|
if isinstance(entry, dict):
|
||||||
@@ -106,7 +120,6 @@ def normalize_profile_entry(entry: Any) -> ProfilePackageRef:
|
|||||||
extract_dir=entry.get("extract-dir"),
|
extract_dir=entry.get("extract-dir"),
|
||||||
install=entry.get("install"),
|
install=entry.get("install"),
|
||||||
post_install=entry.get("post-install"),
|
post_install=entry.get("post-install"),
|
||||||
allow_sudo=entry.get("allow-sudo"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
raise ConfigError(f"Invalid profile package entry: {entry}")
|
raise ConfigError(f"Invalid profile package entry: {entry}")
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
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" | "cask"
|
type: Literal["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]
|
||||||
@@ -18,7 +18,6 @@ class PackageDef:
|
|||||||
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]
|
||||||
allow_sudo: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -33,7 +32,6 @@ class ProfilePackageRef:
|
|||||||
extract_dir: Optional[str] = None
|
extract_dir: Optional[str] = None
|
||||||
install: Optional[dict[str, Any]] = None
|
install: Optional[dict[str, Any]] = None
|
||||||
post_install: Optional[str] = None
|
post_install: Optional[str] = None
|
||||||
allow_sudo: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -96,19 +94,25 @@ class InstalledState:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "InstalledState":
|
def from_dict(cls, data: dict) -> "InstalledState":
|
||||||
|
from flow.core.errors import ConfigError
|
||||||
|
|
||||||
version = data.get("version")
|
version = data.get("version")
|
||||||
if version is not None and version != 1:
|
if version is not None and version != 1:
|
||||||
from flow.core.errors import ConfigError
|
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f"Unsupported installed.json version {version}. "
|
f"Unsupported installed.json version {version}. "
|
||||||
"Delete ~/.local/state/flow/installed.json and reinstall."
|
"Delete ~/.local/state/flow/installed.json and reinstall."
|
||||||
)
|
)
|
||||||
packages: dict[str, InstalledPackage] = {}
|
packages: dict[str, InstalledPackage] = {}
|
||||||
for name, info in data.get("packages", {}).items():
|
for name, info in data.get("packages", {}).items():
|
||||||
packages[name] = InstalledPackage(
|
try:
|
||||||
name=name,
|
packages[name] = InstalledPackage(
|
||||||
version=str(info.get("version", "")),
|
name=name,
|
||||||
type=str(info.get("type", "")),
|
version=str(info["version"]),
|
||||||
files=[Path(f) for f in info.get("files", [])],
|
type=str(info["type"]),
|
||||||
)
|
files=[Path(f) for f in info["files"]],
|
||||||
|
)
|
||||||
|
except KeyError as e:
|
||||||
|
raise ConfigError(
|
||||||
|
f"Corrupt installed.json: missing field {e.args[0]!r} for package {name!r}"
|
||||||
|
) from e
|
||||||
return cls(packages=packages)
|
return cls(packages=packages)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from flow.domain.packages.models import (
|
|||||||
PkgRemoveOp,
|
PkgRemoveOp,
|
||||||
)
|
)
|
||||||
from flow.domain.packages.resolution import (
|
from flow.domain.packages.resolution import (
|
||||||
detect_package_manager,
|
|
||||||
pm_install_command,
|
pm_install_command,
|
||||||
resolve_binary_asset,
|
resolve_binary_asset,
|
||||||
resolve_download_url,
|
resolve_download_url,
|
||||||
@@ -23,12 +22,13 @@ def plan_install(
|
|||||||
packages: list[PackageDef],
|
packages: list[PackageDef],
|
||||||
installed: InstalledState,
|
installed: InstalledState,
|
||||||
platform_str: str,
|
platform_str: str,
|
||||||
pm: Optional[str] = None,
|
pm: Optional[str],
|
||||||
) -> PackagePlan:
|
) -> PackagePlan:
|
||||||
"""Plan installation of packages."""
|
"""Plan installation of packages.
|
||||||
if pm is None:
|
|
||||||
pm = detect_package_manager()
|
|
||||||
|
|
||||||
|
``pm`` may be None if no package manager is available, but any ``pkg``
|
||||||
|
or ``cask`` package will then raise FlowError.
|
||||||
|
"""
|
||||||
install_ops: list[PkgInstallOp] = []
|
install_ops: list[PkgInstallOp] = []
|
||||||
pm_packages: list[str] = []
|
pm_packages: list[str] = []
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,10 @@ 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=ref.platform_map or {},
|
platform_map=ref.platform_map if ref.platform_map is not None else {},
|
||||||
extract_dir=ref.extract_dir,
|
extract_dir=ref.extract_dir,
|
||||||
install=ref.install or {},
|
install=ref.install if ref.install is not None else {},
|
||||||
post_install=ref.post_install,
|
post_install=ref.post_install,
|
||||||
allow_sudo=bool(ref.allow_sudo),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Merge: profile overrides catalog
|
# Merge: profile overrides catalog
|
||||||
@@ -38,11 +37,10 @@ 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=ref.platform_map or base.platform_map,
|
platform_map=ref.platform_map if ref.platform_map is not None else base.platform_map,
|
||||||
extract_dir=ref.extract_dir or base.extract_dir,
|
extract_dir=ref.extract_dir or base.extract_dir,
|
||||||
install=ref.install or base.install,
|
install=ref.install if ref.install is not None else base.install,
|
||||||
post_install=ref.post_install or base.post_install,
|
post_install=ref.post_install or base.post_install,
|
||||||
allow_sudo=ref.allow_sudo if ref.allow_sudo is not None else base.allow_sudo,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -117,27 +117,6 @@ def build_destination(user: str, host: str) -> str:
|
|||||||
return f"{user}@{host}"
|
return f"{user}@{host}"
|
||||||
|
|
||||||
|
|
||||||
def terminfo_fix_command(
|
|
||||||
term: Optional[str] = "xterm-256color",
|
|
||||||
destination: str = "TARGET",
|
|
||||||
) -> 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 [
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ class BootstrapService:
|
|||||||
self.ctx.console.info("No profiles defined in manifest.")
|
self.ctx.console.info("No profiles defined in manifest.")
|
||||||
return
|
return
|
||||||
|
|
||||||
rows = [
|
rows = []
|
||||||
[name, data.get("os", "linux"), data.get("hostname", "-")]
|
for name, data in sorted(profiles.items()):
|
||||||
for name, data in sorted(profiles.items())
|
parsed = parse_profile(name, data)
|
||||||
]
|
rows.append([name, parsed.os, parsed.hostname or "-"])
|
||||||
self.ctx.console.table(["PROFILE", "OS", "HOSTNAME"], rows)
|
self.ctx.console.table(["PROFILE", "OS", "HOSTNAME"], rows)
|
||||||
|
|
||||||
def _execute_action(
|
def _execute_action(
|
||||||
@@ -95,6 +95,9 @@ class BootstrapService:
|
|||||||
dot_svc.link(profile=dotfiles_profile)
|
dot_svc.link(profile=dotfiles_profile)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Shell phases: setup, shell, post-link
|
if action.phase in ("setup", "shell", "post-link"):
|
||||||
for cmd in action.commands:
|
for cmd in action.commands:
|
||||||
self.ctx.runtime.runner.run_shell(cmd, check=True)
|
self.ctx.runtime.runner.run_shell(cmd, check=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
raise FlowError(f"Unhandled bootstrap phase: {action.phase!r}")
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class ContainerService:
|
|||||||
),
|
),
|
||||||
resolve_mounts(
|
resolve_mounts(
|
||||||
paths.HOME,
|
paths.HOME,
|
||||||
|
filesystem_check=lambda p: p.exists(),
|
||||||
project_path=project_path,
|
project_path=project_path,
|
||||||
dotfiles_dir=paths.DOTFILES_DIR,
|
dotfiles_dir=paths.DOTFILES_DIR,
|
||||||
socket_path=self.rt.socket_path,
|
socket_path=self.rt.socket_path,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@@ -148,8 +149,11 @@ class PackageService:
|
|||||||
extracted = tmp_dir / "extract"
|
extracted = tmp_dir / "extract"
|
||||||
|
|
||||||
self.ctx.console.info(f"Downloading {pkg.name}...")
|
self.ctx.console.info(f"Downloading {pkg.name}...")
|
||||||
with urllib.request.urlopen(url, timeout=60) as response:
|
try:
|
||||||
self.ctx.runtime.fs.write_bytes(archive, response.read())
|
with urllib.request.urlopen(url, timeout=60) as response:
|
||||||
|
self.ctx.runtime.fs.write_bytes(archive, response.read())
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise FlowError(f"Failed to download {url}: {e}") from e
|
||||||
|
|
||||||
self.ctx.runtime.fs.ensure_dir(extracted)
|
self.ctx.runtime.fs.ensure_dir(extracted)
|
||||||
try:
|
try:
|
||||||
@@ -201,7 +205,7 @@ class PackageService:
|
|||||||
self, pkg: PackageDef, url: str, state: InstalledState,
|
self, pkg: PackageDef, url: str, state: InstalledState,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Download and install an AppImage."""
|
"""Download and install an AppImage."""
|
||||||
bin_dir = Path.home() / ".local" / "bin"
|
bin_dir = paths.HOME / ".local" / "bin"
|
||||||
self.ctx.runtime.fs.ensure_dir(bin_dir)
|
self.ctx.runtime.fs.ensure_dir(bin_dir)
|
||||||
target = bin_dir / pkg.name
|
target = bin_dir / pkg.name
|
||||||
|
|
||||||
@@ -307,7 +311,7 @@ class PackageService:
|
|||||||
self._validate_install_path(package_name, declared_path)
|
self._validate_install_path(package_name, declared_path)
|
||||||
|
|
||||||
source = (source_root / declared_path).resolve(strict=False)
|
source = (source_root / declared_path).resolve(strict=False)
|
||||||
if not str(source).startswith(str(source_root_resolved)):
|
if not source.is_relative_to(source_root_resolved):
|
||||||
raise FlowError(
|
raise FlowError(
|
||||||
f"Install path escapes extract-dir for '{package_name}': {declared_path}"
|
f"Install path escapes extract-dir for '{package_name}': {declared_path}"
|
||||||
)
|
)
|
||||||
@@ -334,19 +338,14 @@ class PackageService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
script = substitute_template(pkg.post_install, self._binary_context(pkg))
|
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)
|
self.ctx.runtime.runner.run_shell(script, check=True)
|
||||||
|
|
||||||
def _install_destination(self, section: str) -> Path:
|
def _install_destination(self, section: str) -> Path:
|
||||||
home = Path.home()
|
|
||||||
destinations = {
|
destinations = {
|
||||||
"bin": home / ".local" / "bin",
|
"bin": paths.HOME / ".local" / "bin",
|
||||||
"share": home / ".local" / "share",
|
"share": paths.HOME / ".local" / "share",
|
||||||
"man": home / ".local" / "share" / "man",
|
"man": paths.HOME / ".local" / "share" / "man",
|
||||||
"lib": home / ".local" / "lib",
|
"lib": paths.HOME / ".local" / "lib",
|
||||||
}
|
}
|
||||||
if section not in destinations:
|
if section not in destinations:
|
||||||
raise FlowError(f"Unsupported install section: {section}")
|
raise FlowError(f"Unsupported install section: {section}")
|
||||||
@@ -378,10 +377,3 @@ class PackageService:
|
|||||||
raise FlowError(
|
raise FlowError(
|
||||||
f"Install path for '{package_name}' must not include parent traversal: {declared_path}"
|
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
|
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ from typing import Optional
|
|||||||
|
|
||||||
from flow.core.config import FlowContext
|
from flow.core.config import FlowContext
|
||||||
from flow.domain.remote.resolution import (
|
from flow.domain.remote.resolution import (
|
||||||
build_destination,
|
|
||||||
build_ssh_command,
|
build_ssh_command,
|
||||||
list_targets,
|
list_targets,
|
||||||
resolve_target,
|
resolve_target,
|
||||||
terminfo_fix_command,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,15 +68,3 @@ class RemoteService:
|
|||||||
for t in targets
|
for t in targets
|
||||||
]
|
]
|
||||||
self.ctx.console.table(["TARGET", "HOST", "IDENTITY"], rows)
|
self.ctx.console.table(["TARGET", "HOST", "IDENTITY"], rows)
|
||||||
|
|
||||||
def fix_terminfo(self, target_spec: str) -> None:
|
|
||||||
"""Show terminfo fix commands."""
|
|
||||||
target = resolve_target(
|
|
||||||
target_spec,
|
|
||||||
self.ctx.config.targets,
|
|
||||||
default_user=os.environ.get("USER") or getpass.getuser(),
|
|
||||||
)
|
|
||||||
destination = build_destination(target.user, target.host)
|
|
||||||
cmd = terminfo_fix_command(os.environ.get("TERM"), destination)
|
|
||||||
self.ctx.console.info("Run this command to fix terminfo:")
|
|
||||||
self.ctx.console.info(f" {cmd}")
|
|
||||||
|
|||||||
@@ -3,10 +3,24 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from flow.commands.completion import complete
|
from flow.commands.completion import complete
|
||||||
|
from flow.core.config import AppConfig, FlowContext
|
||||||
|
from flow.core.console import Console
|
||||||
|
from flow.core.platform import PlatformInfo
|
||||||
|
from flow.core.runtime import SystemRuntime
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ctx():
|
||||||
|
return FlowContext(
|
||||||
|
config=AppConfig(),
|
||||||
|
manifest={},
|
||||||
|
platform=PlatformInfo(),
|
||||||
|
console=Console(color=False),
|
||||||
|
runtime=SystemRuntime(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_complete_top_level():
|
def test_complete_top_level():
|
||||||
result = complete(["flow", ""], 1)
|
result = complete(_make_ctx(), ["flow", ""], 1)
|
||||||
assert "dotfiles" in result
|
assert "dotfiles" in result
|
||||||
assert "packages" in result
|
assert "packages" in result
|
||||||
assert "setup" in result
|
assert "setup" in result
|
||||||
@@ -16,12 +30,12 @@ def test_complete_top_level():
|
|||||||
|
|
||||||
|
|
||||||
def test_complete_top_level_prefix():
|
def test_complete_top_level_prefix():
|
||||||
result = complete(["flow", "do"], 1)
|
result = complete(_make_ctx(), ["flow", "do"], 1)
|
||||||
assert result == ["dotfiles"]
|
assert result == ["dotfiles"]
|
||||||
|
|
||||||
|
|
||||||
def test_complete_dotfiles_subcommands():
|
def test_complete_dotfiles_subcommands():
|
||||||
result = complete(["flow", "dotfiles", ""], 2)
|
result = complete(_make_ctx(), ["flow", "dotfiles", ""], 2)
|
||||||
assert "link" in result
|
assert "link" in result
|
||||||
assert "unlink" in result
|
assert "unlink" in result
|
||||||
assert "status" in result
|
assert "status" in result
|
||||||
@@ -36,7 +50,7 @@ def test_complete_dotfiles_subcommands():
|
|||||||
|
|
||||||
|
|
||||||
def test_complete_dotfiles_repos_subcommands():
|
def test_complete_dotfiles_repos_subcommands():
|
||||||
result = complete(["flow", "dotfiles", "repos", ""], 3)
|
result = complete(_make_ctx(), ["flow", "dotfiles", "repos", ""], 3)
|
||||||
assert "list" in result
|
assert "list" in result
|
||||||
assert "status" in result
|
assert "status" in result
|
||||||
assert "pull" in result
|
assert "pull" in result
|
||||||
@@ -44,43 +58,41 @@ def test_complete_dotfiles_repos_subcommands():
|
|||||||
|
|
||||||
|
|
||||||
def test_complete_dotfiles_repos_pull_flags():
|
def test_complete_dotfiles_repos_pull_flags():
|
||||||
result = complete(["flow", "dotfiles", "repos", "pull", "--"], 4)
|
result = complete(_make_ctx(), ["flow", "dotfiles", "repos", "pull", "--"], 4)
|
||||||
assert "--repo" in result
|
assert "--repo" in result
|
||||||
assert "--dry-run" in result
|
assert "--dry-run" in result
|
||||||
|
|
||||||
|
|
||||||
def test_complete_dotfiles_edit_packages():
|
def test_complete_dotfiles_edit_packages():
|
||||||
result = complete(["flow", "dotfiles", "edit", "--"], 3)
|
result = complete(_make_ctx(), ["flow", "dotfiles", "edit", "--"], 3)
|
||||||
assert "--no-commit" in result
|
assert "--no-commit" in result
|
||||||
|
|
||||||
|
|
||||||
def test_complete_dotfiles_link_flags():
|
def test_complete_dotfiles_link_flags():
|
||||||
result = complete(["flow", "dotfiles", "link", "--"], 3)
|
result = complete(_make_ctx(), ["flow", "dotfiles", "link", "--"], 3)
|
||||||
assert "--profile" in result
|
assert "--profile" in result
|
||||||
assert "--dry-run" in result
|
assert "--dry-run" in result
|
||||||
|
|
||||||
|
|
||||||
def test_complete_unknown_command():
|
def test_complete_unknown_command():
|
||||||
result = complete(["flow", "unknown", ""], 2)
|
result = complete(_make_ctx(), ["flow", "unknown", ""], 2)
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
def test_complete_packages_subcommands():
|
def test_complete_packages_subcommands():
|
||||||
result = complete(["flow", "packages", ""], 2)
|
result = complete(_make_ctx(), ["flow", "packages", ""], 2)
|
||||||
assert "install" in result
|
assert "install" in result
|
||||||
assert "remove" in result
|
assert "remove" in result
|
||||||
assert "list" in result
|
assert "list" in result
|
||||||
|
|
||||||
|
|
||||||
def test_complete_dev_attach_returns_empty_on_timeout(monkeypatch):
|
def test_complete_dev_attach_returns_empty_on_timeout():
|
||||||
class FakeRuntime:
|
ctx = _make_ctx()
|
||||||
def __init__(self, runner, *, mode="auto"):
|
|
||||||
self.runner = runner
|
|
||||||
|
|
||||||
def ps(self, **kwargs):
|
def fake_ps(**kwargs):
|
||||||
assert kwargs["timeout"] == 1.0
|
assert kwargs["timeout"] == 1.0
|
||||||
raise subprocess.TimeoutExpired("docker ps", kwargs["timeout"])
|
raise subprocess.TimeoutExpired("docker ps", kwargs["timeout"])
|
||||||
|
|
||||||
monkeypatch.setattr("flow.commands.completion.ContainerRuntime", FakeRuntime)
|
ctx.runtime.containers.ps = fake_ps # type: ignore[method-assign]
|
||||||
result = complete(["flow", "dev", "attach", ""], 3)
|
result = complete(ctx, ["flow", "dev", "attach", ""], 3)
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from flow.core.config import AppConfig, load_config, load_manifest
|
from flow.core.config import AppConfig, load_config, load_manifest
|
||||||
|
from flow.core.errors import ConfigError
|
||||||
|
|
||||||
|
|
||||||
def test_load_config_missing_path(tmp_path):
|
def test_load_config_missing_path(tmp_path):
|
||||||
@@ -141,3 +142,22 @@ def test_load_config_container_runtime(tmp_path):
|
|||||||
)
|
)
|
||||||
cfg = load_config(tmp_path)
|
cfg = load_config(tmp_path)
|
||||||
assert cfg.container_runtime == "podman-rootful"
|
assert cfg.container_runtime == "podman-rootful"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("runtime", ["auto", "docker", "podman", "podman-rootful"])
|
||||||
|
def test_load_config_container_runtime_accepts_known_values(tmp_path, runtime):
|
||||||
|
(tmp_path / "config.yaml").write_text(
|
||||||
|
"defaults:\n"
|
||||||
|
f" container-runtime: {runtime}\n"
|
||||||
|
)
|
||||||
|
cfg = load_config(tmp_path)
|
||||||
|
assert cfg.container_runtime == runtime
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_container_runtime_rejects_unknown(tmp_path):
|
||||||
|
(tmp_path / "config.yaml").write_text(
|
||||||
|
"defaults:\n"
|
||||||
|
" container-runtime: nspawn\n"
|
||||||
|
)
|
||||||
|
with pytest.raises(ConfigError, match="Invalid container-runtime"):
|
||||||
|
load_config(tmp_path)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Tests for bootstrap planning."""
|
"""Tests for bootstrap planning."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from flow.core.errors import ConfigError
|
from flow.core.errors import ConfigError
|
||||||
@@ -22,33 +24,45 @@ class TestParseProfile:
|
|||||||
assert profile.shell == "zsh"
|
assert profile.shell == "zsh"
|
||||||
assert len(profile.packages) == 2
|
assert len(profile.packages) == 2
|
||||||
|
|
||||||
def test_defaults(self):
|
def test_missing_os_raises(self):
|
||||||
profile = parse_profile("minimal", {})
|
with pytest.raises(ConfigError, match=r"Profile 'minimal': required field 'os' is missing"):
|
||||||
|
parse_profile("minimal", {})
|
||||||
|
|
||||||
|
def test_optional_fields_default(self):
|
||||||
|
profile = parse_profile("minimal", {"os": "linux"})
|
||||||
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 = {"os": "linux", "ssh-keys": [{"path": "~/.ssh/id_ed25519", "type": "ed25519"}]}
|
||||||
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_keys_with_filename(self):
|
def test_ssh_keys_with_filename(self):
|
||||||
raw = {"ssh-keys": [{"filename": "id_work", "type": "ed25519"}]}
|
raw = {"os": "linux", "ssh-keys": [{"filename": "id_work", "type": "ed25519"}]}
|
||||||
profile = parse_profile("test", raw)
|
profile = parse_profile("test", raw)
|
||||||
assert profile.ssh_keys[0]["path"] == "~/.ssh/id_work"
|
assert profile.ssh_keys[0]["path"] == "~/.ssh/id_work"
|
||||||
|
|
||||||
def test_env_required(self):
|
def test_env_required(self):
|
||||||
profile = parse_profile("test", {"env-required": ["USER_EMAIL"]})
|
profile = parse_profile("test", {"os": "linux", "env-required": ["USER_EMAIL"]})
|
||||||
assert profile.env_required == ("USER_EMAIL",)
|
assert profile.env_required == ("USER_EMAIL",)
|
||||||
|
|
||||||
def test_post_link_and_dotfiles_profile(self):
|
def test_post_link_and_dotfiles_profile(self):
|
||||||
profile = parse_profile("test", {"dotfiles-profile": "linux-work", "post-link": "echo done"})
|
profile = parse_profile(
|
||||||
|
"test",
|
||||||
|
{"os": "linux", "dotfiles-profile": "linux-work", "post-link": "echo done"},
|
||||||
|
)
|
||||||
assert profile.dotfiles_profile == "linux-work"
|
assert profile.dotfiles_profile == "linux-work"
|
||||||
assert profile.post_link == "echo done"
|
assert profile.post_link == "echo done"
|
||||||
|
|
||||||
|
|
||||||
class TestPlanBootstrap:
|
class TestPlanBootstrap:
|
||||||
|
def test_env_is_required_keyword(self):
|
||||||
|
sig = inspect.signature(plan_bootstrap)
|
||||||
|
param = sig.parameters["env"]
|
||||||
|
assert param.default is inspect.Parameter.empty
|
||||||
|
|
||||||
def test_basic_plan(self):
|
def test_basic_plan(self):
|
||||||
profile = Profile(
|
profile = Profile(
|
||||||
name="test", os="linux", arch=None,
|
name="test", os="linux", arch=None,
|
||||||
@@ -57,7 +71,7 @@ class TestPlanBootstrap:
|
|||||||
packages=["fd"], env_required=[],
|
packages=["fd"], env_required=[],
|
||||||
)
|
)
|
||||||
manifest = {"packages": [{"name": "fd", "type": "pkg"}]}
|
manifest = {"packages": [{"name": "fd", "type": "pkg"}]}
|
||||||
plan = plan_bootstrap(profile, manifest)
|
plan = plan_bootstrap(profile, manifest, env={})
|
||||||
assert plan.profile == "test"
|
assert plan.profile == "test"
|
||||||
assert plan.total_steps > 0
|
assert plan.total_steps > 0
|
||||||
phases = [a.phase for a in plan.actions]
|
phases = [a.phase for a in plan.actions]
|
||||||
@@ -65,8 +79,7 @@ class TestPlanBootstrap:
|
|||||||
assert "packages" in phases
|
assert "packages" in phases
|
||||||
assert "dotfiles" in phases
|
assert "dotfiles" in phases
|
||||||
|
|
||||||
def test_missing_env_raises(self, monkeypatch):
|
def test_missing_env_raises(self):
|
||||||
monkeypatch.delenv("REQUIRED_VAR", raising=False)
|
|
||||||
profile = Profile(
|
profile = Profile(
|
||||||
name="test", os="linux", arch=None,
|
name="test", os="linux", arch=None,
|
||||||
hostname=None, locale=None, shell=None,
|
hostname=None, locale=None, shell=None,
|
||||||
@@ -74,7 +87,7 @@ class TestPlanBootstrap:
|
|||||||
env_required=["REQUIRED_VAR"],
|
env_required=["REQUIRED_VAR"],
|
||||||
)
|
)
|
||||||
with pytest.raises(ConfigError, match="REQUIRED_VAR"):
|
with pytest.raises(ConfigError, match="REQUIRED_VAR"):
|
||||||
plan_bootstrap(profile, {})
|
plan_bootstrap(profile, {}, env={})
|
||||||
|
|
||||||
def test_runcmd_produces_action(self):
|
def test_runcmd_produces_action(self):
|
||||||
profile = Profile(
|
profile = Profile(
|
||||||
@@ -83,7 +96,7 @@ class TestPlanBootstrap:
|
|||||||
ssh_keys=[], runcmd=["echo hello", "echo world"],
|
ssh_keys=[], runcmd=["echo hello", "echo world"],
|
||||||
packages=[], env_required=[],
|
packages=[], env_required=[],
|
||||||
)
|
)
|
||||||
plan = plan_bootstrap(profile, {})
|
plan = plan_bootstrap(profile, {}, env={})
|
||||||
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
|
||||||
|
|
||||||
@@ -94,7 +107,7 @@ class TestPlanBootstrap:
|
|||||||
ssh_keys=[], runcmd=[], packages=[], env_required=[],
|
ssh_keys=[], runcmd=[], packages=[], env_required=[],
|
||||||
post_link="echo done",
|
post_link="echo done",
|
||||||
)
|
)
|
||||||
plan = plan_bootstrap(profile, {})
|
plan = plan_bootstrap(profile, {}, env={})
|
||||||
assert any(action.phase == "post-link" for action in plan.actions)
|
assert any(action.phase == "post-link" for action in plan.actions)
|
||||||
|
|
||||||
def test_ssh_keys_action(self):
|
def test_ssh_keys_action(self):
|
||||||
@@ -104,6 +117,6 @@ class TestPlanBootstrap:
|
|||||||
ssh_keys=[{"path": "~/.ssh/id", "type": "ed25519"}],
|
ssh_keys=[{"path": "~/.ssh/id", "type": "ed25519"}],
|
||||||
runcmd=[], packages=[], env_required=[],
|
runcmd=[], packages=[], env_required=[],
|
||||||
)
|
)
|
||||||
plan = plan_bootstrap(profile, {})
|
plan = plan_bootstrap(profile, {}, env={})
|
||||||
ssh_actions = [a for a in plan.actions if "SSH" in a.description]
|
ssh_actions = [a for a in plan.actions if "SSH" in a.description]
|
||||||
assert len(ssh_actions) == 1
|
assert len(ssh_actions) == 1
|
||||||
|
|||||||
@@ -42,28 +42,40 @@ 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, project_path=str(projects))
|
mounts = resolve_mounts(
|
||||||
|
tmp_path, filesystem_check=lambda p: p.exists(), project_path=str(projects),
|
||||||
|
)
|
||||||
project_mounts = [m for m in mounts if m.target == "/workspace"]
|
project_mounts = [m for m in mounts if m.target == "/workspace"]
|
||||||
assert len(project_mounts) == 1
|
assert len(project_mounts) == 1
|
||||||
|
|
||||||
def test_dotfiles_mount(self, tmp_path):
|
def test_dotfiles_mount(self, tmp_path):
|
||||||
dotfiles = tmp_path / "dotfiles"
|
dotfiles = tmp_path / "dotfiles"
|
||||||
dotfiles.mkdir()
|
dotfiles.mkdir()
|
||||||
mounts = resolve_mounts(tmp_path, dotfiles_dir=dotfiles)
|
mounts = resolve_mounts(
|
||||||
|
tmp_path, filesystem_check=lambda p: p.exists(), dotfiles_dir=dotfiles,
|
||||||
|
)
|
||||||
assert any(m.target.endswith("/flow/dotfiles") for m in mounts)
|
assert any(m.target.endswith("/flow/dotfiles") for m in mounts)
|
||||||
|
|
||||||
def test_socket_path_mount(self, tmp_path):
|
def test_socket_path_mount(self, tmp_path):
|
||||||
sock = tmp_path / "docker.sock"
|
sock = tmp_path / "docker.sock"
|
||||||
sock.write_text("")
|
sock.write_text("")
|
||||||
mounts = resolve_mounts(tmp_path, socket_path=sock)
|
mounts = resolve_mounts(
|
||||||
|
tmp_path, filesystem_check=lambda p: p.exists(), socket_path=sock,
|
||||||
|
)
|
||||||
socket_mounts = [m for m in mounts if m.target == "/var/run/docker.sock"]
|
socket_mounts = [m for m in mounts if m.target == "/var/run/docker.sock"]
|
||||||
assert len(socket_mounts) == 1
|
assert len(socket_mounts) == 1
|
||||||
assert socket_mounts[0].source == sock
|
assert socket_mounts[0].source == sock
|
||||||
|
|
||||||
def test_no_socket_path(self, tmp_path):
|
def test_no_socket_path(self, tmp_path):
|
||||||
mounts = resolve_mounts(tmp_path)
|
mounts = resolve_mounts(tmp_path, filesystem_check=lambda p: p.exists())
|
||||||
assert not any(m.target == "/var/run/docker.sock" for m in mounts)
|
assert not any(m.target == "/var/run/docker.sock" for m in mounts)
|
||||||
|
|
||||||
|
def test_filesystem_check_controls_standard_mounts(self, tmp_path):
|
||||||
|
mounts = resolve_mounts(tmp_path, filesystem_check=lambda p: False)
|
||||||
|
# No standard mounts present when filesystem_check returns False.
|
||||||
|
assert not any(m.target == "/home/dev/.ssh" for m in mounts)
|
||||||
|
assert not any(m.target.endswith("/flow/dotfiles") for m in mounts)
|
||||||
|
|
||||||
|
|
||||||
class TestBuildContainerSpec:
|
class TestBuildContainerSpec:
|
||||||
def test_basic(self):
|
def test_basic(self):
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class TestResolveSpec:
|
|||||||
name="fd", type="pkg", sources={"apt": "fd-find"},
|
name="fd", type="pkg", sources={"apt": "fd-find"},
|
||||||
source=None, version=None, asset_pattern=None,
|
source=None, version=None, asset_pattern=None,
|
||||||
platform_map={}, extract_dir=None, install={},
|
platform_map={}, extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)}
|
)}
|
||||||
ref = ProfilePackageRef(name="fd", type=None, source=None, version="1.0", asset_pattern=None)
|
ref = ProfilePackageRef(name="fd", type=None, source=None, version="1.0", asset_pattern=None)
|
||||||
result = resolve_spec(ref, catalog)
|
result = resolve_spec(ref, catalog)
|
||||||
@@ -86,7 +86,7 @@ class TestResolveSpec:
|
|||||||
name="docker", type="pkg", sources={"apt": "docker-ce"},
|
name="docker", type="pkg", sources={"apt": "docker-ce"},
|
||||||
source=None, version=None, asset_pattern=None,
|
source=None, version=None, asset_pattern=None,
|
||||||
platform_map={}, extract_dir=None, install={},
|
platform_map={}, extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)}
|
)}
|
||||||
ref = ProfilePackageRef(
|
ref = ProfilePackageRef(
|
||||||
name="docker",
|
name="docker",
|
||||||
@@ -95,11 +95,9 @@ class TestResolveSpec:
|
|||||||
version=None,
|
version=None,
|
||||||
asset_pattern=None,
|
asset_pattern=None,
|
||||||
post_install="sudo groupadd docker || true",
|
post_install="sudo groupadd docker || true",
|
||||||
allow_sudo=True,
|
|
||||||
)
|
)
|
||||||
result = resolve_spec(ref, catalog)
|
result = resolve_spec(ref, catalog)
|
||||||
assert result.post_install == "sudo groupadd docker || true"
|
assert result.post_install == "sudo groupadd docker || true"
|
||||||
assert result.allow_sudo is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveSourceName:
|
class TestResolveSourceName:
|
||||||
@@ -108,7 +106,7 @@ class TestResolveSourceName:
|
|||||||
name="fd", type="pkg", sources={"apt": "fd-find"},
|
name="fd", type="pkg", sources={"apt": "fd-find"},
|
||||||
source=None, version=None, asset_pattern=None,
|
source=None, version=None, asset_pattern=None,
|
||||||
platform_map={}, extract_dir=None, install={},
|
platform_map={}, extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)
|
)
|
||||||
assert resolve_source_name(pkg, "apt") == "fd-find"
|
assert resolve_source_name(pkg, "apt") == "fd-find"
|
||||||
|
|
||||||
@@ -117,7 +115,7 @@ class TestResolveSourceName:
|
|||||||
name="fd", type="pkg", sources={},
|
name="fd", type="pkg", sources={},
|
||||||
source=None, version=None, asset_pattern=None,
|
source=None, version=None, asset_pattern=None,
|
||||||
platform_map={}, extract_dir=None, install={},
|
platform_map={}, extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)
|
)
|
||||||
assert resolve_source_name(pkg, "apt") == "fd"
|
assert resolve_source_name(pkg, "apt") == "fd"
|
||||||
|
|
||||||
@@ -131,7 +129,7 @@ class TestResolveBinaryAsset:
|
|||||||
asset_pattern=None,
|
asset_pattern=None,
|
||||||
platform_map={"linux-x64": "nvim-linux-x86_64.tar.gz"},
|
platform_map={"linux-x64": "nvim-linux-x86_64.tar.gz"},
|
||||||
extract_dir=None, install={},
|
extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)
|
)
|
||||||
assert resolve_binary_asset(pkg, "linux-x64") == "nvim-linux-x86_64.tar.gz"
|
assert resolve_binary_asset(pkg, "linux-x64") == "nvim-linux-x86_64.tar.gz"
|
||||||
|
|
||||||
@@ -143,7 +141,7 @@ class TestResolveBinaryAsset:
|
|||||||
asset_pattern="fd-v10.2.0-{{arch}}-unknown-{{os}}-gnu.tar.gz",
|
asset_pattern="fd-v10.2.0-{{arch}}-unknown-{{os}}-gnu.tar.gz",
|
||||||
platform_map={},
|
platform_map={},
|
||||||
extract_dir=None, install={},
|
extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)
|
)
|
||||||
result = resolve_binary_asset(pkg, "linux-x64")
|
result = resolve_binary_asset(pkg, "linux-x64")
|
||||||
assert "x64" in result
|
assert "x64" in result
|
||||||
@@ -157,7 +155,7 @@ class TestResolveBinaryAsset:
|
|||||||
asset_pattern="nvim-{{os}}-{{arch}}.tar.gz",
|
asset_pattern="nvim-{{os}}-{{arch}}.tar.gz",
|
||||||
platform_map={"linux-x64": {"os": "linux", "arch": "x86_64"}},
|
platform_map={"linux-x64": {"os": "linux", "arch": "x86_64"}},
|
||||||
extract_dir="nvim-{{os}}64", install={},
|
extract_dir="nvim-{{os}}64", install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)
|
)
|
||||||
assert resolve_binary_asset(pkg, "linux-x64") == "nvim-linux-x86_64.tar.gz"
|
assert resolve_binary_asset(pkg, "linux-x64") == "nvim-linux-x86_64.tar.gz"
|
||||||
assert resolve_extract_dir(pkg, "linux-x64") == "nvim-linux64"
|
assert resolve_extract_dir(pkg, "linux-x64") == "nvim-linux64"
|
||||||
@@ -171,7 +169,7 @@ class TestResolveDownloadUrl:
|
|||||||
version="v0.10.4",
|
version="v0.10.4",
|
||||||
asset_pattern=None, platform_map={},
|
asset_pattern=None, platform_map={},
|
||||||
extract_dir=None, install={},
|
extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)
|
)
|
||||||
url = resolve_download_url(pkg, "nvim.tar.gz")
|
url = resolve_download_url(pkg, "nvim.tar.gz")
|
||||||
assert "github.com/neovim/neovim" in url
|
assert "github.com/neovim/neovim" in url
|
||||||
@@ -184,7 +182,7 @@ class TestResolveDownloadUrl:
|
|||||||
version="0.10.4",
|
version="0.10.4",
|
||||||
asset_pattern=None, platform_map={},
|
asset_pattern=None, platform_map={},
|
||||||
extract_dir=None, install={},
|
extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)
|
)
|
||||||
url = resolve_download_url(pkg, "nvim.tar.gz", "linux-x64")
|
url = resolve_download_url(pkg, "nvim.tar.gz", "linux-x64")
|
||||||
assert "/download/v0.10.4/" in url
|
assert "/download/v0.10.4/" in url
|
||||||
@@ -196,7 +194,7 @@ class TestResolveDownloadUrl:
|
|||||||
version=None,
|
version=None,
|
||||||
asset_pattern=None, platform_map={},
|
asset_pattern=None, platform_map={},
|
||||||
extract_dir=None, install={},
|
extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)
|
)
|
||||||
url = resolve_download_url(pkg, "nvim.tar.gz")
|
url = resolve_download_url(pkg, "nvim.tar.gz")
|
||||||
assert "latest" in url
|
assert "latest" in url
|
||||||
@@ -208,7 +206,7 @@ class TestResolveDownloadUrl:
|
|||||||
version=None,
|
version=None,
|
||||||
asset_pattern=None, platform_map={},
|
asset_pattern=None, platform_map={},
|
||||||
extract_dir=None, install={},
|
extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)
|
)
|
||||||
url = resolve_download_url(pkg, "x.tar.gz")
|
url = resolve_download_url(pkg, "x.tar.gz")
|
||||||
assert url == "https://example.com/download/x.tar.gz"
|
assert url == "https://example.com/download/x.tar.gz"
|
||||||
@@ -247,7 +245,7 @@ class TestPlanning:
|
|||||||
name="wezterm", type="cask", sources={"brew": "wezterm"},
|
name="wezterm", type="cask", sources={"brew": "wezterm"},
|
||||||
source=None, version=None, asset_pattern=None,
|
source=None, version=None, asset_pattern=None,
|
||||||
platform_map={}, extract_dir=None, install={},
|
platform_map={}, extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)
|
)
|
||||||
plan = plan_install([pkg], InstalledState(), "macos-arm64", "brew")
|
plan = plan_install([pkg], InstalledState(), "macos-arm64", "brew")
|
||||||
assert plan.install_ops[0].method == "cask"
|
assert plan.install_ops[0].method == "cask"
|
||||||
|
|||||||
@@ -32,12 +32,36 @@ def test_installed_state_version_mismatch():
|
|||||||
InstalledState.from_dict({"version": 99, "packages": {}})
|
InstalledState.from_dict({"version": 99, "packages": {}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_installed_state_corrupt_missing_version_raises():
|
||||||
|
with pytest.raises(ConfigError, match="missing field 'version'"):
|
||||||
|
InstalledState.from_dict({
|
||||||
|
"version": 1,
|
||||||
|
"packages": {"fd": {"type": "pkg", "files": []}},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_installed_state_corrupt_missing_type_raises():
|
||||||
|
with pytest.raises(ConfigError, match="missing field 'type'"):
|
||||||
|
InstalledState.from_dict({
|
||||||
|
"version": 1,
|
||||||
|
"packages": {"fd": {"version": "1.0", "files": []}},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_installed_state_corrupt_missing_files_raises():
|
||||||
|
with pytest.raises(ConfigError, match="missing field 'files'"):
|
||||||
|
InstalledState.from_dict({
|
||||||
|
"version": 1,
|
||||||
|
"packages": {"fd": {"version": "1.0", "type": "pkg"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def test_package_def_fields():
|
def test_package_def_fields():
|
||||||
pkg = PackageDef(
|
pkg = PackageDef(
|
||||||
name="fd", type="pkg", sources={"apt": "fd-find"},
|
name="fd", type="pkg", sources={"apt": "fd-find"},
|
||||||
source=None, version=None, asset_pattern=None,
|
source=None, version=None, asset_pattern=None,
|
||||||
platform_map={}, extract_dir=None, install={},
|
platform_map={}, extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)
|
)
|
||||||
assert pkg.name == "fd"
|
assert pkg.name == "fd"
|
||||||
assert pkg.type == "pkg"
|
assert pkg.type == "pkg"
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
"""Tests for package install/remove planning."""
|
"""Tests for package install/remove planning."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from flow.core.errors import FlowError
|
||||||
from flow.domain.packages.models import (
|
from flow.domain.packages.models import (
|
||||||
InstalledPackage,
|
InstalledPackage,
|
||||||
InstalledState,
|
InstalledState,
|
||||||
@@ -16,7 +20,7 @@ def _pkg(name, type="pkg", sources=None, source=None, version=None,
|
|||||||
name=name, type=type, sources=sources or {},
|
name=name, type=type, sources=sources or {},
|
||||||
source=source, version=version, asset_pattern=asset_pattern,
|
source=source, version=version, asset_pattern=asset_pattern,
|
||||||
platform_map=platform_map or {}, extract_dir=None, install={},
|
platform_map=platform_map or {}, extract_dir=None, install={},
|
||||||
post_install=None, allow_sudo=False,
|
post_install=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -62,3 +66,15 @@ class TestPlanRemove:
|
|||||||
def test_remove_not_installed(self):
|
def test_remove_not_installed(self):
|
||||||
plan = plan_remove(["missing"], InstalledState())
|
plan = plan_remove(["missing"], InstalledState())
|
||||||
assert len(plan.remove_ops) == 0
|
assert len(plan.remove_ops) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanInstallSignature:
|
||||||
|
def test_pm_is_required_positional(self):
|
||||||
|
sig = inspect.signature(plan_install)
|
||||||
|
param = sig.parameters["pm"]
|
||||||
|
assert param.default is inspect.Parameter.empty
|
||||||
|
|
||||||
|
def test_pkg_without_pm_raises(self):
|
||||||
|
pkgs = [_pkg("fd", sources={"apt": "fd-find"})]
|
||||||
|
with pytest.raises(FlowError, match="No supported package manager"):
|
||||||
|
plan_install(pkgs, InstalledState(), "linux-x64", None)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from flow.domain.remote.resolution import (
|
|||||||
list_targets,
|
list_targets,
|
||||||
parse_target,
|
parse_target,
|
||||||
resolve_target,
|
resolve_target,
|
||||||
terminfo_fix_command,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -84,9 +83,3 @@ class TestListTargets:
|
|||||||
targets = list_targets(configs)
|
targets = list_targets(configs)
|
||||||
assert len(targets) == 2
|
assert len(targets) == 2
|
||||||
assert targets[0].label == "a@b"
|
assert targets[0].label == "a@b"
|
||||||
|
|
||||||
|
|
||||||
class TestTerminfoFix:
|
|
||||||
def test_returns_command(self):
|
|
||||||
cmd = terminfo_fix_command()
|
|
||||||
assert "infocmp" in cmd
|
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ class TestBootstrapService:
|
|||||||
"os": "linux",
|
"os": "linux",
|
||||||
"packages": [{
|
"packages": [{
|
||||||
"name": "docker",
|
"name": "docker",
|
||||||
"allow-sudo": True,
|
|
||||||
"post-install": "sudo groupadd docker || true",
|
"post-install": "sudo groupadd docker || true",
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
@@ -93,9 +92,27 @@ class TestBootstrapService:
|
|||||||
ctx = _make_ctx(manifest)
|
ctx = _make_ctx(manifest)
|
||||||
BootstrapService(ctx).run("linux-auto")
|
BootstrapService(ctx).run("linux-auto")
|
||||||
|
|
||||||
assert captured["packages"][0].allow_sudo is True
|
|
||||||
assert captured["packages"][0].post_install == "sudo groupadd docker || true"
|
assert captured["packages"][0].post_install == "sudo groupadd docker || true"
|
||||||
|
|
||||||
|
def test_unknown_phase_raises(self):
|
||||||
|
from flow.domain.bootstrap.models import BootstrapAction, BootstrapPlan
|
||||||
|
from flow.domain.bootstrap.models import VALID_PHASES
|
||||||
|
|
||||||
|
manifest = {"profiles": {"work": {"os": "linux"}}}
|
||||||
|
ctx = _make_ctx(manifest)
|
||||||
|
svc = BootstrapService(ctx)
|
||||||
|
# Forge an action with a phase that VALID_PHASES contains but the
|
||||||
|
# dispatch can't handle (shouldn't happen, but tests the explicit guard).
|
||||||
|
# Use a phase NOT in VALID_PHASES first to confirm the "Unknown" branch.
|
||||||
|
action = BootstrapAction.__new__(BootstrapAction)
|
||||||
|
object.__setattr__(action, "phase", "no-such-phase")
|
||||||
|
object.__setattr__(action, "description", "")
|
||||||
|
object.__setattr__(action, "commands", ())
|
||||||
|
object.__setattr__(action, "needs_sudo", False)
|
||||||
|
plan = BootstrapPlan(profile="work", actions=(), packages_to_install=())
|
||||||
|
with pytest.raises(FlowError, match="Unknown bootstrap phase"):
|
||||||
|
svc._execute_action(action, plan, "work")
|
||||||
|
|
||||||
def test_run_uses_dotfiles_profile_override(self, monkeypatch):
|
def test_run_uses_dotfiles_profile_override(self, monkeypatch):
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import tarfile
|
import tarfile
|
||||||
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -12,7 +13,7 @@ from flow.core.errors import FlowError
|
|||||||
from flow.core.platform import PlatformInfo
|
from flow.core.platform import PlatformInfo
|
||||||
from flow.core.runtime import SystemRuntime
|
from flow.core.runtime import SystemRuntime
|
||||||
from flow.core import paths
|
from flow.core import paths
|
||||||
from flow.domain.packages.models import InstalledPackage, InstalledState
|
from flow.domain.packages.models import InstalledPackage, InstalledState, PackageDef
|
||||||
from flow.services.packages import PackageService
|
from flow.services.packages import PackageService
|
||||||
|
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ class TestPackageService:
|
|||||||
home = tmp_path / "home"
|
home = tmp_path / "home"
|
||||||
home.mkdir()
|
home.mkdir()
|
||||||
monkeypatch.setenv("HOME", str(home))
|
monkeypatch.setenv("HOME", str(home))
|
||||||
|
monkeypatch.setattr(paths, "HOME", home)
|
||||||
monkeypatch.setattr(paths, "DATA_DIR", tmp_path / "data")
|
monkeypatch.setattr(paths, "DATA_DIR", tmp_path / "data")
|
||||||
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
|
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
|
||||||
|
|
||||||
@@ -130,3 +132,105 @@ class TestPackageService:
|
|||||||
assert (home / ".local" / "bin" / "nvim").exists()
|
assert (home / ".local" / "bin" / "nvim").exists()
|
||||||
assert (home / ".local" / "share" / "nvim" / "runtime.txt").exists()
|
assert (home / ".local" / "share" / "nvim" / "runtime.txt").exists()
|
||||||
assert (home / ".local" / "share" / "man" / "man1" / "nvim.1").exists()
|
assert (home / ".local" / "share" / "man" / "man1" / "nvim.1").exists()
|
||||||
|
|
||||||
|
def test_post_install_with_sudo_runs_unchecked(self, tmp_path, monkeypatch):
|
||||||
|
"""No allow_sudo gate -- post-install scripts run as written."""
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
monkeypatch.setattr(paths, "HOME", home)
|
||||||
|
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
|
||||||
|
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
class _Runner:
|
||||||
|
def run_shell(self, command, **kwargs):
|
||||||
|
calls.append(command)
|
||||||
|
|
||||||
|
class _Result:
|
||||||
|
returncode = 0
|
||||||
|
stdout = ""
|
||||||
|
stderr = ""
|
||||||
|
|
||||||
|
return _Result()
|
||||||
|
|
||||||
|
ctx = _make_ctx(tmp_path)
|
||||||
|
ctx.runtime.runner = _Runner()
|
||||||
|
svc = PackageService(ctx)
|
||||||
|
pkg = PackageDef(
|
||||||
|
name="docker", type="pkg", sources={},
|
||||||
|
source=None, version=None, asset_pattern=None,
|
||||||
|
platform_map={}, extract_dir=None, install={},
|
||||||
|
post_install="sudo groupadd docker || true",
|
||||||
|
)
|
||||||
|
svc._run_post_install(pkg)
|
||||||
|
assert calls == ["sudo groupadd docker || true"]
|
||||||
|
|
||||||
|
def test_install_binary_url_failure_raises_flow_error(self, tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
monkeypatch.setattr(paths, "HOME", home)
|
||||||
|
monkeypatch.setattr(paths, "DATA_DIR", tmp_path / "data")
|
||||||
|
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
|
||||||
|
|
||||||
|
def _raise(*args, **kwargs):
|
||||||
|
raise urllib.error.URLError("Network unreachable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"flow.services.packages.urllib.request.urlopen", _raise,
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"packages": [{
|
||||||
|
"name": "neovim",
|
||||||
|
"type": "binary",
|
||||||
|
"source": "github:neovim/neovim",
|
||||||
|
"version": "0.10.4",
|
||||||
|
"platform-map": {"linux-x64": "nvim-linux-x64.tar.gz"},
|
||||||
|
"install": {"bin": ["bin/nvim"]},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
ctx = _make_ctx(tmp_path, manifest)
|
||||||
|
svc = PackageService(ctx)
|
||||||
|
packages = svc.resolve_install_packages(package_names=["neovim"])
|
||||||
|
with pytest.raises(FlowError, match="Failed to download"):
|
||||||
|
svc.install(packages)
|
||||||
|
|
||||||
|
def test_install_path_absolute_raises(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(paths, "HOME", tmp_path / "home")
|
||||||
|
ctx = _make_ctx(tmp_path)
|
||||||
|
svc = PackageService(ctx)
|
||||||
|
with pytest.raises(FlowError, match="must be relative"):
|
||||||
|
svc._validate_install_path("pkg", Path("/etc/passwd"))
|
||||||
|
|
||||||
|
def test_install_path_parent_traversal_raises(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(paths, "HOME", tmp_path / "home")
|
||||||
|
ctx = _make_ctx(tmp_path)
|
||||||
|
svc = PackageService(ctx)
|
||||||
|
with pytest.raises(FlowError, match="parent traversal"):
|
||||||
|
svc._validate_install_path("pkg", Path("../etc/passwd"))
|
||||||
|
|
||||||
|
def test_install_path_escapes_extract_dir_raises(self, tmp_path, monkeypatch):
|
||||||
|
"""A relative path whose resolved location is outside the extract dir."""
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
monkeypatch.setattr(paths, "HOME", home)
|
||||||
|
ctx = _make_ctx(tmp_path)
|
||||||
|
svc = PackageService(ctx)
|
||||||
|
|
||||||
|
extract_root = tmp_path / "extract"
|
||||||
|
extract_root.mkdir()
|
||||||
|
sibling = tmp_path / "sibling"
|
||||||
|
sibling.mkdir()
|
||||||
|
# Symlink inside the extract root pointing outside -- the resolved
|
||||||
|
# source escapes the root.
|
||||||
|
link = extract_root / "evil"
|
||||||
|
link.symlink_to(sibling)
|
||||||
|
|
||||||
|
with pytest.raises(FlowError, match="escapes extract-dir"):
|
||||||
|
svc._copy_install_item(
|
||||||
|
"pkg",
|
||||||
|
extract_root,
|
||||||
|
extract_root.resolve(),
|
||||||
|
"bin",
|
||||||
|
"evil/escape",
|
||||||
|
)
|
||||||
|
|||||||
@@ -55,10 +55,3 @@ class TestRemoteService:
|
|||||||
svc = RemoteService(ctx)
|
svc = RemoteService(ctx)
|
||||||
svc.list()
|
svc.list()
|
||||||
assert "No targets" in capsys.readouterr().out
|
assert "No targets" in capsys.readouterr().out
|
||||||
|
|
||||||
def test_fix_terminfo(self, capsys):
|
|
||||||
ctx = _make_ctx()
|
|
||||||
svc = RemoteService(ctx)
|
|
||||||
svc.fix_terminfo("personal@orb")
|
|
||||||
output = capsys.readouterr().out
|
|
||||||
assert "infocmp" in output
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from flow.core.errors import ConfigError
|
||||||
from flow.core.template import substitute, substitute_template
|
from flow.core.template import substitute, substitute_template
|
||||||
|
|
||||||
|
|
||||||
@@ -36,8 +39,18 @@ class TestSubstituteTemplate:
|
|||||||
ctx = {"platform": {"arch": "arm64"}}
|
ctx = {"platform": {"arch": "arm64"}}
|
||||||
assert substitute_template("{{ platform.arch }}", ctx) == "arm64"
|
assert substitute_template("{{ platform.arch }}", ctx) == "arm64"
|
||||||
|
|
||||||
def test_preserves_unknown_templates(self):
|
def test_unknown_variable_raises(self):
|
||||||
assert substitute_template("{{ unknown }}", {}) == "{{ unknown }}"
|
with pytest.raises(ConfigError, match=r"\{\{ unknown \}\}"):
|
||||||
|
substitute_template("{{ unknown }}", {})
|
||||||
|
|
||||||
|
def test_nested_unresolved_raises(self):
|
||||||
|
with pytest.raises(ConfigError, match=r"\{\{ platform.missing \}\}"):
|
||||||
|
substitute_template("{{ platform.missing }}", {"platform": {"arch": "arm64"}})
|
||||||
|
|
||||||
|
def test_unresolved_env_raises(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("SOME_NEVER_SET_VAR", raising=False)
|
||||||
|
with pytest.raises(ConfigError):
|
||||||
|
substitute_template("{{ env.SOME_NEVER_SET_VAR }}", {"env": {}})
|
||||||
|
|
||||||
def test_non_string_passthrough(self):
|
def test_non_string_passthrough(self):
|
||||||
assert substitute_template(42, {}) == 42
|
assert substitute_template(42, {}) == 42
|
||||||
|
|||||||
Reference in New Issue
Block a user