Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
110 changes: 104 additions & 6 deletions src/wco/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import os
import re
import shlex
import socket
import string
import subprocess
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -123,6 +128,13 @@ class ValidationConfig:
startup_commands: tuple[str, ...]


@dataclass(frozen=True)
class ShellConfig:
"""The shells 'wco exec <ID>' tries, in order, when no command is given."""

candidates: tuple[str, ...]


@dataclass(frozen=True)
class WorkspaceConfig:
path: Path
Expand All @@ -133,6 +145,7 @@ class WorkspaceConfig:
environment: dict[str, str]
validation: ValidationConfig
isolation: IsolationConfig
shell: ShellConfig


@dataclass(frozen=True)
Expand Down Expand Up @@ -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'.")

Expand Down Expand Up @@ -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,
Expand All @@ -354,6 +389,7 @@ def load_config(path: Path) -> WorkspaceConfig:
rewrite_container_names,
rewrite_ports,
),
shell=ShellConfig(tuple(shell_candidates)),
)


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading