"""flow bootstrap — environment provisioning with plan-then-execute model.""" import os import shutil import subprocess import sys from pathlib import Path from typing import Any, Dict, List from flow.core.action import Action, ActionExecutor from flow.core.config import FlowContext, load_manifest from flow.core.paths import DOTFILES_DIR from flow.core.process import run_command from flow.core.variables import substitute def register(subparsers): p = subparsers.add_parser( "bootstrap", aliases=["setup", "provision"], help="Environment provisioning", ) sub = p.add_subparsers(dest="bootstrap_command") # run run_p = sub.add_parser("run", help="Run bootstrap actions") run_p.add_argument("--profile", help="Profile name to use") run_p.add_argument("--dry-run", action="store_true", help="Show plan without executing") run_p.add_argument("--var", action="append", default=[], help="Set variable KEY=VALUE") run_p.set_defaults(handler=run_bootstrap) # list ls = sub.add_parser("list", help="List available profiles") ls.set_defaults(handler=run_list) # show show = sub.add_parser("show", help="Show profile configuration") show.add_argument("profile", help="Profile name") show.set_defaults(handler=run_show) p.set_defaults(handler=lambda ctx, args: p.print_help()) def _get_profiles(ctx: FlowContext) -> dict: profiles = ctx.manifest.get("profiles") if profiles is None: if "environments" in ctx.manifest: raise RuntimeError( "Manifest key 'environments' is no longer supported. Rename it to 'profiles'." ) return {} if not isinstance(profiles, dict): raise RuntimeError("Manifest key 'profiles' must be a mapping") return profiles def _parse_variables(var_args: list) -> dict: variables = {} for v in var_args: if "=" not in v: raise ValueError(f"Invalid --var value '{v}'. Expected KEY=VALUE") key, value = v.split("=", 1) if not key: raise ValueError(f"Invalid --var value '{v}'. KEY cannot be empty") variables[key] = value return variables def _plan_actions(ctx: FlowContext, profile_name: str, env_config: dict, variables: dict) -> List[Action]: """Plan all actions from a profile configuration.""" actions = [] # Variable checks for req_var in env_config.get("requires", []): actions.append(Action( type="check-variable", description=f"Check required variable: {req_var}", data={"variable": req_var, "variables": variables}, skip_on_error=False, )) # Hostname if "hostname" in env_config: hostname = substitute(env_config["hostname"], variables) actions.append(Action( type="set-hostname", description=f"Set hostname to: {hostname}", data={"hostname": hostname}, skip_on_error=False, )) # Locale if "locale" in env_config: actions.append(Action( type="set-locale", description=f"Set locale to: {env_config['locale']}", data={"locale": env_config["locale"]}, skip_on_error=True, os_filter="linux", )) # Shell if "shell" in env_config: actions.append(Action( type="set-shell", description=f"Set shell to: {env_config['shell']}", data={"shell": env_config["shell"]}, skip_on_error=True, os_filter="linux", )) # Packages if "packages" in env_config: packages_config = env_config["packages"] pm = env_config.get("package-manager", "apt-get") # Package manager update actions.append(Action( type="pm-update", description=f"Update {pm} package repositories", data={"pm": pm}, skip_on_error=False, )) # Standard packages standard = [] for pkg in packages_config.get("standard", []) + packages_config.get("package", []): if isinstance(pkg, str): standard.append(pkg) else: standard.append(pkg["name"]) if standard: actions.append(Action( type="install-packages", description=f"Install {len(standard)} packages via {pm}", data={"pm": pm, "packages": standard, "type": "standard"}, skip_on_error=False, )) # Cask packages (macOS) cask = [] for pkg in packages_config.get("cask", []): if isinstance(pkg, str): cask.append(pkg) else: cask.append(pkg["name"]) if cask: actions.append(Action( type="install-packages", description=f"Install {len(cask)} cask packages via {pm}", data={"pm": pm, "packages": cask, "type": "cask"}, skip_on_error=False, os_filter="macos", )) # Binary packages binaries_manifest = ctx.manifest.get("binaries", {}) for pkg in packages_config.get("binary", []): pkg_name = pkg if isinstance(pkg, str) else pkg["name"] binary_def = binaries_manifest.get(pkg_name, {}) actions.append(Action( type="install-binary", description=f"Install binary: {pkg_name}", data={"name": pkg_name, "definition": binary_def, "spec": pkg if isinstance(pkg, dict) else {}}, skip_on_error=True, )) # SSH keygen for ssh_config in env_config.get("ssh_keygen", []): filename = ssh_config.get("filename", f"id_{ssh_config['type']}") actions.append(Action( type="generate-ssh-key", description=f"Generate SSH key: {filename}", data=ssh_config, skip_on_error=True, )) # Config linking for config in env_config.get("configs", []): config_name = config if isinstance(config, str) else config["name"] actions.append(Action( type="link-config", description=f"Link configuration: {config_name}", data={"config_name": config_name}, skip_on_error=True, )) # Custom commands for i, command in enumerate(env_config.get("runcmd", []), 1): actions.append(Action( type="run-command", description=f"Run custom command {i}", data={"command": command}, skip_on_error=True, )) return actions def _register_handlers(executor: ActionExecutor, ctx: FlowContext, variables: dict): """Register all action type handlers.""" def handle_check_variable(data): var = data["variable"] if var not in data.get("variables", {}): raise RuntimeError(f"Required variable not set: {var}") def handle_set_hostname(data): hostname = data["hostname"] if ctx.platform.os == "macos": run_command(f"sudo scutil --set ComputerName '{hostname}'", ctx.console) run_command(f"sudo scutil --set HostName '{hostname}'", ctx.console) run_command(f"sudo scutil --set LocalHostName '{hostname}'", ctx.console) else: run_command(f"sudo hostnamectl set-hostname '{hostname}'", ctx.console) def handle_set_locale(data): locale = data["locale"] run_command(f"sudo locale-gen {locale}", ctx.console) run_command(f"sudo update-locale LANG={locale}", ctx.console) def handle_set_shell(data): shell = data["shell"] shell_path = shutil.which(shell) if not shell_path: raise RuntimeError(f"Shell not found: {shell}") try: with open("/etc/shells") as f: if shell_path not in f.read(): run_command(f"echo '{shell_path}' | sudo tee -a /etc/shells", ctx.console) except FileNotFoundError: pass run_command(f"chsh -s {shell_path}", ctx.console) def handle_pm_update(data): pm = data["pm"] commands = { "apt-get": "sudo apt-get update -qq", "apt": "sudo apt update -qq", "brew": "brew update", } cmd = commands.get(pm, f"sudo {pm} update") run_command(cmd, ctx.console) def handle_install_packages(data): pm = data["pm"] packages = data["packages"] pkg_type = data.get("type", "standard") pkg_str = " ".join(packages) if pm in ("apt-get", "apt"): cmd = f"sudo {pm} install -y {pkg_str}" elif pm == "brew" and pkg_type == "cask": cmd = f"brew install --cask {pkg_str}" elif pm == "brew": cmd = f"brew install {pkg_str}" else: cmd = f"sudo {pm} install {pkg_str}" run_command(cmd, ctx.console) def handle_install_binary(data): from flow.core.variables import substitute_template pkg_name = data["name"] pkg_def = data["definition"] if not pkg_def: raise RuntimeError(f"No binary definition for: {pkg_name}") source = pkg_def.get("source", "") if not source.startswith("github:"): raise RuntimeError(f"Unsupported source: {source}") 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(ctx.platform.platform) if not mapping: raise RuntimeError(f"No platform mapping for {ctx.platform.platform}") 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 install_script = pkg_def.get("install-script", "") if install_script: resolved = substitute_template(install_script, template_ctx) run_command(resolved, ctx.console) def handle_generate_ssh_key(data): ssh_dir = Path.home() / ".ssh" ssh_dir.mkdir(mode=0o700, exist_ok=True) key_type = data["type"] comment = substitute(data.get("comment", ""), variables) filename = data.get("filename", f"id_{key_type}") key_path = ssh_dir / filename if key_path.exists(): ctx.console.warn(f"SSH key already exists: {key_path}") return run_command(f'ssh-keygen -t {key_type} -f "{key_path}" -N "" -C "{comment}"', ctx.console) def handle_link_config(data): config_name = data["config_name"] ctx.console.info(f"Linking config: {config_name}") def handle_run_command(data): command = substitute(data["command"], variables) run_command(command, ctx.console) executor.register("check-variable", handle_check_variable) executor.register("set-hostname", handle_set_hostname) executor.register("set-locale", handle_set_locale) executor.register("set-shell", handle_set_shell) executor.register("pm-update", handle_pm_update) executor.register("install-packages", handle_install_packages) executor.register("install-binary", handle_install_binary) executor.register("generate-ssh-key", handle_generate_ssh_key) executor.register("link-config", handle_link_config) executor.register("run-command", handle_run_command) def run_bootstrap(ctx: FlowContext, args): # Check if flow package exists in dotfiles and link it first flow_pkg = DOTFILES_DIR / "common" / "flow" if flow_pkg.exists() and (flow_pkg / ".config" / "flow").exists(): ctx.console.info("Found flow config in dotfiles, linking...") # Link flow package first result = subprocess.run( [sys.executable, "-m", "flow", "dotfiles", "link", "flow"], capture_output=True, text=True, ) if result.returncode == 0: ctx.console.success("Flow config linked from dotfiles") # Reload manifest from newly linked location ctx.manifest = load_manifest() else: detail = (result.stderr or "").strip() or (result.stdout or "").strip() or "unknown error" ctx.console.warn(f"Failed to link flow config: {detail}") profiles = _get_profiles(ctx) if not profiles: ctx.console.error("No profiles found in manifest.") sys.exit(1) profile_name = args.profile if not profile_name: if len(profiles) == 1: profile_name = next(iter(profiles)) else: ctx.console.error(f"Multiple profiles available. Specify with --profile: {', '.join(profiles.keys())}") sys.exit(1) if profile_name not in profiles: ctx.console.error(f"Profile not found: {profile_name}. Available: {', '.join(profiles.keys())}") sys.exit(1) env_config = profiles[profile_name] profile_os = env_config.get("os") if profile_os and profile_os != ctx.platform.os: ctx.console.error( f"Profile '{profile_name}' targets '{profile_os}', current OS is '{ctx.platform.os}'" ) sys.exit(1) try: variables = _parse_variables(args.var) except ValueError as e: ctx.console.error(str(e)) sys.exit(1) actions = _plan_actions(ctx, profile_name, env_config, variables) executor = ActionExecutor(ctx.console) _register_handlers(executor, ctx, variables) executor.execute(actions, dry_run=args.dry_run, current_os=ctx.platform.os) def run_list(ctx: FlowContext, args): profiles = _get_profiles(ctx) if not profiles: ctx.console.info("No profiles defined in manifest.") return headers = ["PROFILE", "OS", "PACKAGES", "ACTIONS"] rows = [] for name, config in sorted(profiles.items()): os_name = config.get("os", "any") pkg_count = 0 for section in config.get("packages", {}).values(): if isinstance(section, list): pkg_count += len(section) action_count = sum(1 for k in ("hostname", "locale", "shell") if k in config) action_count += len(config.get("ssh_keygen", [])) action_count += len(config.get("configs", [])) action_count += len(config.get("runcmd", [])) rows.append([name, os_name, str(pkg_count), str(action_count)]) ctx.console.table(headers, rows) def run_show(ctx: FlowContext, args): profiles = _get_profiles(ctx) profile_name = args.profile if profile_name not in profiles: ctx.console.error(f"Profile not found: {profile_name}. Available: {', '.join(profiles.keys())}") sys.exit(1) env_config = profiles[profile_name] variables = {} actions = _plan_actions(ctx, profile_name, env_config, variables) executor = ActionExecutor(ctx.console) executor.execute(actions, dry_run=True)