"""flow package — binary package management from manifest definitions.""" import json import subprocess import sys from typing import Any, Dict, Optional, Tuple from flow.core.config import FlowContext from flow.core.paths import INSTALLED_STATE from flow.core.variables import substitute_template def register(subparsers): p = subparsers.add_parser("package", aliases=["pkg"], help="Manage binary packages") sub = p.add_subparsers(dest="package_command") # install 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) # list 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) # remove 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 INSTALLED_STATE.exists(): with open(INSTALLED_STATE) as f: return json.load(f) return {} def _save_installed(state: dict): INSTALLED_STATE.parent.mkdir(parents=True, exist_ok=True) with open(INSTALLED_STATE, "w") as f: json.dump(state, f, indent=2) def _get_definitions(ctx: FlowContext) -> dict: """Get package definitions from manifest (binaries section).""" return ctx.manifest.get("binaries", {}) def _resolve_download_url( pkg_def: Dict[str, Any], platform_str: str, ) -> Optional[Tuple[str, Dict[str, str]]]: """Build GitHub release download URL from package definition.""" source = pkg_def.get("source", "") if not source.startswith("github:"): return None owner_repo = source[len("github:"):] version = pkg_def.get("version", "") asset_pattern = pkg_def.get("asset-pattern", "") platform_map = pkg_def.get("platform-map", {}) mapping = platform_map.get(platform_str) if not mapping: return None # Build template context template_ctx = {**mapping, "version": version} asset = substitute_template(asset_pattern, template_ctx) url = f"https://github.com/{owner_repo}/releases/download/v{version}/{asset}" template_ctx["downloadUrl"] = url return url, template_ctx def run_install(ctx: FlowContext, args): definitions = _get_definitions(ctx) installed = _load_installed() platform_str = ctx.platform.platform had_error = False for pkg_name in args.packages: pkg_def = definitions.get(pkg_name) if not pkg_def: ctx.console.error(f"Package not found in manifest: {pkg_name}") had_error = True continue ctx.console.info(f"Installing {pkg_name} v{pkg_def.get('version', '?')}...") result = _resolve_download_url(pkg_def, platform_str) if not result: ctx.console.error(f"No download available for {pkg_name} on {platform_str}") had_error = True continue url, template_ctx = result if args.dry_run: ctx.console.info(f"[{pkg_name}] Would download: {url}") install_script = pkg_def.get("install-script", "") if install_script: ctx.console.info(f"[{pkg_name}] Would run install script") continue # Run install script with template vars resolved install_script = pkg_def.get("install-script", "") if not install_script: ctx.console.error(f"Package '{pkg_name}' has no install-script") had_error = True continue resolved_script = substitute_template(install_script, template_ctx) ctx.console.info(f"Running install script for {pkg_name}...") proc = subprocess.run( resolved_script, shell=True, capture_output=False, ) if proc.returncode != 0: ctx.console.error(f"Install script failed for {pkg_name}") had_error = True continue installed[pkg_name] = { "version": pkg_def.get("version", ""), "source": pkg_def.get("source", ""), } ctx.console.success(f"Installed {pkg_name} v{pkg_def.get('version', '')}") _save_installed(installed) if had_error: sys.exit(1) def run_list(ctx: FlowContext, args): definitions = _get_definitions(ctx) installed = _load_installed() headers = ["PACKAGE", "INSTALLED", "AVAILABLE"] rows = [] if args.all: # Show all defined packages if not definitions: ctx.console.info("No packages defined in manifest.") return for name, pkg_def in sorted(definitions.items()): inst_ver = installed.get(name, {}).get("version", "-") avail_ver = pkg_def.get("version", "?") rows.append([name, inst_ver, avail_ver]) else: # Show installed only if not installed: ctx.console.info("No packages installed.") return for name, info in sorted(installed.items()): avail = definitions.get(name, {}).get("version", "?") rows.append([name, info.get("version", "?"), avail]) ctx.console.table(headers, rows) def run_remove(ctx: FlowContext, args): installed = _load_installed() for pkg_name in args.packages: if pkg_name not in installed: ctx.console.warn(f"Package not installed: {pkg_name}") continue # Remove from installed state del installed[pkg_name] ctx.console.success(f"Removed {pkg_name} from installed packages") ctx.console.warn("Note: binary files were not automatically deleted. Remove manually if needed.") _save_installed(installed)