feat: add CLI entry point, command modules, and zsh completion
- CLI with context detection, config merging, VM blocking - Command modules: dotfiles, packages, setup, remote, dev, projects - Zsh completion with declarative command/subcommand/flag structure Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
156
src/flow/cli.py
156
src/flow/cli.py
@@ -1,101 +1,107 @@
|
||||
"""CLI entry point — argparse routing and context creation."""
|
||||
"""Flow CLI entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from flow import __version__
|
||||
from flow.commands import bootstrap, completion, container, dotfiles, enter, package, sync
|
||||
from flow.core.config import FlowContext, load_config, load_manifest
|
||||
from flow.core.console import ConsoleLogger
|
||||
from flow.core.paths import ensure_dirs
|
||||
from flow.core.platform import detect_platform
|
||||
|
||||
COMMAND_MODULES = [enter, container, dotfiles, bootstrap, package, sync, completion]
|
||||
from flow.core.config import AppConfig, FlowContext, load_config, load_manifest
|
||||
from flow.core.console import Console
|
||||
from flow.core.errors import FlowError
|
||||
from flow.core import paths
|
||||
from flow.core.platform import PlatformInfo, detect_context, detect_platform
|
||||
from flow.core.runtime import SystemRuntime
|
||||
|
||||
|
||||
def _ensure_non_root(console: ConsoleLogger) -> None:
|
||||
if os.geteuid() == 0:
|
||||
console.error("flow must be run as a regular user (not root/sudo)")
|
||||
def main(argv: Optional[list[str]] = None) -> None:
|
||||
"""Main entry point."""
|
||||
if os.getuid() == 0:
|
||||
print("Error: flow must not run as root", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="flow",
|
||||
description="DevFlow - A unified toolkit for managing development instances, containers, and profiles",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--version", action="version", version=f"flow {__version__}"
|
||||
)
|
||||
if args.version:
|
||||
print(f"flow {__version__}")
|
||||
return
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
for module in COMMAND_MODULES:
|
||||
module.register(subparsers)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(0)
|
||||
|
||||
console = ConsoleLogger()
|
||||
_ensure_non_root(console)
|
||||
|
||||
if args.command == "completion":
|
||||
handler = getattr(args, "handler", None)
|
||||
if handler:
|
||||
handler(None, args)
|
||||
return
|
||||
if not hasattr(args, "handler"):
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
ensure_dirs()
|
||||
# Build context
|
||||
console = Console(quiet=getattr(args, "quiet", False), color=None)
|
||||
platform_info = detect_platform()
|
||||
context = detect_context()
|
||||
|
||||
try:
|
||||
platform_info = detect_platform()
|
||||
except RuntimeError as e:
|
||||
console.error(str(e))
|
||||
# Block remote commands inside VMs
|
||||
cmd_name = getattr(args, "command", "")
|
||||
if context == "vm" and cmd_name in ("remote", "dev"):
|
||||
console.error(f"Command '{cmd_name}' is not available inside a VM.")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
manifest = load_manifest()
|
||||
except Exception as e:
|
||||
console.error(f"Failed to load configuration: {e}")
|
||||
sys.exit(1)
|
||||
paths.ensure_dirs()
|
||||
config = load_config(paths.CONFIG_DIR)
|
||||
# Also try loading config from dotfiles
|
||||
if paths.DOTFILES_FLOW_CONFIG.is_dir():
|
||||
dotfiles_config = load_config(paths.DOTFILES_FLOW_CONFIG)
|
||||
config = _merge_config(config, dotfiles_config)
|
||||
|
||||
manifest = load_manifest(paths.DOTFILES_FLOW_CONFIG)
|
||||
|
||||
ctx = FlowContext(
|
||||
config=config,
|
||||
manifest=manifest,
|
||||
platform=platform_info,
|
||||
console=console,
|
||||
runtime=SystemRuntime(),
|
||||
)
|
||||
|
||||
handler = getattr(args, "handler", None)
|
||||
if handler:
|
||||
try:
|
||||
handler(ctx, args)
|
||||
except KeyboardInterrupt:
|
||||
console.error("Interrupted")
|
||||
sys.exit(130)
|
||||
except subprocess.CalledProcessError as e:
|
||||
detail = (e.stderr or "").strip() or (e.stdout or "").strip()
|
||||
if detail:
|
||||
console.error(detail.splitlines()[-1])
|
||||
else:
|
||||
console.error(f"Command failed with exit code {e.returncode}")
|
||||
sys.exit(e.returncode or 1)
|
||||
except RuntimeError as e:
|
||||
console.error(str(e))
|
||||
sys.exit(1)
|
||||
except OSError as e:
|
||||
console.error(str(e))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
console.error(f"Unexpected error: {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
parser.print_help()
|
||||
try:
|
||||
args.handler(ctx, args)
|
||||
except FlowError as e:
|
||||
console.error(str(e))
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
console.error("Interrupted.")
|
||||
sys.exit(130)
|
||||
|
||||
|
||||
def _merge_config(base: AppConfig, overlay: AppConfig) -> AppConfig:
|
||||
"""Merge two configs: overlay values override base when non-default."""
|
||||
return AppConfig(
|
||||
dotfiles_url=overlay.dotfiles_url or base.dotfiles_url,
|
||||
dotfiles_branch=overlay.dotfiles_branch if overlay.dotfiles_branch != "main" else base.dotfiles_branch,
|
||||
projects_dir=overlay.projects_dir if overlay.projects_dir != "~/projects" else base.projects_dir,
|
||||
container_registry=overlay.container_registry if overlay.container_registry != "registry.tomastm.com" else base.container_registry,
|
||||
container_tag=overlay.container_tag if overlay.container_tag != "latest" else base.container_tag,
|
||||
tmux_session=overlay.tmux_session if overlay.tmux_session != "default" else base.tmux_session,
|
||||
targets=overlay.targets or base.targets,
|
||||
)
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="flow",
|
||||
description="DevFlow - development environment manager",
|
||||
)
|
||||
parser.add_argument("--version", action="store_true", help="Show version")
|
||||
parser.add_argument("--quiet", "-q", action="store_true", help="Suppress info output")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
# Import and register all command modules
|
||||
from flow.commands import dotfiles, packages, setup, remote, dev, projects, completion
|
||||
dotfiles.register(subparsers)
|
||||
packages.register(subparsers)
|
||||
setup.register(subparsers)
|
||||
remote.register(subparsers)
|
||||
dev.register(subparsers)
|
||||
projects.register(subparsers)
|
||||
completion.register(subparsers)
|
||||
|
||||
return parser
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
"""flow bootstrap — thin CLI adapter over the bootstrap service."""
|
||||
|
||||
import shutil
|
||||
import urllib.request
|
||||
|
||||
from flow.services import bootstrap as _service
|
||||
|
||||
DEFAULT_LOCALE = _service.DEFAULT_LOCALE
|
||||
PACKAGE_TYPES = _service.PACKAGE_TYPES
|
||||
_SERVICE_COPY_INSTALL_ITEM = _service._copy_install_item
|
||||
|
||||
|
||||
def _sync_service_module() -> None:
|
||||
_service.shutil = shutil
|
||||
_service.urllib = urllib
|
||||
_service._copy_install_item = _copy_install_item
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
_sync_service_module()
|
||||
return _service.register(subparsers)
|
||||
|
||||
|
||||
def _get_profiles(ctx):
|
||||
_sync_service_module()
|
||||
return _service._get_profiles(ctx)
|
||||
|
||||
|
||||
def _parse_variables(var_args: list):
|
||||
_sync_service_module()
|
||||
return _service._parse_variables(var_args)
|
||||
|
||||
|
||||
def _profile_template_context(ctx, extra_env, extra=None):
|
||||
_sync_service_module()
|
||||
return _service._profile_template_context(ctx, extra_env, extra)
|
||||
|
||||
|
||||
def _render_template_value(value, template_ctx):
|
||||
_sync_service_module()
|
||||
return _service._render_template_value(value, template_ctx)
|
||||
|
||||
|
||||
def _linux_detect_package_manager():
|
||||
_sync_service_module()
|
||||
return _service._linux_detect_package_manager()
|
||||
|
||||
|
||||
def _resolve_package_manager(ctx, profile_cfg):
|
||||
_sync_service_module()
|
||||
return _service._resolve_package_manager(ctx, profile_cfg)
|
||||
|
||||
|
||||
def _get_package_catalog(ctx):
|
||||
_sync_service_module()
|
||||
return _service._get_package_catalog(ctx)
|
||||
|
||||
|
||||
def _normalize_profile_package_entry(entry):
|
||||
_sync_service_module()
|
||||
return _service._normalize_profile_package_entry(entry)
|
||||
|
||||
|
||||
def _resolve_package_spec(catalog, profile_entry):
|
||||
_sync_service_module()
|
||||
return _service._resolve_package_spec(catalog, profile_entry)
|
||||
|
||||
|
||||
def _resolve_pkg_source_name(spec, package_manager):
|
||||
_sync_service_module()
|
||||
return _service._resolve_pkg_source_name(spec, package_manager)
|
||||
|
||||
|
||||
def _install_binary_package(ctx, spec, extra_env, dry_run):
|
||||
_sync_service_module()
|
||||
return _service._install_binary_package(ctx, spec, extra_env, dry_run)
|
||||
|
||||
|
||||
def _copy_install_item(kind, src, declared_path):
|
||||
return _SERVICE_COPY_INSTALL_ITEM(kind, src, declared_path)
|
||||
|
||||
|
||||
def _ensure_required_variables(profile_cfg, env_map):
|
||||
_sync_service_module()
|
||||
return _service._ensure_required_variables(profile_cfg, env_map)
|
||||
|
||||
|
||||
def run_bootstrap(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_bootstrap(ctx, args)
|
||||
|
||||
|
||||
def run_list(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_list(ctx, args)
|
||||
|
||||
|
||||
def run_show(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_show(ctx, args)
|
||||
|
||||
|
||||
def run_packages(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_packages(ctx, args)
|
||||
@@ -1,623 +1,99 @@
|
||||
"""flow completion — shell completion support (dynamic zsh)."""
|
||||
"""Zsh completion for flow CLI."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Sequence, Set
|
||||
|
||||
from flow.commands.enter import HOST_TEMPLATES
|
||||
from flow.core.config import load_config, load_manifest
|
||||
from flow.core.paths import DOTFILES_DIR, INSTALLED_STATE
|
||||
COMMANDS = {
|
||||
"dotfiles": {
|
||||
"subcommands": ["link", "unlink", "status", "sync"],
|
||||
"flags": {
|
||||
"link": ["--profile", "--dry-run", "--skip"],
|
||||
"unlink": ["--dry-run"],
|
||||
"status": [],
|
||||
"sync": [],
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"subcommands": ["install", "remove", "list"],
|
||||
"flags": {
|
||||
"install": ["--profile", "--dry-run"],
|
||||
"remove": ["--dry-run"],
|
||||
"list": [],
|
||||
},
|
||||
},
|
||||
"setup": {
|
||||
"subcommands": ["run", "show", "list"],
|
||||
"flags": {
|
||||
"run": ["--dry-run"],
|
||||
"show": [],
|
||||
"list": [],
|
||||
},
|
||||
},
|
||||
"remote": {
|
||||
"subcommands": ["enter", "list"],
|
||||
"flags": {
|
||||
"enter": ["--dry-run"],
|
||||
"list": [],
|
||||
},
|
||||
},
|
||||
"dev": {
|
||||
"subcommands": ["create", "enter", "stop", "remove", "list"],
|
||||
"flags": {
|
||||
"create": ["--namespace", "--dry-run"],
|
||||
"enter": ["--shell"],
|
||||
"stop": [],
|
||||
"remove": [],
|
||||
"list": [],
|
||||
},
|
||||
},
|
||||
"projects": {
|
||||
"subcommands": ["check"],
|
||||
"flags": {
|
||||
"check": ["--fetch"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ZSH_RC_START = "# >>> flow completion >>>"
|
||||
ZSH_RC_END = "# <<< flow completion <<<"
|
||||
|
||||
TOP_LEVEL_COMMANDS = [
|
||||
"enter",
|
||||
"dev",
|
||||
"dotfiles",
|
||||
"dot",
|
||||
"bootstrap",
|
||||
"setup",
|
||||
"provision",
|
||||
"package",
|
||||
"pkg",
|
||||
"sync",
|
||||
"completion",
|
||||
]
|
||||
def complete(comp_words: list[str], comp_cword: int) -> list[str]:
|
||||
"""Return completions for the current word."""
|
||||
if comp_cword <= 1:
|
||||
# Complete top-level commands
|
||||
prefix = comp_words[1] if len(comp_words) > 1 else ""
|
||||
return [c for c in COMMANDS if c.startswith(prefix)]
|
||||
|
||||
command = comp_words[1] if len(comp_words) > 1 else ""
|
||||
if command not in COMMANDS:
|
||||
return []
|
||||
|
||||
cmd_def = COMMANDS[command]
|
||||
|
||||
if comp_cword == 2:
|
||||
# Complete subcommands
|
||||
prefix = comp_words[2] if len(comp_words) > 2 else ""
|
||||
return [s for s in cmd_def["subcommands"] if s.startswith(prefix)]
|
||||
|
||||
if comp_cword >= 3:
|
||||
# Complete flags for the subcommand
|
||||
subcommand = comp_words[2] if len(comp_words) > 2 else ""
|
||||
flags = cmd_def["flags"].get(subcommand, [])
|
||||
prefix = comp_words[comp_cword] if comp_cword < len(comp_words) else ""
|
||||
return [f for f in flags if f.startswith(prefix)]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
p = subparsers.add_parser("completion", help="Shell completion helpers")
|
||||
sub = p.add_subparsers(dest="completion_command")
|
||||
"""Register completion subcommand."""
|
||||
p = subparsers.add_parser("completion", help="Shell completion")
|
||||
p.add_argument("--shell", default="zsh", choices=["zsh"])
|
||||
p.set_defaults(handler=_handle)
|
||||
|
||||
zsh = sub.add_parser("zsh", help="Print zsh completion script")
|
||||
zsh.set_defaults(handler=run_zsh_script)
|
||||
|
||||
install = sub.add_parser("install-zsh", help="Install zsh completion script")
|
||||
install.add_argument(
|
||||
"--dir",
|
||||
default="~/.zsh/completions",
|
||||
help="Directory where _flow completion file is written",
|
||||
)
|
||||
install.add_argument(
|
||||
"--rc",
|
||||
default="~/.zshrc",
|
||||
help="Shell rc file to update with fpath/compinit snippet",
|
||||
)
|
||||
install.add_argument(
|
||||
"--no-rc",
|
||||
action="store_true",
|
||||
help="Do not modify rc file; only write completion script",
|
||||
)
|
||||
install.set_defaults(handler=run_install_zsh)
|
||||
def _handle(ctx, args):
|
||||
"""Output completion script."""
|
||||
import os
|
||||
comp_words = os.environ.get("COMP_WORDS", "").split()
|
||||
comp_cword = int(os.environ.get("COMP_CWORD", "0"))
|
||||
|
||||
hidden = sub.add_parser("_zsh_complete", help=argparse.SUPPRESS)
|
||||
hidden.add_argument("--cword", type=int, required=True, help=argparse.SUPPRESS)
|
||||
hidden.add_argument("words", nargs="*", help=argparse.SUPPRESS)
|
||||
hidden.set_defaults(handler=run_zsh_complete)
|
||||
|
||||
p.set_defaults(handler=lambda _ctx, args: p.print_help())
|
||||
|
||||
|
||||
def _canonical_command(command: str) -> str:
|
||||
alias_map = {
|
||||
"dot": "dotfiles",
|
||||
"setup": "bootstrap",
|
||||
"provision": "bootstrap",
|
||||
"pkg": "package",
|
||||
}
|
||||
return alias_map.get(command, command)
|
||||
|
||||
|
||||
def _safe_config():
|
||||
try:
|
||||
return load_config()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_manifest():
|
||||
try:
|
||||
return load_manifest()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _list_targets() -> List[str]:
|
||||
cfg = _safe_config()
|
||||
if cfg is None:
|
||||
return []
|
||||
return sorted({f"{t.namespace}@{t.platform}" for t in cfg.targets})
|
||||
|
||||
|
||||
def _list_namespaces() -> List[str]:
|
||||
cfg = _safe_config()
|
||||
if cfg is None:
|
||||
return []
|
||||
return sorted({t.namespace for t in cfg.targets})
|
||||
|
||||
|
||||
def _list_platforms() -> List[str]:
|
||||
cfg = _safe_config()
|
||||
config_platforms: Set[str] = set()
|
||||
if cfg is not None:
|
||||
config_platforms = {t.platform for t in cfg.targets}
|
||||
return sorted(set(HOST_TEMPLATES.keys()) | config_platforms)
|
||||
|
||||
|
||||
def _list_bootstrap_profiles() -> List[str]:
|
||||
manifest = _safe_manifest()
|
||||
return sorted(manifest.get("profiles", {}).keys())
|
||||
|
||||
|
||||
def _list_manifest_packages() -> List[str]:
|
||||
manifest = _safe_manifest()
|
||||
packages = manifest.get("packages", [])
|
||||
names: Set[str] = set()
|
||||
|
||||
if isinstance(packages, list):
|
||||
for pkg in packages:
|
||||
if not isinstance(pkg, dict):
|
||||
continue
|
||||
name = pkg.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
continue
|
||||
if str(pkg.get("type", "pkg")) == "binary":
|
||||
names.add(name)
|
||||
return sorted(names)
|
||||
|
||||
if isinstance(packages, dict):
|
||||
for key, pkg in packages.items():
|
||||
if not isinstance(pkg, dict):
|
||||
continue
|
||||
if str(pkg.get("type", "pkg")) != "binary":
|
||||
continue
|
||||
raw_name = pkg.get("name")
|
||||
if isinstance(raw_name, str) and raw_name:
|
||||
names.add(raw_name)
|
||||
elif isinstance(key, str) and key:
|
||||
names.add(key)
|
||||
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def _list_installed_packages() -> List[str]:
|
||||
if not INSTALLED_STATE.exists():
|
||||
return []
|
||||
try:
|
||||
with open(INSTALLED_STATE) as f:
|
||||
state = json.load(f)
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(state, dict):
|
||||
return []
|
||||
return sorted(state.keys())
|
||||
|
||||
|
||||
def _list_dotfiles_profiles() -> List[str]:
|
||||
flow_dir = DOTFILES_DIR
|
||||
if not flow_dir.is_dir():
|
||||
return []
|
||||
|
||||
return sorted(
|
||||
[
|
||||
p.name
|
||||
for p in flow_dir.iterdir()
|
||||
if p.is_dir() and not p.name.startswith(".") and not p.name.startswith("_")
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _list_dotfiles_packages(profile: Optional[str] = None) -> List[str]:
|
||||
package_names: Set[str] = set()
|
||||
flow_dir = DOTFILES_DIR
|
||||
|
||||
if not flow_dir.is_dir():
|
||||
return []
|
||||
|
||||
shared = flow_dir / "_shared"
|
||||
if shared.is_dir():
|
||||
for pkg in shared.iterdir():
|
||||
if pkg.is_dir() and not pkg.name.startswith("."):
|
||||
package_names.add(pkg.name)
|
||||
|
||||
if profile:
|
||||
profile_dir = flow_dir / profile
|
||||
if profile_dir.is_dir():
|
||||
for pkg in profile_dir.iterdir():
|
||||
if pkg.is_dir() and not pkg.name.startswith("."):
|
||||
package_names.add(pkg.name)
|
||||
else:
|
||||
for profile_dir in flow_dir.iterdir():
|
||||
if profile_dir.name.startswith(".") or profile_dir.name.startswith("_"):
|
||||
continue
|
||||
if not profile_dir.is_dir():
|
||||
continue
|
||||
for pkg in profile_dir.iterdir():
|
||||
if pkg.is_dir() and not pkg.name.startswith("."):
|
||||
package_names.add(pkg.name)
|
||||
|
||||
return sorted(package_names)
|
||||
|
||||
|
||||
def _list_container_names() -> List[str]:
|
||||
runtime = None
|
||||
for rt in ("docker", "podman"):
|
||||
if shutil.which(rt):
|
||||
runtime = rt
|
||||
break
|
||||
|
||||
if not runtime:
|
||||
return []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
runtime,
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
"label=dev=true",
|
||||
"--format",
|
||||
'{{.Label "dev.name"}}',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
names = []
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
names.append(line)
|
||||
return sorted(set(names))
|
||||
|
||||
|
||||
def _split_words(words: Sequence[str], cword: int):
|
||||
tokens = list(words)
|
||||
index = max(0, cword - 1)
|
||||
|
||||
if tokens:
|
||||
tokens = tokens[1:]
|
||||
index = max(0, cword - 2)
|
||||
|
||||
if index > len(tokens):
|
||||
index = len(tokens)
|
||||
|
||||
current = tokens[index] if index < len(tokens) else ""
|
||||
before = tokens[:index]
|
||||
return before, current
|
||||
|
||||
|
||||
def _filter(candidates: Sequence[str], prefix: str) -> List[str]:
|
||||
unique = sorted(set(candidates))
|
||||
if not prefix:
|
||||
return unique
|
||||
return [c for c in unique if c.startswith(prefix)]
|
||||
|
||||
|
||||
def _profile_from_before(before: Sequence[str]) -> Optional[str]:
|
||||
for i, token in enumerate(before):
|
||||
if token == "--profile" and i + 1 < len(before):
|
||||
return before[i + 1]
|
||||
return None
|
||||
|
||||
|
||||
def _complete_dev(before: Sequence[str], current: str) -> List[str]:
|
||||
if len(before) <= 1:
|
||||
return _filter(["create", "exec", "connect", "list", "stop", "remove", "rm", "respawn"], current)
|
||||
|
||||
sub = "remove" if before[1] == "rm" else before[1]
|
||||
|
||||
if sub in {"remove", "stop", "connect", "exec", "respawn"}:
|
||||
options = {
|
||||
"remove": ["-f", "--force", "-h", "--help"],
|
||||
"stop": ["--kill", "-h", "--help"],
|
||||
"exec": ["-h", "--help"],
|
||||
"connect": ["-h", "--help"],
|
||||
"respawn": ["-h", "--help"],
|
||||
}[sub]
|
||||
|
||||
if current.startswith("-"):
|
||||
return _filter(options, current)
|
||||
|
||||
non_opt = [t for t in before[2:] if not t.startswith("-")]
|
||||
if len(non_opt) == 0:
|
||||
return _filter(_list_container_names(), current)
|
||||
return []
|
||||
|
||||
if sub == "create":
|
||||
options = ["-i", "--image", "-p", "--project", "-h", "--help"]
|
||||
if before and before[-1] in ("-i", "--image"):
|
||||
return _filter(["tm0/node", "docker/python", "docker/alpine"], current)
|
||||
|
||||
if current.startswith("-"):
|
||||
return _filter(options, current)
|
||||
|
||||
return []
|
||||
|
||||
if sub == "list":
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _complete_dotfiles(before: Sequence[str], current: str) -> List[str]:
|
||||
if len(before) <= 1:
|
||||
if current.startswith("-"):
|
||||
return _filter(["--verbose", "-h", "--help"], current)
|
||||
return _filter(
|
||||
["init", "link", "unlink", "undo", "status", "sync", "relink", "clean", "edit", "repo", "modules"],
|
||||
current,
|
||||
)
|
||||
|
||||
if before[1] == "--verbose":
|
||||
if len(before) <= 2:
|
||||
if current.startswith("-"):
|
||||
return _filter(["-h", "--help"], current)
|
||||
return _filter(
|
||||
["init", "link", "unlink", "undo", "status", "sync", "relink", "clean", "edit", "repo", "modules"],
|
||||
current,
|
||||
)
|
||||
before = [before[0]] + list(before[2:])
|
||||
|
||||
sub = before[1]
|
||||
|
||||
if sub == "init":
|
||||
return _filter(["--repo", "-h", "--help"], current) if current.startswith("-") else []
|
||||
|
||||
if sub == "repo":
|
||||
if len(before) <= 2:
|
||||
return _filter(["status", "pull", "push"], current)
|
||||
|
||||
repo_sub = before[2]
|
||||
if repo_sub == "pull":
|
||||
if before and before[-1] == "--profile":
|
||||
return _filter(_list_dotfiles_profiles(), current)
|
||||
if current.startswith("-"):
|
||||
return _filter(["--rebase", "--no-rebase", "--relink", "--profile", "-h", "--help"], current)
|
||||
elif current.startswith("-"):
|
||||
return _filter(["-h", "--help"], current)
|
||||
|
||||
return []
|
||||
|
||||
if sub == "modules":
|
||||
if len(before) <= 2:
|
||||
if current.startswith("-"):
|
||||
return _filter(["-h", "--help"], current)
|
||||
return _filter(["list", "sync"], current)
|
||||
|
||||
modules_sub = before[2]
|
||||
if modules_sub in {"list", "sync"}:
|
||||
if before and before[-1] == "--profile":
|
||||
return _filter(_list_dotfiles_profiles(), current)
|
||||
|
||||
if current.startswith("-"):
|
||||
return _filter(["--profile", "-h", "--help"], current)
|
||||
|
||||
profile = _profile_from_before(before)
|
||||
return _filter(_list_dotfiles_packages(profile), current)
|
||||
|
||||
return []
|
||||
|
||||
if sub in {"link", "relink"}:
|
||||
if before and before[-1] == "--profile":
|
||||
return _filter(_list_dotfiles_profiles(), current)
|
||||
|
||||
if current.startswith("-"):
|
||||
return _filter(["--profile", "--copy", "--force", "--dry-run", "-h", "--help"], current)
|
||||
|
||||
profile = _profile_from_before(before)
|
||||
return _filter(_list_dotfiles_packages(profile), current)
|
||||
|
||||
if sub == "unlink":
|
||||
if current.startswith("-"):
|
||||
return _filter(["-h", "--help"], current)
|
||||
return _filter(_list_dotfiles_packages(), current)
|
||||
|
||||
if sub == "undo":
|
||||
return _filter(["-h", "--help"], current) if current.startswith("-") else []
|
||||
|
||||
if sub == "edit":
|
||||
if current.startswith("-"):
|
||||
return _filter(["--no-commit", "-h", "--help"], current)
|
||||
non_opt = [t for t in before[2:] if not t.startswith("-")]
|
||||
if len(non_opt) == 0:
|
||||
return _filter(_list_dotfiles_packages(), current)
|
||||
return []
|
||||
|
||||
if sub == "clean":
|
||||
return _filter(["--dry-run", "-h", "--help"], current) if current.startswith("-") else []
|
||||
|
||||
if sub == "sync":
|
||||
if before and before[-1] == "--profile":
|
||||
return _filter(_list_dotfiles_profiles(), current)
|
||||
return _filter(["--relink", "--profile", "-h", "--help"], current) if current.startswith("-") else []
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _complete_bootstrap(before: Sequence[str], current: str) -> List[str]:
|
||||
if len(before) <= 1:
|
||||
return _filter(["run", "list", "show", "packages"], current)
|
||||
|
||||
sub = before[1]
|
||||
|
||||
if sub == "run":
|
||||
if before and before[-1] == "--profile":
|
||||
return _filter(_list_bootstrap_profiles(), current)
|
||||
if current.startswith("-"):
|
||||
return _filter(["--profile", "--dry-run", "--var", "-h", "--help"], current)
|
||||
return []
|
||||
|
||||
if sub == "show":
|
||||
if current.startswith("-"):
|
||||
return _filter(["-h", "--help"], current)
|
||||
non_opt = [t for t in before[2:] if not t.startswith("-")]
|
||||
if len(non_opt) == 0:
|
||||
return _filter(_list_bootstrap_profiles(), current)
|
||||
return []
|
||||
|
||||
if sub == "packages":
|
||||
if before and before[-1] == "--profile":
|
||||
return _filter(_list_bootstrap_profiles(), current)
|
||||
if current.startswith("-"):
|
||||
return _filter(["--profile", "--resolved", "-h", "--help"], current)
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _complete_package(before: Sequence[str], current: str) -> List[str]:
|
||||
if len(before) <= 1:
|
||||
return _filter(["install", "list", "remove"], current)
|
||||
|
||||
sub = before[1]
|
||||
|
||||
if sub == "install":
|
||||
if current.startswith("-"):
|
||||
return _filter(["--dry-run", "-h", "--help"], current)
|
||||
return _filter(_list_manifest_packages(), current)
|
||||
|
||||
if sub == "remove":
|
||||
if current.startswith("-"):
|
||||
return _filter(["-h", "--help"], current)
|
||||
return _filter(_list_installed_packages(), current)
|
||||
|
||||
if sub == "list":
|
||||
if current.startswith("-"):
|
||||
return _filter(["--all", "-h", "--help"], current)
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _complete_sync(before: Sequence[str], current: str) -> List[str]:
|
||||
if len(before) <= 1:
|
||||
return _filter(["check", "fetch", "summary"], current)
|
||||
|
||||
sub = before[1]
|
||||
if sub == "check" and current.startswith("-"):
|
||||
return _filter(["--fetch", "--no-fetch", "-h", "--help"], current)
|
||||
|
||||
if current.startswith("-"):
|
||||
return _filter(["-h", "--help"], current)
|
||||
return []
|
||||
|
||||
|
||||
def complete(words: Sequence[str], cword: int) -> List[str]:
|
||||
before, current = _split_words(words, cword)
|
||||
|
||||
if not before:
|
||||
return _filter(TOP_LEVEL_COMMANDS + ["-h", "--help", "-v", "--version"], current)
|
||||
|
||||
command = _canonical_command(before[0])
|
||||
|
||||
if command == "enter":
|
||||
if before and before[-1] in ("-p", "--platform"):
|
||||
return _filter(_list_platforms(), current)
|
||||
if before and before[-1] in ("-n", "--namespace"):
|
||||
return _filter(_list_namespaces(), current)
|
||||
if current.startswith("-"):
|
||||
return _filter(
|
||||
["-u", "--user", "-n", "--namespace", "-p", "--platform", "-s", "--session", "--no-tmux", "-d", "--dry-run", "-h", "--help"],
|
||||
current,
|
||||
)
|
||||
return _filter(_list_targets(), current)
|
||||
|
||||
if command == "dev":
|
||||
return _complete_dev(before, current)
|
||||
|
||||
if command == "dotfiles":
|
||||
return _complete_dotfiles(before, current)
|
||||
|
||||
if command == "bootstrap":
|
||||
return _complete_bootstrap(before, current)
|
||||
|
||||
if command == "package":
|
||||
return _complete_package(before, current)
|
||||
|
||||
if command == "sync":
|
||||
return _complete_sync(before, current)
|
||||
|
||||
if command == "completion":
|
||||
if len(before) <= 1:
|
||||
return _filter(["zsh", "install-zsh"], current)
|
||||
|
||||
sub = before[1]
|
||||
if sub == "install-zsh" and current.startswith("-"):
|
||||
return _filter(["--dir", "--rc", "--no-rc", "-h", "--help"], current)
|
||||
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def run_zsh_complete(_ctx, args):
|
||||
candidates = complete(args.words, args.cword)
|
||||
for item in candidates:
|
||||
print(item)
|
||||
|
||||
|
||||
def _zsh_script_text() -> str:
|
||||
return r'''#compdef flow
|
||||
|
||||
_flow() {
|
||||
local -a suggestions
|
||||
suggestions=("${(@f)$(flow completion _zsh_complete --cword "$CURRENT" -- "${words[@]}" 2>/dev/null)}")
|
||||
|
||||
if (( ${#suggestions[@]} > 0 )); then
|
||||
compadd -Q -- "${suggestions[@]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$words[CURRENT]" == */* || "$words[CURRENT]" == ./* || "$words[CURRENT]" == ~* ]]; then
|
||||
_files
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
compdef _flow flow
|
||||
'''
|
||||
|
||||
|
||||
def _zsh_dir_for_rc(path: Path) -> str:
|
||||
home = Path.home().resolve()
|
||||
resolved = path.expanduser().resolve()
|
||||
try:
|
||||
rel = resolved.relative_to(home)
|
||||
return f"~/{rel}" if str(rel) != "." else "~"
|
||||
except ValueError:
|
||||
return str(resolved)
|
||||
|
||||
|
||||
def _zsh_rc_snippet(completions_dir: Path) -> str:
|
||||
dir_expr = _zsh_dir_for_rc(completions_dir)
|
||||
return (
|
||||
f"{ZSH_RC_START}\n"
|
||||
f"fpath=({dir_expr} $fpath)\n"
|
||||
"autoload -Uz compinit && compinit\n"
|
||||
f"{ZSH_RC_END}\n"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_rc_snippet(rc_path: Path, completions_dir: Path) -> bool:
|
||||
snippet = _zsh_rc_snippet(completions_dir)
|
||||
if rc_path.exists():
|
||||
content = rc_path.read_text()
|
||||
else:
|
||||
content = ""
|
||||
|
||||
if ZSH_RC_START in content and ZSH_RC_END in content:
|
||||
start = content.find(ZSH_RC_START)
|
||||
end = content.find(ZSH_RC_END, start)
|
||||
if end >= 0:
|
||||
end += len(ZSH_RC_END)
|
||||
updated = content[:start] + snippet.rstrip("\n") + content[end:]
|
||||
if updated == content:
|
||||
return False
|
||||
rc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rc_path.write_text(updated)
|
||||
return True
|
||||
|
||||
sep = "" if content.endswith("\n") or content == "" else "\n"
|
||||
rc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rc_path.write_text(content + sep + snippet)
|
||||
return True
|
||||
|
||||
|
||||
def run_install_zsh(_ctx, args):
|
||||
completions_dir = Path(args.dir).expanduser()
|
||||
completions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
completion_file = completions_dir / "_flow"
|
||||
completion_file.write_text(_zsh_script_text())
|
||||
print(f"Installed completion script: {completion_file}")
|
||||
|
||||
if args.no_rc:
|
||||
print("Skipped rc file update (--no-rc)")
|
||||
return
|
||||
|
||||
rc_path = Path(args.rc).expanduser()
|
||||
changed = _ensure_rc_snippet(rc_path, completions_dir)
|
||||
if changed:
|
||||
print(f"Updated shell rc: {rc_path}")
|
||||
else:
|
||||
print(f"Shell rc already configured: {rc_path}")
|
||||
|
||||
print("Restart shell or run: autoload -Uz compinit && compinit")
|
||||
|
||||
|
||||
def run_zsh_script(_ctx, _args):
|
||||
print(_zsh_script_text())
|
||||
completions = complete(comp_words, comp_cword)
|
||||
for c in completions:
|
||||
print(c)
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
"""flow dev <subcommand> — container management."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from flow.services.containers import (
|
||||
CONTAINER_HOME,
|
||||
DEFAULT_REGISTRY,
|
||||
DEFAULT_TAG,
|
||||
ContainerService,
|
||||
container_name as _cname,
|
||||
parse_image_ref as _parse_image_ref,
|
||||
runtime as _runtime_service,
|
||||
)
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
parser = subparsers.add_parser("dev", help="Manage development containers")
|
||||
sub = parser.add_subparsers(dest="dev_command")
|
||||
|
||||
create = sub.add_parser("create", help="Create and start a development container")
|
||||
create.add_argument("name", help="Container name")
|
||||
create.add_argument("-i", "--image", required=True, help="Container image")
|
||||
create.add_argument("-p", "--project", help="Path to project directory")
|
||||
create.set_defaults(handler=run_create)
|
||||
|
||||
exec_cmd = sub.add_parser("exec", help="Execute command in a container")
|
||||
exec_cmd.add_argument("name", help="Container name")
|
||||
exec_cmd.add_argument("cmd", nargs="*", help="Command to run (default: interactive shell)")
|
||||
exec_cmd.set_defaults(handler=run_exec)
|
||||
|
||||
connect = sub.add_parser("connect", help="Attach to container tmux session")
|
||||
connect.add_argument("name", help="Container name")
|
||||
connect.set_defaults(handler=run_connect)
|
||||
|
||||
list_parser = sub.add_parser("list", help="List development containers")
|
||||
list_parser.set_defaults(handler=run_list)
|
||||
|
||||
stop = sub.add_parser("stop", help="Stop a development container")
|
||||
stop.add_argument("name", help="Container name")
|
||||
stop.add_argument("--kill", action="store_true", help="Kill instead of graceful stop")
|
||||
stop.set_defaults(handler=run_stop)
|
||||
|
||||
remove = sub.add_parser("remove", aliases=["rm"], help="Remove a development container")
|
||||
remove.add_argument("name", help="Container name")
|
||||
remove.add_argument("-f", "--force", action="store_true", help="Force removal")
|
||||
remove.set_defaults(handler=run_remove)
|
||||
|
||||
respawn = sub.add_parser("respawn", help="Respawn all tmux panes for a session")
|
||||
respawn.add_argument("name", help="Session/container name")
|
||||
respawn.set_defaults(handler=run_respawn)
|
||||
|
||||
parser.set_defaults(handler=lambda ctx, args: parser.print_help())
|
||||
|
||||
|
||||
def _runtime():
|
||||
return _runtime_service()
|
||||
|
||||
|
||||
def _container_exists(rt: str, cname: str) -> bool:
|
||||
result = subprocess.run(
|
||||
[rt, "container", "ls", "-a", "--format", "{{.Names}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return cname in result.stdout.strip().splitlines()
|
||||
|
||||
|
||||
def _container_running(rt: str, cname: str) -> bool:
|
||||
result = subprocess.run(
|
||||
[rt, "container", "ls", "--format", "{{.Names}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return cname in result.stdout.strip().splitlines()
|
||||
|
||||
|
||||
def run_create(ctx, args):
|
||||
ContainerService(ctx).run_create(args)
|
||||
|
||||
|
||||
def run_exec(ctx, args):
|
||||
ContainerService(ctx).run_exec(args)
|
||||
|
||||
|
||||
def run_connect(ctx, args):
|
||||
ContainerService(ctx).run_connect(args)
|
||||
|
||||
|
||||
def run_list(ctx, args):
|
||||
ContainerService(ctx).run_list(args)
|
||||
|
||||
|
||||
def run_stop(ctx, args):
|
||||
ContainerService(ctx).run_stop(args)
|
||||
|
||||
|
||||
def run_remove(ctx, args):
|
||||
ContainerService(ctx).run_remove(args)
|
||||
|
||||
|
||||
def run_respawn(ctx, args):
|
||||
ContainerService(ctx).run_respawn(args)
|
||||
|
||||
|
||||
def _tmux_fallback(cname: str):
|
||||
if not os.environ.get("TMUX"):
|
||||
return
|
||||
result = subprocess.run(
|
||||
["tmux", "display-message", "-p", "#S"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
current = result.stdout.strip()
|
||||
if current == cname:
|
||||
subprocess.run(["tmux", "new-session", "-ds", "default"], capture_output=True, check=False)
|
||||
subprocess.run(["tmux", "switch-client", "-t", "default"], check=False)
|
||||
62
src/flow/commands/dev.py
Normal file
62
src/flow/commands/dev.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Dev container commands."""
|
||||
|
||||
from flow.core.config import FlowContext
|
||||
from flow.services.containers import ContainerService
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
p = subparsers.add_parser("dev", help="Manage development containers")
|
||||
sub = p.add_subparsers(dest="dev_action")
|
||||
|
||||
create = sub.add_parser("create", help="Create a container")
|
||||
create.add_argument("image", help="Container image")
|
||||
create.add_argument("--namespace", "-n", default="default")
|
||||
create.add_argument("--dry-run", action="store_true")
|
||||
create.set_defaults(handler=_create)
|
||||
|
||||
enter = sub.add_parser("enter", help="Enter a running container")
|
||||
enter.add_argument("name", help="Container name")
|
||||
enter.add_argument("--shell", default="/bin/bash")
|
||||
enter.set_defaults(handler=_enter)
|
||||
|
||||
stop = sub.add_parser("stop", help="Stop a container")
|
||||
stop.add_argument("name", help="Container name")
|
||||
stop.set_defaults(handler=_stop)
|
||||
|
||||
rm = sub.add_parser("remove", help="Remove a container")
|
||||
rm.add_argument("name", help="Container name")
|
||||
rm.set_defaults(handler=_remove)
|
||||
|
||||
ls = sub.add_parser("list", help="List flow containers")
|
||||
ls.set_defaults(handler=_list)
|
||||
|
||||
p.set_defaults(handler=_default)
|
||||
|
||||
|
||||
def _default(ctx: FlowContext, args):
|
||||
_list(ctx, args)
|
||||
|
||||
|
||||
def _create(ctx: FlowContext, args):
|
||||
svc = ContainerService(ctx)
|
||||
svc.create(args.image, args.namespace, dry_run=args.dry_run)
|
||||
|
||||
|
||||
def _enter(ctx: FlowContext, args):
|
||||
svc = ContainerService(ctx)
|
||||
svc.enter(args.name, shell=args.shell)
|
||||
|
||||
|
||||
def _stop(ctx: FlowContext, args):
|
||||
svc = ContainerService(ctx)
|
||||
svc.stop(args.name)
|
||||
|
||||
|
||||
def _remove(ctx: FlowContext, args):
|
||||
svc = ContainerService(ctx)
|
||||
svc.remove(args.name)
|
||||
|
||||
|
||||
def _list(ctx: FlowContext, args):
|
||||
svc = ContainerService(ctx)
|
||||
svc.list()
|
||||
@@ -1,193 +1,59 @@
|
||||
"""flow dotfiles — thin CLI adapter over the dotfiles service."""
|
||||
"""Dotfiles commands."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flow.core.paths import DOTFILES_DIR, LINKED_STATE, MODULES_DIR
|
||||
from flow.services import dotfiles as _service
|
||||
|
||||
RESERVED_SHARED = "_shared"
|
||||
RESERVED_ROOT = "_root"
|
||||
MODULE_FILE = "_module.yaml"
|
||||
LINK_BACKUP_DIR = LINKED_STATE.parent / "link-backups"
|
||||
|
||||
LinkSpec = _service.LinkSpec
|
||||
ModuleSpec = _service.ModuleSpec
|
||||
|
||||
|
||||
def _sync_service_module() -> None:
|
||||
_service.DOTFILES_DIR = DOTFILES_DIR
|
||||
_service.MODULES_DIR = MODULES_DIR
|
||||
_service.LINKED_STATE = LINKED_STATE
|
||||
_service.LINK_BACKUP_DIR = LINKED_STATE.parent / "link-backups"
|
||||
_service.RESERVED_SHARED = RESERVED_SHARED
|
||||
_service.RESERVED_ROOT = RESERVED_ROOT
|
||||
_service.MODULE_FILE = MODULE_FILE
|
||||
from flow.core.config import FlowContext
|
||||
from flow.services.dotfiles import DotfilesService
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
_sync_service_module()
|
||||
return _service.register(subparsers)
|
||||
p = subparsers.add_parser("dotfiles", help="Manage dotfile symlinks")
|
||||
sub = p.add_subparsers(dest="dotfiles_action")
|
||||
|
||||
link = sub.add_parser("link", help="Link dotfiles to home")
|
||||
link.add_argument("--profile", help="Profile to include")
|
||||
link.add_argument("--dry-run", "-n", action="store_true")
|
||||
link.add_argument("--skip", nargs="*", default=[])
|
||||
link.set_defaults(handler=_link)
|
||||
|
||||
unlink = sub.add_parser("unlink", help="Remove managed symlinks")
|
||||
unlink.add_argument("packages", nargs="*", help="Packages to unlink (all if empty)")
|
||||
unlink.add_argument("--dry-run", "-n", action="store_true")
|
||||
unlink.set_defaults(handler=_unlink)
|
||||
|
||||
status = sub.add_parser("status", help="Show link status")
|
||||
status.set_defaults(handler=_status)
|
||||
|
||||
sync = sub.add_parser("sync", help="Pull dotfiles and sync modules")
|
||||
sync.set_defaults(handler=_sync)
|
||||
|
||||
p.set_defaults(handler=_default)
|
||||
|
||||
|
||||
def _flow_config_dir(dotfiles_dir=None):
|
||||
_sync_service_module()
|
||||
return _service._flow_config_dir(dotfiles_dir)
|
||||
def _default(ctx: FlowContext, args):
|
||||
_status(ctx, args)
|
||||
|
||||
|
||||
def _pull_requires_ack(stdout: str, stderr: str) -> bool:
|
||||
_sync_service_module()
|
||||
return _service._pull_requires_ack(stdout, stderr)
|
||||
|
||||
|
||||
def _load_state() -> dict:
|
||||
_sync_service_module()
|
||||
return _service._load_state()
|
||||
|
||||
|
||||
def _save_state(state: dict) -> None:
|
||||
_sync_service_module()
|
||||
return _service._save_state(state)
|
||||
|
||||
|
||||
def _load_link_specs_from_state():
|
||||
_sync_service_module()
|
||||
return _service._load_link_specs_from_state()
|
||||
|
||||
|
||||
def _save_link_specs_to_state(specs):
|
||||
_sync_service_module()
|
||||
return _service._save_link_specs_to_state(specs)
|
||||
|
||||
|
||||
def _list_profiles(flow_dir: Path):
|
||||
_sync_service_module()
|
||||
return _service._list_profiles(flow_dir)
|
||||
|
||||
|
||||
def _walk_package(source_dir: Path):
|
||||
_sync_service_module()
|
||||
return _service._walk_package(source_dir)
|
||||
|
||||
|
||||
def _discover_packages(dotfiles_dir: Path, profile=None):
|
||||
_sync_service_module()
|
||||
return _service._discover_packages(dotfiles_dir, profile)
|
||||
|
||||
|
||||
def _resolve_edit_target(target: str, dotfiles_dir=None):
|
||||
_sync_service_module()
|
||||
return _service._resolve_edit_target(target, dotfiles_dir)
|
||||
|
||||
|
||||
def _resolved_package_source(ctx, package: str, package_dir: Path, *, verbose: bool = False):
|
||||
_sync_service_module()
|
||||
return _service._resolved_package_source(ctx, package, package_dir, verbose=verbose)
|
||||
|
||||
|
||||
def _run_sudo(cmd, *, dry_run: bool = False):
|
||||
_sync_service_module()
|
||||
return _service._run_sudo(cmd, dry_run=dry_run)
|
||||
|
||||
|
||||
def _collect_home_specs(ctx, flow_dir, home, profile, skip, package_filter, *, verbose: bool = False):
|
||||
_sync_service_module()
|
||||
return _service._collect_home_specs(
|
||||
ctx,
|
||||
flow_dir,
|
||||
home,
|
||||
profile,
|
||||
skip,
|
||||
package_filter,
|
||||
verbose=verbose,
|
||||
def _link(ctx: FlowContext, args):
|
||||
svc = DotfilesService(ctx)
|
||||
svc.link(
|
||||
profile=args.profile,
|
||||
dry_run=args.dry_run,
|
||||
skip=set(args.skip) if args.skip else None,
|
||||
)
|
||||
|
||||
|
||||
def _sync_modules(ctx, *, verbose: bool = False, profile=None, package_filter=None):
|
||||
_sync_service_module()
|
||||
return _service._sync_modules(
|
||||
ctx,
|
||||
verbose=verbose,
|
||||
profile=profile,
|
||||
package_filter=package_filter,
|
||||
def _unlink(ctx: FlowContext, args):
|
||||
svc = DotfilesService(ctx)
|
||||
svc.unlink(
|
||||
packages=args.packages if args.packages else None,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
|
||||
def _sync_to_desired(ctx, desired, *, force: bool, dry_run: bool, copy: bool):
|
||||
_sync_service_module()
|
||||
return _service._sync_to_desired(
|
||||
ctx,
|
||||
desired,
|
||||
force=force,
|
||||
dry_run=dry_run,
|
||||
copy=copy,
|
||||
)
|
||||
def _status(ctx: FlowContext, args):
|
||||
svc = DotfilesService(ctx)
|
||||
svc.status()
|
||||
|
||||
|
||||
def run_init(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_init(ctx, args)
|
||||
|
||||
|
||||
def run_link(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_link(ctx, args)
|
||||
|
||||
|
||||
def run_unlink(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_unlink(ctx, args)
|
||||
|
||||
|
||||
def run_undo(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_undo(ctx, args)
|
||||
|
||||
|
||||
def run_status(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_status(ctx, args)
|
||||
|
||||
|
||||
def run_sync(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_sync(ctx, args)
|
||||
|
||||
|
||||
def run_modules_list(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_modules_list(ctx, args)
|
||||
|
||||
|
||||
def run_modules_sync(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_modules_sync(ctx, args)
|
||||
|
||||
|
||||
def run_repo_status(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_repo_status(ctx, args)
|
||||
|
||||
|
||||
def run_repo_pull(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_repo_pull(ctx, args)
|
||||
|
||||
|
||||
def run_repo_push(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_repo_push(ctx, args)
|
||||
|
||||
|
||||
def run_relink(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_relink(ctx, args)
|
||||
|
||||
|
||||
def run_clean(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_clean(ctx, args)
|
||||
|
||||
|
||||
def run_edit(ctx, args):
|
||||
_sync_service_module()
|
||||
return _service.run_edit(ctx, args)
|
||||
def _sync(ctx: FlowContext, args):
|
||||
svc = DotfilesService(ctx)
|
||||
svc.sync()
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""flow enter — connect to a development instance via SSH."""
|
||||
|
||||
from flow.services.ssh import (
|
||||
HOST_TEMPLATES,
|
||||
EnterService,
|
||||
build_destination as _build_destination,
|
||||
handle_terminfo_warning as _handle_terminfo_warning,
|
||||
parse_target as _parse_target_model,
|
||||
terminfo_fix_command as _terminfo_fix_command,
|
||||
)
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
parser = subparsers.add_parser("enter", help="Connect to a development instance via SSH")
|
||||
parser.add_argument("target", help="Target: [user@]namespace@platform")
|
||||
parser.add_argument("-u", "--user", help="SSH user (overrides target)")
|
||||
parser.add_argument("-n", "--namespace", help="Namespace (overrides target)")
|
||||
parser.add_argument("-p", "--platform", help="Platform (overrides target)")
|
||||
parser.add_argument("-s", "--session", default="default", help="Tmux session name (default: 'default')")
|
||||
parser.add_argument("--no-tmux", action="store_true", help="Skip tmux attachment")
|
||||
parser.add_argument("-d", "--dry-run", action="store_true", help="Show command without executing")
|
||||
parser.set_defaults(handler=run)
|
||||
|
||||
|
||||
def _parse_target(target: str):
|
||||
return _parse_target_model(target)
|
||||
|
||||
|
||||
def run(ctx, args):
|
||||
EnterService(ctx).run(args)
|
||||
@@ -1,150 +0,0 @@
|
||||
"""flow package — package management from unified manifest definitions."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from flow.core.paths import INSTALLED_STATE
|
||||
from flow.services.package_defs import BinaryInstaller, get_package_catalog
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
p = subparsers.add_parser("package", aliases=["pkg"], help="Manage packages")
|
||||
sub = p.add_subparsers(dest="package_command")
|
||||
|
||||
inst = sub.add_parser("install", help="Install packages from manifest")
|
||||
inst.add_argument("packages", nargs="+", help="Package names to install")
|
||||
inst.add_argument("--dry-run", action="store_true", help="Show what would be done")
|
||||
inst.set_defaults(handler=run_install)
|
||||
|
||||
ls = sub.add_parser("list", help="List installed and available packages")
|
||||
ls.add_argument("--all", action="store_true", help="Show all available packages")
|
||||
ls.set_defaults(handler=run_list)
|
||||
|
||||
rm = sub.add_parser("remove", help="Remove installed packages")
|
||||
rm.add_argument("packages", nargs="+", help="Package names to remove")
|
||||
rm.set_defaults(handler=run_remove)
|
||||
|
||||
p.set_defaults(handler=lambda ctx, args: p.print_help())
|
||||
|
||||
|
||||
def _load_installed() -> dict:
|
||||
if not INSTALLED_STATE.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(INSTALLED_STATE, "r", encoding="utf-8") as handle:
|
||||
state = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
if isinstance(state, dict):
|
||||
return state
|
||||
return {}
|
||||
|
||||
|
||||
def _save_installed(state: dict):
|
||||
INSTALLED_STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(INSTALLED_STATE, "w", encoding="utf-8") as handle:
|
||||
json.dump(state, handle, indent=2)
|
||||
|
||||
|
||||
def _get_definitions(ctx):
|
||||
return get_package_catalog(ctx)
|
||||
|
||||
|
||||
def _install_binary_package(ctx, spec, extra_env, dry_run):
|
||||
return BinaryInstaller(ctx).install(spec, extra_env, dry_run=dry_run)
|
||||
|
||||
|
||||
def run_install(ctx, args):
|
||||
definitions = _get_definitions(ctx)
|
||||
installed = _load_installed()
|
||||
had_error = False
|
||||
|
||||
for package_name in args.packages:
|
||||
package_def = definitions.get(package_name)
|
||||
if not package_def:
|
||||
ctx.console.error(f"Package not found in manifest: {package_name}")
|
||||
had_error = True
|
||||
continue
|
||||
|
||||
package_type = package_def.get("type", "pkg")
|
||||
if package_type != "binary":
|
||||
ctx.console.error(
|
||||
f"'flow package install' supports binary packages only. "
|
||||
f"'{package_name}' is type '{package_type}'."
|
||||
)
|
||||
had_error = True
|
||||
continue
|
||||
|
||||
ctx.console.info(f"Installing {package_name}...")
|
||||
try:
|
||||
_install_binary_package(ctx, package_def, {}, args.dry_run)
|
||||
except RuntimeError as exc:
|
||||
ctx.console.error(str(exc))
|
||||
had_error = True
|
||||
continue
|
||||
|
||||
if not args.dry_run:
|
||||
installed[package_name] = {
|
||||
"version": str(package_def.get("version", "")),
|
||||
"type": package_type,
|
||||
}
|
||||
ctx.console.success(f"Installed {package_name}")
|
||||
|
||||
if not args.dry_run:
|
||||
_save_installed(installed)
|
||||
if had_error:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_list(ctx, args):
|
||||
definitions = _get_definitions(ctx)
|
||||
installed = _load_installed()
|
||||
|
||||
rows = []
|
||||
if args.all:
|
||||
if not definitions:
|
||||
ctx.console.info("No packages defined in manifest.")
|
||||
return
|
||||
for name, package_def in sorted(definitions.items()):
|
||||
rows.append(
|
||||
[
|
||||
name,
|
||||
str(package_def.get("type", "pkg")),
|
||||
str(installed.get(name, {}).get("version", "-")),
|
||||
str(package_def.get("version", "")) or "-",
|
||||
]
|
||||
)
|
||||
else:
|
||||
if not installed:
|
||||
ctx.console.info("No packages installed.")
|
||||
return
|
||||
for name, info in sorted(installed.items()):
|
||||
rows.append(
|
||||
[
|
||||
name,
|
||||
str(info.get("type", "?")),
|
||||
str(info.get("version", "?")),
|
||||
str(definitions.get(name, {}).get("version", "")) or "-",
|
||||
]
|
||||
)
|
||||
|
||||
ctx.console.table(["PACKAGE", "TYPE", "INSTALLED", "AVAILABLE"], rows)
|
||||
|
||||
|
||||
def run_remove(ctx, args):
|
||||
installed = _load_installed()
|
||||
|
||||
for package_name in args.packages:
|
||||
if package_name not in installed:
|
||||
ctx.console.warn(f"Package not installed: {package_name}")
|
||||
continue
|
||||
|
||||
del installed[package_name]
|
||||
ctx.console.success(f"Removed {package_name} from installed packages")
|
||||
ctx.console.warn(
|
||||
"Note: installed files were not automatically deleted. Remove manually if needed."
|
||||
)
|
||||
|
||||
_save_installed(installed)
|
||||
48
src/flow/commands/packages.py
Normal file
48
src/flow/commands/packages.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Package commands."""
|
||||
|
||||
from flow.core.config import FlowContext
|
||||
from flow.services.packages import PackageService
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
p = subparsers.add_parser("packages", help="Manage packages")
|
||||
sub = p.add_subparsers(dest="packages_action")
|
||||
|
||||
install = sub.add_parser("install", help="Install packages")
|
||||
install.add_argument("packages", nargs="*")
|
||||
install.add_argument("--profile", help="Install profile packages")
|
||||
install.add_argument("--dry-run", "-n", action="store_true")
|
||||
install.set_defaults(handler=_install)
|
||||
|
||||
remove = sub.add_parser("remove", help="Remove packages")
|
||||
remove.add_argument("packages", nargs="+")
|
||||
remove.add_argument("--dry-run", "-n", action="store_true")
|
||||
remove.set_defaults(handler=_remove)
|
||||
|
||||
ls = sub.add_parser("list", help="List installed packages")
|
||||
ls.set_defaults(handler=_list)
|
||||
|
||||
p.set_defaults(handler=_default)
|
||||
|
||||
|
||||
def _default(ctx: FlowContext, args):
|
||||
_list(ctx, args)
|
||||
|
||||
|
||||
def _install(ctx: FlowContext, args):
|
||||
svc = PackageService(ctx)
|
||||
svc.install(
|
||||
package_names=args.packages if args.packages else None,
|
||||
profile=args.profile,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
|
||||
def _remove(ctx: FlowContext, args):
|
||||
svc = PackageService(ctx)
|
||||
svc.remove(args.packages, dry_run=args.dry_run)
|
||||
|
||||
|
||||
def _list(ctx: FlowContext, args):
|
||||
svc = PackageService(ctx)
|
||||
svc.list_packages()
|
||||
24
src/flow/commands/projects.py
Normal file
24
src/flow/commands/projects.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Projects commands."""
|
||||
|
||||
from flow.core.config import FlowContext
|
||||
from flow.services.projects import ProjectService
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
p = subparsers.add_parser("projects", help="Manage git projects")
|
||||
sub = p.add_subparsers(dest="projects_action")
|
||||
|
||||
check = sub.add_parser("check", help="Check project status")
|
||||
check.add_argument("--fetch", "-f", action="store_true", help="Fetch remotes first")
|
||||
check.set_defaults(handler=_check)
|
||||
|
||||
p.set_defaults(handler=_default)
|
||||
|
||||
|
||||
def _default(ctx: FlowContext, args):
|
||||
_check(ctx, args)
|
||||
|
||||
|
||||
def _check(ctx: FlowContext, args):
|
||||
svc = ProjectService(ctx)
|
||||
svc.check(fetch=getattr(args, "fetch", False))
|
||||
33
src/flow/commands/remote.py
Normal file
33
src/flow/commands/remote.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Remote commands."""
|
||||
|
||||
from flow.core.config import FlowContext
|
||||
from flow.services.remote import RemoteService
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
p = subparsers.add_parser("remote", help="Manage remote targets")
|
||||
sub = p.add_subparsers(dest="remote_action")
|
||||
|
||||
enter = sub.add_parser("enter", help="SSH into a target")
|
||||
enter.add_argument("target", help="Target (namespace@platform)")
|
||||
enter.add_argument("--dry-run", "-n", action="store_true")
|
||||
enter.set_defaults(handler=_enter)
|
||||
|
||||
ls = sub.add_parser("list", help="List configured targets")
|
||||
ls.set_defaults(handler=_list)
|
||||
|
||||
p.set_defaults(handler=_default)
|
||||
|
||||
|
||||
def _default(ctx: FlowContext, args):
|
||||
_list(ctx, args)
|
||||
|
||||
|
||||
def _enter(ctx: FlowContext, args):
|
||||
svc = RemoteService(ctx)
|
||||
svc.enter(args.target, dry_run=args.dry_run)
|
||||
|
||||
|
||||
def _list(ctx: FlowContext, args):
|
||||
svc = RemoteService(ctx)
|
||||
svc.list()
|
||||
42
src/flow/commands/setup.py
Normal file
42
src/flow/commands/setup.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Setup/bootstrap commands."""
|
||||
|
||||
from flow.core.config import FlowContext
|
||||
from flow.services.bootstrap import BootstrapService
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
p = subparsers.add_parser("setup", help="Bootstrap a system profile")
|
||||
sub = p.add_subparsers(dest="setup_action")
|
||||
|
||||
run = sub.add_parser("run", help="Run bootstrap for a profile")
|
||||
run.add_argument("profile", help="Profile name")
|
||||
run.add_argument("--dry-run", "-n", action="store_true")
|
||||
run.set_defaults(handler=_run)
|
||||
|
||||
show = sub.add_parser("show", help="Show bootstrap plan")
|
||||
show.add_argument("profile", help="Profile name")
|
||||
show.set_defaults(handler=_show)
|
||||
|
||||
ls = sub.add_parser("list", help="List available profiles")
|
||||
ls.set_defaults(handler=_list)
|
||||
|
||||
p.set_defaults(handler=_default)
|
||||
|
||||
|
||||
def _default(ctx: FlowContext, args):
|
||||
_list(ctx, args)
|
||||
|
||||
|
||||
def _run(ctx: FlowContext, args):
|
||||
svc = BootstrapService(ctx)
|
||||
svc.run(args.profile, dry_run=args.dry_run)
|
||||
|
||||
|
||||
def _show(ctx: FlowContext, args):
|
||||
svc = BootstrapService(ctx)
|
||||
svc.show(args.profile)
|
||||
|
||||
|
||||
def _list(ctx: FlowContext, args):
|
||||
svc = BootstrapService(ctx)
|
||||
svc.list_profiles()
|
||||
@@ -1,176 +0,0 @@
|
||||
"""flow sync — check git sync status of all projects."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from flow.core.config import FlowContext
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
parser = subparsers.add_parser("sync", help="Git sync tools for projects")
|
||||
sub = parser.add_subparsers(dest="sync_command")
|
||||
|
||||
check = sub.add_parser("check", help="Check all projects status")
|
||||
check.add_argument("--fetch", dest="fetch", action="store_true", help="Run git fetch before checking (default)")
|
||||
check.add_argument("--no-fetch", dest="fetch", action="store_false", help="Skip git fetch")
|
||||
check.set_defaults(fetch=True)
|
||||
check.set_defaults(handler=run_check)
|
||||
|
||||
fetch = sub.add_parser("fetch", help="Fetch all project remotes")
|
||||
fetch.set_defaults(handler=run_fetch)
|
||||
|
||||
summary = sub.add_parser("summary", help="Quick overview of project status")
|
||||
summary.set_defaults(handler=run_summary)
|
||||
|
||||
parser.set_defaults(handler=lambda ctx, args: parser.print_help())
|
||||
|
||||
|
||||
def _git(repo: str, *cmd, capture: bool = True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["git", "-C", repo] + list(cmd),
|
||||
capture_output=capture,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _is_git_repo(repo_path: str) -> bool:
|
||||
git_dir = os.path.join(repo_path, ".git")
|
||||
return os.path.isdir(git_dir) or os.path.isfile(git_dir)
|
||||
|
||||
|
||||
def _check_repo(repo_path: str, do_fetch: bool = True):
|
||||
name = os.path.basename(repo_path)
|
||||
if not _is_git_repo(repo_path):
|
||||
return name, None
|
||||
|
||||
issues = []
|
||||
if do_fetch:
|
||||
fetch_result = _git(repo_path, "fetch", "--all", "--quiet")
|
||||
if fetch_result.returncode != 0:
|
||||
issues.append("git fetch failed")
|
||||
|
||||
result = _git(repo_path, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
branch = result.stdout.strip() if result.returncode == 0 else "HEAD"
|
||||
|
||||
diff_result = _git(repo_path, "diff", "--quiet")
|
||||
cached_result = _git(repo_path, "diff", "--cached", "--quiet")
|
||||
if diff_result.returncode != 0 or cached_result.returncode != 0:
|
||||
issues.append("uncommitted changes")
|
||||
else:
|
||||
untracked = _git(repo_path, "ls-files", "--others", "--exclude-standard")
|
||||
if untracked.stdout.strip():
|
||||
issues.append("untracked files")
|
||||
|
||||
upstream_check = _git(repo_path, "rev-parse", "--abbrev-ref", f"{branch}@{{u}}")
|
||||
if upstream_check.returncode == 0:
|
||||
unpushed = _git(repo_path, "rev-list", "--oneline", f"{branch}@{{u}}..{branch}")
|
||||
if unpushed.stdout.strip():
|
||||
issues.append(f"{len(unpushed.stdout.strip().splitlines())} unpushed commit(s) on {branch}")
|
||||
else:
|
||||
issues.append(f"no upstream for {branch}")
|
||||
|
||||
branches_result = _git(repo_path, "for-each-ref", "--format=%(refname:short)", "refs/heads")
|
||||
for branch_name in branches_result.stdout.strip().splitlines():
|
||||
if not branch_name or branch_name == branch:
|
||||
continue
|
||||
upstream = _git(repo_path, "rev-parse", "--abbrev-ref", f"{branch_name}@{{u}}")
|
||||
if upstream.returncode == 0:
|
||||
ahead = _git(repo_path, "rev-list", "--count", f"{branch_name}@{{u}}..{branch_name}")
|
||||
if ahead.stdout.strip() != "0":
|
||||
issues.append(f"branch {branch_name}: {ahead.stdout.strip()} ahead")
|
||||
else:
|
||||
issues.append(f"branch {branch_name}: no upstream")
|
||||
|
||||
return name, issues
|
||||
|
||||
|
||||
def run_check(ctx: FlowContext, args):
|
||||
projects_dir = os.path.expanduser(ctx.config.projects_dir)
|
||||
if not os.path.isdir(projects_dir):
|
||||
ctx.console.error(f"Projects directory not found: {projects_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
rows = []
|
||||
needs_action = []
|
||||
not_git = []
|
||||
checked = 0
|
||||
|
||||
for entry in sorted(os.listdir(projects_dir)):
|
||||
repo_path = os.path.join(projects_dir, entry)
|
||||
if not os.path.isdir(repo_path):
|
||||
continue
|
||||
name, issues = _check_repo(repo_path, do_fetch=args.fetch)
|
||||
if issues is None:
|
||||
not_git.append(name)
|
||||
continue
|
||||
checked += 1
|
||||
rows.append([name, "; ".join(issues) if issues else "clean and synced"])
|
||||
if issues:
|
||||
needs_action.append(name)
|
||||
|
||||
if checked == 0:
|
||||
ctx.console.info("No git repositories found in projects directory.")
|
||||
if not_git:
|
||||
ctx.console.info(f"Skipped non-git directories: {', '.join(sorted(not_git))}")
|
||||
return
|
||||
|
||||
ctx.console.table(["PROJECT", "STATUS"], rows)
|
||||
if needs_action:
|
||||
ctx.console.warn(f"Projects needing action: {', '.join(sorted(needs_action))}")
|
||||
else:
|
||||
ctx.console.success("All repositories clean and synced.")
|
||||
if not_git:
|
||||
ctx.console.info(f"Skipped non-git directories: {', '.join(sorted(not_git))}")
|
||||
|
||||
|
||||
def run_fetch(ctx: FlowContext, args):
|
||||
projects_dir = os.path.expanduser(ctx.config.projects_dir)
|
||||
if not os.path.isdir(projects_dir):
|
||||
ctx.console.error(f"Projects directory not found: {projects_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
had_error = False
|
||||
fetched = 0
|
||||
for entry in sorted(os.listdir(projects_dir)):
|
||||
repo_path = os.path.join(projects_dir, entry)
|
||||
if not _is_git_repo(repo_path):
|
||||
continue
|
||||
ctx.console.info(f"Fetching {entry}...")
|
||||
result = _git(repo_path, "fetch", "--all", "--quiet")
|
||||
fetched += 1
|
||||
if result.returncode != 0:
|
||||
had_error = True
|
||||
ctx.console.error(f"Failed to fetch {entry}")
|
||||
|
||||
if fetched == 0:
|
||||
ctx.console.info("No git repositories found in projects directory.")
|
||||
return
|
||||
if had_error:
|
||||
sys.exit(1)
|
||||
ctx.console.success("All remotes fetched.")
|
||||
|
||||
|
||||
def run_summary(ctx: FlowContext, args):
|
||||
projects_dir = os.path.expanduser(ctx.config.projects_dir)
|
||||
if not os.path.isdir(projects_dir):
|
||||
ctx.console.error(f"Projects directory not found: {projects_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
rows = []
|
||||
for entry in sorted(os.listdir(projects_dir)):
|
||||
repo_path = os.path.join(projects_dir, entry)
|
||||
if not os.path.isdir(repo_path):
|
||||
continue
|
||||
name, issues = _check_repo(repo_path, do_fetch=False)
|
||||
if issues is None:
|
||||
rows.append([name, "not a git repo"])
|
||||
elif issues:
|
||||
rows.append([name, "; ".join(issues)])
|
||||
else:
|
||||
rows.append([name, "clean"])
|
||||
|
||||
if not rows:
|
||||
ctx.console.info("No projects found.")
|
||||
return
|
||||
ctx.console.table(["PROJECT", "STATUS"], rows)
|
||||
@@ -1,49 +1,32 @@
|
||||
"""Tests for CLI routing and command registration."""
|
||||
"""Tests for CLI."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _clean_env():
|
||||
"""Return env dict without DF_* variables that trigger enter's guard."""
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith("DF_")}
|
||||
env["FLOW_SKIP_SUDO_REFRESH"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def test_version():
|
||||
def test_version_flag():
|
||||
"""Test --version flag works."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "--version"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "0.1.0" in result.stdout
|
||||
assert "flow" in result.stdout
|
||||
|
||||
|
||||
def test_help():
|
||||
def test_help_flag():
|
||||
"""Test --help shows commands."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "enter" in result.stdout
|
||||
assert "dev" in result.stdout
|
||||
assert "dotfiles" in result.stdout
|
||||
assert "bootstrap" in result.stdout
|
||||
assert "package" in result.stdout
|
||||
assert "sync" in result.stdout
|
||||
assert "completion" in result.stdout
|
||||
|
||||
|
||||
def test_enter_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "enter", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "target" in result.stdout
|
||||
assert "--dry-run" in result.stdout
|
||||
assert "packages" in result.stdout
|
||||
assert "setup" in result.stdout
|
||||
|
||||
|
||||
def test_dotfiles_help():
|
||||
@@ -52,136 +35,14 @@ def test_dotfiles_help():
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "init" in result.stdout
|
||||
assert "link" in result.stdout
|
||||
assert "unlink" in result.stdout
|
||||
assert "undo" in result.stdout
|
||||
assert "status" in result.stdout
|
||||
assert "sync" in result.stdout
|
||||
assert "repo" in result.stdout
|
||||
|
||||
|
||||
def test_dotfiles_help_without_sudo_in_path():
|
||||
env = _clean_env()
|
||||
env["PATH"] = os.path.dirname(sys.executable)
|
||||
|
||||
def test_packages_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "dotfiles", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "dotfiles" in result.stdout
|
||||
|
||||
|
||||
def test_bootstrap_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "bootstrap", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "run" in result.stdout
|
||||
assert "list" in result.stdout
|
||||
assert "show" in result.stdout
|
||||
assert "packages" in result.stdout
|
||||
|
||||
|
||||
def test_package_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "package", "--help"],
|
||||
[sys.executable, "-m", "flow", "packages", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "install" in result.stdout
|
||||
assert "list" in result.stdout
|
||||
assert "remove" in result.stdout
|
||||
|
||||
|
||||
def test_sync_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "sync", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "check" in result.stdout
|
||||
assert "fetch" in result.stdout
|
||||
assert "summary" in result.stdout
|
||||
|
||||
|
||||
def test_dev_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "dev", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "create" in result.stdout
|
||||
assert "exec" in result.stdout
|
||||
assert "connect" in result.stdout
|
||||
assert "list" in result.stdout
|
||||
assert "stop" in result.stdout
|
||||
assert "remove" in result.stdout
|
||||
assert "respawn" in result.stdout
|
||||
|
||||
|
||||
def test_enter_dry_run():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "enter", "--dry-run", "personal@orb"],
|
||||
capture_output=True, text=True, env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "ssh" in result.stdout
|
||||
assert "personal.orb" in result.stdout
|
||||
assert "tmux" in result.stdout
|
||||
|
||||
|
||||
def test_enter_dry_run_no_tmux():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "enter", "--dry-run", "--no-tmux", "personal@orb"],
|
||||
capture_output=True, text=True, env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "ssh" in result.stdout
|
||||
assert "tmux" not in result.stdout
|
||||
|
||||
|
||||
def test_enter_dry_run_with_user():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "enter", "--dry-run", "root@personal@orb"],
|
||||
capture_output=True, text=True, env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "root@personal.orb" in result.stdout
|
||||
|
||||
|
||||
def test_enter_dry_run_shows_terminfo_hint_for_ghostty():
|
||||
env = _clean_env()
|
||||
env["TERM"] = "xterm-ghostty"
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "enter", "--dry-run", "personal@orb"],
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "flow will not install or modify terminfo" in result.stdout
|
||||
assert "infocmp -x xterm-ghostty | ssh" in result.stdout
|
||||
|
||||
|
||||
def test_aliases():
|
||||
"""Test that command aliases work."""
|
||||
for alias, cmd in [("dot", "dotfiles"), ("pkg", "package"), ("setup", "bootstrap")]:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", alias, "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0, f"Alias '{alias}' failed"
|
||||
|
||||
|
||||
def test_dev_remove_alias():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "dev", "rm", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
@@ -1,130 +1,43 @@
|
||||
"""Tests for flow.commands.completion dynamic suggestions."""
|
||||
"""Tests for zsh completion."""
|
||||
|
||||
from flow.commands import completion
|
||||
from flow.commands.completion import complete
|
||||
|
||||
|
||||
def test_complete_top_level():
|
||||
result = complete(["flow", ""], 1)
|
||||
assert "dotfiles" in result
|
||||
assert "packages" in result
|
||||
assert "setup" in result
|
||||
assert "remote" in result
|
||||
assert "dev" in result
|
||||
assert "projects" in result
|
||||
|
||||
|
||||
def test_complete_top_level_prefix():
|
||||
out = completion.complete(["flow", "do"], 2)
|
||||
assert "dotfiles" in out
|
||||
assert "dot" in out
|
||||
result = complete(["flow", "do"], 1)
|
||||
assert result == ["dotfiles"]
|
||||
|
||||
|
||||
def test_complete_bootstrap_profiles(monkeypatch):
|
||||
monkeypatch.setattr(completion, "_list_bootstrap_profiles", lambda: ["linux-vm", "macos-host"])
|
||||
out = completion.complete(["flow", "bootstrap", "show", "li"], 4)
|
||||
assert out == ["linux-vm"]
|
||||
def test_complete_dotfiles_subcommands():
|
||||
result = complete(["flow", "dotfiles", ""], 2)
|
||||
assert "link" in result
|
||||
assert "unlink" in result
|
||||
assert "status" in result
|
||||
|
||||
|
||||
def test_complete_bootstrap_packages_options(monkeypatch):
|
||||
monkeypatch.setattr(completion, "_list_bootstrap_profiles", lambda: ["linux-vm", "macos-host"])
|
||||
out = completion.complete(["flow", "bootstrap", "packages", "--p"], 4)
|
||||
assert out == ["--profile"]
|
||||
|
||||
out = completion.complete(["flow", "bootstrap", "packages", "--profile", "m"], 5)
|
||||
assert out == ["macos-host"]
|
||||
def test_complete_dotfiles_link_flags():
|
||||
result = complete(["flow", "dotfiles", "link", "--"], 3)
|
||||
assert "--profile" in result
|
||||
assert "--dry-run" in result
|
||||
|
||||
|
||||
def test_complete_package_install(monkeypatch):
|
||||
monkeypatch.setattr(completion, "_list_manifest_packages", lambda: ["neovim", "fzf"])
|
||||
out = completion.complete(["flow", "package", "install", "n"], 4)
|
||||
assert out == ["neovim"]
|
||||
def test_complete_unknown_command():
|
||||
result = complete(["flow", "unknown", ""], 2)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_complete_package_remove(monkeypatch):
|
||||
monkeypatch.setattr(completion, "_list_installed_packages", lambda: ["hello", "jq"])
|
||||
out = completion.complete(["flow", "package", "remove", "h"], 4)
|
||||
assert out == ["hello"]
|
||||
|
||||
|
||||
def test_list_manifest_packages_is_consistent_for_list_and_dict_forms(monkeypatch):
|
||||
manifests = [
|
||||
{
|
||||
"packages": [
|
||||
{"name": "neovim", "type": "binary"},
|
||||
{"name": "ripgrep", "type": "pkg"},
|
||||
{"name": "fzf", "type": "binary"},
|
||||
]
|
||||
},
|
||||
{
|
||||
"packages": {
|
||||
"neovim": {"type": "binary"},
|
||||
"ripgrep": {"type": "pkg"},
|
||||
"fzf": {"type": "binary"},
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(completion, "_safe_manifest", lambda: manifests.pop(0))
|
||||
|
||||
from_list = completion._list_manifest_packages()
|
||||
from_dict = completion._list_manifest_packages()
|
||||
|
||||
assert from_list == ["fzf", "neovim"]
|
||||
assert from_dict == ["fzf", "neovim"]
|
||||
|
||||
|
||||
def test_list_manifest_packages_uses_mapping_key_when_name_missing(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
completion,
|
||||
"_safe_manifest",
|
||||
lambda: {"packages": {"bat": {"type": "binary"}, "git": {"type": "pkg"}}},
|
||||
)
|
||||
|
||||
assert completion._list_manifest_packages() == ["bat"]
|
||||
|
||||
|
||||
def test_complete_dotfiles_profile_value(monkeypatch):
|
||||
monkeypatch.setattr(completion, "_list_dotfiles_profiles", lambda: ["work", "personal"])
|
||||
out = completion.complete(["flow", "dotfiles", "link", "--profile", "w"], 5)
|
||||
assert out == ["work"]
|
||||
|
||||
|
||||
def test_complete_dotfiles_repo_subcommands():
|
||||
out = completion.complete(["flow", "dotfiles", "repo", "p"], 4)
|
||||
assert out == ["pull", "push"]
|
||||
|
||||
|
||||
def test_complete_dotfiles_top_level_includes_undo():
|
||||
out = completion.complete(["flow", "dotfiles", "u"], 3)
|
||||
assert out == ["undo", "unlink"]
|
||||
|
||||
|
||||
def test_complete_dotfiles_modules_subcommands():
|
||||
out = completion.complete(["flow", "dotfiles", "modules", "s"], 4)
|
||||
assert out == ["sync"]
|
||||
|
||||
|
||||
def test_complete_dotfiles_modules_profile_value(monkeypatch):
|
||||
monkeypatch.setattr(completion, "_list_dotfiles_profiles", lambda: ["work", "personal"])
|
||||
out = completion.complete(["flow", "dotfiles", "modules", "list", "--profile", "w"], 6)
|
||||
assert out == ["work"]
|
||||
|
||||
|
||||
def test_complete_enter_targets(monkeypatch):
|
||||
monkeypatch.setattr(completion, "_list_targets", lambda: ["personal@orb", "work@ec2"])
|
||||
out = completion.complete(["flow", "enter", "p"], 3)
|
||||
assert out == ["personal@orb"]
|
||||
|
||||
|
||||
def test_complete_dev_subcommands():
|
||||
out = completion.complete(["flow", "dev", "c"], 3)
|
||||
assert out == ["connect", "create"]
|
||||
|
||||
|
||||
def test_complete_completion_subcommands():
|
||||
out = completion.complete(["flow", "completion", "i"], 3)
|
||||
assert out == ["install-zsh"]
|
||||
|
||||
|
||||
def test_rc_snippet_is_idempotent(tmp_path):
|
||||
rc_path = tmp_path / ".zshrc"
|
||||
completion_dir = tmp_path / "completions"
|
||||
|
||||
first = completion._ensure_rc_snippet(rc_path, completion_dir)
|
||||
second = completion._ensure_rc_snippet(rc_path, completion_dir)
|
||||
|
||||
assert first is True
|
||||
assert second is False
|
||||
text = rc_path.read_text()
|
||||
assert text.count(completion.ZSH_RC_START) == 1
|
||||
assert text.count(completion.ZSH_RC_END) == 1
|
||||
def test_complete_packages_subcommands():
|
||||
result = complete(["flow", "packages", ""], 2)
|
||||
assert "install" in result
|
||||
assert "remove" in result
|
||||
assert "list" in result
|
||||
|
||||
Reference in New Issue
Block a user