From 7d724dbc72c76e81c59ef808fa9c1b732b487413 Mon Sep 17 00:00:00 2001 From: rosstaco Date: Wed, 27 May 2026 23:38:17 +1000 Subject: [PATCH 1/4] feat(research): add findings on extending with SSH agent forwarding and VS Code CLI bridge --- .../research-findings.md | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 project/plans/2026-05-27-dcode-shell-host-bridges/research-findings.md diff --git a/project/plans/2026-05-27-dcode-shell-host-bridges/research-findings.md b/project/plans/2026-05-27-dcode-shell-host-bridges/research-findings.md new file mode 100644 index 0000000..a291344 --- /dev/null +++ b/project/plans/2026-05-27-dcode-shell-host-bridges/research-findings.md @@ -0,0 +1,157 @@ +# Research Findings — `dcode shell` host bridges + +Scope: ways to extend `dcode shell` so it does more of what a VS Code integrated terminal does, without requiring VS Code to be open. Two related but independent topics: + +1. **SSH agent socket forwarding** — make `git push` / `ssh` work inside `dcode shell` without "open VS Code first". +2. **VS Code `code` CLI bridge** — make `code ` inside `dcode shell` open the file in the already-connected VS Code window. + +Both topics arose from the question "is `dcode shell` doing something like `python-socks`?" — clarified to mean the SSH agent socket detection in `find_ssh_socket` (`src/dcode/shell.py:389`). + +## Current state (status quo) + +- `find_ssh_socket(container_id)` in `src/dcode/shell.py:389` probes `docker inspect`'s `Config.Env` for `SSH_AUTH_SOCK`, then falls back to `ls -t /tmp/vscode-ssh-auth*.sock | head -1`. If found, dcode injects it via `docker exec -e SSH_AUTH_SOCK=...`. +- The relay socket only exists while a VS Code client is actively connected to the container — VS Code creates it on connect and it dies when VS Code disconnects. +- README (`README.md:65-68`) documents the "open VS Code first" expectation. +- No equivalent exists today for the `code` CLI shim — `code ` inside `dcode shell` is unset/broken. + +--- + +## Topic 1: SSH agent socket forwarding without VS Code + +### Why VS Code's design works + +VS Code's Remote-Containers runs a small Node relay inside the container at `/tmp/vscode-ssh-auth-.sock` and forwards the SSH agent protocol over the existing docker-exec stdio tunnel back to the local VS Code process — which is simultaneously the **only thing** with access to the host's `$SSH_AUTH_SOCK` on macOS/Windows (where the host socket is outside Docker Desktop's VM). + +This is meaningfully better than the legacy devcontainer patterns: + +- **Bind-mount `~/.ssh`** → leaks private keys into the container filesystem (any process can read them). +- **Bind-mount `$SSH_AUTH_SOCK`** → Linux-only; host sockets are not reachable through Docker Desktop's VM boundary on Mac/Windows. +- **Relay (VS Code's approach)** → cross-platform (rides existing docker-exec stdio), keys never leave the host, composes with hardware keys / 1Password / gpg-agent, dynamic (new keys immediately available). + +### Why we can't borrow VS Code Server for this + +Pre-installing or pre-starting VS Code Server **does not** make the SSH socket appear, because the socket is fundamentally a proxy that needs three live pieces: + +1. A server in the container listening at the socket. ✓ (pre-startable) +2. A live tunnel between server and client. ✗ (needs VS Code connected) +3. A client process on the host that can reach `$SSH_AUTH_SOCK`. ✗ (only the local VS Code desktop process has that) + +`code tunnel` (the official standalone CLI) has the same problem — its "clients" are browsers or remote VS Code instances, neither of which can reach the local host's SSH agent. + +The only way to use VS Code Server as the in-container component would be to write a dcode-side client that connects to the Server and provides the host-side relay — i.e., reverse-engineer VS Code's private remote-server protocol. Not stable, not documented, breaks on VS Code updates. Not viable. + +### Failure mode for "existing shell" if we pre-start the server + +Walked through end-to-end: + +1. `dcode shell` does `docker exec -e SSH_AUTH_SOCK= -it ...`. The env var is fixed for the lifetime of the shell. +2. If VS Code is not yet connected, no working socket exists. Either `SSH_AUTH_SOCK` is unset (`git` has no agent), or set to a predicted path (`connect()` fails or hangs because there's no other end of the tunnel). +3. When VS Code later connects, the socket starts working — but only for shells started **after** the predicted path becomes live, and only while VS Code stays connected. As soon as VS Code disconnects, the tunnel dies and the shell breaks again. + +So pre-starting VS Code Server gives at best **fragile, intermittently-working** SSH auth that flips based on whether VS Code is currently connected — worse UX than the current "open VS Code first, then `dcode shell` reuses the socket" pattern. + +### Viable alternatives if we ever want to eliminate the VS Code dependency + +| Approach | Works on | Cost | Notes | +|---|---|---|---| +| Bind-mount `$SSH_AUTH_SOCK` via `runArgs`/`mounts` | Linux host only | low | Needs container recreate; useless on Mac/Windows | +| Roll our own relay (`socat`) | All platforms | medium | Requires `socat` in container (missing in alpine/distroless); ~20 LOC orchestration in dcode | +| Roll our own relay (tiny Go binary, `docker cp` in) | All platforms | medium-high | Zero in-container deps; ~150 LOC Go + lifecycle code in dcode | +| Roll our own relay (Python + `paramiko.agent`) | Container with Python | medium | Most images have Python but not all | + +The genuine difficulty in all "roll our own" options is **not** the bytes-mover (the SSH agent protocol is just opaque request/response blobs) — it's the **lifecycle**: start helper on first `dcode shell`, reuse on subsequent ones, detect container restart (stale socket), clean up on exit, surface failures, and handle the cross-platform host-side `$SSH_AUTH_SOCK` location (Windows named pipes vs Unix sockets). + +### Ecosystem survey: existing libs/tools + +- **Building blocks for the protocol**: `paramiko.agent` (Python), `asyncssh.SSHAgentClient` (Python), `golang.org/x/crypto/ssh/agent` (Go, standard). Mature, well-tested. +- **`socat`**: the de-facto runtime answer. Two-line recipe (host listener + docker-exec'd in-container counterpart). Needs socat installed inside the container. +- **VS Code Server / `code-server` (Coder) / `code tunnel`**: all bundled, none reusable as libraries. +- **Docker BuildKit `--ssh default`** and **`docker run --mount type=ssh`**: build-time only — does not help `docker exec`. +- **`devcontainers/cli` exec**: no relay; relies entirely on whatever `mounts:` the user wrote in `devcontainer.json`. +- **Standalone GitHub projects** (`docker-ssh-agent-forward` etc.): several exist, none well-maintained, none with meaningful adoption — all are `socat` wrappers or ~100 LOC of Go. Forking the code is safer than depending on them. +- **`gh codespace ssh`**: uses real SSH with `-A`; different connection model, not applicable to `docker exec`. + +**Conclusion:** no off-the-shelf "agent-forward-into-docker" library exists that we'd want to depend on. The protocol part is trivial; the integration glue is environment-specific and would have to live in dcode regardless. + +### Recommendation for topic 1 + +**Do not build this now.** The status quo (detect VS Code's socket, warn if missing) is ~40 lines and zero runtime cost. Replacing it is a real feature (~150–300 LOC + ongoing platform support) for a niche win: "you don't have to open VS Code first." Given dcode's positioning as a launcher into devcontainer-based VS Code workflows, that's a small benefit. + +If we ever do build it, the cheap MVP path is **`socat` recipe with a clear error if socat is missing in the container**, gated behind a flag. The Go-binary path is the right long-term answer but is materially more work. + +--- + +## Topic 2: VS Code `code` CLI bridge + +This one is the opposite — **cheap, high-value, no lifecycle headaches**. + +### How VS Code does it + +When VS Code's terminal opens inside a remote container, it injects two things into the shell environment: + +1. **`VSCODE_IPC_HOOK_CLI=/tmp/vscode-ipc-.sock`** — a Unix socket the connected VS Code window is listening on. +2. **`PATH` prepended with `~/.vscode-server/bin//bin/remote-cli/`** — which contains tiny `code` and `code-insiders` shell scripts. + +The `code` script is a thin wrapper around VS Code's bundled Node binary that writes a JSON-RPC message to `VSCODE_IPC_HOOK_CLI` saying "please open this file/folder." VS Code on the host receives it and acts on it (opens a tab in the connected window, or a diff view, etc.). + +For Insiders, the equivalents are `~/.vscode-server-insiders/` and `VSCODE_IPC_HOOK_CLI` points at a different socket. + +### What dcode can do — same parasitic trick as SSH + +Mirror `find_ssh_socket` with a `find_vscode_ipc()`-style helper: + +```python +# Probe newest socket +ls -t /tmp/vscode-ipc-*.sock | head -1 +# Probe newest remote-cli dir (handle stable + insiders) +ls -td ~/.vscode-server/bin/*/bin/remote-cli | head -1 +ls -td ~/.vscode-server-insiders/bin/*/bin/remote-cli | head -1 +``` + +Then in `run_shell` inject: + +- `-e VSCODE_IPC_HOOK_CLI=` +- PATH prepend with the `remote-cli` dir + +Note: `docker exec -e PATH=...` sets a literal value, so PATH-prepending needs a small wrapper — easiest is wrapping the shell invocation as `bash -c 'export PATH=:$PATH; exec "$SHELL" -l'` (or similar) rather than relying on `-e`. Alternative: drop a `bashrc.d/` snippet. The wrapper approach is cleaner because it works regardless of user's shell. + +### Caveats (mostly the same shape as SSH discovery) + +- **Requires VS Code already connected.** No VS Code → no socket → `code` not available (or available-but-broken). Same "open VS Code first" expectation as today's SSH behavior. Intuitive failure: `code` is simply absent on PATH. +- **Multiple connected VS Code windows** to the same container: pick newest socket. `code .` opens in that window. +- **Version drift in `~/.vscode-server/bin/`**: usually only one dir; pick newest if multiple. The shim is self-contained per-commit, so just always grab the newest `remote-cli` from the same family (stable or insiders). +- **Stable vs Insiders**: `~/.vscode-server-insiders/` path. Pick based on `dcode shell --insiders` flag (mirror the existing flag plumbing). +- **Architecture**: not a concern — `remote-cli/code` is a shell script, the bundled `node` it invokes is architecture-matched at install time by VS Code itself. + +### What this does NOT solve + +- **`dcode ` from inside the container** — i.e., the full dcode pipeline (worktree resolution, devcontainer.json discovery, URI construction, auto-build). That would require a host-side relay listening for "run `dcode X`" requests from the container — same plumbing as the SSH-agent-relay discussion above. Out of scope. + +| Inside `dcode shell` after this feature | Works? | +|---|---| +| `code .` (open current container folder in connected VS Code) | ✓ | +| `code path/to/file` | ✓ | +| `code --diff a.txt b.txt` | ✓ | +| `code` when no VS Code is open | ✗ (intuitive — shim is absent) | +| `dcode ` (full dcode pipeline from inside container) | ✗ (needs host-side relay) | + +### Recommendation for topic 2 + +**Build this.** Small surface area (~30 LOC mirroring `find_ssh_socket`), no new deps, no lifecycle concerns (we're not standing up any helper processes — we're just pointing the shell at infrastructure VS Code already runs). Failure mode is intuitive (no VS Code → `code` is absent, same conceptual model as today's SSH behavior). + +Open design questions if/when this becomes a task: + +- PATH injection mechanism: `bash -c` wrapper vs `rcfile` drop-in vs accept that user must source something. **Wrapper is recommended** — works across shells, no on-disk state. +- Whether to also expose any other VS Code-injected env vars (`VSCODE_GIT_IPC_HANDLE` for the git extension, `BROWSER`, etc.). Probably no — `VSCODE_IPC_HOOK_CLI` + the `code` shim is the 95% case. Revisit if specific use cases come up. +- Whether `dcode shell` should print an informational hint when the IPC socket is detected ("`code` command available — opens in connected VS Code window"), mirroring the SSH warning style. + +--- + +## Cross-topic summary + +| Feature | Cost | Win | Lifecycle burden | Build now? | +|---|---|---|---|---| +| SSH agent forwarding without VS Code | High (~150–300 LOC + platform matrix) | Niche (skip "open VS Code first" for SSH) | Yes — long-lived helper, cleanup, platform-specific | **No** | +| `code` CLI bridge in `dcode shell` | Low (~30 LOC) | Material QoL inside shell | None — just env-var injection | **Yes** (when prioritized) | + +Both topics share the same architectural insight: **VS Code's remote infrastructure is the right thing to piggyback on, not to replace.** Wherever VS Code already runs something useful in the container, dcode can detect it and inject the right env vars. Wherever VS Code does *not* run something, replacing it ourselves is a real engineering project, not a config tweak. From c60ed14020abbf8136b59b883c6842e971348b88 Mon Sep 17 00:00:00 2001 From: rosstaco Date: Thu, 28 May 2026 18:10:50 +1000 Subject: [PATCH 2/4] feat!: rename project to InDevContainer; CLI binary becomes idc The project has outgrown the 'dcode' name. It now builds containers, shells in, and (next commit) execs the GitHub Copilot CLI inside them. Rebrand to InDevContainer with the binary 'idc'. Mechanics in this commit: - git mv src/dcode -> src/indevcontainer (history preserved) - pyproject.toml: name -> indevcontainer; [project.scripts] entry becomes 'idc = indevcontainer.cli:main'; project URLs point at rosstaco/InDevContainer; hatch version-file path follows the move - release-please-config.json: package-name -> indevcontainer - .gitignore: version-file path follows the move - All Python imports rewritten from 'dcode.X' to 'indevcontainer.X' - All user-facing 'dcode:' stderr prefixes rewritten to 'idc:' - All in-message command refs rewritten ('dcode ' -> 'idc code ', 'dcode shell' -> 'idc shell', etc.) - update.py: 'uv tool upgrade dcode' -> 'uv tool upgrade indevcontainer'; regex _DCODE_LINE -> _IDC_LINE matching '^indevcontainer\s+v'; install URL updated - version_check.py: GitHub API URLs updated; User-Agent header becomes 'idc-doctor' - doctor.py: doctor section label, install-method text, and all user-facing messages updated to the new branding - Tests: all 'from dcode.X' and 'patch("dcode.X.Y", ...)' references rewritten; test fixtures for 'uv tool list' output updated BREAKING CHANGE: The 'dcode' tool is no longer published. Users must uninstall the old package and install the new one: uv tool uninstall dcode uv tool install git+https://github.com/rosstaco/InDevContainer The Python package import path also changes from 'dcode' to 'indevcontainer'. No 'dcode' shim is shipped. Release-As: 1.0.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 2 +- pyproject.toml | 16 +- release-please-config.json | 2 +- src/{dcode => indevcontainer}/__init__.py | 4 +- src/{dcode => indevcontainer}/__main__.py | 2 +- src/{dcode => indevcontainer}/_progress.py | 2 +- src/{dcode => indevcontainer}/_rich.py | 0 src/{dcode => indevcontainer}/cli.py | 0 src/{dcode => indevcontainer}/core.py | 14 +- .../devcontainer_cli.py | 22 +- src/{dcode => indevcontainer}/doctor.py | 66 ++--- src/{dcode => indevcontainer}/shell.py | 0 src/{dcode => indevcontainer}/update.py | 30 +-- .../version_check.py | 10 +- src/{dcode => indevcontainer}/wsl.py | 4 +- tests/test_core.py | 76 +++--- tests/test_devcontainer_cli.py | 52 ++-- tests/test_doctor.py | 180 +++++++------- tests/test_progress.py | 8 +- tests/test_shell.py | 232 +++++++++--------- tests/test_update.py | 42 ++-- tests/test_version_check.py | 14 +- tests/test_wsl.py | 44 ++-- 23 files changed, 411 insertions(+), 411 deletions(-) rename src/{dcode => indevcontainer}/__init__.py (69%) rename src/{dcode => indevcontainer}/__main__.py (54%) rename src/{dcode => indevcontainer}/_progress.py (99%) rename src/{dcode => indevcontainer}/_rich.py (100%) rename src/{dcode => indevcontainer}/cli.py (100%) rename src/{dcode => indevcontainer}/core.py (92%) rename src/{dcode => indevcontainer}/devcontainer_cli.py (91%) rename src/{dcode => indevcontainer}/doctor.py (92%) rename src/{dcode => indevcontainer}/shell.py (100%) rename src/{dcode => indevcontainer}/update.py (76%) rename src/{dcode => indevcontainer}/version_check.py (91%) rename src/{dcode => indevcontainer}/wsl.py (98%) diff --git a/.gitignore b/.gitignore index 8b19cb6..cbcd707 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,5 @@ __pycache__/ dist/ .pytest_cache/ uv.lock -src/dcode/_version.py +src/indevcontainer/_version.py .ruff_cache/ diff --git a/pyproject.toml b/pyproject.toml index 9e49a40..b36961d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] -name = "dcode" -description = "Open folders in VS Code devcontainers from the CLI" +name = "indevcontainer" +description = "Run code, shells, and the Copilot CLI in VS Code devcontainers from the terminal" readme = "README.md" license = "MIT" requires-python = ">=3.11" @@ -8,12 +8,12 @@ dependencies = ["json5", "rich>=13.0"] dynamic = ["version"] [project.urls] -Homepage = "https://github.com/rosstaco/dcode" -Issues = "https://github.com/rosstaco/dcode/issues" -Source = "https://github.com/rosstaco/dcode" +Homepage = "https://github.com/rosstaco/InDevContainer" +Issues = "https://github.com/rosstaco/InDevContainer/issues" +Source = "https://github.com/rosstaco/InDevContainer" [project.scripts] -dcode = "dcode.cli:main" +idc = "indevcontainer.cli:main" [build-system] requires = ["hatchling", "hatch-vcs"] @@ -23,10 +23,10 @@ build-backend = "hatchling.build" source = "vcs" [tool.hatch.build.hooks.vcs] -version-file = "src/dcode/_version.py" +version-file = "src/indevcontainer/_version.py" [tool.hatch.build.targets.wheel] -packages = ["src/dcode"] +packages = ["src/indevcontainer"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/release-please-config.json b/release-please-config.json index b4d2348..c4210b8 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -6,7 +6,7 @@ "include-component-in-tag": false, "packages": { ".": { - "package-name": "dcode", + "package-name": "indevcontainer", "changelog-path": "CHANGELOG.md" } } diff --git a/src/dcode/__init__.py b/src/indevcontainer/__init__.py similarity index 69% rename from src/dcode/__init__.py rename to src/indevcontainer/__init__.py index ec6f033..bb7a17c 100644 --- a/src/dcode/__init__.py +++ b/src/indevcontainer/__init__.py @@ -1,4 +1,4 @@ -"""dcode — open folders in VS Code devcontainers from the CLI.""" +"""idc — open folders in VS Code devcontainers from the CLI.""" from __future__ import annotations @@ -6,6 +6,6 @@ from importlib.metadata import version as _pkg_version try: - __version__ = _pkg_version("dcode") + __version__ = _pkg_version("indevcontainer") except PackageNotFoundError: # pragma: no cover - only happens when not installed __version__ = "0.0.0+unknown" diff --git a/src/dcode/__main__.py b/src/indevcontainer/__main__.py similarity index 54% rename from src/dcode/__main__.py rename to src/indevcontainer/__main__.py index 7c89143..92f292e 100644 --- a/src/dcode/__main__.py +++ b/src/indevcontainer/__main__.py @@ -1,4 +1,4 @@ -"""Allow ``python -m dcode`` to invoke the CLI.""" +"""Allow ``python -m idc`` to invoke the CLI.""" from .cli import main diff --git a/src/dcode/_progress.py b/src/indevcontainer/_progress.py similarity index 99% rename from src/dcode/_progress.py rename to src/indevcontainer/_progress.py index 5c3e96c..1e86d56 100644 --- a/src/dcode/_progress.py +++ b/src/indevcontainer/_progress.py @@ -28,7 +28,7 @@ from rich.console import Console -from dcode._rich import get_console +from indevcontainer._rich import get_console @dataclass(frozen=True, slots=True) diff --git a/src/dcode/_rich.py b/src/indevcontainer/_rich.py similarity index 100% rename from src/dcode/_rich.py rename to src/indevcontainer/_rich.py diff --git a/src/dcode/cli.py b/src/indevcontainer/cli.py similarity index 100% rename from src/dcode/cli.py rename to src/indevcontainer/cli.py diff --git a/src/dcode/core.py b/src/indevcontainer/core.py similarity index 92% rename from src/dcode/core.py rename to src/indevcontainer/core.py index 3c31126..5f8fe67 100644 --- a/src/dcode/core.py +++ b/src/indevcontainer/core.py @@ -1,4 +1,4 @@ -"""Core dcode logic: locate devcontainers and launch VS Code.""" +"""Core idc logic: locate devcontainers and launch VS Code.""" from __future__ import annotations @@ -8,8 +8,8 @@ import json5 -from dcode._progress import with_spinner -from dcode.wsl import _ensure_wsl_docker_settings, build_uri_wsl, is_wsl +from indevcontainer._progress import with_spinner +from indevcontainer.wsl import _ensure_wsl_docker_settings, build_uri_wsl, is_wsl def _find_repo_root(start: Path) -> Path | None: @@ -65,7 +65,7 @@ def resolve_worktree(target: Path) -> tuple[Path, Path] | None: rel_path = target.relative_to(main_repo) except ValueError: print( - "dcode: worktree is outside the main repo tree — " + "idc: worktree is outside the main repo tree — " "shared-container mode is not supported for external worktrees", file=sys.stderr, ) @@ -94,7 +94,7 @@ def get_workspace_folder(devcontainer_path: Path, target: Path) -> str: config = json5.load(f) except (OSError, ValueError) as exc: print( - f"dcode: failed to parse {devcontainer_path} ({exc}); " + f"idc: failed to parse {devcontainer_path} ({exc}); " f"using default workspace folder {default}", file=sys.stderr, ) @@ -125,7 +125,7 @@ def _launch_editor(argv: list[str], *, label: str) -> int: text=True, ) except OSError as exc: - print(f"dcode: failed to launch {argv[0]}: {exc}", file=sys.stderr) + print(f"idc: failed to launch {argv[0]}: {exc}", file=sys.stderr) return 127 if result.returncode: @@ -135,7 +135,7 @@ def _launch_editor(argv: list[str], *, label: str) -> int: return result.returncode -def run_dcode(path: str, *, insiders: bool = False) -> None: +def run_code(path: str, *, insiders: bool = False) -> None: """Open a folder in VS Code, using devcontainer if available.""" editor = "code-insiders" if insiders else "code" target = Path(path).resolve() diff --git a/src/dcode/devcontainer_cli.py b/src/indevcontainer/devcontainer_cli.py similarity index 91% rename from src/dcode/devcontainer_cli.py rename to src/indevcontainer/devcontainer_cli.py index cd0e7cb..c323b52 100644 --- a/src/dcode/devcontainer_cli.py +++ b/src/indevcontainer/devcontainer_cli.py @@ -1,6 +1,6 @@ """Helpers for the official ``@devcontainers/cli`` (the ``devcontainer`` CLI). -Used by ``dcode shell`` to build & start a devcontainer on demand when the +Used by ``idc shell`` to build & start a devcontainer on demand when the project has no running container yet. The CLI is the same Node.js app VS Code's Dev Containers extension drives under the hood, so containers it creates carry the same ``devcontainer.local_folder`` / @@ -31,8 +31,8 @@ from rich.console import Console -from dcode import _progress -from dcode._rich import get_console +from indevcontainer import _progress +from indevcontainer._rich import get_console INSTALL_SCRIPT_URL = ( "https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh" @@ -83,7 +83,7 @@ def install_cli( The upstream script bundles its own Node.js runtime, so no host Node is required. Defaults to ``~/.devcontainers``; pass ``prefix`` to override. - Prints status to *console* (defaults to the dcode stderr console). On + Prints status to *console* (defaults to the idc stderr console). On success returns the absolute path to the installed binary; on any failure returns ``None`` after printing a hint. """ @@ -91,7 +91,7 @@ def install_cli( install_prefix = prefix or DEFAULT_INSTALL_PREFIX cons.print( - f"dcode: downloading Dev Containers CLI installer from {INSTALL_SCRIPT_URL}", + f"idc: downloading Dev Containers CLI installer from {INSTALL_SCRIPT_URL}", highlight=False, ) @@ -108,7 +108,7 @@ def install_cli( shutil.copyfileobj(resp, tmp) except (urllib.error.URLError, OSError) as exc: cons.print( - f"[red]dcode: failed to download installer ({exc})[/]", + f"[red]idc: failed to download installer ({exc})[/]", highlight=False, ) return None @@ -121,13 +121,13 @@ def install_cli( ) if result.error is not None: cons.print( - f"[red]dcode: failed to run installer: {result.error}[/]", + f"[red]idc: failed to run installer: {result.error}[/]", highlight=False, ) return None if result.returncode != 0: cons.print( - f"[red]dcode: Dev Containers CLI install failed " + f"[red]idc: Dev Containers CLI install failed " f"(exit {result.returncode}) — see output above[/]", highlight=False, ) @@ -141,14 +141,14 @@ def install_cli( binary = install_prefix / "bin" / "devcontainer" if not (binary.is_file() and os.access(binary, os.X_OK)): cons.print( - f"[red]dcode: installer reported success but {binary} is not " + f"[red]idc: installer reported success but {binary} is not " "an executable file[/]", highlight=False, ) return None cons.print( - f"[green]dcode: installed Dev Containers CLI at {binary}[/]", + f"[green]idc: installed Dev Containers CLI at {binary}[/]", highlight=False, ) return binary @@ -165,7 +165,7 @@ def up( Runs `` up --workspace-folder --config `` while streaming the build's stderr live above a pinned spinner (via - :func:`dcode._progress.run_streaming`). devcontainers/cli writes one + :func:`indevcontainer._progress.run_streaming`). devcontainers/cli writes one final ``JSON.stringify(result)`` line to stdout when finished; we parse that for ``containerId``. diff --git a/src/dcode/doctor.py b/src/indevcontainer/doctor.py similarity index 92% rename from src/dcode/doctor.py rename to src/indevcontainer/doctor.py index aab5a7b..ef6ea96 100644 --- a/src/dcode/doctor.py +++ b/src/indevcontainer/doctor.py @@ -1,8 +1,8 @@ -"""Implementation of the ``dcode doctor`` subcommand. +"""Implementation of the ``idc doctor`` subcommand. Diagnoses the local environment (editor, container runtime, git, WSL setup, -devcontainer in target, dcode version, install method) and prints a -"what would `dcode ` do" plan summary. Read-only — never patches +devcontainer in target, idc version, install method) and prints a +"what would `idc code ` do" plan summary. Read-only — never patches settings.json or spawns the editor. """ @@ -19,16 +19,16 @@ from rich.table import Table from rich.text import Text -import dcode -from dcode import devcontainer_cli, update, version_check -from dcode._rich import STATUS_STYLES, get_console -from dcode.core import ( +import indevcontainer +from indevcontainer import devcontainer_cli, update, version_check +from indevcontainer._rich import STATUS_STYLES, get_console +from indevcontainer.core import ( build_uri, find_devcontainer, get_workspace_folder, resolve_worktree, ) -from dcode.wsl import ( +from indevcontainer.wsl import ( _get_windows_vscode_settings_path, _wsl_to_windows_path, build_uri_wsl, @@ -172,7 +172,7 @@ def check_devcontainer_cli() -> CheckResult: "warn", "Dev Containers CLI: not on PATH " f"or at {devcontainer_cli.DEFAULT_INSTALL_PREFIX}/bin/devcontainer " - "(needed by `dcode shell` to build a missing devcontainer)", + "(needed by `idc shell` to build a missing devcontainer)", devcontainer_cli.install_hint(), ) version = devcontainer_cli.cli_version(cli) @@ -204,7 +204,7 @@ def check_wsl_distro() -> CheckResult: return ( "warn", "WSL distro: WSL_DISTRO_NAME not set", - "dcode cannot auto-set dev.containers.executeInWSLDistro without " + "idc cannot auto-set dev.containers.executeInWSLDistro without " "WSL_DISTRO_NAME — set it in your shell rc", ) @@ -242,7 +242,7 @@ def check_wsl_executeInWSL_settings() -> list[CheckResult]: ( "warn", f"WSL devcontainer settings ({label}): failed to parse {path} ({exc})", - "dcode auto-patches these on launch; or set them manually in settings.json", + "idc auto-patches these on launch; or set them manually in settings.json", ) ) continue @@ -262,8 +262,8 @@ def check_wsl_executeInWSL_settings() -> list[CheckResult]: ( "warn", f"WSL devcontainer settings ({label}): {'; '.join(problems)} " - '(will auto-fix on next "dcode ")', - "dcode auto-patches these on launch; or set them manually in settings.json", + '(will auto-fix on next "idc code ")', + "idc auto-patches these on launch; or set them manually in settings.json", ) ) else: @@ -288,7 +288,7 @@ def check_devcontainer(target: Path) -> CheckResult: return ( "warn", f"devcontainer: none in main repo ({main_repo}) — " - "dcode will fall back to opening the directory directly", + "idc will fall back to opening the directory directly", "add .devcontainer/devcontainer.json to enable container support", ) devcontainer = find_devcontainer(target) @@ -297,7 +297,7 @@ def check_devcontainer(target: Path) -> CheckResult: return ( "warn", f"devcontainer: none found in {target} — " - "dcode will open the folder directly without a container", + "idc will open the folder directly without a container", "add .devcontainer/devcontainer.json to enable container support", ) @@ -337,32 +337,32 @@ def check_worktree(target: Path) -> CheckResult: "warn", f"worktree: {target} looks like a worktree or submodule but cannot be " "resolved (external worktree or submodule)", - "dcode opens this path directly without shared-container support", + "idc opens this path directly without shared-container support", ) return ("ok", "worktree: not a git repo", None) def check_version() -> CheckResult: - local = dcode.__version__ + local = indevcontainer.__version__ try: info = version_check.get_latest_release() except version_check.NetworkError as exc: return ( "warn", - f"dcode version: cannot reach GitHub API ({exc})", - 're-run when online; or skip with "dcode update --check"', + f"idc version: cannot reach GitHub API ({exc})", + 're-run when online; or skip with "idc update --check"', ) latest_tag = info["tag_name"] url = info["html_url"] try: cmp = version_check.compare_versions(local, latest_tag.lstrip("v")) except ValueError: - return ("warn", f"dcode version: cannot parse local version {local!r}", None) + return ("warn", f"idc version: cannot parse local version {local!r}", None) if cmp < 0: return ( "warn", - f"dcode version: {local} installed; latest is {latest_tag} ({url})", - 'run "dcode update" to upgrade', + f"idc version: {local} installed; latest is {latest_tag} ({url})", + 'run "idc update" to upgrade', ) try: _, local_is_dev = version_check.parse_version(local) @@ -371,10 +371,10 @@ def check_version() -> CheckResult: if cmp > 0 or local_is_dev: return ( "ok", - f"dcode version: {local} (ahead of latest release {latest_tag})", + f"idc version: {local} (ahead of latest release {latest_tag})", None, ) - return ("ok", f"dcode version: {local} (latest)", None) + return ("ok", f"idc version: {local} (latest)", None) def check_install_method() -> CheckResult: @@ -382,22 +382,22 @@ def check_install_method() -> CheckResult: if method == "uv-tool": return ( "ok", - 'install method: uv tool (upgradable via "dcode update")', + 'install method: uv tool (upgradable via "idc update")', None, ) if method == "uv-missing": return ( "warn", "install method: uv not on PATH; cannot detect or upgrade automatically", - 'install uv (https://docs.astral.sh/uv/) to enable "dcode update"', + 'install uv (https://docs.astral.sh/uv/) to enable "idc update"', ) if method == "not-uv-tool": return ( "warn", - 'install method: dcode is not installed via "uv tool" — ' - '"dcode update" will not work', - "re-install via \"uv tool install git+https://github.com/rosstaco/dcode\" " - "to use dcode update", + 'install method: idc is not installed via "uv tool" — ' + '"idc update" will not work', + "re-install via \"uv tool install git+https://github.com/rosstaco/InDevContainer\" " + "to use idc update", ) return ( "warn", @@ -475,7 +475,7 @@ def _build_plan_renderable( if code_present: editor = "code" extra_note = ( - "also available: `dcode -i ` would use code-insiders" + "also available: `idc code -i ` would use code-insiders" if insiders_present else None ) @@ -617,7 +617,7 @@ def render_plan( ("Git", ("git",)), ("WSL", ("wsl", "wsl_distro", "wsl_settings_paths", "wsl_execute_in_wsl")), ("Workspace", ("devcontainer", "devcontainer_parses", "worktree")), - ("dcode", ("version", "install_method")), + ("InDevContainer", ("version", "install_method")), ) @@ -699,7 +699,7 @@ def collect(check_id: str, fn, *args): fail_style = "bold red" if n_fail > 0 else "dim" cons.print() cons.print( - f"dcode doctor: [green]{n_ok} ok[/], " + f"idc doctor: [green]{n_ok} ok[/], " f"[yellow]{n_warn} warn[/], [{fail_style}]{n_fail} fail[/]" ) diff --git a/src/dcode/shell.py b/src/indevcontainer/shell.py similarity index 100% rename from src/dcode/shell.py rename to src/indevcontainer/shell.py diff --git a/src/dcode/update.py b/src/indevcontainer/update.py similarity index 76% rename from src/dcode/update.py rename to src/indevcontainer/update.py index 1bf1b44..108c941 100644 --- a/src/dcode/update.py +++ b/src/indevcontainer/update.py @@ -1,4 +1,4 @@ -"""Implementation of the ``dcode update`` subcommand.""" +"""Implementation of the ``idc update`` subcommand.""" from __future__ import annotations @@ -11,12 +11,12 @@ from rich.panel import Panel from rich.text import Text -import dcode +import indevcontainer from . import version_check from ._rich import get_console -_DCODE_LINE = re.compile(r"^dcode\s+v\d") +_IDC_LINE = re.compile(r"^indevcontainer\s+v\d") def detect_install_method() -> str: @@ -36,47 +36,47 @@ def detect_install_method() -> str: if result.returncode != 0: return "unknown" for line in result.stdout.splitlines(): - if _DCODE_LINE.match(line): + if _IDC_LINE.match(line): return "uv-tool" return "not-uv-tool" def run_update() -> int: - """Driver for ``dcode update`` (no flags).""" + """Driver for ``idc update`` (no flags).""" method = detect_install_method() if method == "uv-missing": - print('dcode update: "uv" is not installed or not on PATH', file=sys.stderr) + print('idc update: "uv" is not installed or not on PATH', file=sys.stderr) print( " install uv: https://docs.astral.sh/uv/getting-started/installation/", file=sys.stderr, ) return 1 if method == "not-uv-tool": - print("dcode update: dcode is not installed via 'uv tool'", file=sys.stderr) + print("idc update: idc is not installed via 'uv tool'", file=sys.stderr) print( - " re-install with: uv tool install git+https://github.com/rosstaco/dcode", + " re-install with: uv tool install git+https://github.com/rosstaco/InDevContainer", file=sys.stderr, ) print(" or upgrade via the original install method", file=sys.stderr) return 1 if method == "unknown": print( - "dcode update: could not detect install method; attempting upgrade anyway", + "idc update: could not detect install method; attempting upgrade anyway", file=sys.stderr, ) # Stream subprocess output to user's terminal — no capture. - result = subprocess.run(["uv", "tool", "upgrade", "dcode"], check=False) + result = subprocess.run(["uv", "tool", "upgrade", "indevcontainer"], check=False) return result.returncode def run_update_check(console: Console | None = None) -> int: - """Driver for ``dcode update --check``.""" + """Driver for ``idc update --check``.""" cons = console or get_console() - local = dcode.__version__ + local = indevcontainer.__version__ try: info = version_check.get_latest_release() except version_check.NetworkError as exc: - cons.print(f"[bold red]dcode update: could not reach github.com ({exc})[/]") + cons.print(f"[bold red]idc update: could not reach github.com ({exc})[/]") return 2 latest_tag = info["tag_name"] @@ -87,7 +87,7 @@ def run_update_check(console: Console | None = None) -> int: if cmp < 0: local_style = "yellow" status_line = Text.from_markup( - "[yellow]update available — run `dcode update`[/]" + "[yellow]update available — run `idc update`[/]" ) rc = 1 elif cmp > 0 or local_is_dev: @@ -109,7 +109,7 @@ def run_update_check(console: Console | None = None) -> int: cons.print( Panel( body, - title="dcode update", + title="idc update", title_align="left", border_style="cyan", padding=(0, 1), diff --git a/src/dcode/version_check.py b/src/indevcontainer/version_check.py similarity index 91% rename from src/dcode/version_check.py rename to src/indevcontainer/version_check.py index 1e0f0e7..507582d 100644 --- a/src/dcode/version_check.py +++ b/src/indevcontainer/version_check.py @@ -1,6 +1,6 @@ """GitHub release version-check helper. -Stdlib-only. Used by `dcode update --check` (and later by `dcode doctor`) +Stdlib-only. Used by `idc update --check` (and later by `idc doctor`) to compare the local installed version against the latest GitHub release. """ @@ -11,12 +11,12 @@ import urllib.error import urllib.request -_LATEST_URL = "https://api.github.com/repos/rosstaco/dcode/releases/latest" -_TAGS_URL = "https://api.github.com/repos/rosstaco/dcode/tags?per_page=1" -_RELEASE_TAG_URL = "https://github.com/rosstaco/dcode/releases/tag/{tag}" +_LATEST_URL = "https://api.github.com/repos/rosstaco/InDevContainer/releases/latest" +_TAGS_URL = "https://api.github.com/repos/rosstaco/InDevContainer/tags?per_page=1" +_RELEASE_TAG_URL = "https://github.com/rosstaco/InDevContainer/releases/tag/{tag}" _HEADERS = { - "User-Agent": "dcode-doctor", + "User-Agent": "idc-doctor", "Accept": "application/vnd.github+json", } diff --git a/src/dcode/wsl.py b/src/indevcontainer/wsl.py similarity index 98% rename from src/dcode/wsl.py rename to src/indevcontainer/wsl.py index 082297d..37ea22b 100644 --- a/src/dcode/wsl.py +++ b/src/indevcontainer/wsl.py @@ -1,4 +1,4 @@ -"""WSL-specific helpers for dcode.""" +"""WSL-specific helpers for idc.""" from __future__ import annotations @@ -190,7 +190,7 @@ def _ensure_wsl_docker_settings(insiders: bool = False) -> None: settings_path.write_text(new_text) suffix = f" ({distro})" if distro else "" print( - f"dcode: configured VS Code to use Docker from WSL{suffix}", + f"idc: configured VS Code to use Docker from WSL{suffix}", file=sys.stderr, ) except OSError: diff --git a/tests/test_core.py b/tests/test_core.py index 5af98e6..f9b0624 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,4 +1,4 @@ -"""Tests for dcode.core.""" +"""Tests for indevcontainer.core.""" import json import subprocess @@ -8,7 +8,7 @@ import pytest from conftest import _make_worktree -from dcode.core import ( +from indevcontainer.core import ( build_uri, find_devcontainer, get_workspace_folder, @@ -113,9 +113,9 @@ def test_launches_with_devcontainer_uri(self, tmp_path): dc_dir.mkdir() (dc_dir / "devcontainer.json").write_text('{"name": "test"}') - with patch("dcode.core.subprocess.run", return_value=_ok()) as mock_run: - from dcode.core import run_dcode - run_dcode(str(tmp_path), insiders=False) + with patch("indevcontainer.core.subprocess.run", return_value=_ok()) as mock_run: + from indevcontainer.core import run_code + run_code(str(tmp_path), insiders=False) args = mock_run.call_args[0][0] assert args[0] == "code" @@ -127,27 +127,27 @@ def test_launches_insiders(self, tmp_path): dc_dir.mkdir() (dc_dir / "devcontainer.json").write_text('{"name": "test"}') - with patch("dcode.core.subprocess.run", return_value=_ok()) as mock_run: - from dcode.core import run_dcode - run_dcode(str(tmp_path), insiders=True) + with patch("indevcontainer.core.subprocess.run", return_value=_ok()) as mock_run: + from indevcontainer.core import run_code + run_code(str(tmp_path), insiders=True) args = mock_run.call_args[0][0] assert args[0] == "code-insiders" def test_fallback_without_devcontainer(self, tmp_path): - with patch("dcode.core.subprocess.run", return_value=_ok()) as mock_run: - from dcode.core import run_dcode - run_dcode(str(tmp_path), insiders=False) + with patch("indevcontainer.core.subprocess.run", return_value=_ok()) as mock_run: + from indevcontainer.core import run_code + run_code(str(tmp_path), insiders=False) args = mock_run.call_args[0][0] assert args == ["code", str(tmp_path)] def test_propagates_nonzero_exit_from_code_launcher(self, tmp_path): failed = subprocess.CompletedProcess(args=[], returncode=2) - with patch("dcode.core.subprocess.run", return_value=failed): - from dcode.core import run_dcode + with patch("indevcontainer.core.subprocess.run", return_value=failed): + from indevcontainer.core import run_code with pytest.raises(SystemExit) as exc: - run_dcode(str(tmp_path), insiders=False) + run_code(str(tmp_path), insiders=False) assert exc.value.code == 2 def test_propagates_nonzero_exit_with_devcontainer(self, tmp_path): @@ -156,10 +156,10 @@ def test_propagates_nonzero_exit_with_devcontainer(self, tmp_path): (dc_dir / "devcontainer.json").write_text('{"name": "test"}') failed = subprocess.CompletedProcess(args=[], returncode=3) - with patch("dcode.core.subprocess.run", return_value=failed): - from dcode.core import run_dcode + with patch("indevcontainer.core.subprocess.run", return_value=failed): + from indevcontainer.core import run_code with pytest.raises(SystemExit) as exc: - run_dcode(str(tmp_path), insiders=False) + run_code(str(tmp_path), insiders=False) assert exc.value.code == 3 def test_uses_wsl_uri_on_wsl(self, tmp_path): @@ -169,13 +169,13 @@ def test_uses_wsl_uri_on_wsl(self, tmp_path): unc = f"\\\\wsl.localhost\\Ubuntu{tmp_path}" with ( - patch("dcode.core.subprocess.run", return_value=_ok()) as mock_run, - patch("dcode.core.is_wsl", return_value=True), - patch("dcode.core._ensure_wsl_docker_settings"), - patch("dcode.wsl._wsl_to_windows_path", return_value=unc), + patch("indevcontainer.core.subprocess.run", return_value=_ok()) as mock_run, + patch("indevcontainer.core.is_wsl", return_value=True), + patch("indevcontainer.core._ensure_wsl_docker_settings"), + patch("indevcontainer.wsl._wsl_to_windows_path", return_value=unc), ): - from dcode.core import run_dcode - run_dcode(str(tmp_path), insiders=False) + from indevcontainer.core import run_code + run_code(str(tmp_path), insiders=False) args = mock_run.call_args[0][0] assert args[0] == "code" @@ -271,9 +271,9 @@ def test_worktree_uses_main_repo_host_path(self, tmp_path): dc_dir.mkdir() (dc_dir / "devcontainer.json").write_text('{"name": "test"}') - with patch("dcode.core.subprocess.run", return_value=_ok()) as mock_run: - from dcode.core import run_dcode - run_dcode(str(worktree)) + with patch("indevcontainer.core.subprocess.run", return_value=_ok()) as mock_run: + from indevcontainer.core import run_code + run_code(str(worktree)) args = mock_run.call_args[0][0] assert args[1] == "--folder-uri" @@ -288,9 +288,9 @@ def test_worktree_workspace_folder_includes_relative_path(self, tmp_path): dc_dir.mkdir() (dc_dir / "devcontainer.json").write_text('{"name": "test"}') - with patch("dcode.core.subprocess.run", return_value=_ok()) as mock_run: - from dcode.core import run_dcode - run_dcode(str(worktree)) + with patch("indevcontainer.core.subprocess.run", return_value=_ok()) as mock_run: + from indevcontainer.core import run_code + run_code(str(worktree)) uri = mock_run.call_args[0][0][2] assert uri.endswith("/workspaces/main-repo/.worktrees/pr-34") @@ -310,9 +310,9 @@ def test_multiple_worktrees_share_same_container(self, tmp_path): wt.mkdir(parents=True) (wt / ".git").write_text(f"gitdir: ../../.git/worktrees/{name}\n") - with patch("dcode.core.subprocess.run", return_value=_ok()) as mock_run: - from dcode.core import run_dcode - run_dcode(str(wt)) + with patch("indevcontainer.core.subprocess.run", return_value=_ok()) as mock_run: + from indevcontainer.core import run_code + run_code(str(wt)) uris.append(mock_run.call_args[0][0][2]) # Same hex prefix = same container @@ -327,9 +327,9 @@ def test_multiple_worktrees_share_same_container(self, tmp_path): def test_worktree_falls_back_when_no_devcontainer_in_main_repo(self, tmp_path): main_repo, worktree = _make_worktree(tmp_path) - with patch("dcode.core.subprocess.run", return_value=_ok()) as mock_run: - from dcode.core import run_dcode - run_dcode(str(worktree)) + with patch("indevcontainer.core.subprocess.run", return_value=_ok()) as mock_run: + from indevcontainer.core import run_code + run_code(str(worktree)) args = mock_run.call_args[0][0] assert args == ["code", str(worktree)] @@ -340,9 +340,9 @@ def test_worktree_with_custom_workspace_folder(self, tmp_path): dc_dir.mkdir() (dc_dir / "devcontainer.json").write_text('{"workspaceFolder": "/workspace"}') - with patch("dcode.core.subprocess.run", return_value=_ok()) as mock_run: - from dcode.core import run_dcode - run_dcode(str(worktree)) + with patch("indevcontainer.core.subprocess.run", return_value=_ok()) as mock_run: + from indevcontainer.core import run_code + run_code(str(worktree)) uri = mock_run.call_args[0][0][2] assert uri.endswith("/workspace/.worktrees/pr-34") diff --git a/tests/test_devcontainer_cli.py b/tests/test_devcontainer_cli.py index 48ba444..39bbb5b 100644 --- a/tests/test_devcontainer_cli.py +++ b/tests/test_devcontainer_cli.py @@ -1,4 +1,4 @@ -"""Tests for dcode.devcontainer_cli.""" +"""Tests for indevcontainer.devcontainer_cli.""" from __future__ import annotations @@ -8,8 +8,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch -from dcode import devcontainer_cli -from dcode._progress import StreamedResult +from indevcontainer import devcontainer_cli +from indevcontainer._progress import StreamedResult def _completed(rc: int = 0, stdout: str = "", stderr: str = "") -> SimpleNamespace: @@ -45,7 +45,7 @@ def __exit__(self_inner, *args): return False return patch( - "dcode.devcontainer_cli.urllib.request.urlopen", + "indevcontainer.devcontainer_cli.urllib.request.urlopen", return_value=_Ctx(), ) @@ -58,7 +58,7 @@ def __exit__(self_inner, *args): class TestFindCli: def test_returns_path_when_on_path(self): with patch( - "dcode.devcontainer_cli.shutil.which", + "indevcontainer.devcontainer_cli.shutil.which", return_value="/usr/local/bin/devcontainer", ): assert devcontainer_cli.find_cli() == Path("/usr/local/bin/devcontainer") @@ -69,16 +69,16 @@ def test_falls_back_to_default_install_dir(self, tmp_path, monkeypatch): binary = fake_home_bin / "devcontainer" _make_executable(binary) monkeypatch.setattr( - "dcode.devcontainer_cli.DEFAULT_INSTALL_PREFIX", tmp_path + "indevcontainer.devcontainer_cli.DEFAULT_INSTALL_PREFIX", tmp_path ) - with patch("dcode.devcontainer_cli.shutil.which", return_value=None): + with patch("indevcontainer.devcontainer_cli.shutil.which", return_value=None): assert devcontainer_cli.find_cli() == binary def test_returns_none_when_missing_everywhere(self, tmp_path, monkeypatch): monkeypatch.setattr( - "dcode.devcontainer_cli.DEFAULT_INSTALL_PREFIX", tmp_path + "indevcontainer.devcontainer_cli.DEFAULT_INSTALL_PREFIX", tmp_path ) - with patch("dcode.devcontainer_cli.shutil.which", return_value=None): + with patch("indevcontainer.devcontainer_cli.shutil.which", return_value=None): assert devcontainer_cli.find_cli() is None def test_default_dir_path_must_be_executable(self, tmp_path, monkeypatch): @@ -89,9 +89,9 @@ def test_default_dir_path_must_be_executable(self, tmp_path, monkeypatch): binary.write_text("not really an executable") binary.chmod(0o644) monkeypatch.setattr( - "dcode.devcontainer_cli.DEFAULT_INSTALL_PREFIX", tmp_path + "indevcontainer.devcontainer_cli.DEFAULT_INSTALL_PREFIX", tmp_path ) - with patch("dcode.devcontainer_cli.shutil.which", return_value=None): + with patch("indevcontainer.devcontainer_cli.shutil.which", return_value=None): assert devcontainer_cli.find_cli() is None @@ -103,21 +103,21 @@ def test_default_dir_path_must_be_executable(self, tmp_path, monkeypatch): class TestCliVersion: def test_returns_version_string(self): with patch( - "dcode.devcontainer_cli.subprocess.run", + "indevcontainer.devcontainer_cli.subprocess.run", return_value=_completed(0, "0.86.0\n", ""), ): assert devcontainer_cli.cli_version(Path("/x/devcontainer")) == "0.86.0" def test_non_zero_returns_none(self): with patch( - "dcode.devcontainer_cli.subprocess.run", + "indevcontainer.devcontainer_cli.subprocess.run", return_value=_completed(1, "", "boom"), ): assert devcontainer_cli.cli_version(Path("/x/devcontainer")) is None def test_oserror_returns_none(self): with patch( - "dcode.devcontainer_cli.subprocess.run", + "indevcontainer.devcontainer_cli.subprocess.run", side_effect=OSError("nope"), ): assert devcontainer_cli.cli_version(Path("/x/devcontainer")) is None @@ -143,7 +143,7 @@ def fake_run(argv, **kwargs): with ( _ok_download_patch(), patch( - "dcode.devcontainer_cli._progress.run_streaming", + "indevcontainer.devcontainer_cli._progress.run_streaming", side_effect=fake_run, ) as m, ): @@ -162,7 +162,7 @@ def test_download_failure_returns_none(self, tmp_path): console = MagicMock() import urllib.error with patch( - "dcode.devcontainer_cli.urllib.request.urlopen", + "indevcontainer.devcontainer_cli.urllib.request.urlopen", side_effect=urllib.error.URLError("dns"), ): assert ( @@ -175,7 +175,7 @@ def test_install_script_failure_returns_none(self, tmp_path): with ( _ok_download_patch(), patch( - "dcode.devcontainer_cli._progress.run_streaming", + "indevcontainer.devcontainer_cli._progress.run_streaming", return_value=_streamed(2, "", "no permission"), ), ): @@ -190,7 +190,7 @@ def test_install_script_succeeds_but_binary_missing(self, tmp_path): with ( _ok_download_patch(), patch( - "dcode.devcontainer_cli._progress.run_streaming", + "indevcontainer.devcontainer_cli._progress.run_streaming", return_value=_streamed(0, "", ""), ), ): @@ -205,7 +205,7 @@ def test_install_script_oserror_returns_none(self, tmp_path): with ( _ok_download_patch(), patch( - "dcode.devcontainer_cli._progress.run_streaming", + "indevcontainer.devcontainer_cli._progress.run_streaming", return_value=_streamed(-1, "", "", error="missing /bin/sh"), ), ): @@ -232,7 +232,7 @@ def test_success_returns_container_id(self, tmp_path): ) console = MagicMock() with patch( - "dcode.devcontainer_cli._progress.run_streaming", + "indevcontainer.devcontainer_cli._progress.run_streaming", return_value=_streamed(0, success + "\n", "log noise"), ) as m: cid, err = devcontainer_cli.up( @@ -261,7 +261,7 @@ def test_success_with_log_lines_before_json(self, tmp_path): stdout = "preparing build...\nfetching layers...\n" + success + "\n" console = MagicMock() with patch( - "dcode.devcontainer_cli._progress.run_streaming", + "indevcontainer.devcontainer_cli._progress.run_streaming", return_value=_streamed(0, stdout, ""), ): cid, err = devcontainer_cli.up( @@ -283,7 +283,7 @@ def test_error_outcome_returns_description(self, tmp_path): ) console = MagicMock() with patch( - "dcode.devcontainer_cli._progress.run_streaming", + "indevcontainer.devcontainer_cli._progress.run_streaming", return_value=_streamed(1, err_payload + "\n", "stderr noise"), ): cid, err = devcontainer_cli.up( @@ -304,7 +304,7 @@ def test_non_zero_with_no_json_returns_exit_summary(self, tmp_path): # already streamed live, so the summary just points back at it. console = MagicMock() with patch( - "dcode.devcontainer_cli._progress.run_streaming", + "indevcontainer.devcontainer_cli._progress.run_streaming", return_value=_streamed(2, "", "Cannot connect to docker daemon\n"), ): cid, err = devcontainer_cli.up( @@ -321,7 +321,7 @@ def test_launch_oserror_returns_helpful_message(self, tmp_path): # run_streaming surfaces pre-launch OSError via .error. console = MagicMock() with patch( - "dcode.devcontainer_cli._progress.run_streaming", + "indevcontainer.devcontainer_cli._progress.run_streaming", return_value=_streamed(-1, "", "", error="not found"), ): cid, err = devcontainer_cli.up( @@ -340,7 +340,7 @@ def test_zero_exit_with_no_container_id_treated_as_failure(self, tmp_path): weird = json.dumps({"outcome": "success"}) console = MagicMock() with patch( - "dcode.devcontainer_cli._progress.run_streaming", + "indevcontainer.devcontainer_cli._progress.run_streaming", return_value=_streamed(0, weird + "\n", ""), ): cid, err = devcontainer_cli.up( @@ -355,7 +355,7 @@ def test_zero_exit_with_no_container_id_treated_as_failure(self, tmp_path): def test_zero_exit_with_unparseable_output_treated_as_failure(self, tmp_path): console = MagicMock() with patch( - "dcode.devcontainer_cli._progress.run_streaming", + "indevcontainer.devcontainer_cli._progress.run_streaming", return_value=_streamed(0, "totally not json", "logs"), ): cid, err = devcontainer_cli.up( diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 8e806ae..19f5c36 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -1,4 +1,4 @@ -"""Tests for ``dcode doctor`` checks, plan summary, and driver.""" +"""Tests for ``idc doctor`` checks, plan summary, and driver.""" from __future__ import annotations @@ -12,7 +12,7 @@ import pytest from conftest import _make_worktree -from dcode import doctor +from indevcontainer import doctor # --------------------------------------------------------------------------- # check_editor @@ -21,7 +21,7 @@ class TestCheckEditor: def test_both_present(self): - with patch("dcode.doctor.shutil.which", side_effect=lambda x: f"/u/{x}"): + with patch("indevcontainer.doctor.shutil.which", side_effect=lambda x: f"/u/{x}"): status, msg, hint = doctor.check_editor() assert status == "ok" assert "code" in msg and "code-insiders" in msg @@ -29,7 +29,7 @@ def test_both_present(self): def test_only_code(self): with patch( - "dcode.doctor.shutil.which", + "indevcontainer.doctor.shutil.which", side_effect=lambda x: "/u/code" if x == "code" else None, ): status, msg, hint = doctor.check_editor() @@ -39,7 +39,7 @@ def test_only_code(self): def test_only_insiders(self): with patch( - "dcode.doctor.shutil.which", + "indevcontainer.doctor.shutil.which", side_effect=lambda x: "/u/code-insiders" if x == "code-insiders" else None, ): status, msg, hint = doctor.check_editor() @@ -47,7 +47,7 @@ def test_only_insiders(self): assert "code not on PATH" in msg def test_neither(self): - with patch("dcode.doctor.shutil.which", return_value=None): + with patch("indevcontainer.doctor.shutil.which", return_value=None): status, msg, hint = doctor.check_editor() assert status == "fail" assert "neither" in msg @@ -63,8 +63,8 @@ class TestCheckExtension: def test_present(self): cp = CompletedProcess([], 0, "ms-vscode-remote.remote-containers\nfoo.bar\n", "") with ( - patch("dcode.doctor.shutil.which", side_effect=lambda x: "/u/code" if x == "code" else None), - patch("dcode.doctor.subprocess.run", return_value=cp), + patch("indevcontainer.doctor.shutil.which", side_effect=lambda x: "/u/code" if x == "code" else None), + patch("indevcontainer.doctor.subprocess.run", return_value=cp), ): status, msg, hint = doctor.check_extension() assert status == "ok" @@ -74,8 +74,8 @@ def test_present(self): def test_missing(self): cp = CompletedProcess([], 0, "foo.bar\n", "") with ( - patch("dcode.doctor.shutil.which", side_effect=lambda x: "/u/code" if x == "code" else None), - patch("dcode.doctor.subprocess.run", return_value=cp), + patch("indevcontainer.doctor.shutil.which", side_effect=lambda x: "/u/code" if x == "code" else None), + patch("indevcontainer.doctor.subprocess.run", return_value=cp), ): status, msg, hint = doctor.check_extension() assert status == "fail" @@ -84,8 +84,8 @@ def test_missing(self): def test_subprocess_oserror_warns(self): with ( - patch("dcode.doctor.shutil.which", side_effect=lambda x: "/u/code" if x == "code" else None), - patch("dcode.doctor.subprocess.run", side_effect=OSError("boom")), + patch("indevcontainer.doctor.shutil.which", side_effect=lambda x: "/u/code" if x == "code" else None), + patch("indevcontainer.doctor.subprocess.run", side_effect=OSError("boom")), ): status, msg, hint = doctor.check_extension() assert status == "warn" @@ -94,14 +94,14 @@ def test_subprocess_oserror_warns(self): def test_returncode_nonzero_warns(self): cp = CompletedProcess([], 1, "", "boom") with ( - patch("dcode.doctor.shutil.which", side_effect=lambda x: "/u/code" if x == "code" else None), - patch("dcode.doctor.subprocess.run", return_value=cp), + patch("indevcontainer.doctor.shutil.which", side_effect=lambda x: "/u/code" if x == "code" else None), + patch("indevcontainer.doctor.subprocess.run", return_value=cp), ): status, _, _ = doctor.check_extension() assert status == "warn" def test_no_editor_skips(self): - with patch("dcode.doctor.shutil.which", return_value=None): + with patch("indevcontainer.doctor.shutil.which", return_value=None): status, msg, _ = doctor.check_extension() assert status == "skip" assert "no editor" in msg @@ -116,8 +116,8 @@ class TestCheckDocker: def test_ok(self): cp = CompletedProcess([], 0, "29.4.0\n", "") with ( - patch("dcode.doctor.shutil.which", return_value="/u/docker"), - patch("dcode.doctor.subprocess.run", return_value=cp), + patch("indevcontainer.doctor.shutil.which", return_value="/u/docker"), + patch("indevcontainer.doctor.subprocess.run", return_value=cp), ): status, msg, _ = doctor.check_docker() assert status == "ok" @@ -126,8 +126,8 @@ def test_ok(self): def test_daemon_down_fails(self): cp = CompletedProcess([], 1, "", "Cannot connect") with ( - patch("dcode.doctor.shutil.which", return_value="/u/docker"), - patch("dcode.doctor.subprocess.run", return_value=cp), + patch("indevcontainer.doctor.shutil.which", return_value="/u/docker"), + patch("indevcontainer.doctor.subprocess.run", return_value=cp), ): status, msg, hint = doctor.check_docker() assert status == "fail" @@ -135,7 +135,7 @@ def test_daemon_down_fails(self): assert hint def test_no_cli_warns(self): - with patch("dcode.doctor.shutil.which", return_value=None): + with patch("indevcontainer.doctor.shutil.which", return_value=None): status, msg, hint = doctor.check_docker() assert status == "warn" assert "not on PATH" in msg @@ -143,9 +143,9 @@ def test_no_cli_warns(self): def test_timeout_fails(self): with ( - patch("dcode.doctor.shutil.which", return_value="/u/docker"), + patch("indevcontainer.doctor.shutil.which", return_value="/u/docker"), patch( - "dcode.doctor.subprocess.run", + "indevcontainer.doctor.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="docker", timeout=5), ), ): @@ -161,13 +161,13 @@ def test_timeout_fails(self): class TestCheckGit: def test_present(self): - with patch("dcode.doctor.shutil.which", return_value="/usr/bin/git"): + with patch("indevcontainer.doctor.shutil.which", return_value="/usr/bin/git"): status, msg, _ = doctor.check_git() assert status == "ok" assert "/usr/bin/git" in msg def test_missing_warns(self): - with patch("dcode.doctor.shutil.which", return_value=None): + with patch("indevcontainer.doctor.shutil.which", return_value=None): status, _, hint = doctor.check_git() assert status == "warn" assert hint @@ -182,11 +182,11 @@ class TestCheckDevcontainerCli: def test_present_with_version(self): with ( patch( - "dcode.doctor.devcontainer_cli.find_cli", + "indevcontainer.doctor.devcontainer_cli.find_cli", return_value=Path("/u/local/bin/devcontainer"), ), patch( - "dcode.doctor.devcontainer_cli.cli_version", + "indevcontainer.doctor.devcontainer_cli.cli_version", return_value="0.86.0", ), ): @@ -199,17 +199,17 @@ def test_present_with_version(self): def test_present_without_version_still_ok(self): with ( patch( - "dcode.doctor.devcontainer_cli.find_cli", + "indevcontainer.doctor.devcontainer_cli.find_cli", return_value=Path("/x/devcontainer"), ), - patch("dcode.doctor.devcontainer_cli.cli_version", return_value=None), + patch("indevcontainer.doctor.devcontainer_cli.cli_version", return_value=None), ): status, msg, _ = doctor.check_devcontainer_cli() assert status == "ok" assert "/x/devcontainer" in msg def test_missing_warns_with_install_hint(self): - with patch("dcode.doctor.devcontainer_cli.find_cli", return_value=None): + with patch("indevcontainer.doctor.devcontainer_cli.find_cli", return_value=None): status, msg, hint = doctor.check_devcontainer_cli() assert status == "warn" assert "not on PATH" in msg @@ -225,13 +225,13 @@ def test_missing_warns_with_install_hint(self): class TestCheckWsl: def test_not_in_wsl(self): - with patch("dcode.doctor.is_wsl", return_value=False): + with patch("indevcontainer.doctor.is_wsl", return_value=False): status, msg, _ = doctor.check_wsl() assert status == "ok" assert "not running in WSL" in msg def test_in_wsl(self): - with patch("dcode.doctor.is_wsl", return_value=True): + with patch("indevcontainer.doctor.is_wsl", return_value=True): status, msg, _ = doctor.check_wsl() assert status == "ok" assert "detected" in msg @@ -239,13 +239,13 @@ def test_in_wsl(self): class TestCheckWslDistro: def test_present(self): - with patch("dcode.doctor.get_wsl_distro", return_value="Ubuntu"): + with patch("indevcontainer.doctor.get_wsl_distro", return_value="Ubuntu"): status, msg, _ = doctor.check_wsl_distro() assert status == "ok" assert "Ubuntu" in msg def test_missing(self): - with patch("dcode.doctor.get_wsl_distro", return_value=None): + with patch("indevcontainer.doctor.get_wsl_distro", return_value=None): status, _, hint = doctor.check_wsl_distro() assert status == "warn" assert hint @@ -254,7 +254,7 @@ def test_missing(self): class TestCheckWslSettingsPaths: def test_resolves(self, tmp_path): with patch( - "dcode.doctor._get_windows_vscode_settings_path", + "indevcontainer.doctor._get_windows_vscode_settings_path", side_effect=lambda insiders: tmp_path / ("ins" if insiders else "stable"), ): results = doctor.check_wsl_settings_paths() @@ -262,7 +262,7 @@ def test_resolves(self, tmp_path): assert len(results) == 2 def test_unresolvable_warns(self): - with patch("dcode.doctor._get_windows_vscode_settings_path", return_value=None): + with patch("indevcontainer.doctor._get_windows_vscode_settings_path", return_value=None): results = doctor.check_wsl_settings_paths() assert all(r[0] == "warn" for r in results) @@ -275,9 +275,9 @@ def test_correct(self, tmp_path): "dev.containers.executeInWSLDistro": "Ubuntu", })) with ( - patch("dcode.doctor._get_windows_vscode_settings_path", + patch("indevcontainer.doctor._get_windows_vscode_settings_path", side_effect=lambda insiders: settings if not insiders else None), - patch("dcode.doctor.get_wsl_distro", return_value="Ubuntu"), + patch("indevcontainer.doctor.get_wsl_distro", return_value="Ubuntu"), ): results = doctor.check_wsl_executeInWSL_settings() assert results @@ -287,9 +287,9 @@ def test_missing_warns(self, tmp_path): settings = tmp_path / "settings.json" settings.write_text("{}") with ( - patch("dcode.doctor._get_windows_vscode_settings_path", + patch("indevcontainer.doctor._get_windows_vscode_settings_path", side_effect=lambda insiders: settings if not insiders else None), - patch("dcode.doctor.get_wsl_distro", return_value="Ubuntu"), + patch("indevcontainer.doctor.get_wsl_distro", return_value="Ubuntu"), ): results = doctor.check_wsl_executeInWSL_settings() assert results[0][0] == "warn" @@ -298,9 +298,9 @@ def test_parse_error_warns(self, tmp_path): settings = tmp_path / "settings.json" settings.write_text("{ not json") with ( - patch("dcode.doctor._get_windows_vscode_settings_path", + patch("indevcontainer.doctor._get_windows_vscode_settings_path", side_effect=lambda insiders: settings if not insiders else None), - patch("dcode.doctor.get_wsl_distro", return_value="Ubuntu"), + patch("indevcontainer.doctor.get_wsl_distro", return_value="Ubuntu"), ): results = doctor.check_wsl_executeInWSL_settings() assert results[0][0] == "warn" @@ -413,9 +413,9 @@ def test_external_warns(self, tmp_path): class TestCheckVersion: def test_up_to_date(self): with ( - patch("dcode.doctor.dcode.__version__", "0.4.2"), + patch("indevcontainer.doctor.indevcontainer.__version__", "0.4.2"), patch( - "dcode.doctor.version_check.get_latest_release", + "indevcontainer.doctor.version_check.get_latest_release", return_value={"tag_name": "v0.4.2", "html_url": "https://x"}, ), ): @@ -425,22 +425,22 @@ def test_up_to_date(self): def test_behind(self): with ( - patch("dcode.doctor.dcode.__version__", "0.4.1"), + patch("indevcontainer.doctor.indevcontainer.__version__", "0.4.1"), patch( - "dcode.doctor.version_check.get_latest_release", + "indevcontainer.doctor.version_check.get_latest_release", return_value={"tag_name": "v0.4.2", "html_url": "https://x"}, ), ): status, msg, hint = doctor.check_version() assert status == "warn" assert "https://x" in msg - assert hint and "dcode update" in hint + assert hint and "idc update" in hint def test_ahead_dev(self): with ( - patch("dcode.doctor.dcode.__version__", "0.4.2.dev1+g123"), + patch("indevcontainer.doctor.indevcontainer.__version__", "0.4.2.dev1+g123"), patch( - "dcode.doctor.version_check.get_latest_release", + "indevcontainer.doctor.version_check.get_latest_release", return_value={"tag_name": "v0.4.2", "html_url": "https://x"}, ), ): @@ -449,10 +449,10 @@ def test_ahead_dev(self): assert "ahead" in msg def test_network_error_warns(self): - from dcode.version_check import NetworkError + from indevcontainer.version_check import NetworkError with patch( - "dcode.doctor.version_check.get_latest_release", + "indevcontainer.doctor.version_check.get_latest_release", side_effect=NetworkError("offline"), ): status, msg, hint = doctor.check_version() @@ -468,26 +468,26 @@ def test_network_error_warns(self): class TestCheckInstallMethod: def test_uv_tool(self): - with patch("dcode.doctor.update.detect_install_method", return_value="uv-tool"): + with patch("indevcontainer.doctor.update.detect_install_method", return_value="uv-tool"): status, msg, _ = doctor.check_install_method() assert status == "ok" assert "uv tool" in msg def test_not_uv_tool(self): - with patch("dcode.doctor.update.detect_install_method", return_value="not-uv-tool"): + with patch("indevcontainer.doctor.update.detect_install_method", return_value="not-uv-tool"): status, _, hint = doctor.check_install_method() assert status == "warn" assert hint and "uv tool install" in hint def test_uv_missing(self): - with patch("dcode.doctor.update.detect_install_method", return_value="uv-missing"): + with patch("indevcontainer.doctor.update.detect_install_method", return_value="uv-missing"): status, msg, hint = doctor.check_install_method() assert status == "warn" assert "uv" in msg assert hint def test_unknown(self): - with patch("dcode.doctor.update.detect_install_method", return_value="unknown"): + with patch("indevcontainer.doctor.update.detect_install_method", return_value="unknown"): status, _, _ = doctor.check_install_method() assert status == "warn" @@ -504,7 +504,7 @@ def test_no_editor(self, tmp_path, capsys): assert "no editor available" in err def test_no_devcontainer_no_worktree(self, tmp_path, capsys): - with patch("dcode.doctor.is_wsl", return_value=False): + with patch("indevcontainer.doctor.is_wsl", return_value=False): doctor.render_plan(tmp_path, code_present=True, insiders_present=False) err = capsys.readouterr().err assert "directly" in err @@ -514,7 +514,7 @@ def test_with_devcontainer_no_worktree(self, tmp_path, capsys): dc = tmp_path / ".devcontainer" dc.mkdir() (dc / "devcontainer.json").write_text('{"workspaceFolder": "/work"}') - with patch("dcode.doctor.is_wsl", return_value=False): + with patch("indevcontainer.doctor.is_wsl", return_value=False): doctor.render_plan(tmp_path, code_present=True, insiders_present=False) err = capsys.readouterr().err assert "devcontainer.json" in err @@ -527,7 +527,7 @@ def test_with_worktree_and_devcontainer(self, tmp_path, capsys): dc = main_repo / ".devcontainer" dc.mkdir() (dc / "devcontainer.json").write_text('{"workspaceFolder": "/work"}') - with patch("dcode.doctor.is_wsl", return_value=False): + with patch("indevcontainer.doctor.is_wsl", return_value=False): doctor.render_plan(worktree, code_present=True, insiders_present=False) err = capsys.readouterr().err assert "MAIN repo" in err @@ -535,7 +535,7 @@ def test_with_worktree_and_devcontainer(self, tmp_path, capsys): def test_external_worktree(self, tmp_path, capsys): (tmp_path / ".git").write_text("gitdir: /elsewhere/that/does/not/exist\n") - with patch("dcode.doctor.is_wsl", return_value=False): + with patch("indevcontainer.doctor.is_wsl", return_value=False): doctor.render_plan(tmp_path, code_present=True, insiders_present=False) err = capsys.readouterr().err assert "cannot be resolved" in err @@ -547,10 +547,10 @@ def test_wsl_shows_uri_and_settings(self, tmp_path, capsys): settings = tmp_path / "settings.json" settings.write_text("{}") with ( - patch("dcode.doctor.is_wsl", return_value=True), - patch("dcode.doctor._wsl_to_windows_path", return_value="\\\\wsl.localhost\\Ubuntu\\x"), - patch("dcode.doctor._get_windows_vscode_settings_path", return_value=settings), - patch("dcode.doctor.get_wsl_distro", return_value="Ubuntu"), + patch("indevcontainer.doctor.is_wsl", return_value=True), + patch("indevcontainer.doctor._wsl_to_windows_path", return_value="\\\\wsl.localhost\\Ubuntu\\x"), + patch("indevcontainer.doctor._get_windows_vscode_settings_path", return_value=settings), + patch("indevcontainer.doctor.get_wsl_distro", return_value="Ubuntu"), ): doctor.render_plan(tmp_path, code_present=True, insiders_present=False) err = capsys.readouterr().err @@ -567,30 +567,30 @@ def test_wsl_settings_no_change(self, tmp_path, capsys): "dev.containers.executeInWSLDistro": "Ubuntu", })) with ( - patch("dcode.doctor.is_wsl", return_value=True), - patch("dcode.doctor._wsl_to_windows_path", return_value="\\\\wsl\\Ubuntu\\x"), - patch("dcode.doctor._get_windows_vscode_settings_path", return_value=settings), - patch("dcode.doctor.get_wsl_distro", return_value="Ubuntu"), + patch("indevcontainer.doctor.is_wsl", return_value=True), + patch("indevcontainer.doctor._wsl_to_windows_path", return_value="\\\\wsl\\Ubuntu\\x"), + patch("indevcontainer.doctor._get_windows_vscode_settings_path", return_value=settings), + patch("indevcontainer.doctor.get_wsl_distro", return_value="Ubuntu"), ): doctor.render_plan(tmp_path, code_present=True, insiders_present=False) err = capsys.readouterr().err assert "already correct" in err def test_editor_only_code_no_note(self, tmp_path, capsys): - with patch("dcode.doctor.is_wsl", return_value=False): + with patch("indevcontainer.doctor.is_wsl", return_value=False): doctor.render_plan(tmp_path, code_present=True, insiders_present=False) err = capsys.readouterr().err assert "also available" not in err def test_editor_both_shows_note(self, tmp_path, capsys): - with patch("dcode.doctor.is_wsl", return_value=False): + with patch("indevcontainer.doctor.is_wsl", return_value=False): doctor.render_plan(tmp_path, code_present=True, insiders_present=True) err = capsys.readouterr().err assert "also available" in err assert "code-insiders" in err def test_editor_only_insiders(self, tmp_path, capsys): - with patch("dcode.doctor.is_wsl", return_value=False): + with patch("indevcontainer.doctor.is_wsl", return_value=False): doctor.render_plan(tmp_path, code_present=False, insiders_present=True) err = capsys.readouterr().err assert "code-insiders" in err @@ -609,10 +609,10 @@ def _all_ok(*_args, **_kw): class TestRunDoctor: def test_no_failures_exits_0(self, tmp_path, capsys): with ( - patch("dcode.doctor.shutil.which", return_value="/u/x"), - patch("dcode.doctor.is_wsl", return_value=False), + patch("indevcontainer.doctor.shutil.which", return_value="/u/x"), + patch("indevcontainer.doctor.is_wsl", return_value=False), patch.multiple( - "dcode.doctor", + "indevcontainer.doctor", check_editor=lambda: ("ok", "e", None), check_extension=lambda: ("ok", "x", None), check_docker=lambda: ("ok", "d", None), @@ -628,15 +628,15 @@ def test_no_failures_exits_0(self, tmp_path, capsys): rc = doctor.run_doctor(tmp_path) assert rc == 0 err = capsys.readouterr().err - assert "dcode doctor:" in err + assert "idc doctor:" in err assert " 0 fail" in err def test_with_failure_exits_1(self, tmp_path, capsys): with ( - patch("dcode.doctor.shutil.which", return_value="/u/x"), - patch("dcode.doctor.is_wsl", return_value=False), + patch("indevcontainer.doctor.shutil.which", return_value="/u/x"), + patch("indevcontainer.doctor.is_wsl", return_value=False), patch.multiple( - "dcode.doctor", + "indevcontainer.doctor", check_editor=lambda: ("ok", "e", None), check_extension=lambda: ("ok", "x", None), check_docker=lambda: ("fail", "d", "h"), @@ -657,10 +657,10 @@ def test_with_failure_exits_1(self, tmp_path, capsys): def test_summary_line_format(self, tmp_path, capsys): with ( - patch("dcode.doctor.shutil.which", return_value=None), - patch("dcode.doctor.is_wsl", return_value=False), + patch("indevcontainer.doctor.shutil.which", return_value=None), + patch("indevcontainer.doctor.is_wsl", return_value=False), patch.multiple( - "dcode.doctor", + "indevcontainer.doctor", check_editor=lambda: ("ok", "e", None), check_extension=lambda: ("ok", "x", None), check_docker=lambda: ("warn", "d", "h"), @@ -675,14 +675,14 @@ def test_summary_line_format(self, tmp_path, capsys): ): doctor.run_doctor(tmp_path) err = capsys.readouterr().err - assert "dcode doctor: 9 ok, 2 warn, 0 fail" in err + assert "idc doctor: 9 ok, 2 warn, 0 fail" in err def test_plan_failure_does_not_change_exit_code(self, tmp_path, capsys): with ( - patch("dcode.doctor.shutil.which", return_value="/u/x"), - patch("dcode.doctor.is_wsl", return_value=False), + patch("indevcontainer.doctor.shutil.which", return_value="/u/x"), + patch("indevcontainer.doctor.is_wsl", return_value=False), patch.multiple( - "dcode.doctor", + "indevcontainer.doctor", check_editor=lambda: ("ok", "e", None), check_extension=lambda: ("ok", "x", None), check_docker=lambda: ("ok", "d", None), @@ -709,10 +709,10 @@ def cap(p): return ("ok", "x", None) with ( - patch("dcode.doctor.shutil.which", return_value="/u/x"), - patch("dcode.doctor.is_wsl", return_value=False), + patch("indevcontainer.doctor.shutil.which", return_value="/u/x"), + patch("indevcontainer.doctor.is_wsl", return_value=False), patch.multiple( - "dcode.doctor", + "indevcontainer.doctor", check_editor=lambda: ("ok", "e", None), check_extension=lambda: ("ok", "x", None), check_docker=lambda: ("ok", "d", None), @@ -743,10 +743,10 @@ def cap(p): def test_run_doctor_no_color_emits_no_ansi(tmp_path, capsys, monkeypatch): monkeypatch.setenv("NO_COLOR", "1") with ( - patch("dcode.doctor.shutil.which", return_value="/u/x"), - patch("dcode.doctor.is_wsl", return_value=False), + patch("indevcontainer.doctor.shutil.which", return_value="/u/x"), + patch("indevcontainer.doctor.is_wsl", return_value=False), patch.multiple( - "dcode.doctor", + "indevcontainer.doctor", check_editor=lambda: ("ok", "e", None), check_extension=lambda: ("ok", "x", None), check_docker=lambda: ("warn", "d", "h"), @@ -761,7 +761,7 @@ def test_run_doctor_no_color_emits_no_ansi(tmp_path, capsys, monkeypatch): ): doctor.run_doctor(tmp_path) err = capsys.readouterr().err - assert "dcode doctor:" in err + assert "idc doctor:" in err assert _ANSI_RE.search(err) is None, f"ANSI escapes leaked: {err!r}" diff --git a/tests/test_progress.py b/tests/test_progress.py index dc73870..f5167f9 100644 --- a/tests/test_progress.py +++ b/tests/test_progress.py @@ -1,11 +1,11 @@ -"""Tests for dcode._progress.""" +"""Tests for indevcontainer._progress.""" from __future__ import annotations import sys from unittest.mock import MagicMock, patch -from dcode._progress import StreamedResult, run_streaming, with_spinner +from indevcontainer._progress import StreamedResult, run_streaming, with_spinner # --------------------------------------------------------------------------- # with_spinner @@ -85,7 +85,7 @@ def test_no_output_subprocess(self): def test_oserror_returns_error_field(self): result = run_streaming( - ["/this/binary/does/not/exist/dcode-test"], + ["/this/binary/does/not/exist/idc-test"], label="L", ) assert result.returncode == -1 @@ -146,7 +146,7 @@ def test_uses_supplied_console(self, capsys): class TestRunStreamingMocked: def test_popen_invoked_with_pipes_and_text_mode(self): - with patch("dcode._progress.subprocess.Popen") as popen: + with patch("indevcontainer._progress.subprocess.Popen") as popen: mock_proc = MagicMock() mock_proc.stdout.read.return_value = "" mock_proc.stderr.__iter__.return_value = iter([]) diff --git a/tests/test_shell.py b/tests/test_shell.py index c0eeef6..7c98e66 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -1,4 +1,4 @@ -"""Tests for dcode.shell.""" +"""Tests for indevcontainer.shell.""" from __future__ import annotations @@ -10,7 +10,7 @@ from conftest import _make_worktree -from dcode.shell import ( +from indevcontainer.shell import ( ContainerLookup, ResolvedShell, _build_missing_container, @@ -93,12 +93,12 @@ class TestFindContainer: def _patch_run(self, results): """Return a MagicMock that returns successive results from `results`.""" m = MagicMock(side_effect=results) - return patch("dcode.shell.subprocess.run", m), m + return patch("indevcontainer.shell.subprocess.run", m), m def test_two_label_hit_running(self): results = [_completed(0, "abc123\n", "")] ctx, m = self._patch_run(results) - with patch("dcode.shell.is_wsl", return_value=False), ctx: + with patch("indevcontainer.shell.is_wsl", return_value=False), ctx: result = find_container("/host/proj", "/host/proj/.devcontainer/devcontainer.json") assert result == ContainerLookup(state="running", id="abc123") assert m.call_count == 1 @@ -109,7 +109,7 @@ def test_two_label_hit_running(self): def test_single_label_fallback_uses_one_filter(self): results = [_completed(0, "", ""), _completed(0, "deadbeef\n", "")] ctx, m = self._patch_run(results) - with patch("dcode.shell.is_wsl", return_value=False), ctx: + with patch("indevcontainer.shell.is_wsl", return_value=False), ctx: result = find_container("/host/proj", "/host/proj/.devcontainer/devcontainer.json") assert result.state == "running" assert result.id == "deadbeef" @@ -123,7 +123,7 @@ def test_stopped_container_via_dash_a(self): _completed(0, "stopped1\nstopped2\n", ""), ] ctx, m = self._patch_run(results) - with patch("dcode.shell.is_wsl", return_value=False), ctx: + with patch("indevcontainer.shell.is_wsl", return_value=False), ctx: result = find_container("/host/proj", "/host/proj/.devcontainer/devcontainer.json") assert result.state == "stopped" assert result.id == "stopped1" @@ -135,21 +135,21 @@ def test_stopped_container_via_dash_a(self): def test_missing_when_no_results_anywhere(self): results = [_completed(0, "", "")] * 3 ctx, _ = self._patch_run(results) - with patch("dcode.shell.is_wsl", return_value=False), ctx: + with patch("indevcontainer.shell.is_wsl", return_value=False), ctx: result = find_container("/host/proj", "/host/proj/.devcontainer/devcontainer.json") assert result == ContainerLookup(state="missing") def test_ambiguous_when_two_label_returns_multiple(self): results = [_completed(0, "id1\nid2\nid3\n", "")] ctx, _ = self._patch_run(results) - with patch("dcode.shell.is_wsl", return_value=False), ctx: + with patch("indevcontainer.shell.is_wsl", return_value=False), ctx: result = find_container("/host/proj", "/host/proj/.devcontainer/devcontainer.json") assert result.state == "ambiguous" assert result.ids == ("id1", "id2", "id3") def test_docker_unavailable_when_file_not_found(self): ctx, _ = self._patch_run([FileNotFoundError("docker not found")]) - with patch("dcode.shell.is_wsl", return_value=False), ctx: + with patch("indevcontainer.shell.is_wsl", return_value=False), ctx: result = find_container("/host/proj", "/host/proj/.devcontainer/devcontainer.json") assert result.state == "docker_unavailable" assert result.detail and "docker" in result.detail.lower() @@ -157,7 +157,7 @@ def test_docker_unavailable_when_file_not_found(self): def test_docker_unavailable_when_nonzero_returncode(self): results = [_completed(1, "", "Cannot connect to the Docker daemon")] ctx, _ = self._patch_run(results) - with patch("dcode.shell.is_wsl", return_value=False), ctx: + with patch("indevcontainer.shell.is_wsl", return_value=False), ctx: result = find_container("/host/proj", "/host/proj/.devcontainer/devcontainer.json") assert result.state == "docker_unavailable" assert result.detail and "Docker daemon" in result.detail @@ -166,8 +166,8 @@ def test_wsl_converts_both_paths_for_label_filters(self): results = [_completed(0, "wid\n", "")] ctx, m = self._patch_run(results) with ( - patch("dcode.shell.is_wsl", return_value=True), - patch("dcode.shell._wsl_to_windows_path", side_effect=lambda p: f"WIN({p})"), + patch("indevcontainer.shell.is_wsl", return_value=True), + patch("indevcontainer.shell._wsl_to_windows_path", side_effect=lambda p: f"WIN({p})"), ctx, ): result = find_container("/h/proj", "/h/proj/.devcontainer/devcontainer.json") @@ -185,46 +185,46 @@ def test_wsl_converts_both_paths_for_label_filters(self): class TestGetUserSettingsPath: def test_macos_default(self, monkeypatch): - monkeypatch.setattr("dcode.shell.platform.system", lambda: "Darwin") - with patch("dcode.shell.is_wsl", return_value=False): + monkeypatch.setattr("indevcontainer.shell.platform.system", lambda: "Darwin") + with patch("indevcontainer.shell.is_wsl", return_value=False): p = get_user_settings_path(insiders=False) assert p == Path.home() / "Library" / "Application Support" / "Code" / "User" / "settings.json" def test_macos_insiders(self, monkeypatch): - monkeypatch.setattr("dcode.shell.platform.system", lambda: "Darwin") - with patch("dcode.shell.is_wsl", return_value=False): + monkeypatch.setattr("indevcontainer.shell.platform.system", lambda: "Darwin") + with patch("indevcontainer.shell.is_wsl", return_value=False): p = get_user_settings_path(insiders=True) assert p is not None assert "Code - Insiders" in str(p) def test_linux_no_xdg(self, monkeypatch): - monkeypatch.setattr("dcode.shell.platform.system", lambda: "Linux") + monkeypatch.setattr("indevcontainer.shell.platform.system", lambda: "Linux") monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) - with patch("dcode.shell.is_wsl", return_value=False): + with patch("indevcontainer.shell.is_wsl", return_value=False): p = get_user_settings_path(insiders=False) assert p == Path.home() / ".config" / "Code" / "User" / "settings.json" def test_linux_with_xdg(self, monkeypatch): - monkeypatch.setattr("dcode.shell.platform.system", lambda: "Linux") + monkeypatch.setattr("indevcontainer.shell.platform.system", lambda: "Linux") monkeypatch.setenv("XDG_CONFIG_HOME", "/custom") - with patch("dcode.shell.is_wsl", return_value=False): + with patch("indevcontainer.shell.is_wsl", return_value=False): p = get_user_settings_path(insiders=False) assert p == Path("/custom") / "Code" / "User" / "settings.json" def test_wsl_delegates_to_windows_helper(self): sentinel = Path("/mnt/c/Users/me/AppData/Roaming/Code/User/settings.json") with ( - patch("dcode.shell.is_wsl", return_value=True), - patch("dcode.shell.get_windows_vscode_settings_path", return_value=sentinel) as m, + patch("indevcontainer.shell.is_wsl", return_value=True), + patch("indevcontainer.shell.get_windows_vscode_settings_path", return_value=sentinel) as m, ): p = get_user_settings_path(insiders=True) assert p == sentinel m.assert_called_once_with(True) def test_returns_path_even_when_not_existing(self, monkeypatch): - monkeypatch.setattr("dcode.shell.platform.system", lambda: "Linux") + monkeypatch.setattr("indevcontainer.shell.platform.system", lambda: "Linux") monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) - with patch("dcode.shell.is_wsl", return_value=False): + with patch("indevcontainer.shell.is_wsl", return_value=False): p = get_user_settings_path(insiders=False) # Path is returned regardless of existence. assert p is not None @@ -265,7 +265,7 @@ def test_workspace_beats_devcontainer_beats_user(self, tmp_path): "terminal.integrated.profiles.linux": {"ws-shell": {"path": "/w"}}, } main_repo, user_path = self._setup(tmp_path, user, workspace) - with patch("dcode.shell.get_user_settings_path", return_value=user_path): + with patch("indevcontainer.shell.get_user_settings_path", return_value=user_path): r = resolve_terminal_profile(main_repo, dc_cfg, insiders=False) assert r == ResolvedShell(path="/w") @@ -285,7 +285,7 @@ def test_deep_merge_across_layers(self, tmp_path): "terminal.integrated.profiles.linux": {"gamma": {"path": "/g"}}, } main_repo, user_path = self._setup(tmp_path, user, workspace) - with patch("dcode.shell.get_user_settings_path", return_value=user_path): + with patch("indevcontainer.shell.get_user_settings_path", return_value=user_path): r = resolve_terminal_profile(main_repo, dc_cfg, insiders=False) # alpha was defined only in user layer; merge preserves it. assert r == ResolvedShell(path="/a") @@ -297,7 +297,7 @@ def test_null_at_higher_layer_deletes_profile(self, tmp_path): } workspace = {"terminal.integrated.profiles.linux": {"alpha": None}} main_repo, user_path = self._setup(tmp_path, user, workspace) - with patch("dcode.shell.get_user_settings_path", return_value=user_path): + with patch("indevcontainer.shell.get_user_settings_path", return_value=user_path): r = resolve_terminal_profile(main_repo, {}, insiders=False) assert r is None @@ -305,7 +305,7 @@ def test_default_pointing_to_missing_profile_returns_none(self, tmp_path): user = {} workspace = {"terminal.integrated.defaultProfile.linux": "foo"} main_repo, user_path = self._setup(tmp_path, user, workspace) - with patch("dcode.shell.get_user_settings_path", return_value=user_path): + with patch("indevcontainer.shell.get_user_settings_path", return_value=user_path): r = resolve_terminal_profile(main_repo, {}, insiders=False) assert r is None @@ -316,7 +316,7 @@ def test_default_pointing_to_null_profile_returns_none(self, tmp_path): } workspace = {} main_repo, user_path = self._setup(tmp_path, user, workspace) - with patch("dcode.shell.get_user_settings_path", return_value=user_path): + with patch("indevcontainer.shell.get_user_settings_path", return_value=user_path): r = resolve_terminal_profile(main_repo, {}, insiders=False) assert r is None @@ -326,7 +326,7 @@ def test_path_as_list_uses_first_entry(self, tmp_path): "terminal.integrated.profiles.linux": {"a": {"path": ["/first", "/second"]}}, } main_repo, user_path = self._setup(tmp_path, {}, workspace) - with patch("dcode.shell.get_user_settings_path", return_value=user_path): + with patch("indevcontainer.shell.get_user_settings_path", return_value=user_path): r = resolve_terminal_profile(main_repo, {}, insiders=False) assert r is not None assert r.path == "/first" @@ -337,7 +337,7 @@ def test_bare_name_returned_as_is(self, tmp_path): "terminal.integrated.profiles.linux": {"a": {"path": "zsh"}}, } main_repo, user_path = self._setup(tmp_path, {}, workspace) - with patch("dcode.shell.get_user_settings_path", return_value=user_path): + with patch("indevcontainer.shell.get_user_settings_path", return_value=user_path): r = resolve_terminal_profile(main_repo, {}, insiders=False) assert r is not None assert r.path == "zsh" @@ -348,7 +348,7 @@ def test_args_become_tuple(self, tmp_path): "terminal.integrated.profiles.linux": {"a": {"path": "/bin/zsh", "args": ["-l"]}}, } main_repo, user_path = self._setup(tmp_path, {}, workspace) - with patch("dcode.shell.get_user_settings_path", return_value=user_path): + with patch("indevcontainer.shell.get_user_settings_path", return_value=user_path): r = resolve_terminal_profile(main_repo, {}, insiders=False) assert r is not None assert r.args == ("-l",) @@ -361,7 +361,7 @@ def test_env_becomes_tuple_of_tuples(self, tmp_path): }, } main_repo, user_path = self._setup(tmp_path, {}, workspace) - with patch("dcode.shell.get_user_settings_path", return_value=user_path): + with patch("indevcontainer.shell.get_user_settings_path", return_value=user_path): r = resolve_terminal_profile(main_repo, {}, insiders=False) assert r is not None assert r.env == (("FOO", "bar"),) @@ -378,7 +378,7 @@ def test_substitution_warning_emitted_at_most_once(self, tmp_path, capsys): }, } main_repo, user_path = self._setup(tmp_path, {}, workspace) - with patch("dcode.shell.get_user_settings_path", return_value=user_path): + with patch("indevcontainer.shell.get_user_settings_path", return_value=user_path): r = resolve_terminal_profile(main_repo, {}, insiders=False) assert r is not None # Substitution values passed through verbatim. @@ -387,7 +387,7 @@ def test_substitution_warning_emitted_at_most_once(self, tmp_path, capsys): err = capsys.readouterr().err # Single warning line — count by line-prefix to avoid matching the word # "substitution" twice within the message body itself. - assert err.count("dcode: terminal profile contains") == 1 + assert err.count("idc: terminal profile contains") == 1 def test_profile_without_path_returns_none(self, tmp_path): workspace = { @@ -395,7 +395,7 @@ def test_profile_without_path_returns_none(self, tmp_path): "terminal.integrated.profiles.linux": {"a": {"args": ["-l"]}}, } main_repo, user_path = self._setup(tmp_path, {}, workspace) - with patch("dcode.shell.get_user_settings_path", return_value=user_path): + with patch("indevcontainer.shell.get_user_settings_path", return_value=user_path): r = resolve_terminal_profile(main_repo, {}, insiders=False) assert r is None @@ -408,7 +408,7 @@ def test_profile_without_path_returns_none(self, tmp_path): class TestDetectLoginShell: def test_getent_returns_zsh(self): results = [_completed(0, "node:x:1000:1000::/home/node:/bin/zsh\n", "")] - with patch("dcode.shell.subprocess.run", side_effect=results): + with patch("indevcontainer.shell.subprocess.run", side_effect=results): assert detect_login_shell("cid", "node") == "/bin/zsh" def test_nologin_falls_through_to_bash(self): @@ -416,7 +416,7 @@ def test_nologin_falls_through_to_bash(self): _completed(0, "svc:x:0:0::/:/usr/sbin/nologin\n", ""), _completed(0, "", ""), # /bin/bash test -x ] - with patch("dcode.shell.subprocess.run", side_effect=results): + with patch("indevcontainer.shell.subprocess.run", side_effect=results): assert detect_login_shell("cid", "svc") == "/bin/bash" def test_false_shell_falls_through_to_bash(self): @@ -424,7 +424,7 @@ def test_false_shell_falls_through_to_bash(self): _completed(0, "svc:x:0:0::/:/bin/false\n", ""), _completed(0, "", ""), ] - with patch("dcode.shell.subprocess.run", side_effect=results): + with patch("indevcontainer.shell.subprocess.run", side_effect=results): assert detect_login_shell("cid", "svc") == "/bin/bash" def test_getent_failure_uses_bash_when_available(self): @@ -432,7 +432,7 @@ def test_getent_failure_uses_bash_when_available(self): _completed(2, "", "no such user"), _completed(0, "", ""), # /bin/bash exists ] - with patch("dcode.shell.subprocess.run", side_effect=results): + with patch("indevcontainer.shell.subprocess.run", side_effect=results): assert detect_login_shell("cid", "ghost") == "/bin/bash" def test_no_bash_falls_through_to_sh(self): @@ -441,7 +441,7 @@ def test_no_bash_falls_through_to_sh(self): _completed(1, "", ""), # /bin/bash test -x fails _completed(0, "", ""), # /bin/sh test -x ok ] - with patch("dcode.shell.subprocess.run", side_effect=results): + with patch("indevcontainer.shell.subprocess.run", side_effect=results): assert detect_login_shell("cid", "x") == "/bin/sh" def test_exec_user_none_invokes_id_un_first(self): @@ -450,7 +450,7 @@ def test_exec_user_none_invokes_id_un_first(self): _completed(0, "vscode:x:1000:1000::/home/vscode:/bin/zsh\n", ""), ] m = MagicMock(side_effect=results) - with patch("dcode.shell.subprocess.run", m): + with patch("indevcontainer.shell.subprocess.run", m): assert detect_login_shell("cid", None) == "/bin/zsh" # First call must be id -un: assert m.call_args_list[0].args[0][-2:] == ["id", "-un"] @@ -468,7 +468,7 @@ class TestInspectContainerMetadata: def test_returns_list_for_array_label(self): label = json.dumps([{"id": "feat"}, {"remoteUser": "node"}]) with patch( - "dcode.shell.subprocess.run", + "indevcontainer.shell.subprocess.run", return_value=_completed(0, label + "\n", ""), ) as m: result = _inspect_container_metadata("cid") @@ -484,7 +484,7 @@ def test_object_label_wrapped_in_single_entry(self): # Older/custom images may write a JSON object instead of an array. label = json.dumps({"remoteUser": "vscode"}) with patch( - "dcode.shell.subprocess.run", + "indevcontainer.shell.subprocess.run", return_value=_completed(0, label + "\n", ""), ): assert _inspect_container_metadata("cid") == [{"remoteUser": "vscode"}] @@ -492,7 +492,7 @@ def test_object_label_wrapped_in_single_entry(self): def test_non_dict_array_entries_filtered(self): label = json.dumps([{"a": 1}, "junk", 42, None, {"b": 2}]) with patch( - "dcode.shell.subprocess.run", + "indevcontainer.shell.subprocess.run", return_value=_completed(0, label + "\n", ""), ): assert _inspect_container_metadata("cid") == [{"a": 1}, {"b": 2}] @@ -502,35 +502,35 @@ def test_missing_label_returns_empty(self): # (or "" depending on Docker version). for stdout in ("", "\n", "\n"): with patch( - "dcode.shell.subprocess.run", + "indevcontainer.shell.subprocess.run", return_value=_completed(0, stdout, ""), ): assert _inspect_container_metadata("cid") == [] def test_malformed_json_returns_empty(self): with patch( - "dcode.shell.subprocess.run", + "indevcontainer.shell.subprocess.run", return_value=_completed(0, "not json at all\n", ""), ): assert _inspect_container_metadata("cid") == [] def test_top_level_scalar_returns_empty(self): with patch( - "dcode.shell.subprocess.run", + "indevcontainer.shell.subprocess.run", return_value=_completed(0, '"justastring"\n', ""), ): assert _inspect_container_metadata("cid") == [] def test_docker_nonzero_returns_empty(self): with patch( - "dcode.shell.subprocess.run", + "indevcontainer.shell.subprocess.run", return_value=_completed(1, "", "no such container"), ): assert _inspect_container_metadata("cid") == [] def test_docker_missing_returns_empty(self): with patch( - "dcode.shell.subprocess.run", + "indevcontainer.shell.subprocess.run", side_effect=FileNotFoundError("docker"), ): assert _inspect_container_metadata("cid") == [] @@ -606,7 +606,7 @@ class TestFindSshSocket: def test_found_via_inspect_env(self): env_json = json.dumps(["FOO=bar", "SSH_AUTH_SOCK=/host/sock"]) results = [_completed(0, env_json + "\n", "")] - with patch("dcode.shell.subprocess.run", side_effect=results): + with patch("indevcontainer.shell.subprocess.run", side_effect=results): assert find_ssh_socket("cid") == "/host/sock" def test_inspect_empty_then_ls_single_path_with_socket(self): @@ -615,7 +615,7 @@ def test_inspect_empty_then_ls_single_path_with_socket(self): _completed(0, "/tmp/vscode-ssh-auth-1.sock\n", ""), # ls -t _completed(0, "", ""), # test -S ok ] - with patch("dcode.shell.subprocess.run", side_effect=results): + with patch("indevcontainer.shell.subprocess.run", side_effect=results): assert find_ssh_socket("cid") == "/tmp/vscode-ssh-auth-1.sock" def test_ls_multiline_uses_first(self): @@ -626,7 +626,7 @@ def test_ls_multiline_uses_first(self): _completed(0, "/tmp/vscode-ssh-auth-newer.sock\n", ""), _completed(0, "", ""), ] - with patch("dcode.shell.subprocess.run", side_effect=results): + with patch("indevcontainer.shell.subprocess.run", side_effect=results): assert find_ssh_socket("cid") == "/tmp/vscode-ssh-auth-newer.sock" def test_ls_empty_returns_none(self): @@ -634,7 +634,7 @@ def test_ls_empty_returns_none(self): _completed(0, "[]\n", ""), _completed(0, "", ""), # nothing matched ] - with patch("dcode.shell.subprocess.run", side_effect=results): + with patch("indevcontainer.shell.subprocess.run", side_effect=results): assert find_ssh_socket("cid") is None def test_ls_path_but_not_socket_returns_none(self): @@ -643,7 +643,7 @@ def test_ls_path_but_not_socket_returns_none(self): _completed(0, "/tmp/vscode-ssh-auth-x.sock\n", ""), _completed(1, "", ""), # test -S fails ] - with patch("dcode.shell.subprocess.run", side_effect=results): + with patch("indevcontainer.shell.subprocess.run", side_effect=results): assert find_ssh_socket("cid") is None def test_inspect_malformed_json_falls_through(self): @@ -651,7 +651,7 @@ def test_inspect_malformed_json_falls_through(self): _completed(0, "not json at all\n", ""), _completed(0, "", ""), # ls produces nothing ] - with patch("dcode.shell.subprocess.run", side_effect=results): + with patch("indevcontainer.shell.subprocess.run", side_effect=results): assert find_ssh_socket("cid") is None @@ -662,20 +662,20 @@ def test_inspect_malformed_json_falls_through(self): class TestProbeWorkdir: def test_candidate_exists(self, capsys): - with patch("dcode.shell.subprocess.run", side_effect=[_completed(0, "", "")]): + with patch("indevcontainer.shell.subprocess.run", side_effect=[_completed(0, "", "")]): assert probe_workdir("cid", "/workspaces/proj/sub", "/workspaces/proj") == "/workspaces/proj/sub" assert capsys.readouterr().err == "" def test_candidate_missing_fallback_succeeds(self, capsys): results = [_completed(1, "", ""), _completed(0, "", "")] - with patch("dcode.shell.subprocess.run", side_effect=results): + with patch("indevcontainer.shell.subprocess.run", side_effect=results): assert probe_workdir("cid", "/workspaces/proj/sub", "/workspaces/proj") == "/workspaces/proj" err = capsys.readouterr().err assert "/workspaces/proj/sub" in err def test_both_fail_returns_none(self): results = [_completed(1, "", ""), _completed(1, "", "")] - with patch("dcode.shell.subprocess.run", side_effect=results): + with patch("indevcontainer.shell.subprocess.run", side_effect=results): assert probe_workdir("cid", "/c", "/f") is None @@ -718,18 +718,18 @@ def __init__( def __enter__(self): self._patches = [ patch( - "dcode.shell.find_container", + "indevcontainer.shell.find_container", return_value=ContainerLookup(state="running", id=self.container_id), ), patch( - "dcode.shell._inspect_container_metadata", + "indevcontainer.shell._inspect_container_metadata", return_value=list(self.metadata_entries), ), - patch("dcode.shell.find_ssh_socket", return_value=self.ssh_sock), - patch("dcode.shell.probe_workdir", return_value=self.workdir), - patch("dcode.shell.resolve_terminal_profile", return_value=self.profile), - patch("dcode.shell.detect_login_shell", return_value=self.login_shell), - patch("dcode.shell.os.execvp", self.execvp), + patch("indevcontainer.shell.find_ssh_socket", return_value=self.ssh_sock), + patch("indevcontainer.shell.probe_workdir", return_value=self.workdir), + patch("indevcontainer.shell.resolve_terminal_profile", return_value=self.profile), + patch("indevcontainer.shell.detect_login_shell", return_value=self.login_shell), + patch("indevcontainer.shell.os.execvp", self.execvp), patch("sys.stdin"), patch("sys.stdout"), ] @@ -883,12 +883,12 @@ def test_tty_check_after_container_lookup(self, tmp_path): return_value=ContainerLookup(state="running", id="cid") ) with ( - patch("dcode.shell.find_container", find_mock), - patch("dcode.shell.find_ssh_socket", return_value=None), - patch("dcode.shell.probe_workdir", return_value=None), - patch("dcode.shell.resolve_terminal_profile", return_value=None), - patch("dcode.shell.detect_login_shell", return_value="/bin/sh"), - patch("dcode.shell.os.execvp"), + patch("indevcontainer.shell.find_container", find_mock), + patch("indevcontainer.shell.find_ssh_socket", return_value=None), + patch("indevcontainer.shell.probe_workdir", return_value=None), + patch("indevcontainer.shell.resolve_terminal_profile", return_value=None), + patch("indevcontainer.shell.detect_login_shell", return_value="/bin/sh"), + patch("indevcontainer.shell.os.execvp"), patch("sys.stdin") as stdin, patch("sys.stdout") as stdout, ): @@ -913,12 +913,12 @@ def test_worktree_uses_main_repo_for_lookup(self, tmp_path): ) probe_mock = MagicMock(return_value="/workspaces/main-repo") with ( - patch("dcode.shell.find_container", find_mock), - patch("dcode.shell.find_ssh_socket", return_value=None), - patch("dcode.shell.probe_workdir", probe_mock), - patch("dcode.shell.resolve_terminal_profile", return_value=None), - patch("dcode.shell.detect_login_shell", return_value="/bin/sh"), - patch("dcode.shell.os.execvp"), + patch("indevcontainer.shell.find_container", find_mock), + patch("indevcontainer.shell.find_ssh_socket", return_value=None), + patch("indevcontainer.shell.probe_workdir", probe_mock), + patch("indevcontainer.shell.resolve_terminal_profile", return_value=None), + patch("indevcontainer.shell.detect_login_shell", return_value="/bin/sh"), + patch("indevcontainer.shell.os.execvp"), patch("sys.stdin") as stdin, patch("sys.stdout") as stdout, ): @@ -955,18 +955,18 @@ def _run_stopped( execvp = MagicMock() with ( patch( - "dcode.shell.find_container", + "indevcontainer.shell.find_container", return_value=ContainerLookup( state="stopped", id="abc123", ids=("abc123",) ), ), - patch("dcode.shell.subprocess.run", start), - patch("dcode.shell._inspect_container_metadata", return_value=[]), - patch("dcode.shell.find_ssh_socket", return_value="/host/ssh.sock"), - patch("dcode.shell.probe_workdir", return_value="/workspaces/proj"), - patch("dcode.shell.resolve_terminal_profile", return_value=None), - patch("dcode.shell.detect_login_shell", return_value="/bin/bash"), - patch("dcode.shell.os.execvp", execvp), + patch("indevcontainer.shell.subprocess.run", start), + patch("indevcontainer.shell._inspect_container_metadata", return_value=[]), + patch("indevcontainer.shell.find_ssh_socket", return_value="/host/ssh.sock"), + patch("indevcontainer.shell.probe_workdir", return_value="/workspaces/proj"), + patch("indevcontainer.shell.resolve_terminal_profile", return_value=None), + patch("indevcontainer.shell.detect_login_shell", return_value="/bin/bash"), + patch("indevcontainer.shell.os.execvp", execvp), ): rc = run_shell(str(proj), insiders=False, shell_override=None) @@ -1110,8 +1110,8 @@ def test_default_no_renders_lower_y(self, capsys): class TestObtainOrInstallCli: def test_returns_existing_path_without_prompting(self, capsys): with ( - patch("dcode.shell.devcontainer_cli.find_cli", return_value=Path("/x/dc")), - patch("dcode.shell.devcontainer_cli.install_cli") as install, + patch("indevcontainer.shell.devcontainer_cli.find_cli", return_value=Path("/x/dc")), + patch("indevcontainer.shell.devcontainer_cli.install_cli") as install, patch("sys.stdin", _TTYStringIO("")), ): assert _obtain_or_install_cli() == Path("/x/dc") @@ -1121,8 +1121,8 @@ def test_returns_existing_path_without_prompting(self, capsys): def test_declines_install_returns_none_with_hint(self, capsys): with ( - patch("dcode.shell.devcontainer_cli.find_cli", return_value=None), - patch("dcode.shell.devcontainer_cli.install_cli") as install, + patch("indevcontainer.shell.devcontainer_cli.find_cli", return_value=None), + patch("indevcontainer.shell.devcontainer_cli.install_cli") as install, patch("sys.stdin", _TTYStringIO("n\n")), ): assert _obtain_or_install_cli() is None @@ -1133,9 +1133,9 @@ def test_declines_install_returns_none_with_hint(self, capsys): def test_accepts_install_returns_installed_path(self, capsys): with ( - patch("dcode.shell.devcontainer_cli.find_cli", return_value=None), + patch("indevcontainer.shell.devcontainer_cli.find_cli", return_value=None), patch( - "dcode.shell.devcontainer_cli.install_cli", + "indevcontainer.shell.devcontainer_cli.install_cli", return_value=Path("/home/u/.devcontainers/bin/devcontainer"), ) as install, patch("sys.stdin", _TTYStringIO("y\n")), @@ -1147,8 +1147,8 @@ def test_accepts_install_returns_installed_path(self, capsys): def test_install_failure_returns_none(self, capsys): with ( - patch("dcode.shell.devcontainer_cli.find_cli", return_value=None), - patch("dcode.shell.devcontainer_cli.install_cli", return_value=None), + patch("indevcontainer.shell.devcontainer_cli.find_cli", return_value=None), + patch("indevcontainer.shell.devcontainer_cli.install_cli", return_value=None), patch("sys.stdin", _TTYStringIO("y\n")), ): assert _obtain_or_install_cli() is None @@ -1166,11 +1166,11 @@ def test_returns_container_id_on_success(self, tmp_path, capsys): cfg = tmp_path / "proj/.devcontainer/devcontainer.json" with ( patch( - "dcode.shell._obtain_or_install_cli", + "indevcontainer.shell._obtain_or_install_cli", return_value=Path("/x/devcontainer"), ), patch( - "dcode.shell.devcontainer_cli.up", + "indevcontainer.shell.devcontainer_cli.up", return_value=("abc123def456", ""), ) as up, ): @@ -1181,17 +1181,17 @@ def test_returns_container_id_on_success(self, tmp_path, capsys): assert "abc123def456"[:12] in err # short-id printed def test_no_cli_returns_none(self, tmp_path): - with patch("dcode.shell._obtain_or_install_cli", return_value=None): + with patch("indevcontainer.shell._obtain_or_install_cli", return_value=None): assert _build_missing_container(tmp_path, tmp_path / "x.json") is None def test_up_failure_prints_error_log(self, tmp_path, capsys): with ( patch( - "dcode.shell._obtain_or_install_cli", + "indevcontainer.shell._obtain_or_install_cli", return_value=Path("/x/devcontainer"), ), patch( - "dcode.shell.devcontainer_cli.up", + "indevcontainer.shell.devcontainer_cli.up", return_value=(None, "Dockerfile RUN failed: package foo not found"), ), ): @@ -1219,21 +1219,21 @@ def _patches( proj_metadata = list(metadata_entries) if metadata_entries else [] return [ patch( - "dcode.shell.find_container", + "indevcontainer.shell.find_container", return_value=ContainerLookup(state="missing"), ), patch( - "dcode.shell._build_missing_container", + "indevcontainer.shell._build_missing_container", return_value=built_container_id, ), patch( - "dcode.shell._inspect_container_metadata", + "indevcontainer.shell._inspect_container_metadata", return_value=proj_metadata, ), - patch("dcode.shell.find_ssh_socket", return_value="/host/ssh.sock"), - patch("dcode.shell.probe_workdir", return_value="/workspaces/proj"), - patch("dcode.shell.resolve_terminal_profile", return_value=None), - patch("dcode.shell.detect_login_shell", return_value="/bin/bash"), + patch("indevcontainer.shell.find_ssh_socket", return_value="/host/ssh.sock"), + patch("indevcontainer.shell.probe_workdir", return_value="/workspaces/proj"), + patch("indevcontainer.shell.resolve_terminal_profile", return_value=None), + patch("indevcontainer.shell.detect_login_shell", return_value="/bin/bash"), patch("sys.stdin", _TTYStringIO(answer)), ] @@ -1248,7 +1248,7 @@ def test_accept_build_then_exec(self, tmp_path, monkeypatch, capsys): execvp = MagicMock() with ExitStack() as stack: - stack.enter_context(patch("dcode.shell.os.execvp", execvp)) + stack.enter_context(patch("indevcontainer.shell.os.execvp", execvp)) self._enter_all( stack, self._patches(answer="y\n", built_container_id="newcid"), @@ -1269,12 +1269,12 @@ def test_decline_build_returns_nonzero_no_exec(self, tmp_path, monkeypatch, caps execvp = MagicMock() build = MagicMock() with ( - patch("dcode.shell.os.execvp", execvp), + patch("indevcontainer.shell.os.execvp", execvp), patch( - "dcode.shell.find_container", + "indevcontainer.shell.find_container", return_value=ContainerLookup(state="missing"), ), - patch("dcode.shell._build_missing_container", build), + patch("indevcontainer.shell._build_missing_container", build), patch("sys.stdin", _TTYStringIO("n\n")), ): rc = run_shell(str(proj), insiders=False, shell_override=None) @@ -1293,7 +1293,7 @@ def test_accept_but_build_fails_returns_nonzero(self, tmp_path, monkeypatch): execvp = MagicMock() with ExitStack() as stack: - stack.enter_context(patch("dcode.shell.os.execvp", execvp)) + stack.enter_context(patch("indevcontainer.shell.os.execvp", execvp)) self._enter_all( stack, self._patches(answer="y\n", built_container_id=None), @@ -1313,7 +1313,7 @@ def test_built_container_metadata_used_for_remote_user( execvp = MagicMock() with ExitStack() as stack: - stack.enter_context(patch("dcode.shell.os.execvp", execvp)) + stack.enter_context(patch("indevcontainer.shell.os.execvp", execvp)) self._enter_all( stack, self._patches( @@ -1342,7 +1342,7 @@ def _run_with(self, lookup_state, *, detail=None, ids=()): ids=ids, detail=detail, ) - return patch("dcode.shell.find_container", return_value=lookup) + return patch("indevcontainer.shell.find_container", return_value=lookup) def test_missing_devcontainer(self, tmp_path, capsys): # tmp_path has no .devcontainer at all @@ -1350,7 +1350,7 @@ def test_missing_devcontainer(self, tmp_path, capsys): proj.mkdir() rc = run_shell(str(proj), insiders=False, shell_override=None) assert rc != 0 - assert "dcode doctor" in capsys.readouterr().err + assert "idc doctor" in capsys.readouterr().err def test_state_missing_non_tty_message(self, tmp_path, capsys, monkeypatch): proj = _make_project(tmp_path) @@ -1362,7 +1362,7 @@ def test_state_missing_non_tty_message(self, tmp_path, capsys, monkeypatch): err = capsys.readouterr().err assert "no devcontainer is running" in err assert "run interactively to be prompted to build it" in err - assert f"dcode {proj}" in err + assert f"idc code {proj}" in err def test_state_stopped_non_tty_message(self, tmp_path, capsys, monkeypatch): proj = _make_project(tmp_path) @@ -1373,7 +1373,7 @@ def test_state_stopped_non_tty_message(self, tmp_path, capsys, monkeypatch): assert rc != 0 err = capsys.readouterr().err assert "run interactively to be prompted to start it" in err - assert f"dcode {proj}" in err + assert f"idc code {proj}" in err def test_state_ambiguous_lists_ids(self, tmp_path, capsys): proj = _make_project(tmp_path) diff --git a/tests/test_update.py b/tests/test_update.py index bf21d64..4416f42 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -1,4 +1,4 @@ -"""Tests for ``dcode.update``.""" +"""Tests for ``indevcontainer.update``.""" from __future__ import annotations @@ -7,9 +7,9 @@ import pytest -from dcode import update -from dcode.update import detect_install_method, run_update, run_update_check -from dcode.version_check import NetworkError +from indevcontainer import update +from indevcontainer.update import detect_install_method, run_update, run_update_check +from indevcontainer.version_check import NetworkError # ---------- detect_install_method ---------- @@ -51,8 +51,8 @@ def test_detect_oserror_is_unknown(): assert detect_install_method() == "unknown" -def test_detect_uv_tool_when_dcode_listed(): - stdout = "dcode v0.4.2\n- dcode\nfs2 v0.1.0\n- fs2\n" +def test_detect_uv_tool_when_indevcontainer_listed(): + stdout = "indevcontainer v0.4.2\n- indevcontainer\nfs2 v0.1.0\n- fs2\n" with ( patch.object(update.shutil, "which", return_value="/usr/local/bin/uv"), patch.object( @@ -64,7 +64,7 @@ def test_detect_uv_tool_when_dcode_listed(): assert detect_install_method() == "uv-tool" -def test_detect_not_uv_tool_when_dcode_missing(): +def test_detect_not_uv_tool_when_indevcontainer_missing(): stdout = "fs2 v0.1.0\n- fs2\nghostcfg v0.1.3\n- gcfg\n" with ( patch.object(update.shutil, "which", return_value="/usr/local/bin/uv"), @@ -108,7 +108,7 @@ def test_run_update_uv_tool_happy_path(): ): rc = run_update() assert rc == 0 - mock_run.assert_called_once_with(["uv", "tool", "upgrade", "dcode"], check=False) + mock_run.assert_called_once_with(["uv", "tool", "upgrade", "indevcontainer"], check=False) def test_run_update_uv_tool_forwards_nonzero(): @@ -142,15 +142,15 @@ def test_run_update_unknown_falls_through(capsys): # ---------- run_update_check ---------- -_RELEASE = {"tag_name": "v0.4.2", "html_url": "https://github.com/rosstaco/dcode/releases/tag/v0.4.2"} +_RELEASE = {"tag_name": "v0.4.2", "html_url": "https://github.com/rosstaco/InDevContainer/releases/tag/v0.4.2"} def test_run_update_check_up_to_date(capsys): with ( - patch.object(update, "dcode") as mock_dcode, + patch.object(update, "indevcontainer") as mock_indevcontainer, patch.object(update.version_check, "get_latest_release", return_value=_RELEASE), ): - mock_dcode.__version__ = "0.4.2" + mock_indevcontainer.__version__ = "0.4.2" rc = run_update_check() err = capsys.readouterr().err assert rc == 0 @@ -161,10 +161,10 @@ def test_run_update_check_up_to_date(capsys): def test_run_update_check_behind(capsys): with ( - patch.object(update, "dcode") as mock_dcode, + patch.object(update, "indevcontainer") as mock_indevcontainer, patch.object(update.version_check, "get_latest_release", return_value=_RELEASE), ): - mock_dcode.__version__ = "0.4.0" + mock_indevcontainer.__version__ = "0.4.0" rc = run_update_check() err = capsys.readouterr().err assert rc == 1 @@ -173,10 +173,10 @@ def test_run_update_check_behind(capsys): def test_run_update_check_ahead_dev(capsys): with ( - patch.object(update, "dcode") as mock_dcode, + patch.object(update, "indevcontainer") as mock_indevcontainer, patch.object(update.version_check, "get_latest_release", return_value=_RELEASE), ): - mock_dcode.__version__ = "0.4.2.dev0+g1234" + mock_indevcontainer.__version__ = "0.4.2.dev0+g1234" rc = run_update_check() err = capsys.readouterr().err assert rc == 0 @@ -185,10 +185,10 @@ def test_run_update_check_ahead_dev(capsys): def test_run_update_check_strictly_ahead(capsys): with ( - patch.object(update, "dcode") as mock_dcode, + patch.object(update, "indevcontainer") as mock_indevcontainer, patch.object(update.version_check, "get_latest_release", return_value=_RELEASE), ): - mock_dcode.__version__ = "0.5.0" + mock_indevcontainer.__version__ = "0.5.0" rc = run_update_check() err = capsys.readouterr().err assert rc == 0 @@ -197,14 +197,14 @@ def test_run_update_check_strictly_ahead(capsys): def test_run_update_check_network_error(capsys): with ( - patch.object(update, "dcode") as mock_dcode, + patch.object(update, "indevcontainer") as mock_indevcontainer, patch.object( update.version_check, "get_latest_release", side_effect=NetworkError("offline"), ), ): - mock_dcode.__version__ = "0.4.2" + mock_indevcontainer.__version__ = "0.4.2" rc = run_update_check() err = capsys.readouterr().err assert rc == 2 @@ -216,10 +216,10 @@ def test_run_update_check_no_color_emits_no_ansi(capsys, monkeypatch): monkeypatch.setenv("NO_COLOR", "1") with ( - patch.object(update, "dcode") as mock_dcode, + patch.object(update, "indevcontainer") as mock_indevcontainer, patch.object(update.version_check, "get_latest_release", return_value=_RELEASE), ): - mock_dcode.__version__ = "0.4.2" + mock_indevcontainer.__version__ = "0.4.2" run_update_check() err = capsys.readouterr().err assert "up to date" in err diff --git a/tests/test_version_check.py b/tests/test_version_check.py index a535a2d..ca8a3c2 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -1,4 +1,4 @@ -"""Tests for ``dcode.version_check``.""" +"""Tests for ``indevcontainer.version_check``.""" from __future__ import annotations @@ -8,8 +8,8 @@ import pytest -from dcode import version_check -from dcode.version_check import ( +from indevcontainer import version_check +from indevcontainer.version_check import ( NetworkError, compare_versions, get_latest_release, @@ -47,13 +47,13 @@ def test_get_latest_release_happy_path(): { "tag_name": "v0.4.2", "name": "v0.4.2", - "html_url": "https://github.com/rosstaco/dcode/releases/tag/v0.4.2", + "html_url": "https://github.com/rosstaco/InDevContainer/releases/tag/v0.4.2", } ).encode() with patch.object(version_check.urllib.request, "urlopen", return_value=_FakeResp(payload)): info = get_latest_release() assert info["tag_name"] == "v0.4.2" - assert info["html_url"] == "https://github.com/rosstaco/dcode/releases/tag/v0.4.2" + assert info["html_url"] == "https://github.com/rosstaco/InDevContainer/releases/tag/v0.4.2" def test_get_latest_release_404_falls_back_to_tags(): @@ -68,7 +68,7 @@ def _side_effect(req, timeout): # noqa: ARG001 with patch.object(version_check.urllib.request, "urlopen", side_effect=_side_effect): info = get_latest_release() assert info["tag_name"] == "v0.4.2" - assert info["html_url"] == "https://github.com/rosstaco/dcode/releases/tag/v0.4.2" + assert info["html_url"] == "https://github.com/rosstaco/InDevContainer/releases/tag/v0.4.2" def test_get_latest_release_empty_tags_raises(): @@ -156,7 +156,7 @@ def _side_effect(req, timeout): # noqa: ARG001 get_latest_release() # urllib normalises header names to title case. - assert captured["headers"].get("User-agent") == "dcode-doctor" + assert captured["headers"].get("User-agent") == "idc-doctor" assert captured["headers"].get("Accept") == "application/vnd.github+json" diff --git a/tests/test_wsl.py b/tests/test_wsl.py index 662a12e..2b5bc6b 100644 --- a/tests/test_wsl.py +++ b/tests/test_wsl.py @@ -1,15 +1,15 @@ -"""Tests for dcode.wsl.""" +"""Tests for indevcontainer.wsl.""" import json from unittest.mock import patch -from dcode.core import build_uri -from dcode.wsl import build_uri_wsl +from indevcontainer.core import build_uri +from indevcontainer.wsl import build_uri_wsl class TestBuildUriWsl: def test_builds_json_payload_with_windows_path(self): - with patch("dcode.wsl._wsl_to_windows_path", return_value="\\\\wsl.localhost\\Ubuntu\\home\\ross\\repos\\myapp"): + with patch("indevcontainer.wsl._wsl_to_windows_path", return_value="\\\\wsl.localhost\\Ubuntu\\home\\ross\\repos\\myapp"): uri = build_uri_wsl("/home/ross/repos/myapp", "/workspaces/myapp") assert "vscode-remote://dev-container+" in uri assert uri.endswith("/workspaces/myapp") @@ -18,7 +18,7 @@ def test_builds_json_payload_with_windows_path(self): assert payload == {"hostPath": "\\\\wsl.localhost\\Ubuntu\\home\\ross\\repos\\myapp"} def test_wsl_uri_differs_from_plain(self): - with patch("dcode.wsl._wsl_to_windows_path", return_value="\\\\wsl.localhost\\Ubuntu\\home\\ross\\project"): + with patch("indevcontainer.wsl._wsl_to_windows_path", return_value="\\\\wsl.localhost\\Ubuntu\\home\\ross\\project"): wsl = build_uri_wsl("/home/ross/project", "/workspaces/project") plain = build_uri("/home/ross/project", "/workspaces/project") assert plain != wsl @@ -31,10 +31,10 @@ def test_patches_settings_when_not_configured(self, tmp_path): settings_file.write_text('{"editor.fontSize": 14}') with ( - patch("dcode.wsl._get_windows_vscode_settings_path", return_value=settings_file), - patch("dcode.wsl.get_wsl_distro", return_value="Ubuntu"), + patch("indevcontainer.wsl._get_windows_vscode_settings_path", return_value=settings_file), + patch("indevcontainer.wsl.get_wsl_distro", return_value="Ubuntu"), ): - from dcode.wsl import _ensure_wsl_docker_settings + from indevcontainer.wsl import _ensure_wsl_docker_settings _ensure_wsl_docker_settings() result = json.loads(settings_file.read_text()) @@ -48,10 +48,10 @@ def test_adds_missing_distro_when_executeInWSL_already_true(self, tmp_path): settings_file.write_text('{"dev.containers.executeInWSL": true, "other": 1}') with ( - patch("dcode.wsl._get_windows_vscode_settings_path", return_value=settings_file), - patch("dcode.wsl.get_wsl_distro", return_value="Ubuntu"), + patch("indevcontainer.wsl._get_windows_vscode_settings_path", return_value=settings_file), + patch("indevcontainer.wsl.get_wsl_distro", return_value="Ubuntu"), ): - from dcode.wsl import _ensure_wsl_docker_settings + from indevcontainer.wsl import _ensure_wsl_docker_settings _ensure_wsl_docker_settings() result = json.loads(settings_file.read_text()) @@ -69,10 +69,10 @@ def test_does_nothing_when_both_keys_already_correct(self, tmp_path): settings_file.write_text(original) with ( - patch("dcode.wsl._get_windows_vscode_settings_path", return_value=settings_file), - patch("dcode.wsl.get_wsl_distro", return_value="Ubuntu"), + patch("indevcontainer.wsl._get_windows_vscode_settings_path", return_value=settings_file), + patch("indevcontainer.wsl.get_wsl_distro", return_value="Ubuntu"), ): - from dcode.wsl import _ensure_wsl_docker_settings + from indevcontainer.wsl import _ensure_wsl_docker_settings _ensure_wsl_docker_settings() # File content byte-identical — no rewrite happened. @@ -90,10 +90,10 @@ def test_preserves_jsonc_comments_and_trailing_commas(self, tmp_path): settings_file.write_text(original) with ( - patch("dcode.wsl._get_windows_vscode_settings_path", return_value=settings_file), - patch("dcode.wsl.get_wsl_distro", return_value="Ubuntu"), + patch("indevcontainer.wsl._get_windows_vscode_settings_path", return_value=settings_file), + patch("indevcontainer.wsl.get_wsl_distro", return_value="Ubuntu"), ): - from dcode.wsl import _ensure_wsl_docker_settings + from indevcontainer.wsl import _ensure_wsl_docker_settings _ensure_wsl_docker_settings() new_text = settings_file.read_text() @@ -111,10 +111,10 @@ def test_falls_back_to_hint_on_unpatchable_file(self, tmp_path, capsys): settings_file.write_text(original) with ( - patch("dcode.wsl._get_windows_vscode_settings_path", return_value=settings_file), - patch("dcode.wsl.get_wsl_distro", return_value="Ubuntu"), + patch("indevcontainer.wsl._get_windows_vscode_settings_path", return_value=settings_file), + patch("indevcontainer.wsl.get_wsl_distro", return_value="Ubuntu"), ): - from dcode.wsl import _ensure_wsl_docker_settings + from indevcontainer.wsl import _ensure_wsl_docker_settings _ensure_wsl_docker_settings() # File untouched. @@ -123,8 +123,8 @@ def test_falls_back_to_hint_on_unpatchable_file(self, tmp_path, capsys): assert "dev.containers.executeInWSL" in capsys.readouterr().err def test_falls_back_to_hint_when_path_not_found(self, capsys): - with patch("dcode.wsl._get_windows_vscode_settings_path", return_value=None): - from dcode.wsl import _ensure_wsl_docker_settings + with patch("indevcontainer.wsl._get_windows_vscode_settings_path", return_value=None): + from indevcontainer.wsl import _ensure_wsl_docker_settings _ensure_wsl_docker_settings() assert "dev.containers.executeInWSL" in capsys.readouterr().err From 78d6ed83556265a4c946108b59b89c0d8e6520ae Mon Sep 17 00:00:00 2001 From: rosstaco Date: Thu, 28 May 2026 18:11:36 +1000 Subject: [PATCH 3/4] feat!: restructure CLI as subcommands and add idc copilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the legacy "dcode " form (where the path was a top-level positional) with a clean subcommand layout. This kills the _looks_like_subcommand workaround that was needed to disambiguate "dcode ./somepath" from subcommand names. New CLI surface: idc # prints help, exits 0 idc code [-i] # was: dcode idc shell [--shell EXE] [-i] idc copilot [] [args...] # NEW idc doctor [] idc update [--check] The -i/--insiders flag moves from the top-level parser onto the subcommands that actually consume it (code, shell). idc copilot details: - Shares container resolution with `idc shell` via a new `prepare_container_exec()` helper in shell.py (returns a `ContainerExec` dataclass with container_id, exec_user, workdir, ssh_sock, workspace_folder, devcontainer_cfg, main_repo, rel_path). - Probes `command -v copilot` in the container before exec'ing so a missing copilot binary fails fast with a clear install hint (e.g. `npm install -g @github/copilot`) instead of an opaque 127. - Bypasses argparse so flags forward transparently: `idc copilot --yolo --resume` → `copilot --yolo --resume` (no `--` separator needed). The first non-flag arg (if any) is the project path. A literal `--` is still honored as an explicit separator for the rare case where the first forwarded token would otherwise be parsed as a path. Renamed `run_dcode` → `run_code` in core.py for consistency with the new subcommand name; `run_shell` is unchanged. Tests: - tests/test_cli.py rewritten end-to-end for the new subcommand structure (TestNoSubcommand, TestCodeDispatch, TestShellDispatch, TestCopilotDispatch, TestUpdateDispatch, TestDoctorDispatch, TestSplitCopilotArgs). - New tests/test_copilot.py covers _copilot_installed probe + the run_copilot happy path, missing-copilot error, extra-args forwarding, and exec-failure paths. BREAKING CHANGE: `dcode .` no longer works; users must run `idc code .`. `dcode shell`, `dcode doctor`, and `dcode update` become `idc shell`, `idc doctor`, and `idc update`. The bare `dcode` (no args) used to open the current directory in VS Code; bare `idc` now prints help. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/indevcontainer/cli.py | 248 ++++++++++++++++------- src/indevcontainer/copilot.py | 79 ++++++++ src/indevcontainer/shell.py | 200 +++++++++++------- tests/test_cli.py | 369 ++++++++++++++++++++++++---------- tests/test_copilot.py | 173 ++++++++++++++++ 5 files changed, 808 insertions(+), 261 deletions(-) create mode 100644 src/indevcontainer/copilot.py create mode 100644 tests/test_copilot.py diff --git a/src/indevcontainer/cli.py b/src/indevcontainer/cli.py index d6ff638..5617260 100644 --- a/src/indevcontainer/cli.py +++ b/src/indevcontainer/cli.py @@ -1,4 +1,18 @@ -"""dcode — open folders in VS Code devcontainers from the CLI.""" +"""``idc`` CLI entrypoint. + +Five subcommands; no top-level positional, so naming-collision workarounds +aren't needed any more (every command name is a literal first argv token). + +* ``idc code [-i] `` — open *path* in VS Code via its devcontainer. +* ``idc shell [--shell EXE] [-i]`` — exec an interactive shell in + the running devcontainer. +* ``idc copilot [-- copilot args...]`` — exec the GitHub Copilot + CLI inside the running devcontainer. +* ``idc doctor []`` — diagnose the local environment. +* ``idc update [--check]`` — upgrade idc via ``uv tool``. + +Bare ``idc`` prints help and exits 0. +""" from __future__ import annotations @@ -6,61 +20,52 @@ import sys from pathlib import Path -from dcode.core import run_dcode -from dcode.doctor import run_doctor -from dcode.update import run_update, run_update_check - -_SUBCOMMANDS = ("doctor", "update", "shell") +from indevcontainer.core import run_code +from indevcontainer.doctor import run_doctor +from indevcontainer.update import run_update, run_update_check def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="dcode", + prog="idc", description=( - "Open a folder in a VS Code devcontainer.\n" + "Run code, shells, and the Copilot CLI in a VS Code devcontainer.\n" "\n" - "`dcode doctor`, `dcode update`, and `dcode shell` always run their " - "respective subcommands. To open a folder literally named " - "'doctor', 'update', or 'shell', run `dcode ./doctor`, " - "`dcode ./update`, or `dcode ./shell`." + "All actions are subcommands. To open a folder literally named " + "'code', 'shell', 'copilot', 'doctor', or 'update', run " + "`idc code ./`." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument( + subparsers = parser.add_subparsers(dest="command", required=False, metavar="COMMAND") + + p_code = subparsers.add_parser( + "code", + help="open a folder in VS Code via its devcontainer", + description=( + "Open `path` (default: current directory) in VS Code via the " + "configured devcontainer. Falls back to plain `code ` " + "when no devcontainer.json is found. Exit code is forwarded " + "from the spawned editor." + ), + ) + p_code.add_argument( "-i", "--insiders", action="store_true", help="use VS Code Insiders", ) - subparsers = parser.add_subparsers(dest="command", required=False, metavar="COMMAND") - - p_doctor = subparsers.add_parser( - "doctor", - help="diagnose the local environment for dcode", - description="Diagnose the local environment for dcode and report issues.", - ) - p_doctor.add_argument( - "doctor_path", + p_code.add_argument( + "code_path", nargs="?", - default=None, + default=".", metavar="path", - help="directory to inspect (default: current directory)", - ) - - p_update = subparsers.add_parser( - "update", - help="upgrade dcode via 'uv tool upgrade dcode'", - description="Upgrade the installed dcode tool via 'uv tool upgrade dcode'.", - ) - p_update.add_argument( - "--check", - action="store_true", - help="check for an available update without installing it", + help="folder to open (default: current directory)", ) p_shell = subparsers.add_parser( "shell", - help="Open a shell in the project's running devcontainer", + help="open a shell in the project's running devcontainer", description=( "Open an interactive shell inside the running devcontainer for " "the project at `path`. Mirrors VS Code's integrated terminal: " @@ -68,7 +73,7 @@ def _build_parser() -> argparse.ArgumentParser: "user), forwards the SSH agent socket when available, runs as " "`remoteUser`/`containerUser` from devcontainer.json. Requires " "an interactive terminal. To open a folder literally named " - "'shell', use `dcode ./shell`." + "'shell', use `idc code ./shell`." ), ) p_shell.add_argument( @@ -78,6 +83,12 @@ def _build_parser() -> argparse.ArgumentParser: metavar="path", help="project folder (default: current directory)", ) + p_shell.add_argument( + "-i", + "--insiders", + action="store_true", + help="resolve VS Code Insiders user settings for terminal profile lookup", + ) p_shell.add_argument( "--shell", default=None, @@ -89,66 +100,151 @@ def _build_parser() -> argparse.ArgumentParser: ), ) - parser.add_argument( - "path", + p_copilot = subparsers.add_parser( + "copilot", + help="run the GitHub Copilot CLI inside the project's devcontainer", + description=( + "Exec the GitHub Copilot CLI (`copilot`) inside the running " + "devcontainer for the project at `path`. Shares container " + "resolution with `idc shell` (auto-build & prompt to start).\n" + "\n" + "Usage:\n" + " idc copilot # cwd, no copilot args\n" + " idc copilot --yolo --resume # cwd; flags forward to copilot\n" + " idc copilot ./proj --resume # explicit path; flags forward\n" + " idc copilot -- --weird-flag # escape hatch when the first\n" + " # forwarded arg would otherwise\n" + " # be parsed as the path\n" + " idc copilot ./proj -- --resume # `--` between path and args is\n" + " # always allowed\n" + "\n" + "The first non-flag argument (if any) is the project path; " + "everything else is forwarded verbatim to `copilot` inside the " + "container. Use `--` to disambiguate when needed." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_copilot.add_argument( + "copilot_path", nargs="?", default=".", - help="folder to open (default: current directory)", + metavar="path", + help="project folder (default: current directory)", + ) + p_copilot.add_argument( + "copilot_args", + nargs=argparse.REMAINDER, + metavar="-- copilot args...", + help="arguments forwarded verbatim to `copilot` inside the container", ) + + p_doctor = subparsers.add_parser( + "doctor", + help="diagnose the local environment for idc", + description="Diagnose the local environment for idc and report issues.", + ) + p_doctor.add_argument( + "doctor_path", + nargs="?", + default=None, + metavar="path", + help="directory to inspect (default: current directory)", + ) + + p_update = subparsers.add_parser( + "update", + help="upgrade idc via 'uv tool upgrade indevcontainer'", + description=( + "Upgrade the installed idc tool via " + "'uv tool upgrade indevcontainer'." + ), + ) + p_update.add_argument( + "--check", + action="store_true", + help="check for an available update without installing it", + ) + return parser -def _looks_like_subcommand(argv: list[str]) -> bool: - """Peek argv to decide whether to use the subcommand-aware parser. +def _split_copilot_args(args: list[str]) -> tuple[str, list[str]]: + """Split raw ``idc copilot`` argv into ``(path, forwarded_args)``. - argparse with both a top-level positional ``path`` and subparsers - misparses ``dcode ./somepath`` ("invalid choice"). Workaround: only - enable subparsers when the first non-flag token is a known subcommand. + The first non-flag token (if any) is treated as the project path; + everything else is forwarded verbatim to ``copilot`` inside the + container. A literal ``--`` separator is honored both at the start + (when the user wants to forward a leading non-flag arg that would + otherwise be parsed as the path) and between the path and the + forwarded args. The separator is stripped from the forwarded list. """ - for tok in argv: - if tok in ("-h", "--help"): - # Let the full parser handle help so users see subcommands. - return True - if tok.startswith("-"): - continue - return tok in _SUBCOMMANDS - return False + if not args: + return (".", []) + if args[0] == "--": + return (".", args[1:]) + if not args[0].startswith("-"): + path = args[0] + rest = args[1:] + if rest and rest[0] == "--": + rest = rest[1:] + return (path, rest) + return (".", list(args)) def main() -> None: - argv = sys.argv[1:] - parser: argparse.ArgumentParser | None = None - if _looks_like_subcommand(argv): - parser = _build_parser() - args = parser.parse_args(argv) - else: - # Legacy path-only parser — avoids argparse's subparser/positional clash. - legacy = argparse.ArgumentParser(prog="dcode") - legacy.add_argument("-i", "--insiders", action="store_true") - legacy.add_argument("path", nargs="?", default=".") - args = legacy.parse_args(argv) - args.command = None + raw = sys.argv[1:] - if args.command == "doctor": - path = Path(args.doctor_path) if args.doctor_path else Path.cwd() - sys.exit(run_doctor(path)) + # Special-case `idc copilot` so flags like `--yolo` / `--resume` forward + # to the in-container `copilot` invocation without requiring an explicit + # `--` separator. argparse would otherwise reject unknown flags. + if raw[:1] == ["copilot"]: + copilot_argv = raw[1:] + if copilot_argv and copilot_argv[0] in ("-h", "--help"): + # Defer to argparse for help rendering so it stays in sync with + # the subparser definition. parse_args() exits via SystemExit. + _build_parser().parse_args(["copilot", "-h"]) + return - if args.command == "update": - if args.check: - sys.exit(run_update_check()) - sys.exit(run_update()) + from indevcontainer.copilot import run_copilot + + path, extra_args = _split_copilot_args(copilot_argv) + sys.exit(run_copilot(path, extra_args=extra_args)) + + parser = _build_parser() + args = parser.parse_args(raw) + + if args.command is None: + parser.print_help() + sys.exit(0) + + if args.command == "code": + run_code(args.code_path, insiders=args.insiders) + return if args.command == "shell": shell_override = args.shell_override if shell_override is not None and ( shell_override.strip() != shell_override or any(c.isspace() for c in shell_override) ): - assert parser is not None parser.error( "--shell must be a single executable path or name (no arguments); " "use VS Code terminal profile args for that" ) - from dcode.shell import run_shell - sys.exit(run_shell(args.shell_path, insiders=args.insiders, shell_override=shell_override)) + from indevcontainer.shell import run_shell + + sys.exit( + run_shell( + args.shell_path, + insiders=args.insiders, + shell_override=shell_override, + ) + ) - run_dcode(args.path, insiders=args.insiders) + if args.command == "doctor": + path = Path(args.doctor_path) if args.doctor_path else Path.cwd() + sys.exit(run_doctor(path)) + + if args.command == "update": + if args.check: + sys.exit(run_update_check()) + sys.exit(run_update()) diff --git a/src/indevcontainer/copilot.py b/src/indevcontainer/copilot.py new file mode 100644 index 0000000..da90adf --- /dev/null +++ b/src/indevcontainer/copilot.py @@ -0,0 +1,79 @@ +"""``idc copilot``: exec the GitHub Copilot CLI inside a devcontainer. + +Shares container-resolution logic with ``idc shell`` via +:func:`indevcontainer.shell.prepare_container_exec` — the only difference is +the command we ``docker exec`` (and a fast-fail check that ``copilot`` is +actually installed in the target container). +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +from indevcontainer.shell import prepare_container_exec + + +def _copilot_installed(container_id: str, exec_user: str | None) -> bool: + """Return True if the ``copilot`` binary is on PATH inside the container. + + Runs ``command -v copilot`` via ``sh -c`` (using the same user we'll + ``docker exec`` as) so the PATH and login-environment match what the + real exec will see. + """ + argv = ["docker", "exec"] + if exec_user: + argv.extend(["-u", exec_user]) + argv.extend([container_id, "sh", "-c", "command -v copilot"]) + try: + proc = subprocess.run(argv, capture_output=True, text=True, check=False) + except (FileNotFoundError, OSError): + return False + return proc.returncode == 0 and bool(proc.stdout.strip()) + + +def run_copilot(path: str, *, extra_args: list[str] | None = None) -> int: + """Exec the GitHub Copilot CLI inside the devcontainer for ``path``. + + Resolves the running container exactly like ``idc shell`` (auto-building + or prompting to start when needed), then ``os.execvp``s + ``docker exec -it ... copilot ``. ``extra_args`` is the + pass-through argv from ``idc copilot -- ``. + + Returns an exit code suitable for ``sys.exit``. On success, replaces + the process via ``os.execvp`` (the explicit ``return 0`` is only + reachable when ``execvp`` is mocked in tests). + """ + ctx = prepare_container_exec(path) + if ctx is None: + return 1 + + if not _copilot_installed(ctx.container_id, ctx.exec_user): + print( + "idc: `copilot` is not installed in this devcontainer. " + "Add it via a devcontainer Feature or install it inside the " + "container (e.g. `npm install -g @github/copilot`), then re-run.", + file=sys.stderr, + ) + return 127 + + argv: list[str] = ["docker", "exec", "-it"] + if ctx.exec_user: + argv.extend(["-u", ctx.exec_user]) + if ctx.workdir: + argv.extend(["-w", ctx.workdir]) + if ctx.ssh_sock: + argv.extend(["-e", f"SSH_AUTH_SOCK={ctx.ssh_sock}"]) + argv.append(ctx.container_id) + argv.append("copilot") + if extra_args: + argv.extend(extra_args) + + try: + os.execvp("docker", argv) + except OSError as exc: + print(f"idc: failed to exec docker: {exc}", file=sys.stderr) + return 127 + + return 0 # only reached when os.execvp is mocked in tests diff --git a/src/indevcontainer/shell.py b/src/indevcontainer/shell.py index 562a083..783b4b2 100644 --- a/src/indevcontainer/shell.py +++ b/src/indevcontainer/shell.py @@ -1,4 +1,4 @@ -"""dcode shell: exec into a running devcontainer. +"""idc shell: exec into a running devcontainer. Locates the container by the Docker labels that ``devcontainers/cli`` sets (``devcontainer.local_folder`` and ``devcontainer.config_file``), resolves a @@ -19,9 +19,9 @@ import json5 -from dcode import devcontainer_cli -from dcode.core import find_devcontainer, get_workspace_folder, resolve_worktree -from dcode.wsl import _wsl_to_windows_path, get_windows_vscode_settings_path, is_wsl +from indevcontainer import devcontainer_cli +from indevcontainer.core import find_devcontainer, get_workspace_folder, resolve_worktree +from indevcontainer.wsl import _wsl_to_windows_path, get_windows_vscode_settings_path, is_wsl _ContainerState = Literal[ "running", "stopped", "missing", "ambiguous", "docker_unavailable" @@ -63,18 +63,18 @@ def _load_jsonc(path: Path) -> dict: try: text = path.read_text() except OSError as exc: - print(f"dcode: failed to read {path}: {exc}", file=sys.stderr) + print(f"idc: failed to read {path}: {exc}", file=sys.stderr) return {} if not text.strip(): return {} try: parsed = json5.loads(text) except ValueError as exc: - print(f"dcode: failed to parse {path}: {exc}", file=sys.stderr) + print(f"idc: failed to parse {path}: {exc}", file=sys.stderr) return {} if not isinstance(parsed, dict): print( - f"dcode: ignoring {path}: top-level value is not an object", + f"idc: ignoring {path}: top-level value is not an object", file=sys.stderr, ) return {} @@ -255,7 +255,7 @@ def _profile_to_resolved( if isinstance(a, str): if "${" in a and not warn_substitution[0]: print( - "dcode: terminal profile contains ${...} substitution; " + "idc: terminal profile contains ${...} substitution; " "passing through unchanged (variable substitution is not " "yet implemented)", file=sys.stderr, @@ -271,7 +271,7 @@ def _profile_to_resolved( continue if "${" in v and not warn_substitution[0]: print( - "dcode: terminal profile contains ${...} substitution; " + "idc: terminal profile contains ${...} substitution; " "passing through unchanged (variable substitution is not " "yet implemented)", file=sys.stderr, @@ -443,7 +443,7 @@ def probe_workdir(container_id: str, candidate: str, fallback: str) -> str | Non return candidate if fallback and fallback != candidate: print( - f"dcode: working directory {candidate} not found in container; " + f"idc: working directory {candidate} not found in container; " f"falling back to {fallback}", file=sys.stderr, ) @@ -538,7 +538,7 @@ def _prompt_yes_no(question: str, *, default_yes: bool) -> bool: Accepts ``y``/``yes`` (any case), declines on ``n``/``no``. An empty answer follows ``default_yes``. All other inputs decline. Decline - paths print ``dcode: aborted`` to stderr. + paths print ``idc: aborted`` to stderr. """ suffix = "[Y/n]" if default_yes else "[y/N]" sys.stderr.write(f"{question} {suffix} ") @@ -548,20 +548,20 @@ def _prompt_yes_no(question: str, *, default_yes: bool) -> bool: return True if answer == "" and default_yes: return True - print("dcode: aborted", file=sys.stderr) + print("idc: aborted", file=sys.stderr) return False def _prompt_start_stopped(container_id: str, host_path: str | Path) -> bool: """Prompt to start a stopped container and run ``docker start`` if accepted.""" if not _prompt_yes_no( - f"dcode: devcontainer for {host_path} is stopped. Start it now?", + f"idc: devcontainer for {host_path} is stopped. Start it now?", default_yes=True, ): return False short_id = container_id[:12] - print(f"dcode: starting container {short_id}...", file=sys.stderr) + print(f"idc: starting container {short_id}...", file=sys.stderr) try: proc = subprocess.run( ["docker", "start", container_id], @@ -570,17 +570,17 @@ def _prompt_start_stopped(container_id: str, host_path: str | Path) -> bool: check=False, ) except (FileNotFoundError, OSError) as exc: - print(f"dcode: failed to start container {short_id}: {exc}", file=sys.stderr) + print(f"idc: failed to start container {short_id}: {exc}", file=sys.stderr) return False if proc.returncode != 0: detail = (proc.stderr or "").strip() if not detail: detail = (proc.stdout or "").strip() or f"exit code {proc.returncode}" - print(f"dcode: failed to start container {short_id}: {detail}", file=sys.stderr) + print(f"idc: failed to start container {short_id}: {detail}", file=sys.stderr) return False - print("dcode: container started", file=sys.stderr) + print("idc: container started", file=sys.stderr) return True @@ -596,12 +596,12 @@ def _obtain_or_install_cli() -> Path | None: return cli print( - "dcode: Dev Containers CLI is not installed (the CLI is what builds the " + "idc: Dev Containers CLI is not installed (the CLI is what builds the " "devcontainer outside of VS Code).", file=sys.stderr, ) if not _prompt_yes_no( - f"dcode: install the Dev Containers CLI now from " + f"idc: install the Dev Containers CLI now from " f"{devcontainer_cli.INSTALL_SCRIPT_URL}\n" f" into {devcontainer_cli.DEFAULT_INSTALL_PREFIX} (no root needed)?", default_yes=False, @@ -609,7 +609,7 @@ def _obtain_or_install_cli() -> Path | None: print( f"hint: {devcontainer_cli.install_hint()}\n" " alternatively, open the project in VS Code first to build the " - "container, then re-run dcode shell", + "container, then re-run idc shell", file=sys.stderr, ) return None @@ -636,27 +636,56 @@ def _build_missing_container(main_repo: Path, devcontainer_path: Path) -> str | return None print( - f"dcode: building devcontainer for {main_repo} via {cli}", + f"idc: building devcontainer for {main_repo} via {cli}", file=sys.stderr, ) container_id, error_log = devcontainer_cli.up(cli, main_repo, devcontainer_path) if container_id is None: - print("dcode: devcontainer build failed", file=sys.stderr) + print("idc: devcontainer build failed", file=sys.stderr) if error_log: print(error_log, file=sys.stderr) return None short = container_id[:12] - print(f"dcode: devcontainer built and started ({short})", file=sys.stderr) + print(f"idc: devcontainer built and started ({short})", file=sys.stderr) return container_id -def run_shell(path: str, *, insiders: bool, shell_override: str | None) -> int: - """Open an interactive shell in the running devcontainer for ``path``. +@dataclass(frozen=True, slots=True) +class ContainerExec: + """Everything ``docker exec`` needs to run something inside a devcontainer. + + Produced by :func:`prepare_container_exec` and consumed by both + :func:`run_shell` and :func:`run_copilot`. Contains the resolved container + id, the user to ``-u`` as, the working directory to ``-w`` into, the SSH + agent socket to forward (or ``None``), and the parsed ``devcontainer.json`` + so callers can layer their own logic on top (e.g. terminal-profile + resolution). + """ - Returns an exit code suitable for ``sys.exit``. On success, replaces the - process via ``os.execvp`` (the explicit ``return 0`` is only reachable - when ``execvp`` is mocked in tests). + container_id: str + exec_user: str | None + workdir: str | None + ssh_sock: str | None + workspace_folder: str + devcontainer_cfg: dict + main_repo: Path + rel_path: Path | None + + +def prepare_container_exec(path: str) -> ContainerExec | None: + """Resolve everything ``docker exec`` needs for the devcontainer at *path*. + + Mirrors the container-discovery flow used by ``idc shell``: detects + worktrees, locates ``devcontainer.json``, looks up the running container + by its devcontainers/cli labels (auto-building or prompting to start + when needed), then inspects the container for user, workdir, and an + SSH agent socket. + + Returns ``None`` after printing a hint to stderr if any step fails — + no devcontainer.json, docker unavailable, ambiguous match, missing + container with no TTY to prompt the user, declined prompt, or build + failure. Callers should propagate exit code ``1`` in that case. """ target = Path(path).resolve() @@ -670,11 +699,11 @@ def run_shell(path: str, *, insiders: bool, shell_override: str | None) -> int: devcontainer_path = find_devcontainer(main_repo) if devcontainer_path is None: print( - f"dcode: no devcontainer.json found for {main_repo}; " - f"run `dcode doctor` to diagnose", + f"idc: no devcontainer.json found for {main_repo}; " + f"run `idc doctor` to diagnose", file=sys.stderr, ) - return 1 + return None devcontainer_cfg = _load_jsonc(devcontainer_path) workspace_folder = get_workspace_folder(devcontainer_path, main_repo) @@ -684,73 +713,73 @@ def run_shell(path: str, *, insiders: bool, shell_override: str | None) -> int: if lookup.state == "docker_unavailable": detail = lookup.detail or "unknown error" print( - f"dcode: docker CLI not available — is Docker Desktop running? " + f"idc: docker CLI not available — is Docker Desktop running? " f"({detail})", file=sys.stderr, ) - return 1 + return None if lookup.state == "ambiguous": ids = ", ".join(lookup.ids) print( - f"dcode: multiple devcontainers match {main_repo}: {ids} — " + f"idc: multiple devcontainers match {main_repo}: {ids} — " f"please remove duplicates with `docker rm`", file=sys.stderr, ) - return 1 + return None is_interactive = sys.stdin.isatty() and sys.stdout.isatty() if lookup.state in ("stopped", "missing") and not is_interactive: if lookup.state == "stopped": print( - f"dcode: devcontainer for {main_repo} exists but is stopped — " + f"idc: devcontainer for {main_repo} exists but is stopped — " "run interactively to be prompted to start it, or run " - f"`dcode {path}` first", + f"`idc code {path}` first", file=sys.stderr, ) else: print( - f"dcode: no devcontainer is running for {main_repo} — " + f"idc: no devcontainer is running for {main_repo} — " "run interactively to be prompted to build it, or run " - f"`dcode {path}` first", + f"`idc code {path}` first", file=sys.stderr, ) - return 1 + return None if not is_interactive: - # state == "running" but no TTY for an interactive shell. + # state == "running" but no TTY — caller wanted an interactive exec. print( - "dcode: dcode shell requires an interactive terminal", + "idc: this command requires an interactive terminal", file=sys.stderr, ) - return 1 + return None if lookup.state == "stopped": stopped_id = lookup.id if stopped_id is None: # pragma: no cover - defensive - print("dcode: container lookup returned no id", file=sys.stderr) - return 1 + print("idc: container lookup returned no id", file=sys.stderr) + return None if not _prompt_start_stopped(stopped_id, main_repo): - return 1 + return None container_id: str = stopped_id elif lookup.state == "missing": if not _prompt_yes_no( - f"dcode: no devcontainer is running for {main_repo}. " + f"idc: no devcontainer is running for {main_repo}. " f"Build & start it now?", default_yes=True, ): - return 1 + return None built = _build_missing_container(main_repo, devcontainer_path) if built is None: - return 1 + return None container_id = built else: # state == "running" running_id = lookup.id if running_id is None: # pragma: no cover - defensive - print("dcode: container lookup returned no id", file=sys.stderr) - return 1 + print("idc: container lookup returned no id", file=sys.stderr) + return None container_id = running_id metadata_entries = _inspect_container_metadata(container_id) @@ -758,56 +787,79 @@ def run_shell(path: str, *, insiders: bool, shell_override: str | None) -> int: if "remoteEnv" in devcontainer_cfg: print( - "dcode: devcontainer remoteEnv is not applied to this shell yet; " + "idc: devcontainer remoteEnv is not applied to this exec yet; " "environment may differ from VS Code terminal", file=sys.stderr, ) - # Resolve shell. - if shell_override: - resolved = ResolvedShell(path=shell_override) - else: - profile = resolve_terminal_profile(main_repo, devcontainer_cfg, insiders) - if profile is not None: - resolved = profile - else: - shell_path = detect_login_shell(container_id, exec_user) - resolved = ResolvedShell(path=shell_path) - - # SSH agent socket forwarding. ssh_sock = find_ssh_socket(container_id) if ssh_sock is None: print( - "dcode: SSH agent socket not found in container — SSH key auth " + "idc: SSH agent socket not found in container — SSH key auth " "may not work (open in VS Code to enable forwarding)", file=sys.stderr, ) - # Working directory probe. if rel_path is not None: candidate_workdir = f"{workspace_folder}/{rel_path.as_posix()}" else: candidate_workdir = workspace_folder workdir = probe_workdir(container_id, candidate_workdir, workspace_folder) + return ContainerExec( + container_id=container_id, + exec_user=exec_user, + workdir=workdir, + ssh_sock=ssh_sock, + workspace_folder=workspace_folder, + devcontainer_cfg=devcontainer_cfg, + main_repo=main_repo, + rel_path=rel_path, + ) + + +def run_shell(path: str, *, insiders: bool, shell_override: str | None) -> int: + """Open an interactive shell in the running devcontainer for ``path``. + + Returns an exit code suitable for ``sys.exit``. On success, replaces the + process via ``os.execvp`` (the explicit ``return 0`` is only reachable + when ``execvp`` is mocked in tests). + """ + ctx = prepare_container_exec(path) + if ctx is None: + return 1 + + # Resolve shell. + if shell_override: + resolved = ResolvedShell(path=shell_override) + else: + profile = resolve_terminal_profile( + ctx.main_repo, ctx.devcontainer_cfg, insiders + ) + if profile is not None: + resolved = profile + else: + shell_path = detect_login_shell(ctx.container_id, ctx.exec_user) + resolved = ResolvedShell(path=shell_path) + # Build argv. argv: list[str] = ["docker", "exec", "-it"] - if exec_user: - argv.extend(["-u", exec_user]) - if workdir: - argv.extend(["-w", workdir]) - if ssh_sock: - argv.extend(["-e", f"SSH_AUTH_SOCK={ssh_sock}"]) + if ctx.exec_user: + argv.extend(["-u", ctx.exec_user]) + if ctx.workdir: + argv.extend(["-w", ctx.workdir]) + if ctx.ssh_sock: + argv.extend(["-e", f"SSH_AUTH_SOCK={ctx.ssh_sock}"]) for k, v in resolved.env: argv.extend(["-e", f"{k}={v}"]) - argv.append(container_id) + argv.append(ctx.container_id) argv.append(resolved.path) argv.extend(resolved.args) try: os.execvp("docker", argv) except OSError as exc: - print(f"dcode: failed to exec docker: {exc}", file=sys.stderr) + print(f"idc: failed to exec docker: {exc}", file=sys.stderr) return 127 return 0 # only reached when os.execvp is mocked in tests diff --git a/tests/test_cli.py b/tests/test_cli.py index f6bfde9..eaa2347 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,57 +1,86 @@ -"""Tests for dcode CLI entrypoint and package metadata.""" +"""Tests for the idc CLI entrypoint and package metadata.""" +from pathlib import Path from unittest.mock import patch import pytest -from dcode import cli +import indevcontainer +from indevcontainer import cli class TestVersion: def test_resolves_via_importlib_metadata(self): from importlib.metadata import version - import dcode + assert indevcontainer.__version__ == version("indevcontainer") - assert dcode.__version__ == version("dcode") +class TestNoSubcommand: + def test_bare_idc_prints_help_and_exits_zero(self, monkeypatch, capsys): + monkeypatch.setattr("sys.argv", ["idc"]) + with pytest.raises(SystemExit) as exc: + cli.main() + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "usage:" in out.lower() + # All five subcommands should appear in the help output. + for cmd in ("code", "shell", "copilot", "doctor", "update"): + assert cmd in out -class TestDispatch: - def test_no_subcommand_calls_run_dcode(self, monkeypatch): - monkeypatch.setattr("sys.argv", ["dcode"]) - with ( - patch("dcode.cli.run_dcode") as m_run, - patch("dcode.cli.run_update") as m_upd, - ): + +class TestCodeDispatch: + def test_code_no_args_opens_cwd(self, monkeypatch): + monkeypatch.setattr("sys.argv", ["idc", "code"]) + with patch("indevcontainer.cli.run_code") as m_run: cli.main() m_run.assert_called_once_with(".", insiders=False) - m_upd.assert_not_called() - def test_path_arg_calls_run_dcode(self, monkeypatch, tmp_path): - monkeypatch.setattr("sys.argv", ["dcode", str(tmp_path)]) - with patch("dcode.cli.run_dcode") as m_run: + def test_code_with_path(self, monkeypatch, tmp_path): + monkeypatch.setattr("sys.argv", ["idc", "code", str(tmp_path)]) + with patch("indevcontainer.cli.run_code") as m_run: cli.main() m_run.assert_called_once_with(str(tmp_path), insiders=False) + def test_code_insiders_flag_short(self, monkeypatch, tmp_path): + monkeypatch.setattr("sys.argv", ["idc", "code", "-i", str(tmp_path)]) + with patch("indevcontainer.cli.run_code") as m_run: + cli.main() + m_run.assert_called_once_with(str(tmp_path), insiders=True) + + def test_code_insiders_flag_long(self, monkeypatch): + monkeypatch.setattr("sys.argv", ["idc", "code", "--insiders"]) + with patch("indevcontainer.cli.run_code") as m_run: + cli.main() + m_run.assert_called_once_with(".", insiders=True) + + def test_code_can_open_folder_named_like_subcommand(self, monkeypatch): + # idc code ./shell opens a folder literally named "shell", not the + # shell subcommand. This is the documented escape hatch. + monkeypatch.setattr("sys.argv", ["idc", "code", "./shell"]) + with patch("indevcontainer.cli.run_code") as m_run: + cli.main() + m_run.assert_called_once_with("./shell", insiders=False) + + +class TestUpdateDispatch: def test_update_calls_run_update(self, monkeypatch): - monkeypatch.setattr("sys.argv", ["dcode", "update"]) + monkeypatch.setattr("sys.argv", ["idc", "update"]) with ( - patch("dcode.cli.run_update", return_value=0) as m_upd, - patch("dcode.cli.run_update_check") as m_chk, - patch("dcode.cli.run_dcode") as m_run, + patch("indevcontainer.cli.run_update", return_value=0) as m_upd, + patch("indevcontainer.cli.run_update_check") as m_chk, pytest.raises(SystemExit) as exc, ): cli.main() assert exc.value.code == 0 m_upd.assert_called_once_with() m_chk.assert_not_called() - m_run.assert_not_called() - def test_update_check_calls_run_update_check(self, monkeypatch): - monkeypatch.setattr("sys.argv", ["dcode", "update", "--check"]) + def test_update_check(self, monkeypatch): + monkeypatch.setattr("sys.argv", ["idc", "update", "--check"]) with ( - patch("dcode.cli.run_update_check", return_value=1) as m_chk, - patch("dcode.cli.run_update") as m_upd, + patch("indevcontainer.cli.run_update_check", return_value=1) as m_chk, + patch("indevcontainer.cli.run_update") as m_upd, pytest.raises(SystemExit) as exc, ): cli.main() @@ -60,83 +89,58 @@ def test_update_check_calls_run_update_check(self, monkeypatch): m_upd.assert_not_called() def test_update_exit_code_forwarded(self, monkeypatch): - monkeypatch.setattr("sys.argv", ["dcode", "update"]) + monkeypatch.setattr("sys.argv", ["idc", "update"]) with ( - patch("dcode.cli.run_update", return_value=42), + patch("indevcontainer.cli.run_update", return_value=42), pytest.raises(SystemExit) as exc, ): cli.main() assert exc.value.code == 42 - def test_path_named_update_workaround(self, monkeypatch): - # Documented escape hatch: prefix with ./ to disambiguate. - monkeypatch.setattr("sys.argv", ["dcode", "./update"]) - with ( - patch("dcode.cli.run_dcode") as m_run, - patch("dcode.cli.run_update") as m_upd, - ): - cli.main() - m_run.assert_called_once_with("./update", insiders=False) - m_upd.assert_not_called() - - def test_doctor_subcommand_calls_run_doctor(self, monkeypatch, tmp_path): - from pathlib import Path +class TestDoctorDispatch: + def test_doctor_no_args(self, monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) - monkeypatch.setattr("sys.argv", ["dcode", "doctor"]) + monkeypatch.setattr("sys.argv", ["idc", "doctor"]) with ( - patch("dcode.cli.run_doctor", return_value=0) as m_doc, - patch("dcode.cli.run_dcode") as m_run, + patch("indevcontainer.cli.run_doctor", return_value=0) as m_doc, pytest.raises(SystemExit) as exc, ): cli.main() assert exc.value.code == 0 m_doc.assert_called_once_with(Path.cwd()) - m_run.assert_not_called() def test_doctor_with_path(self, monkeypatch, tmp_path): - from pathlib import Path - - monkeypatch.setattr("sys.argv", ["dcode", "doctor", str(tmp_path)]) + monkeypatch.setattr("sys.argv", ["idc", "doctor", str(tmp_path)]) with ( - patch("dcode.cli.run_doctor", return_value=0) as m_doc, + patch("indevcontainer.cli.run_doctor", return_value=0) as m_doc, pytest.raises(SystemExit), ): cli.main() m_doc.assert_called_once_with(Path(str(tmp_path))) def test_doctor_exit_code_forwarded(self, monkeypatch): - monkeypatch.setattr("sys.argv", ["dcode", "doctor"]) + monkeypatch.setattr("sys.argv", ["idc", "doctor"]) with ( - patch("dcode.cli.run_doctor", return_value=1), + patch("indevcontainer.cli.run_doctor", return_value=1), pytest.raises(SystemExit) as exc, ): cli.main() assert exc.value.code == 1 - def test_path_named_doctor_workaround(self, monkeypatch): - monkeypatch.setattr("sys.argv", ["dcode", "./doctor"]) - with ( - patch("dcode.cli.run_dcode") as m_run, - patch("dcode.cli.run_doctor") as m_doc, - ): - cli.main() - m_run.assert_called_once_with("./doctor", insiders=False) - m_doc.assert_not_called() - class TestShellDispatch: - """Dispatch tests for the `dcode shell` subcommand. + """Dispatch tests for ``idc shell``. - NOTE: `run_shell` is lazy-imported inside `cli.main()` via - `from dcode.shell import run_shell`, so it MUST be patched at - `dcode.shell.run_shell` rather than `dcode.cli.run_shell`. + ``run_shell`` is lazy-imported inside ``cli.main()`` via + ``from indevcontainer.shell import run_shell``, so it MUST be patched + at ``indevcontainer.shell.run_shell``. """ def test_shell_no_args(self, monkeypatch): - monkeypatch.setattr("sys.argv", ["dcode", "shell"]) + monkeypatch.setattr("sys.argv", ["idc", "shell"]) with ( - patch("dcode.shell.run_shell", return_value=0) as m_run, + patch("indevcontainer.shell.run_shell", return_value=0) as m_run, pytest.raises(SystemExit) as exc, ): cli.main() @@ -144,18 +148,20 @@ def test_shell_no_args(self, monkeypatch): m_run.assert_called_once_with(".", insiders=False, shell_override=None) def test_shell_with_path(self, monkeypatch): - monkeypatch.setattr("sys.argv", ["dcode", "shell", "./project"]) + monkeypatch.setattr("sys.argv", ["idc", "shell", "./project"]) with ( - patch("dcode.shell.run_shell", return_value=0) as m_run, + patch("indevcontainer.shell.run_shell", return_value=0) as m_run, pytest.raises(SystemExit), ): cli.main() - m_run.assert_called_once_with("./project", insiders=False, shell_override=None) + m_run.assert_called_once_with( + "./project", insiders=False, shell_override=None + ) def test_shell_with_shell_override(self, monkeypatch): - monkeypatch.setattr("sys.argv", ["dcode", "shell", "--shell", "zsh"]) + monkeypatch.setattr("sys.argv", ["idc", "shell", "--shell", "zsh"]) with ( - patch("dcode.shell.run_shell", return_value=0) as m_run, + patch("indevcontainer.shell.run_shell", return_value=0) as m_run, pytest.raises(SystemExit), ): cli.main() @@ -163,42 +169,41 @@ def test_shell_with_shell_override(self, monkeypatch): def test_shell_with_path_and_shell_override(self, monkeypatch): monkeypatch.setattr( - "sys.argv", ["dcode", "shell", "./path", "--shell", "bash"] + "sys.argv", ["idc", "shell", "./path", "--shell", "bash"] ) with ( - patch("dcode.shell.run_shell", return_value=0) as m_run, + patch("indevcontainer.shell.run_shell", return_value=0) as m_run, pytest.raises(SystemExit), ): cli.main() - m_run.assert_called_once_with("./path", insiders=False, shell_override="bash") + m_run.assert_called_once_with( + "./path", insiders=False, shell_override="bash" + ) - def test_insiders_flag_before_shell(self, monkeypatch): - monkeypatch.setattr("sys.argv", ["dcode", "-i", "shell"]) + def test_shell_insiders_flag(self, monkeypatch): + monkeypatch.setattr("sys.argv", ["idc", "shell", "-i"]) with ( - patch("dcode.shell.run_shell", return_value=0) as m_run, + patch("indevcontainer.shell.run_shell", return_value=0) as m_run, pytest.raises(SystemExit), ): cli.main() m_run.assert_called_once_with(".", insiders=True, shell_override=None) - def test_insiders_flag_after_shell_rejected(self, monkeypatch, capsys): - # The shell subparser does NOT redeclare -i, so argparse rejects - # `dcode shell -i` as an unrecognized argument (exit code 2). - monkeypatch.setattr("sys.argv", ["dcode", "shell", "-i"]) + def test_shell_insiders_long_form(self, monkeypatch): + monkeypatch.setattr("sys.argv", ["idc", "shell", "--insiders"]) with ( - patch("dcode.shell.run_shell") as m_run, - pytest.raises(SystemExit) as exc, + patch("indevcontainer.shell.run_shell", return_value=0) as m_run, + pytest.raises(SystemExit), ): cli.main() - assert exc.value.code == 2 - m_run.assert_not_called() + m_run.assert_called_once_with(".", insiders=True, shell_override=None) def test_shell_override_with_internal_whitespace_rejected( self, monkeypatch, capsys ): - monkeypatch.setattr("sys.argv", ["dcode", "shell", "--shell", "bash -l"]) + monkeypatch.setattr("sys.argv", ["idc", "shell", "--shell", "bash -l"]) with ( - patch("dcode.shell.run_shell") as m_run, + patch("indevcontainer.shell.run_shell") as m_run, pytest.raises(SystemExit) as exc, ): cli.main() @@ -210,9 +215,9 @@ def test_shell_override_with_internal_whitespace_rejected( def test_shell_override_with_leading_whitespace_rejected( self, monkeypatch, capsys ): - monkeypatch.setattr("sys.argv", ["dcode", "shell", "--shell", " zsh"]) + monkeypatch.setattr("sys.argv", ["idc", "shell", "--shell", " zsh"]) with ( - patch("dcode.shell.run_shell") as m_run, + patch("indevcontainer.shell.run_shell") as m_run, pytest.raises(SystemExit) as exc, ): cli.main() @@ -221,29 +226,17 @@ def test_shell_override_with_leading_whitespace_rejected( assert "single executable" in err or "whitespace" in err.lower() m_run.assert_not_called() - def test_path_named_shell_workaround(self, monkeypatch): - # `dcode ./shell` must open a folder literally named 'shell', - # not dispatch to the shell subcommand. - monkeypatch.setattr("sys.argv", ["dcode", "./shell"]) - with patch("dcode.cli.run_dcode") as m_run: - cli.main() - m_run.assert_called_once_with("./shell", insiders=False) - - def test_looks_like_subcommand_recognizes_shell(self): - assert cli._looks_like_subcommand(["shell"]) is True - assert cli._looks_like_subcommand(["./shell"]) is False - - def test_top_level_help_mentions_shell(self, monkeypatch, capsys): - monkeypatch.setattr("sys.argv", ["dcode", "--help"]) - with pytest.raises(SystemExit) as exc: + def test_shell_exit_code_forwarded(self, monkeypatch): + monkeypatch.setattr("sys.argv", ["idc", "shell"]) + with ( + patch("indevcontainer.shell.run_shell", return_value=127), + pytest.raises(SystemExit) as exc, + ): cli.main() - assert exc.value.code == 0 - out = capsys.readouterr().out - assert "shell" in out - assert "dcode ./shell" in out + assert exc.value.code == 127 def test_shell_subcommand_help(self, monkeypatch, capsys): - monkeypatch.setattr("sys.argv", ["dcode", "shell", "--help"]) + monkeypatch.setattr("sys.argv", ["idc", "shell", "--help"]) with pytest.raises(SystemExit) as exc: cli.main() assert exc.value.code == 0 @@ -251,11 +244,165 @@ def test_shell_subcommand_help(self, monkeypatch, capsys): assert "--shell" in out assert "devcontainer" in out.lower() - def test_shell_exit_code_forwarded(self, monkeypatch): - monkeypatch.setattr("sys.argv", ["dcode", "shell"]) + +class TestCopilotDispatch: + """Dispatch tests for ``idc copilot``. + + ``run_copilot`` is lazy-imported inside ``cli.main()``, so it MUST be + patched at ``indevcontainer.copilot.run_copilot``. Note: ``idc copilot`` + bypasses argparse so that arbitrary flags (``--yolo``, ``--resume``, + etc.) forward to copilot without requiring a ``--`` separator. + """ + + def test_copilot_no_args(self, monkeypatch): + monkeypatch.setattr("sys.argv", ["idc", "copilot"]) with ( - patch("dcode.shell.run_shell", return_value=127), + patch("indevcontainer.copilot.run_copilot", return_value=0) as m_run, + pytest.raises(SystemExit) as exc, + ): + cli.main() + assert exc.value.code == 0 + m_run.assert_called_once_with(".", extra_args=[]) + + def test_copilot_with_path(self, monkeypatch): + monkeypatch.setattr("sys.argv", ["idc", "copilot", "./project"]) + with ( + patch("indevcontainer.copilot.run_copilot", return_value=0) as m_run, + pytest.raises(SystemExit), + ): + cli.main() + m_run.assert_called_once_with("./project", extra_args=[]) + + def test_copilot_inline_flags_without_path(self, monkeypatch): + # Primary new behavior: `idc copilot --yolo --resume` forwards + # both flags to copilot, with path defaulting to ".". + monkeypatch.setattr( + "sys.argv", ["idc", "copilot", "--yolo", "--resume"] + ) + with ( + patch("indevcontainer.copilot.run_copilot", return_value=0) as m_run, + pytest.raises(SystemExit), + ): + cli.main() + m_run.assert_called_once_with(".", extra_args=["--yolo", "--resume"]) + + def test_copilot_path_then_inline_flags(self, monkeypatch): + monkeypatch.setattr( + "sys.argv", ["idc", "copilot", "./proj", "--yolo", "--resume"] + ) + with ( + patch("indevcontainer.copilot.run_copilot", return_value=0) as m_run, + pytest.raises(SystemExit), + ): + cli.main() + m_run.assert_called_once_with( + "./proj", extra_args=["--yolo", "--resume"] + ) + + def test_copilot_with_double_dash_separator(self, monkeypatch): + # The `--` escape hatch still works for the rare case where the + # first forwarded arg would otherwise be parsed as the path. + monkeypatch.setattr( + "sys.argv", ["idc", "copilot", ".", "--", "--resume"] + ) + with ( + patch("indevcontainer.copilot.run_copilot", return_value=0) as m_run, + pytest.raises(SystemExit), + ): + cli.main() + m_run.assert_called_once_with(".", extra_args=["--resume"]) + + def test_copilot_double_dash_first_forwards_all(self, monkeypatch): + # `idc copilot -- weird-positional --flag` forces "." as the path + # and forwards everything else verbatim. + monkeypatch.setattr( + "sys.argv", + ["idc", "copilot", "--", "weird-positional", "--flag"], + ) + with ( + patch("indevcontainer.copilot.run_copilot", return_value=0) as m_run, + pytest.raises(SystemExit), + ): + cli.main() + m_run.assert_called_once_with( + ".", extra_args=["weird-positional", "--flag"] + ) + + def test_copilot_with_multiple_passthrough_args(self, monkeypatch): + monkeypatch.setattr( + "sys.argv", + ["idc", "copilot", "./proj", "--allow-tool", "shell"], + ) + with ( + patch("indevcontainer.copilot.run_copilot", return_value=0) as m_run, + pytest.raises(SystemExit), + ): + cli.main() + m_run.assert_called_once_with( + "./proj", extra_args=["--allow-tool", "shell"] + ) + + def test_copilot_exit_code_forwarded(self, monkeypatch): + monkeypatch.setattr("sys.argv", ["idc", "copilot"]) + with ( + patch("indevcontainer.copilot.run_copilot", return_value=127), pytest.raises(SystemExit) as exc, ): cli.main() assert exc.value.code == 127 + + def test_copilot_subcommand_help(self, monkeypatch, capsys): + monkeypatch.setattr("sys.argv", ["idc", "copilot", "--help"]) + with pytest.raises(SystemExit) as exc: + cli.main() + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "copilot" in out.lower() + assert "devcontainer" in out.lower() + + def test_copilot_short_help_flag(self, monkeypatch, capsys): + # `-h` should also trigger help and NOT be forwarded. + monkeypatch.setattr("sys.argv", ["idc", "copilot", "-h"]) + with pytest.raises(SystemExit) as exc: + cli.main() + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "usage:" in out.lower() + + +class TestSplitCopilotArgs: + def test_empty(self): + assert cli._split_copilot_args([]) == (".", []) + + def test_only_double_dash(self): + assert cli._split_copilot_args(["--"]) == (".", []) + + def test_leading_double_dash_forwards_all(self): + assert cli._split_copilot_args(["--", "--help"]) == (".", ["--help"]) + + def test_first_flag_no_path(self): + assert cli._split_copilot_args(["--yolo", "--resume"]) == ( + ".", + ["--yolo", "--resume"], + ) + + def test_first_short_flag_no_path(self): + assert cli._split_copilot_args(["-v"]) == (".", ["-v"]) + + def test_first_token_is_path(self): + assert cli._split_copilot_args(["./proj"]) == ("./proj", []) + + def test_path_then_flags(self): + assert cli._split_copilot_args(["./proj", "--yolo"]) == ( + "./proj", + ["--yolo"], + ) + + def test_path_then_double_dash_then_flags(self): + assert cli._split_copilot_args(["./proj", "--", "--yolo"]) == ( + "./proj", + ["--yolo"], + ) + + def test_path_then_double_dash_only(self): + assert cli._split_copilot_args(["./proj", "--"]) == ("./proj", []) diff --git a/tests/test_copilot.py b/tests/test_copilot.py new file mode 100644 index 0000000..beff99b --- /dev/null +++ b/tests/test_copilot.py @@ -0,0 +1,173 @@ +"""Tests for indevcontainer.copilot.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from indevcontainer.copilot import _copilot_installed, run_copilot +from indevcontainer.shell import ContainerExec + + +def _completed(rc: int = 0, stdout: str = "", stderr: str = "") -> SimpleNamespace: + return SimpleNamespace(returncode=rc, stdout=stdout, stderr=stderr) + + +def _make_ctx( + *, + container_id: str = "abc123", + exec_user: str | None = "node", + workdir: str | None = "/workspaces/proj", + ssh_sock: str | None = "/tmp/vscode-ssh-auth.sock", +) -> ContainerExec: + return ContainerExec( + container_id=container_id, + exec_user=exec_user, + workdir=workdir, + ssh_sock=ssh_sock, + workspace_folder="/workspaces/proj", + devcontainer_cfg={}, + main_repo=Path("/tmp/proj"), + rel_path=None, + ) + + +# --------------------------------------------------------------------------- +# _copilot_installed +# --------------------------------------------------------------------------- + + +class TestCopilotInstalled: + def test_returns_true_when_command_v_succeeds(self): + ok = _completed(rc=0, stdout="/usr/local/bin/copilot\n") + with patch("indevcontainer.copilot.subprocess.run", return_value=ok) as run: + assert _copilot_installed("abc123", "node") is True + args = run.call_args[0][0] + assert args[:5] == ["docker", "exec", "-u", "node", "abc123"] + # The probe always invokes a shell so PATH is resolved. + assert args[-3:] == ["sh", "-c", "command -v copilot"] + + def test_returns_false_when_exit_nonzero(self): + not_found = _completed(rc=1, stdout="") + with patch("indevcontainer.copilot.subprocess.run", return_value=not_found): + assert _copilot_installed("abc123", "node") is False + + def test_returns_false_when_stdout_empty(self): + # Some shells print nothing but exit 0 when the command is missing. + empty_ok = _completed(rc=0, stdout="\n") + with patch("indevcontainer.copilot.subprocess.run", return_value=empty_ok): + assert _copilot_installed("abc123", "node") is False + + def test_returns_false_when_docker_missing(self): + with patch( + "indevcontainer.copilot.subprocess.run", + side_effect=FileNotFoundError("no docker"), + ): + assert _copilot_installed("abc123", "node") is False + + def test_omits_user_flag_when_no_exec_user(self): + ok = _completed(rc=0, stdout="/bin/copilot\n") + with patch("indevcontainer.copilot.subprocess.run", return_value=ok) as run: + assert _copilot_installed("abc123", None) is True + args = run.call_args[0][0] + assert args[:3] == ["docker", "exec", "abc123"] + assert "-u" not in args + + +# --------------------------------------------------------------------------- +# run_copilot +# --------------------------------------------------------------------------- + + +class TestRunCopilot: + def test_returns_1_when_prepare_fails(self): + with patch("indevcontainer.copilot.prepare_container_exec", return_value=None): + assert run_copilot(".") == 1 + + def test_fails_with_helpful_message_when_copilot_missing(self, capsys): + ctx = _make_ctx() + with ( + patch("indevcontainer.copilot.prepare_container_exec", return_value=ctx), + patch("indevcontainer.copilot._copilot_installed", return_value=False), + patch("indevcontainer.copilot.os.execvp") as execvp, + ): + rc = run_copilot(".") + assert rc == 127 + execvp.assert_not_called() + err = capsys.readouterr().err + assert "idc:" in err + assert "copilot" in err + assert "not installed" in err + + def test_execs_docker_exec_copilot(self): + ctx = _make_ctx() + with ( + patch("indevcontainer.copilot.prepare_container_exec", return_value=ctx), + patch("indevcontainer.copilot._copilot_installed", return_value=True), + patch("indevcontainer.copilot.os.execvp") as execvp, + ): + rc = run_copilot(".") + # 0 is only reached when execvp is mocked. + assert rc == 0 + execvp.assert_called_once() + prog, argv = execvp.call_args[0] + assert prog == "docker" + assert argv[:3] == ["docker", "exec", "-it"] + assert "-u" in argv and "node" in argv + assert "-w" in argv and "/workspaces/proj" in argv + assert "-e" in argv + assert "SSH_AUTH_SOCK=/tmp/vscode-ssh-auth.sock" in argv + # container id comes before the command. + assert "abc123" in argv + assert argv[-1] == "copilot" + + def test_forwards_extra_args_to_copilot(self): + ctx = _make_ctx(exec_user=None, workdir=None, ssh_sock=None) + with ( + patch("indevcontainer.copilot.prepare_container_exec", return_value=ctx), + patch("indevcontainer.copilot._copilot_installed", return_value=True), + patch("indevcontainer.copilot.os.execvp") as execvp, + ): + run_copilot(".", extra_args=["--resume", "--allow-tool", "shell"]) + _, argv = execvp.call_args[0] + assert argv == [ + "docker", + "exec", + "-it", + "abc123", + "copilot", + "--resume", + "--allow-tool", + "shell", + ] + + def test_omits_optional_flags_when_unset(self): + ctx = _make_ctx(exec_user=None, workdir=None, ssh_sock=None) + with ( + patch("indevcontainer.copilot.prepare_container_exec", return_value=ctx), + patch("indevcontainer.copilot._copilot_installed", return_value=True), + patch("indevcontainer.copilot.os.execvp") as execvp, + ): + run_copilot(".") + _, argv = execvp.call_args[0] + assert "-u" not in argv + assert "-w" not in argv + assert "-e" not in argv + assert argv == ["docker", "exec", "-it", "abc123", "copilot"] + + def test_returns_127_when_execvp_fails(self, capsys): + ctx = _make_ctx() + with ( + patch("indevcontainer.copilot.prepare_container_exec", return_value=ctx), + patch("indevcontainer.copilot._copilot_installed", return_value=True), + patch( + "indevcontainer.copilot.os.execvp", + side_effect=OSError("docker not found"), + ), + ): + rc = run_copilot(".") + assert rc == 127 + err = capsys.readouterr().err + assert "idc:" in err + assert "failed to exec docker" in err From cdb2d3a02191fa95e22d47846c38ece7573e06bf Mon Sep 17 00:00:00 2001 From: rosstaco Date: Thu, 28 May 2026 18:11:53 +1000 Subject: [PATCH 4/4] docs: rewrite README and copilot-instructions for InDevContainer rebrand README: - New title and tagline reflecting the broader scope (code, shell, copilot in one CLI). - "Renamed from `dcode`" callout near the top with the `uv tool uninstall dcode` migration step. - Install command points at the new repo URL. - All command examples updated to the new `idc ` form. - New section for `idc copilot` with the transparent-forwarding syntax (`idc copilot --yolo --resume` works directly). - Quick-start shows the most common new flows. - Worktree, WSL, and auto-build sections updated for the new binary/branding. .github/copilot-instructions.md: - New heading and architecture section describing the subcommand layout, the shared `prepare_container_exec()` helper, and the bypass-argparse behavior of `idc copilot`. - Notes the binary/package split: `idc` (binary) vs `indevcontainer` (Python package / pyproject name). - `idc:` stderr-prefix convention documented. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 37 ++++++- README.md | 174 +++++++++++++++++++------------- 2 files changed, 134 insertions(+), 77 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f51d9cc..2a24a9a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,4 @@ -# Copilot Instructions for dcode +# Copilot Instructions for InDevContainer ## Build & Test @@ -13,13 +13,39 @@ uv run pytest -k test_resolves_worktree_with_relative_gitdir uv run pytest -k TestResolveWorktree ``` -Linting is via `ruff` (`uv run ruff check`). Versioning is automated: `hatch-vcs` derives the version from the latest git tag, and [release-please](https://github.com/googleapis/release-please) manages tags + the changelog from [Conventional Commits](https://www.conventionalcommits.org/). **Do not** edit a version field in `pyproject.toml` or `src/dcode/__init__.py` — both are derived from the git tag at install/build time via `importlib.metadata`. +Linting is via `ruff` (`uv run ruff check`). Versioning is automated: `hatch-vcs` derives the version from the latest git tag, and [release-please](https://github.com/googleapis/release-please) manages tags + the changelog from [Conventional Commits](https://www.conventionalcommits.org/). **Do not** edit a version field in `pyproject.toml` or `src/indevcontainer/__init__.py` — both are derived from the git tag at install/build time via `importlib.metadata`. ## Architecture -dcode is a single-module CLI (`src/dcode/cli.py`) that constructs `vscode-remote://dev-container+` URIs and launches VS Code with `--folder-uri`. The entrypoint is `dcode.cli:main`. +InDevContainer is a Python CLI installed as the `idc` binary (package +`indevcontainer`). It constructs `vscode-remote://dev-container+` URIs and launches VS Code with `--folder-uri`, or shells/copilot directly into running containers via `docker exec`. The entrypoint is `indevcontainer.cli:main`. -The core flow in `run_dcode()`: +The CLI is subcommand-only — no top-level positional. Subcommands: + +* `idc code [-i] ` — `indevcontainer.core.run_code`. Constructs the + devcontainer URI and launches VS Code (or VS Code Insiders). +* `idc shell [--shell EXE] [-i]` — `indevcontainer.shell.run_shell`. + `docker exec -it`s into the project's running container with the resolved + terminal profile. +* `idc copilot [] [copilot-args...]` — + `indevcontainer.copilot.run_copilot`. Same container resolution as + `idc shell`, but execs `copilot` inside the container. **This subcommand + bypasses argparse** (see `_split_copilot_args` in `cli.py`) so that + arbitrary flags like `--yolo` / `--resume` forward to `copilot` without + requiring a `--` separator. The first non-flag arg is the project path; + `--` is honored as an optional explicit separator. +* `idc doctor [path]` — `indevcontainer.doctor.run_doctor`. +* `idc update [--check]` — `indevcontainer.update.{run_update, run_update_check}`. + +Bare `idc` (no subcommand) prints `--help` and exits 0. + +The container-resolution logic shared by `idc shell` and `idc copilot` lives +in `indevcontainer.shell.prepare_container_exec()`, which returns a +`ContainerExec` dataclass (container_id, exec_user, workdir, ssh_sock, +workspace_folder, devcontainer_cfg, main_repo, rel_path) or `None` on error +(after printing a hint to stderr). + +The core flow in `run_code()`: 1. **Worktree detection** — `resolve_worktree()` checks if `.git` is a file (not directory), parses the `gitdir:` pointer, and validates the path structure to distinguish worktrees from submodules. When the gitdir contains an absolute path from a different environment (e.g. a container), it falls back to walking ancestor directories for the real `.git` dir. 2. **Config lookup** — `find_devcontainer()` searches for `.devcontainer/devcontainer.json` or `.devcontainer.json` in the target (or main repo for worktrees). @@ -28,6 +54,7 @@ The core flow in `run_dcode()`: ## Conventions - `json5` is used to parse `devcontainer.json` (supports JSONC comments and trailing commas). -- All user-facing messages go to `sys.stderr`; stdout is reserved for machine output. +- All user-facing messages go to `sys.stderr` and are prefixed with `idc:`. Stdout is reserved for machine output. - Tests use `tmp_path` fixtures with mock filesystem layouts (fake `.git` files/dirs) — no real git repos needed. `subprocess.run` is always patched in integration tests. - The helper `_make_worktree(tmp_path, name)` in the test file creates a complete fake main-repo + worktree layout for test reuse. +- The CLI binary is `idc`; the Python package is `indevcontainer`. They differ on purpose: the short binary is for users, the longer package name is for `importlib.metadata` and `uv tool` (which use the project name from `pyproject.toml`). diff --git a/README.md b/README.md index 01def12..9152548 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,57 @@ -# dcode 🚀 +# InDevContainer 🚀 -Open folders in VS Code devcontainers directly from the CLI. +Run code, shells, and the GitHub Copilot CLI inside VS Code devcontainers — straight from your terminal. -Replace the two-step `code .` → "Reopen in Container" workflow with a single command. ✨ +> Renamed from `dcode`. Same project, new name and a broader scope. The CLI binary is now `idc`. ## 📦 Install ```bash -uv tool install git+https://github.com/rosstaco/dcode +uv tool install git+https://github.com/rosstaco/InDevContainer ``` -## 🔧 Usage +If you have the old `dcode` tool installed, remove it first: ```bash -# Open current folder in devcontainer -dcode . +uv tool uninstall dcode +``` + +## 🔧 Quick start + +```bash +# Open current folder in VS Code via its devcontainer (was: dcode .) +idc code . # Open a specific path -dcode /path/to/project +idc code /path/to/project # Use VS Code Insiders -dcode --insiders . -dcode -i . +idc code -i . + +# Drop into an interactive shell inside the running devcontainer +idc shell + +# Run the GitHub Copilot CLI inside the running devcontainer +idc copilot --yolo --resume ``` -If the folder has no `.devcontainer/devcontainer.json`, falls back to plain `code .`. +If the folder has no `.devcontainer/devcontainer.json`, `idc code` falls back to plain `code `. ## 🛠 Commands -### `dcode ` +### `idc code [-i] ` -Open `` (default: current directory) in VS Code via the configured devcontainer. -Exit code is forwarded from the spawned editor. +Open `` (default: current directory) in VS Code via the configured devcontainer. Exit code is forwarded from the spawned editor. -### `dcode shell` +### `idc shell ` Open an interactive shell inside the project's running devcontainer. ```bash -dcode shell # current directory -dcode shell ./my-project # specific path -dcode shell --shell zsh # explicit shell executable (overrides settings) +idc shell # current directory +idc shell ./my-project # specific path +idc shell --shell zsh # explicit shell executable (overrides settings) +idc shell -i # resolve VS Code Insiders user settings ``` Shell selection priority (highest first): @@ -56,25 +67,43 @@ Shell selection priority (highest first): 5. Container login shell from `getent passwd ` (`nologin` and `false` are rejected) 6. Fallback: `/bin/bash`, then `/bin/sh` -`dcode shell` always reads the `.linux` terminal settings because devcontainers +`idc shell` always reads the `.linux` terminal settings because devcontainers run Linux, even on macOS and WSL hosts. Profile `args` and `env` are honored; if a profile `path` is a list, the first entry is used. `${...}` substitution in profile values is not resolved in this version, so those values are passed through verbatim with a warning. SSH agent forwarding works automatically when VS Code is open and connected to -the devcontainer. `dcode shell` detects the VS Code relay socket at +the devcontainer. `idc shell` detects the VS Code relay socket at `/tmp/vscode-ssh-auth-*.sock` and sets `SSH_AUTH_SOCK` on `docker exec`. If no socket is found, it prints a hint to open the project in VS Code first. +### `idc copilot [] [copilot args...]` + +Exec the GitHub Copilot CLI (`copilot`) inside the project's running devcontainer. +Shares container resolution with `idc shell` (auto-build, auto-start prompts). + +```bash +idc copilot # current directory, no copilot args +idc copilot --yolo --resume # cwd; --yolo and --resume go to copilot +idc copilot ./my-project --resume # explicit path; --resume goes to copilot +idc copilot . -- --some-flag # explicit `--` separator (rarely needed) +idc copilot -- weird-positional # escape hatch: first forwarded arg + # would otherwise be parsed as the path +``` + +The first non-flag argument (if any) is the project path; everything else is forwarded verbatim to `copilot` inside the container. Use `--` if the first forwarded token would otherwise look like a path. + +`copilot` must be installed inside the container — `idc copilot` does a fast `command -v copilot` probe first and fails with a hint if it's missing. Add it via a devcontainer Feature, or install it inside the container (for example, `npm install -g @github/copilot`). + ### Auto-build: starting a brand-new devcontainer -If you run `dcode shell` in a project whose devcontainer has never been built, -`dcode shell` will offer to build it for you so you don't have to open VS Code -first: +If you run `idc shell` (or `idc copilot`) in a project whose devcontainer has +never been built, `idc` will offer to build it for you so you don't have to +open VS Code first: ``` -dcode: no devcontainer is running for /path/to/proj. Build & start it now? [Y/n] +idc: no devcontainer is running for /path/to/proj. Build & start it now? [Y/n] ``` This uses the official **`@devcontainers/cli`** (the same Node.js CLI VS Code's @@ -83,12 +112,12 @@ carries the same `devcontainer.local_folder`, `devcontainer.config_file`, and `devcontainer.metadata` labels VS Code expects — open the project in VS Code later and it'll attach to the same container. -If the CLI isn't installed, `dcode shell` will offer to install it: +If the CLI isn't installed, `idc` will offer to install it: ``` -dcode: install the Dev Containers CLI now from - https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh - into ~/.devcontainers (no root needed)? [y/N] +idc: install the Dev Containers CLI now from + https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh + into ~/.devcontainers (no root needed)? [y/N] ``` This downloads a self-contained install (bundled Node.js runtime included), so @@ -104,16 +133,16 @@ npm install -g @devcontainers/cli Build progress (Docker layer pulls, feature installation, lifecycle hooks) streams live above a pinned spinner so you can watch what the CLI is doing -without losing the loader UX. The same spinner shows briefly when `dcode .` -launches VS Code so you always know dcode is doing something. +without losing the loader UX. The same spinner shows briefly when `idc code` +launches VS Code so you always know `idc` is doing something. -If you decline the install, `dcode shell` exits with a hint pointing at the -above commands and at `dcode ` (which opens VS Code, where the Dev +If you decline the install, `idc` exits with a hint pointing at the +above commands and at `idc code ` (which opens VS Code, where the Dev Containers extension can build the container instead). Auto-build always prompts and never runs without an interactive TTY. The shell runs as `remoteUser` from `devcontainer.json` when set, then -`containerUser`. When neither is set in `devcontainer.json`, dcode reads the +`containerUser`. When neither is set in `devcontainer.json`, `idc` reads the container's `devcontainer.metadata` Docker label (written by the Dev Containers extension / devcontainers/cli) and applies the same `remoteUser` → `containerUser` resolution against the merged metadata layers, so users defined @@ -123,13 +152,13 @@ image's `USER` applies. The working directory matches the URI logic: `/` for worktrees, otherwise ``. The path is probed with `test -d`; -if it does not exist, `dcode shell` falls back to the base `` +if it does not exist, `idc` falls back to the base `` with a warning, or omits `-w` entirely if that is missing too. -Limitations: +Limitations (apply to both `idc shell` and `idc copilot`): -- **GPG agent forwarding is not yet supported.** Commit signing inside the shell - will not work unless you've configured your own GPG forwarding via +- **GPG agent forwarding is not yet supported.** Commit signing inside the + container will not work unless you've configured your own GPG forwarding via `containerEnv` and a bind mount. - **`remoteEnv` is not applied.** The environment may differ from VS Code's integrated terminal; a warning is printed when `remoteEnv` is present in @@ -140,38 +169,49 @@ Limitations: Compose service `user`) is not merged; only the raw `devcontainer.json` file is read. For complex setups, shell selection may differ from VS Code's resolved view. -- **Requires an interactive terminal.** `dcode shell` exits with an error when - stdin or stdout is not a TTY, such as in piped or scripted contexts. +- **Requires an interactive terminal.** Both `idc shell` and `idc copilot` + exit with an error when stdin or stdout is not a TTY (e.g. piped or + scripted contexts). Common errors: -- No `devcontainer.json`: exits non-zero and points you at `dcode doctor`. +- No `devcontainer.json`: exits non-zero and points you at `idc doctor`. - Container not running, in a non-interactive context (e.g. piped): no - matching devcontainer was found and `dcode shell` cannot prompt; run it - interactively, or run `dcode ` first. -- Container stopped: `dcode shell` will prompt to start it. + matching devcontainer was found and `idc` cannot prompt; run it + interactively, or run `idc code ` first. +- Container stopped: `idc` will prompt to start it. - Multiple matching containers: clean up the duplicate containers listed in the error. - Docker not available: install/start Docker or Docker Desktop and try again. - Dev Containers CLI not installed and user declined install: see the *Auto-build* section above for the curl/npm install commands. -To open a folder literally named `shell`, run `dcode ./shell`. +### Naming-collision workaround + +`code`, `shell`, `copilot`, `doctor`, and `update` are subcommands, so +`idc code`, `idc shell`, etc. always invoke them. To open a folder literally +named `shell`, `doctor`, or `update`, prefix the path with `./`: + +```bash +idc code ./shell +idc code ./doctor +idc code "$(pwd)/update" +``` -### `dcode doctor [path]` +### `idc doctor [path]` -Diagnose the local environment for dcode and print a "what would `dcode ` do here?" +Diagnose the local environment for `idc` and print a "what would `idc code ` do here?" plan summary. Read-only — never patches `settings.json` or spawns the editor. Checks: VS Code editor on PATH, Dev Containers extension, Docker daemon, -Dev Containers CLI on PATH (used by `dcode shell` to auto-build a missing +Dev Containers CLI on PATH (used by `idc shell`/`idc copilot` to auto-build a missing devcontainer), git, WSL setup (distro, Windows-side `settings.json`, `dev.containers.executeInWSL`), devcontainer discovery + parse, worktree -sanity, dcode version vs latest GitHub release, install method. +sanity, `idc` version vs latest GitHub release, install method. ```bash -dcode doctor # inspect current directory -dcode doctor /some/path # inspect a specific path +idc doctor # inspect current directory +idc doctor /some/path # inspect a specific path ``` Exit codes: @@ -179,13 +219,13 @@ Exit codes: - `0` — no failing checks (warnings allowed) - `1` — one or more failing checks -### `dcode update` +### `idc update` -Upgrade the installed `dcode` tool via `uv tool upgrade dcode`. Exit code is forwarded -from `uv`. Returns `1` if `uv` is not on PATH or if `dcode` was not installed via +Upgrade the installed `idc` tool via `uv tool upgrade indevcontainer`. Exit code is forwarded +from `uv`. Returns `1` if `uv` is not on PATH or if `idc` was not installed via `uv tool`. -### `dcode update --check` +### `idc update --check` Check for an available update without installing it. Prints local version, latest GitHub release, and the release URL. @@ -196,45 +236,35 @@ Exit codes: - `1` — a newer release is available - `2` — network or GitHub API error -### Naming-collision workaround - -`shell`, `doctor`, and `update` are subcommands, so `dcode shell`, `dcode doctor`, -and `dcode update` always invoke them. To open a folder literally named `shell`, -`doctor`, or `update`, prefix the path: - -```bash -dcode ./shell -dcode ./doctor -dcode "$(pwd)/update" -``` - ## 🌳 Git worktrees -When you run `dcode .` inside a git worktree, it automatically detects the main repo, finds the devcontainer config there, and opens the worktree folder inside the same container. This means all worktrees share a single devcontainer instance — same extensions, same Copilot context, multiple VS Code windows. 🪟🪟🪟 +When you run `idc code .` inside a git worktree, it automatically detects the main repo, finds the devcontainer config there, and opens the worktree folder inside the same container. This means all worktrees share a single devcontainer instance — same extensions, same Copilot context, multiple VS Code windows. 🪟🪟🪟 ```bash cd ~/repos/my-project git worktree add .worktrees/pr-42 pr-42 # Opens pr-42 in the devcontainer defined in my-project -dcode .worktrees/pr-42 +idc code .worktrees/pr-42 # Opens pr-99 in the SAME container, different window git worktree add .worktrees/pr-99 pr-99 -dcode .worktrees/pr-99 +idc code .worktrees/pr-99 ``` > ⚠️ The worktree must live inside the main repo directory tree (e.g. `.worktrees/`) so it's accessible from the container's mounted volume. ## 🧠 How it works -Constructs a `vscode-remote://dev-container+/workspaces/` URI and launches VS Code with `--folder-uri`. VS Code handles the container lifecycle automatically. +`idc code` constructs a `vscode-remote://dev-container+/workspaces/` URI and launches VS Code with `--folder-uri`. VS Code handles the container lifecycle automatically. For worktrees, the hex-encoded path points to the main repo (so all worktrees resolve to the same container), while the workspace folder is adjusted to open the worktree subfolder inside the container. +`idc shell` and `idc copilot` skip the VS Code URI dance and `docker exec -it` directly into the container that VS Code (or the Dev Containers CLI auto-build) already created — sharing user, workdir, and SSH agent socket so the inner process behaves like VS Code's integrated terminal. + ## 🐧 WSL behavior -When `dcode` runs inside WSL, it: +When `idc code` runs inside WSL, it: 1. Builds the URI using a Windows UNC path (`\\wsl.localhost\\…`) so VS Code on Windows can resolve the folder. 2. Auto-edits your **Windows** VS Code `settings.json` (under `%APPDATA%\Code\User\` or `Code - Insiders`) to set: @@ -243,7 +273,7 @@ When `dcode` runs inside WSL, it: This is required so the Dev Containers extension talks to Docker inside WSL instead of `docker.exe` on Windows. Comments and trailing commas in your `settings.json` are preserved (in-place patching, not a rewrite). -To opt out, pre-set those keys to whatever values you want — `dcode` only writes them when they're missing or differ from the desired values. +To opt out, pre-set those keys to whatever values you want — `idc` only writes them when they're missing or differ from the desired values. ## 🤝 Contributing