From beb5b49e25b70e2028e3d9a67a089210ff298a3d Mon Sep 17 00:00:00 2001 From: Simon Halvorsen Date: Thu, 6 Aug 2026 16:40:50 +0200 Subject: [PATCH 1/8] Added `cfengine show` to list your saved hosts --- src/cfengine_cli/cfengine_wrapper/arg_parse.py | 9 +++++++++ .../cfengine_wrapper/cfengine_commands.py | 11 ++++++++++- src/cfengine_cli/main.py | 2 ++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/cfengine_cli/cfengine_wrapper/arg_parse.py b/src/cfengine_cli/cfengine_wrapper/arg_parse.py index 3211243..32af191 100644 --- a/src/cfengine_cli/cfengine_wrapper/arg_parse.py +++ b/src/cfengine_cli/cfengine_wrapper/arg_parse.py @@ -11,6 +11,15 @@ def parse_wrapper_args(subp: argparse._SubParsersAction): + show_parser = subp.add_parser( + "show", help="Shows your saved host-groups or info about a specified host" + ) + show_parser.add_argument( + "--hosts", + "--host", + "-H", + help="Shows more specific information about specific host(s)", + ) add_save_args( subp.add_parser( diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py index a688739..35dbd36 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py @@ -2,9 +2,10 @@ from cfbs.commands import build_command from cf_remote import log -from cf_remote.commands import deploy as deploy_command +from cf_remote.commands import deploy as deploy_command, info from cf_remote.commands import destroy as destroy_command from cf_remote.commands import save as save_command +from cf_remote.commands import show as show_command from cf_remote.remote import run_command, transfer_file from cfengine_cli.utils import UserError @@ -242,3 +243,11 @@ def deploy(target: str | list[str] | None, masterfiles: str | None = None) -> in target = [target] hubs = [require_executable("cf-agent", h).location for h in (target or [])] or None return deploy_command(hubs, masterfiles) + + +def show(target: list[str] | None = None) -> int: + if target == [] or target is None: + return show_command(False) + if isinstance(target, str): + target = [target] + return info(target) diff --git a/src/cfengine_cli/main.py b/src/cfengine_cli/main.py index 1f4e9a2..20708cf 100644 --- a/src/cfengine_cli/main.py +++ b/src/cfengine_cli/main.py @@ -325,6 +325,8 @@ def run_command_with_args(args) -> int: return commands.profile(args) if args.command == "up": return commands.up(args) + if args.command == "show": + return cfengine_commands.show(args.hosts) raise UserError(f"Unknown command: '{args.command}'") From 76d13124a8eaf569a3695369f178acdb3c07aab5 Mon Sep 17 00:00:00 2001 From: Simon Halvorsen Date: Wed, 29 Jul 2026 12:36:24 +0200 Subject: [PATCH 2/8] Added support to specify hub for automatic deploy&run after `cfengine build` --- .../cfengine_wrapper/arg_parse.py | 21 ++++++++++--- .../cfengine_wrapper/cfengine_commands.py | 31 +++++++++++++++---- .../cfengine_wrapper/cfengine_utils.py | 4 ++- src/cfengine_cli/main.py | 4 +-- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/cfengine_cli/cfengine_wrapper/arg_parse.py b/src/cfengine_cli/cfengine_wrapper/arg_parse.py index 32af191..60fa253 100644 --- a/src/cfengine_cli/cfengine_wrapper/arg_parse.py +++ b/src/cfengine_cli/cfengine_wrapper/arg_parse.py @@ -38,18 +38,29 @@ def parse_wrapper_args(subp: argparse._SubParsersAction): default=None, ) - subp.add_parser( + sp = subp.add_parser( "build", - help="""Build a policy set from a CFEngine Build project. -A wrapper around the cfbs `build`-function.""", + help="Build a policy set from a CFEngine Build project", + description="A wrapper around the cf-remote `build`-function with some added niceties", + ) + sp.add_argument( + "--non-interactive", + help="Non-interactive mode (picks the default for all prompts)", + action="store_true", ) + sp.add_argument("--hub", help="Hub(s) to deploy to after building", type=str) deploy_parser = subp.add_parser( "deploy", - help="""Deploy policy-set (masterfiles) to hub. -A wrapper around the cf-remote `deploy`-function with some added niceties.""", + help="Deploy policy-set (masterfiles) to hub.", + description="A wrapper around the cf-remote `deploy`-function with some added niceties.", ) add_deploy_args(deploy_parser) + deploy_parser.add_argument( + "--non-interactive", + help="Non-interactive mode (picks the default for all prompts)", + action="store_true", + ) install_parser = subp.add_parser( "install", diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py index 35dbd36..8b87523 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py @@ -229,20 +229,39 @@ def destroy(groupname, del_all=False) -> int: return destroy_command(groupname) -def build() -> int: +def build(hub=None, non_interactive=False) -> int: rc = build_command() if rc != 0: return rc - if prompt_yes_no("Deploy the built policy set now?", default=True): - return deploy(None, None) + if prompt_yes_no("Deploy the built policy set now?", default=True, non_interactive=non_interactive): + return deploy(hub, None, non_interactive) return 0 -def deploy(target: str | list[str] | None, masterfiles: str | None = None) -> int: +def deploy( + target: str | list[str] | None, + masterfiles: str | None = None, + non_interactive: bool = False, +) -> int: + error = 0 if isinstance(target, str): target = [target] - hubs = [require_executable("cf-agent", h).location for h in (target or [])] or None - return deploy_command(hubs, masterfiles) + hubs = { + x.location: x + for h in (target or []) + for x in [require_executable("cf-agent", h)] + } or None + + # TODO/WOULD be nice: Deploy without run + if hubs: + error = deploy_command(hubs.keys(), masterfiles) + else: + return deploy_command(hubs, masterfiles) + + if prompt_yes_no("Run policy set now?", default=True, non_interactive=non_interactive): + for hub in hubs: + hubs[hub].run("-KIf update.cf", "-KI") + return error def show(target: list[str] | None = None) -> int: diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py index 4b16ee0..cf6b246 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py @@ -16,7 +16,9 @@ DEFAULT_MAX_REPORT_HOSTS = 25 -def prompt_yes_no(prompt: str, default: bool = True) -> bool: +def prompt_yes_no(prompt: str, default: bool = True, non_interactive=False) -> bool: + if non_interactive: + return default suffix = "[Y/n]" if default else "[y/N]" answer = input(f"{prompt} {suffix} ").strip().lower() if not answer: diff --git a/src/cfengine_cli/main.py b/src/cfengine_cli/main.py index 20708cf..2d1969a 100644 --- a/src/cfengine_cli/main.py +++ b/src/cfengine_cli/main.py @@ -222,9 +222,9 @@ def run_command_with_args(args) -> int: if args.command == "init": return commands.init(args) if args.command == "build": - return cfengine_commands.build() + return cfengine_commands.build(args.hub, args.non_interactive) if args.command == "deploy": - return cfengine_commands.deploy(args.hub, args.masterfiles) + return cfengine_commands.deploy(args.hub, args.masterfiles, args.non_interactive) if args.command == "format": return commands.format(args.files, args.line_length, args.check) if args.command == "lint": From 7e40beded50cbafc68afe2ce0214b38d16f74d78 Mon Sep 17 00:00:00 2001 From: Simon Halvorsen Date: Thu, 6 Aug 2026 17:37:01 +0200 Subject: [PATCH 3/8] Added `cfengine moduleinfo` to show module-information/status of a cfbs-project --- .../cfengine_wrapper/arg_parse.py | 12 ++++++++++ .../cfengine_wrapper/cfengine_commands.py | 24 ++++++++++++++++--- src/cfengine_cli/main.py | 2 ++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/cfengine_cli/cfengine_wrapper/arg_parse.py b/src/cfengine_cli/cfengine_wrapper/arg_parse.py index 60fa253..e146179 100644 --- a/src/cfengine_cli/cfengine_wrapper/arg_parse.py +++ b/src/cfengine_cli/cfengine_wrapper/arg_parse.py @@ -11,6 +11,18 @@ def parse_wrapper_args(subp: argparse._SubParsersAction): + moduleinfo_parser = subp.add_parser( + "moduleinfo", + help="Shows information about your cfbs-project or a specific module", + description="A wrapper around the cfbs `status` function", + ) + + moduleinfo_parser.add_argument( + "modules", + nargs="*", + help="Module(s) for which you would like more info, utilizes cfbs `info` function", + ) + show_parser = subp.add_parser( "show", help="Shows your saved host-groups or info about a specified host" ) diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py index 8b87523..b08579e 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py @@ -1,6 +1,7 @@ import os -from cfbs.commands import build_command +import cfbs +from cfbs.commands import build_command, info_command, status_command from cf_remote import log from cf_remote.commands import deploy as deploy_command, info from cf_remote.commands import destroy as destroy_command @@ -233,7 +234,11 @@ def build(hub=None, non_interactive=False) -> int: rc = build_command() if rc != 0: return rc - if prompt_yes_no("Deploy the built policy set now?", default=True, non_interactive=non_interactive): + if prompt_yes_no( + "Deploy the built policy set now?", + default=True, + non_interactive=non_interactive, + ): return deploy(hub, None, non_interactive) return 0 @@ -258,7 +263,9 @@ def deploy( else: return deploy_command(hubs, masterfiles) - if prompt_yes_no("Run policy set now?", default=True, non_interactive=non_interactive): + if prompt_yes_no( + "Run policy set now?", default=True, non_interactive=non_interactive + ): for hub in hubs: hubs[hub].run("-KIf update.cf", "-KI") return error @@ -270,3 +277,14 @@ def show(target: list[str] | None = None) -> int: if isinstance(target, str): target = [target] return info(target) + + +def moduleinfo(modules: list[str]) -> int: + if modules == []: + try: + status_command() + except Exception as e: + log.error(f"Failed to validate cfbs-status, make sure you are inside a cfbs-project: {e}") + return -1 + + return info_command(modules) diff --git a/src/cfengine_cli/main.py b/src/cfengine_cli/main.py index 2d1969a..60e40dd 100644 --- a/src/cfengine_cli/main.py +++ b/src/cfengine_cli/main.py @@ -327,6 +327,8 @@ def run_command_with_args(args) -> int: return commands.up(args) if args.command == "show": return cfengine_commands.show(args.hosts) + if args.command == "moduleinfo": + return cfengine_commands.moduleinfo(args.modules) raise UserError(f"Unknown command: '{args.command}'") From 8683f5fdd9010b58c2417653935183d8c5c32d67 Mon Sep 17 00:00:00 2001 From: Simon Halvorsen Date: Fri, 7 Aug 2026 11:39:46 +0200 Subject: [PATCH 4/8] Added `cfengine connect` to open a SSH-session to a saved host --- src/cfengine_cli/cfengine_wrapper/arg_parse.py | 9 +++++++++ src/cfengine_cli/cfengine_wrapper/cfengine_commands.py | 9 ++++++++- src/cfengine_cli/main.py | 2 ++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/cfengine_cli/cfengine_wrapper/arg_parse.py b/src/cfengine_cli/cfengine_wrapper/arg_parse.py index e146179..3381044 100644 --- a/src/cfengine_cli/cfengine_wrapper/arg_parse.py +++ b/src/cfengine_cli/cfengine_wrapper/arg_parse.py @@ -7,10 +7,19 @@ add_uninstall_args, add_spawn_args, add_destroy_args, + add_connect_args, ) def parse_wrapper_args(subp: argparse._SubParsersAction): + add_connect_args( + subp.add_parser( + "connect", + help="Opens interactive ssh shell", + description="A wrapper around cf-remote `connect` function", + ) + ) + moduleinfo_parser = subp.add_parser( "moduleinfo", help="Shows information about your cfbs-project or a specific module", diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py index b08579e..dbdbd7c 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py @@ -8,6 +8,7 @@ from cf_remote.commands import save as save_command from cf_remote.commands import show as show_command from cf_remote.remote import run_command, transfer_file +from cf_remote.commands import connect_cmd from cfengine_cli.utils import UserError from cfengine_cli.cfengine_wrapper.cfengine_objects import ( @@ -284,7 +285,13 @@ def moduleinfo(modules: list[str]) -> int: try: status_command() except Exception as e: - log.error(f"Failed to validate cfbs-status, make sure you are inside a cfbs-project: {e}") + log.error( + f"Failed to validate cfbs-status, make sure you are inside a cfbs-project: {e}" + ) return -1 return info_command(modules) + + +def connect(host) -> int: + return connect_cmd(host) diff --git a/src/cfengine_cli/main.py b/src/cfengine_cli/main.py index 60e40dd..b9912df 100644 --- a/src/cfengine_cli/main.py +++ b/src/cfengine_cli/main.py @@ -329,6 +329,8 @@ def run_command_with_args(args) -> int: return cfengine_commands.show(args.hosts) if args.command == "moduleinfo": return cfengine_commands.moduleinfo(args.modules) + if args.command == "connect": + return cfengine_commands.connect(args.hosts) raise UserError(f"Unknown command: '{args.command}'") From 68833f5b7563bc5ca4b361ac090a233acb9e2bf7 Mon Sep 17 00:00:00 2001 From: Simon Halvorsen Date: Fri, 7 Aug 2026 14:12:50 +0200 Subject: [PATCH 5/8] Added `cfengine input` to set input for specified module --- src/cfengine_cli/cfengine_wrapper/arg_parse.py | 11 +++++++++++ .../cfengine_wrapper/cfengine_commands.py | 6 +++++- src/cfengine_cli/main.py | 2 ++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/cfengine_cli/cfengine_wrapper/arg_parse.py b/src/cfengine_cli/cfengine_wrapper/arg_parse.py index 3381044..3e5f56b 100644 --- a/src/cfengine_cli/cfengine_wrapper/arg_parse.py +++ b/src/cfengine_cli/cfengine_wrapper/arg_parse.py @@ -12,6 +12,17 @@ def parse_wrapper_args(subp: argparse._SubParsersAction): + input_parser = subp.add_parser( + "input", + help="Sets/updates input.json for selected module(s)", + description="A wrapper around the cfbs `input` function", + ) + input_parser.add_argument( + "module", + nargs="+", + help="Module(s) for which to set input", + ) + add_connect_args( subp.add_parser( "connect", diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py index dbdbd7c..d3e9300 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py @@ -1,6 +1,5 @@ import os -import cfbs from cfbs.commands import build_command, info_command, status_command from cf_remote import log from cf_remote.commands import deploy as deploy_command, info @@ -9,6 +8,7 @@ from cf_remote.commands import show as show_command from cf_remote.remote import run_command, transfer_file from cf_remote.commands import connect_cmd +from cfbs.commands import input_command from cfengine_cli.utils import UserError from cfengine_cli.cfengine_wrapper.cfengine_objects import ( @@ -295,3 +295,7 @@ def moduleinfo(modules: list[str]) -> int: def connect(host) -> int: return connect_cmd(host) + + +def cfbs_input(modules: list[str] | None = None) -> int: + return input_command(modules, "cfengine input") diff --git a/src/cfengine_cli/main.py b/src/cfengine_cli/main.py index b9912df..e0a85da 100644 --- a/src/cfengine_cli/main.py +++ b/src/cfengine_cli/main.py @@ -225,6 +225,8 @@ def run_command_with_args(args) -> int: return cfengine_commands.build(args.hub, args.non_interactive) if args.command == "deploy": return cfengine_commands.deploy(args.hub, args.masterfiles, args.non_interactive) + if args.command == "input": + return cfengine_commands.cfbs_input(args.module) if args.command == "format": return commands.format(args.files, args.line_length, args.check) if args.command == "lint": From 48674ee176d0a79eeac48f77c31b043409e4fe95 Mon Sep 17 00:00:00 2001 From: Simon Halvorsen Date: Fri, 7 Aug 2026 14:52:54 +0200 Subject: [PATCH 6/8] Fixed help-descriptor for `cfengine run` --- src/cfengine_cli/cfengine_wrapper/arg_parse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cfengine_cli/cfengine_wrapper/arg_parse.py b/src/cfengine_cli/cfengine_wrapper/arg_parse.py index 3e5f56b..bf6b862 100644 --- a/src/cfengine_cli/cfengine_wrapper/arg_parse.py +++ b/src/cfengine_cli/cfengine_wrapper/arg_parse.py @@ -133,8 +133,8 @@ def parse_wrapper_args(subp: argparse._SubParsersAction): run_parser = subp.add_parser( "run", - description="Run the CFEngine agent, fetching, evaluating, and enforcing policy.\n\ -A wrapper around the cf-remote `run`-function with some added niceties", + help="Run the CFEngine agent, fetching, evaluating, and enforcing policy.", + description="A wrapper around the cf-remote `run`-function with some added niceties", epilog="""Examples: `cfengine run` defaults to use `cf-agent -KIf update.cf && cf-agent -KI` From e8fa6bfec1d35ffa6d610f6c60a8dd1e720651d5 Mon Sep 17 00:00:00 2001 From: Simon Halvorsen Date: Fri, 7 Aug 2026 15:23:23 +0200 Subject: [PATCH 7/8] Added `cfengine add/remove/search/update` to add/remove/search for modules or update cfbs project --- .../cfengine_wrapper/arg_parse.py | 42 +++++++++++++++++++ .../cfengine_wrapper/cfengine_commands.py | 26 +++++++++++- src/cfengine_cli/main.py | 12 +++++- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/cfengine_cli/cfengine_wrapper/arg_parse.py b/src/cfengine_cli/cfengine_wrapper/arg_parse.py index bf6b862..a00df17 100644 --- a/src/cfengine_cli/cfengine_wrapper/arg_parse.py +++ b/src/cfengine_cli/cfengine_wrapper/arg_parse.py @@ -12,6 +12,48 @@ def parse_wrapper_args(subp: argparse._SubParsersAction): + update_parser = subp.add_parser( + "update", + help="Updates the current cfbs project", + description="A wrapper around the cfbs `update` function", + ) + update_parser.add_argument( + "to_update", + nargs="*", + help="Directory of cfbs-project to update", + ) + remove_parser = subp.add_parser( + "remove", + help="Removes the specified module(s) from cfbs project", + description="A wrapper around the cfbs `remove` function", + ) + remove_parser.add_argument( + "module", + nargs="+", + help="Module(s) for which to remove", + ) + + add_parser = subp.add_parser( + "add", + help="Adds the specified module(s) to cfbs project", + description="A wrapper around the cfbs `add` function", + ) + add_parser.add_argument( + "module", + nargs="+", + help="Module(s) for which to add", + ) + search_parser = subp.add_parser( + "search", + help="Searches the build-index for specified module(s)", + description="A wrapper around the cfbs `search` function", + ) + search_parser.add_argument( + "module", + nargs="+", + help="Module(s) for which to lookup", + ) + input_parser = subp.add_parser( "input", help="Sets/updates input.json for selected module(s)", diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py index d3e9300..3447b19 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_commands.py @@ -8,7 +8,13 @@ from cf_remote.commands import show as show_command from cf_remote.remote import run_command, transfer_file from cf_remote.commands import connect_cmd -from cfbs.commands import input_command +from cfbs.commands import ( + input_command, + add_command, + remove_command, + update_command, + search_command, +) from cfengine_cli.utils import UserError from cfengine_cli.cfengine_wrapper.cfengine_objects import ( @@ -297,5 +303,21 @@ def connect(host) -> int: return connect_cmd(host) -def cfbs_input(modules: list[str] | None = None) -> int: +def cfbs_input(modules: list[str]) -> int: return input_command(modules, "cfengine input") + + +def cfbs_add(modules: list[str]) -> int: + return add_command(modules, "cfengine input") + + +def cfbs_remove(modules: list[str] | None = None) -> int: + return remove_command(modules, "cfengine input") + + +def cfbs_update(to_update) -> int: + return update_command(to_update) + + +def cfbs_search(modules: list[str]) -> int: + return search_command(modules) diff --git a/src/cfengine_cli/main.py b/src/cfengine_cli/main.py index e0a85da..c8e8c5a 100644 --- a/src/cfengine_cli/main.py +++ b/src/cfengine_cli/main.py @@ -224,9 +224,19 @@ def run_command_with_args(args) -> int: if args.command == "build": return cfengine_commands.build(args.hub, args.non_interactive) if args.command == "deploy": - return cfengine_commands.deploy(args.hub, args.masterfiles, args.non_interactive) + return cfengine_commands.deploy( + args.hub, args.masterfiles, args.non_interactive + ) if args.command == "input": return cfengine_commands.cfbs_input(args.module) + if args.command == "add": + return cfengine_commands.cfbs_add(args.module) + if args.command == "remove": + return cfengine_commands.cfbs_remove(args.module) + if args.command == "search": + return cfengine_commands.cfbs_search(args.module) + if args.command == "update": + return cfengine_commands.cfbs_update(args.to_update) if args.command == "format": return commands.format(args.files, args.line_length, args.check) if args.command == "lint": From a26b902a10740c1796482e38ed83790daff7fd3e Mon Sep 17 00:00:00 2001 From: Simon Halvorsen Date: Fri, 7 Aug 2026 17:01:25 +0200 Subject: [PATCH 8/8] Changed selection strategy as to not resolve all hosts if --host HOST is specified Ticket: None Changelog: Title Signed-off-by: Simon Halvorsen --- .../cfengine_wrapper/cfengine_utils.py | 70 +++++++++++++++---- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py index cf6b246..17e5ca3 100644 --- a/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py +++ b/src/cfengine_cli/cfengine_wrapper/cfengine_utils.py @@ -1,3 +1,4 @@ +from collections import namedtuple import os import shutil import random @@ -105,21 +106,34 @@ def _hosts_with_info(role_filter=None): yield host, aliases, data -def _find_all(binary_name: str) -> list[Executable]: - """Every location -- local, plus every matching remote host -- with `binary_name` installed.""" - executables = [] +_Id = namedtuple("_Id", "location aliases") + + +def _identities(binary_name: str) -> Iterator[_Id]: + """local + every known host as (location, aliases), without connecting.""" + yield _Id("local", []) + for host, aliases in _known_hosts(None if binary_name == "cf-agent" else "hub"): + yield _Id(host, aliases) + + +def _resolve(binary_name: str, ident: _Id) -> Executable | None: + """Connect (if remote) and build an Executable, or None if unavailable.""" + if ident.location == "local": + path = _find_local_path(binary_name) + return Executable(binary_name, "local", path) if path else None + data = _host_info(ident.location) + if not data: + return None + # band-aid: hostinfo has no path for cf-hub, so assume it's on PATH + path = data.get("agent") if binary_name == "cf-agent" else "cf-hub" + return ( + Executable(binary_name, ident.location, path, ident.aliases) if path else None + ) - local_path = _find_local_path(binary_name) - if local_path: - executables.append(Executable(binary_name, "local", local_path)) - is_agent = binary_name == "cf-agent" - for host, aliases, data in _hosts_with_info(None if is_agent else "hub"): - # band-aid: hostinfo has no path for cf-hub, so assume it's on PATH - path = data.get("agent") if is_agent else "cf-hub" - if path: - executables.append(Executable(binary_name, host, path, aliases)) - return executables +def _find_all(binary_name: str) -> list[Executable]: + """Every location with `binary_name` installed (connects to all).""" + return [e for i in _identities(binary_name) if (e := _resolve(binary_name, i))] def _find_all_paired() -> list[Installation]: @@ -197,7 +211,35 @@ def _select(candidates, description, target: str | None = None): def require_executable(name: str, target: str | None = None) -> Executable: - chosen = _select(_find_all(name), name, target) + if isinstance(target, list): + if len(target) > 1: + raise UserError( + f"Expected a single {name}, but got {len(target)}: {', '.join(target)}." + ) + target = target[0] if target else None + + if target: + idents = list(_identities(name)) + matched = [i for i in idents if _exact_match(i, target)] or [ + i for i in idents if _loose_match(i, target) + ] + if not matched: + raise UserError( + f"No installation of {name} matches '{target}'. " + f"Known: {', '.join(i.location for i in idents)}." + ) + candidates = [e for i in matched if (e := _resolve(name, i))] + if not candidates: + raise UserError( + f"'{target}' matches a known host for {name}, but it is " + f"unreachable or lacks {name}." + ) + chosen = ( + candidates[0] if len(candidates) == 1 else _prompt_choice(candidates, name) + ) + else: + chosen = _select(_find_all(name), name) + log.info( f"Using {'local' if chosen.is_local else 'remote'} installation of {name} ({chosen.label})" )