From 8c64ea90b67c5165ced68cd51bd7b04e176f9b3b Mon Sep 17 00:00:00 2001 From: Rojenson Lugo Date: Tue, 4 Aug 2026 17:24:40 +0800 Subject: [PATCH] feat: open a shell when 'wco exec' gets no command 'wco exec 2.1' now drops into an interactive shell instead of failing with a Compose usage error. The shell is chosen inside the container by a 'sh -c' shim that execs the first candidate present, defaulting to bash then sh and configurable per workspace via a new [shell] table. Also splice the service resolved from a container ID in after exec's own options rather than directly after the command: Compose stops reading options at the first positional argument, so 'wco exec 2.1 -u root whoami' previously produced 'exec php -u root whoami' and failed. Other commands keep the original position. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WvpyWdYg51uCtmwTnSqPrn --- CHANGELOG.md | 17 +++++ README.md | 21 ++++++ src/wco/cli.py | 110 +++++++++++++++++++++++++++-- tests/test_cli.py | 175 +++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 316 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58eaf1a..79690a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Make the command optional in `wco exec`. `wco exec 2.1` — or `wco exec php` — + now opens an interactive shell instead of failing with a Compose usage error, + picking the first shell that exists inside the container. The candidates + default to `bash` then `sh` and are configurable per workspace with a new + `[shell]` table in `.wco.toml`. Passing a command keeps the previous + behaviour. + +### Fixed + +- Splice the service resolved from a container ID in after `exec`'s own options + rather than directly after the command. Docker Compose stops reading options + at the first positional argument, so `wco exec 2.1 -u root whoami` previously + produced `exec php -u root whoami` and failed with `"-u": executable file not + found in $PATH`. Other commands are unchanged. + ## [1.3.0] - 2026-08-03 ### Added diff --git a/README.md b/README.md index 854c89c..245d9e3 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ wco down 2 # bring down stack 2, wherever you are wco restart 2.1 # restart just that container's service wco logs 2.1 -f # ...the ID comes first, then the flags wco exec 2.1 sh +wco exec 2.1 # ...or omit the command to drop into a shell ``` A container ID resolves to its Compose service name, so it works with any command that accepts @@ -151,6 +152,21 @@ named `2` cannot be targeted this way. Stack IDs are recorded alongside port slo (see [Port assignments and state](#port-assignments-and-state)) and are reused once the worktree they name is gone. +**Opening a shell.** `wco exec` accepts a command as usual, but when none is given it opens an +interactive shell in the target container: + +```bash +wco exec 2.1 # a shell in that container +wco exec php # a shell in the shared stack's php service +wco exec -u root 2.1 # a root shell; flags are preserved +wco exec 2.1 whoami # an explicit command still runs as-is +``` + +WCO does not guess which shell the image ships. It execs the first candidate that exists inside the +container, defaulting to `bash` and falling back to `sh`, so the same command works on Alpine and +Debian-based images. Set `[shell]` in `.wco.toml` (see +[Configuration reference](#configuration-reference)) to change the order or add one, such as `zsh`. + **Where containers run.** The `WORKTREE` column reports the worktree each listed container was actually created from — not the one you are standing in — and is highlighted when the two differ. `BRANCH` shows that worktree's checked-out branch, or the short revision in parentheses when the @@ -239,6 +255,11 @@ rewrite_ports = true HTTP_PORT = 8080 DEV_PORT = 5173 REDIS_PORT = 6379 + +[shell] +# Shells 'wco exec' tries, in order, when no command is given. +default = "bash" +fallback = ["sh"] ``` `instance_name` defaults to `project_name`. All validation lists are optional and may be empty. diff --git a/src/wco/cli.py b/src/wco/cli.py index eb0ca43..4ec011c 100644 --- a/src/wco/cli.py +++ b/src/wco/cli.py @@ -4,6 +4,7 @@ import json import os import re +import shlex import socket import string import subprocess @@ -31,6 +32,9 @@ PROJECT_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") CONTAINER_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") ENVIRONMENT_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +SHELL_RE = re.compile(r"^[A-Za-z0-9._/-]+$") +DEFAULT_SHELL = "bash" +DEFAULT_SHELL_FALLBACK = ("sh",) STARTUP_COMMANDS = ("up", "create", "start", "restart", "run", "watch") BUILD_CONTEXT_COMMANDS = {"build", "up", "create", "run", "watch"} COMPOSE_COMMANDS = { @@ -79,6 +83,7 @@ "--progress", "--project-directory", } +EXEC_OPTIONS_WITH_VALUES = {"-e", "--env", "-u", "--user", "-w", "--workdir", "--index"} RESERVED_PROJECT_OPTIONS = {"-p", "--project-name"} ALLOWED_TEMPLATE_FIELDS = {"worktree", "workspace", "project", "instance"} COMPOSE_FILE_CANDIDATES = ( @@ -123,6 +128,13 @@ class ValidationConfig: startup_commands: tuple[str, ...] +@dataclass(frozen=True) +class ShellConfig: + """The shells 'wco exec ' tries, in order, when no command is given.""" + + candidates: tuple[str, ...] + + @dataclass(frozen=True) class WorkspaceConfig: path: Path @@ -133,6 +145,7 @@ class WorkspaceConfig: environment: dict[str, str] validation: ValidationConfig isolation: IsolationConfig + shell: ShellConfig @dataclass(frozen=True) @@ -236,7 +249,11 @@ def load_config(path: Path) -> WorkspaceConfig: if not isinstance(data, dict): raise WcoError(f"{path} must contain a TOML table.") - _only_keys(data, {"version", "compose", "environment", "validation", "isolation"}, "root") + _only_keys( + data, + {"version", "compose", "environment", "validation", "isolation", "shell"}, + "root", + ) if data.get("version") != 1: raise WcoError(f"{path} must declare 'version = 1'.") @@ -339,6 +356,24 @@ def load_config(path: Path) -> WorkspaceConfig: f"{', '.join(map(str, duplicate_ports))}." ) + shell_table = _table(data, "shell") + _only_keys(shell_table, {"default", "fallback"}, "[shell]") + default_shell = shell_table.get("default", DEFAULT_SHELL) + if not isinstance(default_shell, str): + raise WcoError("'shell.default' must be a string.") + fallback_shells = _string_list( + shell_table.get("fallback"), "shell.fallback", DEFAULT_SHELL_FALLBACK + ) + shell_candidates: list[str] = [] + for candidate in (default_shell, *fallback_shells): + if not SHELL_RE.fullmatch(candidate): + raise WcoError( + f"Shell '{candidate}' must contain only letters, digits, '.', '_', " + "'-' or '/'." + ) + if candidate not in shell_candidates: + shell_candidates.append(candidate) + return WorkspaceConfig( path=path, workspace=workspace, @@ -354,6 +389,7 @@ def load_config(path: Path) -> WorkspaceConfig: rewrite_container_names, rewrite_ports, ), + shell=ShellConfig(tuple(shell_candidates)), ) @@ -435,17 +471,66 @@ def split_target(arguments: Sequence[str]) -> tuple[list[str], Target | None]: return remaining, Target(stack=stack, container=container) +def _service_insertion_index(arguments: Sequence[str], command_index: int) -> int: + """Where a resolved service name belongs, just past the command's own options. + + 'exec' stops parsing options at its first positional argument, so a service + spliced in ahead of them would swallow the flags as the command to run. + Other commands accept a service before their flags, so they keep the + original position. + """ + if arguments[command_index] != "exec": + return command_index + 1 + index = command_index + 1 + while index < len(arguments): + argument = arguments[index] + if not argument.startswith("-"): + break + index += 2 if argument in EXEC_OPTIONS_WITH_VALUES else 1 + return min(index, len(arguments)) + + def _insert_after_command(arguments: Sequence[str], extra: Sequence[str]) -> list[str]: if not extra: return list(arguments) command_index = _compose_command_index(arguments) if command_index is None: raise WcoError("Cannot locate the Docker Compose command.") - return [ - *arguments[: command_index + 1], - *extra, - *arguments[command_index + 1 :], - ] + position = _service_insertion_index(arguments, command_index) + return [*arguments[:position], *extra, *arguments[position:]] + + +def _exec_lacks_command(arguments: Sequence[str]) -> bool: + """True when this is an 'exec' that names a service but no command to run.""" + command_index = _compose_command_index(arguments) + if command_index is None or arguments[command_index] != "exec": + return False + index = command_index + 1 + seen_service = False + while index < len(arguments): + argument = arguments[index] + if not argument.startswith("-"): + if seen_service: + return False + seen_service = True + index += 1 + continue + if argument in EXEC_OPTIONS_WITH_VALUES: + index += 2 + continue + index += 1 + return seen_service + + +def _default_shell_arguments(shell: ShellConfig) -> list[str]: + """A 'sh -c' shim that execs the first configured shell present in the image.""" + *probed, last = shell.candidates + script = "".join( + f"command -v {shlex.quote(candidate)} >/dev/null 2>&1 && " + f"exec {shlex.quote(candidate)}; " + for candidate in probed + ) + return ["sh", "-c", f"{script}exec {shlex.quote(last)}"] def reject_reserved_arguments(arguments: Sequence[str]) -> None: @@ -2602,6 +2687,9 @@ def _run_pretty_ps( container inside stack 2. Because only that one position is read as an ID, put it before any flags: 'wco logs 2.1 -f', not 'wco logs -f 2.1'. +'wco exec' without a command opens an interactive shell, trying each shell in +the [shell] table of .wco.toml in turn ('bash' then 'sh' by default). + Options: --isolated Use a worktree-specific project name and persistent port slot. --format Select table or JSON output for wco ports and wco stacks. @@ -2620,6 +2708,8 @@ def _run_pretty_ps( wco down 2 wco restart 2.1 wco logs 2.1 -f + wco exec 2.1 + wco exec 2.1 php -v wco ports show wco ports show --all wco stacks --all @@ -2685,6 +2775,14 @@ def run( arguments = _insert_after_command(arguments, extra) invocation = prepare_invocation(arguments, isolated, cwd) + if _exec_lacks_command(invocation.compose_args): + invocation = replace( + invocation, + compose_args=( + *invocation.compose_args, + *_default_shell_arguments(invocation.config.shell), + ), + ) override = prepare_isolation_override(invocation, run_process) is_startup = ( isolated diff --git a/tests/test_cli.py b/tests/test_cli.py index f8be0e7..5ba06b9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2176,7 +2176,9 @@ def test_zero_is_rejected_as_an_id(self) -> None: self.assertIn("IDs start at 1", str(error.exception)) -class TargetDispatchTests(unittest.TestCase): +class DispatchFixture(unittest.TestCase): + """A workspace whose 'feature' worktree holds stack ID 2, with exec_fn captured.""" + def setUp(self) -> None: self.fixture = WorkspaceFixture() self.feature = self.fixture.create_repo("feature") @@ -2251,6 +2253,8 @@ def _run(self, argv: list[str], run_process=None, cwd: Path | None = None): ) return result, captured, stdout.getvalue(), stderr.getvalue() + +class TargetDispatchTests(DispatchFixture): def test_a_stack_id_retargets_another_worktree(self) -> None: result, captured, _stdout, stderr = self._run(["down", "2"]) @@ -2356,5 +2360,174 @@ def fake_run(command: list[str], **_kwargs): self.assertTrue(any(command[-1] == "down" for command in commands)) +DEFAULT_SHELL_SHIM = [ + "sh", + "-c", + "command -v bash >/dev/null 2>&1 && exec bash; exec sh", +] + + +class ExecShellTests(DispatchFixture): + """'wco exec' without a command opens a shell inside the target container.""" + + def _exec_run(self, argv: list[str]): + # Sub-IDs follow service-name order, so '2.1' is 'php'. + return self._run(argv, run_process=self._container_run(["php", "worker"])) + + def test_an_id_without_a_command_appends_the_shell_shim(self) -> None: + result, captured, _stdout, stderr = self._exec_run(["exec", "2.1"]) + + self.assertEqual(result, 0, stderr) + self.assertEqual( + list(captured["args"])[-5:], ["exec", "php", *DEFAULT_SHELL_SHIM] + ) + + def test_a_service_without_a_command_appends_the_shell_shim(self) -> None: + result, captured, _stdout, stderr = self._exec_run(["exec", "php"]) + + self.assertEqual(result, 0, stderr) + self.assertEqual( + list(captured["args"])[-5:], ["exec", "php", *DEFAULT_SHELL_SHIM] + ) + + def test_an_explicit_shell_is_left_alone(self) -> None: + _result, captured, _stdout, _stderr = self._exec_run(["exec", "2.1", "bash"]) + + self.assertEqual(list(captured["args"])[-3:], ["exec", "php", "bash"]) + + def test_an_explicit_command_with_arguments_is_left_alone(self) -> None: + _result, captured, _stdout, _stderr = self._exec_run( + ["exec", "2.1", "ls", "-la"] + ) + + self.assertEqual(list(captured["args"])[-4:], ["exec", "php", "ls", "-la"]) + + def test_the_service_is_spliced_in_after_exec_options(self) -> None: + """Compose stops reading options at the first positional argument.""" + _result, captured, _stdout, _stderr = self._exec_run( + ["exec", "2.1", "-u", "root"] + ) + + self.assertEqual( + list(captured["args"])[-7:], + ["exec", "-u", "root", "php", *DEFAULT_SHELL_SHIM], + ) + + def test_options_before_a_command_suppress_the_shim(self) -> None: + _result, captured, _stdout, _stderr = self._exec_run( + ["exec", "2.1", "-u", "root", "whoami"] + ) + + self.assertEqual( + list(captured["args"])[-5:], ["exec", "-u", "root", "php", "whoami"] + ) + + def test_an_inline_option_value_is_not_a_command(self) -> None: + _result, captured, _stdout, _stderr = self._exec_run( + ["exec", "2.1", "--index=2", "whoami"] + ) + + self.assertEqual( + list(captured["args"])[-4:], ["exec", "--index=2", "php", "whoami"] + ) + + def test_boolean_flags_do_not_count_as_a_command(self) -> None: + _result, captured, _stdout, _stderr = self._exec_run(["exec", "2.1", "-it"]) + + self.assertEqual( + list(captured["args"])[-6:], + ["exec", "-it", "php", *DEFAULT_SHELL_SHIM], + ) + + def test_a_flag_before_a_plain_service_is_handled(self) -> None: + _result, captured, _stdout, _stderr = self._exec_run(["exec", "-it", "php"]) + + self.assertEqual( + list(captured["args"])[-6:], + ["exec", "-it", "php", *DEFAULT_SHELL_SHIM], + ) + + def test_only_exec_reorders_the_service(self) -> None: + """'logs' and friends accept a service ahead of their flags.""" + _result, captured, _stdout, _stderr = self._exec_run(["logs", "2.1", "-f"]) + + self.assertEqual(list(captured["args"])[-3:], ["logs", "php", "-f"]) + + def test_other_commands_are_untouched(self) -> None: + _result, captured, _stdout, _stderr = self._exec_run(["logs", "2.1"]) + + self.assertEqual(list(captured["args"])[-2:], ["logs", "php"]) + + _result, captured, _stdout, _stderr = self._exec_run(["restart", "2.1"]) + + self.assertEqual(list(captured["args"])[-2:], ["restart", "php"]) + + def test_a_configured_shell_replaces_the_default(self) -> None: + (self.fixture.workspace / ".wco.toml").write_text( + CONFIG + '\n[shell]\ndefault = "zsh"\nfallback = ["bash", "sh"]\n' + ) + + _result, captured, _stdout, _stderr = self._exec_run(["exec", "2.1"]) + + self.assertEqual( + list(captured["args"])[-1], + "command -v zsh >/dev/null 2>&1 && exec zsh; " + "command -v bash >/dev/null 2>&1 && exec bash; exec sh", + ) + + def test_a_single_candidate_needs_no_probe(self) -> None: + (self.fixture.workspace / ".wco.toml").write_text( + CONFIG + '\n[shell]\ndefault = "sh"\nfallback = []\n' + ) + + _result, captured, _stdout, _stderr = self._exec_run(["exec", "2.1"]) + + self.assertEqual(list(captured["args"])[-1], "exec sh") + + def test_a_duplicated_fallback_is_collapsed(self) -> None: + (self.fixture.workspace / ".wco.toml").write_text( + CONFIG + '\n[shell]\ndefault = "bash"\nfallback = ["bash", "sh"]\n' + ) + + _result, captured, _stdout, _stderr = self._exec_run(["exec", "2.1"]) + + self.assertEqual( + list(captured["args"])[-1], + "command -v bash >/dev/null 2>&1 && exec bash; exec sh", + ) + + +class ShellConfigTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = WorkspaceFixture() + self.addCleanup(self.fixture.close) + self.path = self.fixture.workspace / ".wco.toml" + + def test_the_default_candidates_are_bash_then_sh(self) -> None: + self.assertEqual(load_config(self.path).shell.candidates, ("bash", "sh")) + + def test_a_shell_name_with_a_metacharacter_is_rejected(self) -> None: + self.path.write_text(CONFIG + '\n[shell]\ndefault = "sh; rm -rf /"\n') + + with self.assertRaises(WcoError) as error: + load_config(self.path) + + self.assertIn("must contain only letters", str(error.exception)) + + def test_a_shell_name_with_a_space_is_rejected(self) -> None: + self.path.write_text(CONFIG + '\n[shell]\nfallback = ["bash -l"]\n') + + with self.assertRaises(WcoError): + load_config(self.path) + + def test_an_unknown_shell_key_is_rejected(self) -> None: + self.path.write_text(CONFIG + '\n[shell]\nshells = ["bash"]\n') + + with self.assertRaises(WcoError) as error: + load_config(self.path) + + self.assertIn("[shell]", str(error.exception)) + + if __name__ == "__main__": unittest.main()