From e564cec9b8dc31ed07cc1103841481858738d3b2 Mon Sep 17 00:00:00 2001 From: rosstaco Date: Tue, 12 May 2026 23:33:48 +1000 Subject: [PATCH 1/2] feat: live progress spinner with streaming subprocess output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a reusable rich-Live-based spinner (`dcode._progress`) that pins to the bottom of the terminal while subprocess output scrolls above it. * `with_spinner(label)` — bare context manager for short non-streaming work (used to wrap the VS Code editor launch in `run_dcode` so users see a brief loader while `code` connects to the running window). * `run_streaming(argv, *, label, console=...)` — runs a subprocess with PIPE stdout/stderr, pumps each stderr line through `console.print(... markup=False, highlight=False)` from a daemon thread while the spinner stays pinned. Returns a `StreamedResult` dataclass with returncode, full captured stdout (for downstream parsing), full captured stderr, and an `error` field for pre-launch OSError. KeyboardInterrupt is propagated after best-effort terminate. `core.run_dcode` now wraps editor launches in the spinner and captures the editor's output, only printing it on non-zero exit so stray VS Code messages don't trample the spinner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/dcode/_progress.py | 153 ++++++++++++++++++++++++++++++++++++++ src/dcode/core.py | 41 +++++++++-- tests/test_progress.py | 164 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 src/dcode/_progress.py create mode 100644 tests/test_progress.py diff --git a/src/dcode/_progress.py b/src/dcode/_progress.py new file mode 100644 index 0000000..5c3e96c --- /dev/null +++ b/src/dcode/_progress.py @@ -0,0 +1,153 @@ +"""Reusable progress UX: a pinned spinner with optional live log streaming. + +Built on rich's :class:`~rich.console.Console.status` (a thin wrapper around +:class:`~rich.live.Live`). Any call to ``console.print()`` while a status is +active is rendered **above** the live region, so stderr lines from a child +process can scroll past while the spinner stays pinned at the bottom of the +terminal. + +Two entry points: + +* :func:`with_spinner` — bare context manager for short, non-streaming work. +* :func:`run_streaming` — runs a subprocess and forwards its stderr lines + to the console live, capturing both stdout (for downstream parsing) and + the full stderr text (for failure-mode reporting). + +Both gracefully degrade on non-TTY consoles: the spinner becomes a one-shot +status line and stderr forwarding still works as plain output. +""" + +from __future__ import annotations + +import contextlib +import subprocess +import threading +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from typing import IO + +from rich.console import Console + +from dcode._rich import get_console + + +@dataclass(frozen=True, slots=True) +class StreamedResult: + """Outcome of :func:`run_streaming`. + + ``stderr`` is the same text that was streamed live to the console, + captured in full so callers can inspect it without re-running the + subprocess. + """ + + returncode: int + stdout: str + stderr: str + error: str | None = None # populated when the subprocess failed to launch + + +@contextlib.contextmanager +def with_spinner( + label: str, + *, + console: Console | None = None, + spinner: str = "dots", +) -> Iterator[None]: + """Show a rich spinner with *label* for the duration of the block. + + On a TTY the spinner animates at the bottom of the terminal and any + ``console.print()`` calls during the block scroll above it. On non-TTY + it degrades to a one-shot status line. + """ + cons = console or get_console() + with cons.status(label, spinner=spinner): + yield + + +def run_streaming( + argv: list[str], + *, + label: str, + console: Console | None = None, + env: Mapping[str, str] | None = None, + cwd: str | None = None, +) -> StreamedResult: + """Run *argv* and stream its stderr above a pinned spinner. + + Both streams are captured: stdout in full (for downstream parsing) and + stderr both live-streamed to the console and captured in full. The + spinner is pinned to the bottom of the terminal via rich's Live display + while stderr lines scroll above it. + + On any pre-launch failure (``OSError`` / missing executable) returns a + :class:`StreamedResult` with ``returncode=-1`` and ``error`` set. + """ + cons = console or get_console() + + try: + proc = subprocess.Popen( + argv, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, # line-buffered + env=dict(env) if env is not None else None, + cwd=cwd, + ) + except (FileNotFoundError, OSError) as exc: + return StreamedResult( + returncode=-1, + stdout="", + stderr="", + error=str(exc), + ) + + captured_stderr: list[str] = [] + + def _pump_stderr(stream: IO[str]) -> None: + # While the rich Status (Live) is active, console.print routes the + # output above the spinner so it scrolls naturally. + for raw_line in stream: + captured_stderr.append(raw_line) + line = raw_line.rstrip("\r\n") + # markup=False prevents user text like "[ERROR]" from being + # interpreted as rich markup; highlight=False keeps the colour + # palette quiet. + cons.print(line, markup=False, highlight=False, emoji=False) + + pump_thread: threading.Thread | None = None + try: + with cons.status(label, spinner="dots"): + assert proc.stderr is not None # noqa: S101 - PIPE configured above + assert proc.stdout is not None # noqa: S101 + pump_thread = threading.Thread( + target=_pump_stderr, + args=(proc.stderr,), + daemon=True, + ) + pump_thread.start() + # Read stdout to completion; both pipes will be drained by the + # time the process exits, then we wait for the OS-level reap. + stdout_text = proc.stdout.read() + proc.wait() + pump_thread.join(timeout=5) + except KeyboardInterrupt: + # Best-effort terminate so we don't leave the child hanging. + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if pump_thread is not None: + pump_thread.join(timeout=2) + raise + + return StreamedResult( + returncode=proc.returncode, + stdout=stdout_text or "", + stderr="".join(captured_stderr), + ) + + +__all__ = ["StreamedResult", "run_streaming", "with_spinner"] diff --git a/src/dcode/core.py b/src/dcode/core.py index 698b7eb..3c31126 100644 --- a/src/dcode/core.py +++ b/src/dcode/core.py @@ -8,6 +8,7 @@ import json5 +from dcode._progress import with_spinner from dcode.wsl import _ensure_wsl_docker_settings, build_uri_wsl, is_wsl @@ -109,6 +110,31 @@ def build_uri(host_path: str, workspace_folder: str) -> str: return f"vscode-remote://dev-container+{hex_path}{workspace_folder}" +def _launch_editor(argv: list[str], *, label: str) -> int: + """Run *argv* (the VS Code launch) under a spinner. + + Output is captured so it doesn't trample the spinner; on a non-zero exit + we surface whatever the editor printed so the user sees the actual error. + """ + with with_spinner(label): + try: + result = subprocess.run( + argv, + check=False, + capture_output=True, + text=True, + ) + except OSError as exc: + print(f"dcode: failed to launch {argv[0]}: {exc}", file=sys.stderr) + return 127 + + if result.returncode: + out = (result.stderr or "").strip() or (result.stdout or "").strip() + if out: + print(out, file=sys.stderr) + return result.returncode + + def run_dcode(path: str, *, insiders: bool = False) -> None: """Open a folder in VS Code, using devcontainer if available.""" editor = "code-insiders" if insiders else "code" @@ -125,9 +151,9 @@ def run_dcode(path: str, *, insiders: bool = False) -> None: devcontainer = find_devcontainer(target) if devcontainer is None: - result = subprocess.run([editor, str(target)], check=False) - if result.returncode: - sys.exit(result.returncode) + rc = _launch_editor([editor, str(target)], label=f"Launching {editor}...") + if rc: + sys.exit(rc) return if main_repo is not None: @@ -144,6 +170,9 @@ def run_dcode(path: str, *, insiders: bool = False) -> None: else: uri = build_uri(host_path, workspace_folder) - result = subprocess.run([editor, "--folder-uri", uri], check=False) - if result.returncode: - sys.exit(result.returncode) + rc = _launch_editor( + [editor, "--folder-uri", uri], + label=f"Launching {editor} in devcontainer...", + ) + if rc: + sys.exit(rc) diff --git a/tests/test_progress.py b/tests/test_progress.py new file mode 100644 index 0000000..dc73870 --- /dev/null +++ b/tests/test_progress.py @@ -0,0 +1,164 @@ +"""Tests for dcode._progress.""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock, patch + +from dcode._progress import StreamedResult, run_streaming, with_spinner + +# --------------------------------------------------------------------------- +# with_spinner +# --------------------------------------------------------------------------- + + +class TestWithSpinner: + def test_acts_as_passthrough_context_manager(self): + sentinel = object() + console = MagicMock() + with with_spinner("doing stuff", console=console): + result = sentinel + assert result is sentinel + console.status.assert_called_once_with("doing stuff", spinner="dots") + + def test_propagates_exceptions(self): + console = MagicMock() + try: + with with_spinner("oops", console=console): + raise RuntimeError("boom") + except RuntimeError as exc: + assert "boom" in str(exc) + else: + raise AssertionError("expected RuntimeError to propagate") + + +# --------------------------------------------------------------------------- +# run_streaming — uses real subprocesses for integration coverage +# --------------------------------------------------------------------------- + + +class TestRunStreaming: + def _python_argv(self, source: str) -> list[str]: + return [sys.executable, "-c", source] + + def test_success_captures_stdout_and_streams_stderr(self, capsys): + argv = self._python_argv( + "import sys;\n" + "print('this-is-stdout');\n" + "print('build line A', file=sys.stderr);\n" + "print('build line B', file=sys.stderr);\n" + ) + result = run_streaming(argv, label="Working") + assert isinstance(result, StreamedResult) + assert result.returncode == 0 + assert "this-is-stdout" in result.stdout + # Captured stderr text is preserved verbatim: + assert "build line A" in result.stderr + assert "build line B" in result.stderr + # And was streamed to the console (rich -> stderr by default): + err = capsys.readouterr().err + assert "build line A" in err + assert "build line B" in err + + def test_non_zero_returncode_propagated(self): + argv = self._python_argv("import sys; sys.exit(7)") + result = run_streaming(argv, label="X") + assert result.returncode == 7 + assert result.error is None + + def test_stderr_only_subprocess_still_completes(self, capsys): + argv = self._python_argv( + "import sys; print('only-on-stderr', file=sys.stderr); sys.exit(0)" + ) + result = run_streaming(argv, label="L") + assert result.returncode == 0 + assert result.stdout == "" + assert "only-on-stderr" in result.stderr + assert "only-on-stderr" in capsys.readouterr().err + + def test_no_output_subprocess(self): + argv = self._python_argv("pass") + result = run_streaming(argv, label="L") + assert result.returncode == 0 + assert result.stdout == "" + assert result.stderr == "" + + def test_oserror_returns_error_field(self): + result = run_streaming( + ["/this/binary/does/not/exist/dcode-test"], + label="L", + ) + assert result.returncode == -1 + assert result.error is not None + assert result.stdout == "" + assert result.stderr == "" + + def test_env_passed_to_subprocess(self): + argv = self._python_argv( + "import os, sys; sys.stdout.write(os.environ.get('DCODE_TEST_VAR', ''))" + ) + result = run_streaming( + argv, + label="L", + env={"DCODE_TEST_VAR": "hello-progress", "PATH": ""}, + ) + assert result.returncode == 0 + assert result.stdout == "hello-progress" + + def test_cwd_passed_to_subprocess(self, tmp_path): + argv = self._python_argv("import os, sys; sys.stdout.write(os.getcwd())") + result = run_streaming(argv, label="L", cwd=str(tmp_path)) + assert result.returncode == 0 + assert tmp_path.name in result.stdout + + def test_markup_in_stderr_is_not_interpreted(self, capsys): + # Ensure that stderr containing rich-like markup ("[red]error[/red]") + # is printed verbatim, not parsed as rich markup (which would either + # apply colour or raise on bad markup). + argv = self._python_argv( + "import sys; print('[red]should-not-be-styled[/red]', file=sys.stderr)" + ) + result = run_streaming(argv, label="L") + assert result.returncode == 0 + err = capsys.readouterr().err + # The literal brackets should appear in the captured terminal output. + assert "[red]should-not-be-styled[/red]" in err + + def test_uses_supplied_console(self, capsys): + # The console argument should be the destination for streamed lines. + from rich.console import Console + + # Create a console pointing at sys.stderr without forcing a TTY. + c = Console(stderr=True, force_terminal=False, width=200, highlight=False) + argv = self._python_argv( + "import sys; print('via-supplied-console', file=sys.stderr)" + ) + result = run_streaming(argv, label="L", console=c) + assert result.returncode == 0 + err = capsys.readouterr().err + assert "via-supplied-console" in err + + +# --------------------------------------------------------------------------- +# Mocked Popen — focused checks for thread/Live wiring +# --------------------------------------------------------------------------- + + +class TestRunStreamingMocked: + def test_popen_invoked_with_pipes_and_text_mode(self): + with patch("dcode._progress.subprocess.Popen") as popen: + mock_proc = MagicMock() + mock_proc.stdout.read.return_value = "" + mock_proc.stderr.__iter__.return_value = iter([]) + mock_proc.wait.return_value = 0 + mock_proc.returncode = 0 + popen.return_value = mock_proc + + run_streaming(["fake-bin", "--flag"], label="L") + + args, kwargs = popen.call_args + assert args[0] == ["fake-bin", "--flag"] + assert kwargs["stdout"] == -1 # subprocess.PIPE sentinel + assert kwargs["stderr"] == -1 + assert kwargs["text"] is True + assert kwargs["bufsize"] == 1 From 99521ac6973c539e6a5981abcc72c3977d9cec9d Mon Sep 17 00:00:00 2001 From: rosstaco Date: Tue, 12 May 2026 23:34:25 +1000 Subject: [PATCH 2/2] feat(shell): auto-build missing devcontainer; resolve user from image metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `dcode shell` runs against a project whose devcontainer has never been built, prompt to build it via the official `@devcontainers/cli` (the same Node CLI VS Code's Dev Containers extension uses under the hood). The build's stderr scrolls live above a pinned spinner via `dcode._progress.run_streaming`, and we trust the `containerId` returned by `devcontainer up` directly (no docker-label re-lookup, which would mismatch on WSL). If the CLI is not installed, prompt to install it via the upstream `install.sh` (which bundles its own Node runtime into `~/.devcontainers`, no host Node required). Install download progress also streams beneath the spinner. If the user declines, exit with a clear hint pointing at the curl/npm install commands and at `dcode ` as a fallback. Auto-build always prompts and never runs without an interactive TTY. Also fix a related correctness bug: `_resolve_exec_user` only read `remoteUser` / `containerUser` from the project's devcontainer.json, which meant images that set the user via metadata (e.g. `mcr.microsoft.com/devcontainers/javascript-node` → `remoteUser: node`) landed the user as root. We now also read the container's `devcontainer.metadata` Docker label, walking layers per-key in reverse to match devcontainers/cli's `mergeConfiguration` precedence, then prefer `remoteUser` over `containerUser`. `devcontainer.json` stays the highest-precedence layer. `dcode doctor` gains a `Dev Containers CLI` check in the Container panel: ok with version when found on PATH or at `~/.devcontainers/bin/devcontainer`, warn with the curl install hint otherwise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 70 +++++- src/dcode/devcontainer_cli.py | 268 ++++++++++++++++++++ src/dcode/doctor.py | 20 +- src/dcode/shell.py | 265 +++++++++++++++---- tests/test_devcontainer_cli.py | 381 ++++++++++++++++++++++++++++ tests/test_doctor.py | 53 +++- tests/test_shell.py | 447 ++++++++++++++++++++++++++++++++- 7 files changed, 1445 insertions(+), 59 deletions(-) create mode 100644 src/dcode/devcontainer_cli.py create mode 100644 tests/test_devcontainer_cli.py diff --git a/README.md b/README.md index 0f469c1..01def12 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,59 @@ the devcontainer. `dcode 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. +### 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: + +``` +dcode: 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 +Dev Containers extension drives under the hood) so the resulting container +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: + +``` +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] +``` + +This downloads a self-contained install (bundled Node.js runtime included), so +you don't need a host Node.js install. To install it manually: + +```bash +# Self-contained install (recommended; bundles its own Node.js): +curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh + +# Or, if you already have Node.js: +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. + +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 +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`, otherwise the container image's `USER` applies. +`containerUser`. When neither is set in `devcontainer.json`, dcode 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 +by base images like `mcr.microsoft.com/devcontainers/javascript-node` +(`remoteUser: node`) are honored. If still nothing is set, the container +image's `USER` applies. The working directory matches the URI logic: `/` for worktrees, otherwise ``. The path is probed with `test -d`; @@ -95,12 +146,15 @@ Limitations: Common errors: - No `devcontainer.json`: exits non-zero and points you at `dcode doctor`. -- Container not running: no matching devcontainer was found; open the project in - VS Code first. -- Container stopped: run `dcode ` to start it. +- 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. - 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`. @@ -109,9 +163,11 @@ To open a folder literally named `shell`, run `dcode ./shell`. Diagnose the local environment for dcode and print a "what would `dcode ` 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, git, WSL setup -(distro, Windows-side `settings.json`, `dev.containers.executeInWSL`), devcontainer -discovery + parse, worktree sanity, dcode version vs latest GitHub release, install method. +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 +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. ```bash dcode doctor # inspect current directory diff --git a/src/dcode/devcontainer_cli.py b/src/dcode/devcontainer_cli.py new file mode 100644 index 0000000..cd0e7cb --- /dev/null +++ b/src/dcode/devcontainer_cli.py @@ -0,0 +1,268 @@ +"""Helpers for the official ``@devcontainers/cli`` (the ``devcontainer`` CLI). + +Used by ``dcode 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`` / +``devcontainer.config_file`` / ``devcontainer.metadata`` labels VS Code +expects. + +Three entry points: + +* :func:`find_cli` — locate the ``devcontainer`` binary (``$PATH`` or the + default install location ``~/.devcontainers/bin/devcontainer``). +* :func:`install_cli` — download the upstream ``install.sh`` and run it to + drop a self-contained CLI (bundled Node runtime) into ``~/.devcontainers``. +* :func:`up` — run ``devcontainer up`` against a workspace and parse the + JSON result for the new container id. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import shutil +import subprocess +import tempfile +import urllib.error +import urllib.request +from pathlib import Path + +from rich.console import Console + +from dcode import _progress +from dcode._rich import get_console + +INSTALL_SCRIPT_URL = ( + "https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh" +) +DEFAULT_INSTALL_PREFIX = Path.home() / ".devcontainers" + + +def find_cli() -> Path | None: + """Locate a usable ``devcontainer`` binary. + + Checks ``$PATH`` first, then the default install location used by the + upstream ``install.sh`` (``~/.devcontainers/bin/devcontainer``). Returns + ``None`` when neither is present or executable. + """ + on_path = shutil.which("devcontainer") + if on_path: + return Path(on_path) + fallback = DEFAULT_INSTALL_PREFIX / "bin" / "devcontainer" + if fallback.is_file() and os.access(fallback, os.X_OK): + return fallback + return None + + +def cli_version(cli_path: Path) -> str | None: + """Return the ``devcontainer --version`` string, or ``None`` on failure.""" + try: + proc = subprocess.run( + [str(cli_path), "--version"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if proc.returncode != 0: + return None + out = proc.stdout.strip() + return out or None + + +def install_cli( + *, + prefix: Path | None = None, + console: Console | None = None, +) -> Path | None: + """Download and run the official Dev Containers CLI install script. + + 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 + success returns the absolute path to the installed binary; on any + failure returns ``None`` after printing a hint. + """ + cons = console or get_console() + install_prefix = prefix or DEFAULT_INSTALL_PREFIX + + cons.print( + f"dcode: downloading Dev Containers CLI installer from {INSTALL_SCRIPT_URL}", + highlight=False, + ) + + tmp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + suffix=".sh", + delete=False, + ) as tmp: + tmp_path = Path(tmp.name) + try: + with urllib.request.urlopen(INSTALL_SCRIPT_URL, timeout=30) as resp: + shutil.copyfileobj(resp, tmp) + except (urllib.error.URLError, OSError) as exc: + cons.print( + f"[red]dcode: failed to download installer ({exc})[/]", + highlight=False, + ) + return None + + cmd = ["sh", str(tmp_path), "--prefix", str(install_prefix)] + result = _progress.run_streaming( + cmd, + label=f"Installing Dev Containers CLI into {install_prefix}...", + console=cons, + ) + if result.error is not None: + cons.print( + f"[red]dcode: 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"(exit {result.returncode}) — see output above[/]", + highlight=False, + ) + return None + + finally: + if tmp_path is not None: + with contextlib.suppress(OSError): + tmp_path.unlink() + + 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 " + "an executable file[/]", + highlight=False, + ) + return None + + cons.print( + f"[green]dcode: installed Dev Containers CLI at {binary}[/]", + highlight=False, + ) + return binary + + +def up( + cli_path: Path, + workspace_folder: Path, + config_path: Path, + *, + console: Console | None = None, +) -> tuple[str | None, str]: + """Build & start a devcontainer with ``devcontainer 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 + final ``JSON.stringify(result)`` line to stdout when finished; we parse + that for ``containerId``. + + Returns ``(container_id, "")`` on success or ``(None, error_summary)`` + on failure. ``error_summary`` is just the JSON ``description``/``message`` + from the CLI when present — the full stderr was already shown live so + we don't dump it again. If no diagnostic is available, falls back to a + one-line "exit code N" message. + """ + cons = console or get_console() + argv = [ + str(cli_path), + "up", + "--workspace-folder", + str(workspace_folder), + "--config", + str(config_path), + ] + + result = _progress.run_streaming( + argv, + label="Building devcontainer (this may take several minutes)...", + console=cons, + ) + if result.error is not None: + return (None, f"failed to launch devcontainer CLI: {result.error}") + + parsed = _parse_up_result(result.stdout) + + if result.returncode == 0 and parsed is not None: + outcome = parsed.get("outcome") + container_id = parsed.get("containerId") + if outcome == "success" and isinstance(container_id, str) and container_id: + return (container_id, "") + if isinstance(container_id, str) and container_id and outcome != "error": + return (container_id, "") + + # Failure path — surface the most useful concise diagnostic. The full + # stderr stream was already printed live above the spinner, so we keep + # this short. + if parsed is not None: + for key in ("description", "message"): + value = parsed.get(key) + if isinstance(value, str) and value.strip(): + return (None, value.strip()) + + return ( + None, + f"devcontainer up exited with code {result.returncode} " + "(see output above for details)", + ) + + +def _parse_up_result(stdout: str) -> dict | None: + """Parse the JSON ``devcontainer up`` result from *stdout*. + + devcontainers/cli writes one JSON object as the final stdout line. + Defensively try the whole stripped stdout first (covers the common case + of clean stdout), then fall back to the last non-empty line. + """ + text = (stdout or "").strip() + if not text: + return None + try: + data = json.loads(text) + except ValueError: + for line in reversed(text.splitlines()): + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + break + except ValueError: + continue + else: + return None + return data if isinstance(data, dict) else None + + +def install_hint() -> str: + """Return a multi-line hint string with install instructions for the CLI.""" + return ( + "install Dev Containers CLI:\n" + f" curl -fsSL {INSTALL_SCRIPT_URL} | sh\n" + " or, if you have Node.js:\n" + " npm install -g @devcontainers/cli\n" + " then ensure ~/.devcontainers/bin (or your npm bin) is on PATH" + ) + + +__all__ = [ + "DEFAULT_INSTALL_PREFIX", + "INSTALL_SCRIPT_URL", + "cli_version", + "find_cli", + "install_cli", + "install_hint", + "up", +] diff --git a/src/dcode/doctor.py b/src/dcode/doctor.py index e3f7d3f..aab5a7b 100644 --- a/src/dcode/doctor.py +++ b/src/dcode/doctor.py @@ -20,7 +20,7 @@ from rich.text import Text import dcode -from dcode import update, version_check +from dcode import devcontainer_cli, update, version_check from dcode._rich import STATUS_STYLES, get_console from dcode.core import ( build_uri, @@ -165,6 +165,21 @@ def check_docker() -> CheckResult: return ("ok", f"Container runtime: docker daemon reachable ({version})", None) +def check_devcontainer_cli() -> CheckResult: + cli = devcontainer_cli.find_cli() + if cli is None: + return ( + "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)", + devcontainer_cli.install_hint(), + ) + version = devcontainer_cli.cli_version(cli) + detail = f" ({version})" if version else "" + return ("ok", f"Dev Containers CLI: {cli}{detail}", None) + + def check_git() -> CheckResult: git = shutil.which("git") if git is None: @@ -598,7 +613,7 @@ def render_plan( _SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("Editor", ("editor", "extension")), - ("Container", ("docker",)), + ("Container", ("docker", "devcontainer_cli")), ("Git", ("git",)), ("WSL", ("wsl", "wsl_distro", "wsl_settings_paths", "wsl_execute_in_wsl")), ("Workspace", ("devcontainer", "devcontainer_parses", "worktree")), @@ -639,6 +654,7 @@ def collect(check_id: str, fn, *args): collect("editor", check_editor) collect("extension", check_extension) collect("docker", check_docker) + collect("devcontainer_cli", check_devcontainer_cli) collect("git", check_git) collect("wsl", check_wsl) diff --git a/src/dcode/shell.py b/src/dcode/shell.py index 27f25a3..562a083 100644 --- a/src/dcode/shell.py +++ b/src/dcode/shell.py @@ -19,6 +19,7 @@ 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 @@ -457,24 +458,106 @@ def probe_workdir(container_id: str, candidate: str, fallback: str) -> str | Non # --------------------------------------------------------------------------- -def _resolve_exec_user(devcontainer_cfg: dict) -> str | None: +def _inspect_container_metadata(container_id: str) -> list[dict]: + """Read and parse the ``devcontainer.metadata`` label from a container. + + devcontainers/cli writes the merged metadata layers (base image → + features → devcontainer.json) onto this label as a JSON array (or, for + older/custom images, a single JSON object). This is where ``remoteUser`` + lives when it comes from a base image like + ``mcr.microsoft.com/devcontainers/javascript-node`` rather than the + project's ``devcontainer.json``. + + Returns a list of metadata entry dicts (in source-array order). Returns + ``[]`` on any failure: missing label, docker error, malformed JSON, or + unexpected shape. + """ + try: + proc = subprocess.run( + [ + "docker", + "inspect", + container_id, + "--format", + '{{ index .Config.Labels "devcontainer.metadata" }}', + ], + capture_output=True, + text=True, + check=False, + ) + except (FileNotFoundError, OSError): + return [] + if proc.returncode != 0: + return [] + raw = proc.stdout.strip() + if not raw or raw == "": + return [] + try: + # The label is generated with JSON.stringify on the devcontainers/cli + # side, so strict json is sufficient and more faithful than json5. + data = json.loads(raw) + except ValueError: + return [] + if isinstance(data, dict): + return [data] + if isinstance(data, list): + return [e for e in data if isinstance(e, dict)] + return [] + + +def _resolve_exec_user( + devcontainer_cfg: dict, + metadata_entries: list[dict] | None = None, +) -> str | None: + """Resolve the user to ``docker exec -u`` as. + + Mirrors devcontainers/cli's ``mergeConfiguration`` semantics: ``remoteUser`` + and ``containerUser`` are each resolved independently with "last entry + wins" across the metadata layers, then ``remoteUser`` is preferred over + ``containerUser`` (since ``remoteUser`` is what VS Code passes to + ``docker exec -u``). + + The local ``devcontainer.json`` is appended as the highest-precedence + layer. It is normally already present as the last metadata entry, but + this defensive duplication ensures we still pick up an explicit + ``remoteUser`` when the container predates the metadata label or the + label was stripped. + """ + layers: list[dict] = list(metadata_entries or []) + layers.append(devcontainer_cfg or {}) for key in ("remoteUser", "containerUser"): - v = devcontainer_cfg.get(key) - if isinstance(v, str) and v.strip(): - return v.strip() + for entry in reversed(layers): + v = entry.get(key) + if isinstance(v, str) and v.strip(): + return v.strip() return None -def _prompt_start_stopped(container_id: str, host_path: str | Path) -> bool: - """Prompt to start a stopped container and run ``docker start`` if accepted.""" - sys.stderr.write( - f"dcode: devcontainer for {host_path} is stopped. Start it now? [Y/n] " - ) - sys.stderr.flush() +def _prompt_yes_no(question: str, *, default_yes: bool) -> bool: + """Prompt the user with a yes/no question on stderr. + 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. + """ + suffix = "[Y/n]" if default_yes else "[y/N]" + sys.stderr.write(f"{question} {suffix} ") + sys.stderr.flush() answer = sys.stdin.readline().strip().lower() - if answer not in ("", "y", "yes"): - print("dcode: aborted", file=sys.stderr) + if answer in ("y", "yes"): + return True + if answer == "" and default_yes: + return True + print("dcode: 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?", + default_yes=True, + ): return False short_id = container_id[:12] @@ -501,6 +584,73 @@ def _prompt_start_stopped(container_id: str, host_path: str | Path) -> bool: return True +def _obtain_or_install_cli() -> Path | None: + """Locate the devcontainer CLI, prompting to install if missing. + + Returns the absolute path to the CLI binary on success, or ``None`` if + the CLI is not present and the user declined or the install failed + (the caller should treat this as a hard failure with a clear hint). + """ + cli = devcontainer_cli.find_cli() + if cli is not None: + return cli + + print( + "dcode: 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"{devcontainer_cli.INSTALL_SCRIPT_URL}\n" + f" into {devcontainer_cli.DEFAULT_INSTALL_PREFIX} (no root needed)?", + default_yes=False, + ): + 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", + file=sys.stderr, + ) + return None + + installed = devcontainer_cli.install_cli() + if installed is None: + print( + f"hint: {devcontainer_cli.install_hint()}", + file=sys.stderr, + ) + return None + return installed + + +def _build_missing_container(main_repo: Path, devcontainer_path: Path) -> str | None: + """Build & start a missing devcontainer via the Dev Containers CLI. + + Returns the new container id on success, or ``None`` after printing an + error / hint on failure. Assumes the caller has already confirmed an + interactive TTY and gotten user consent to build. + """ + cli = _obtain_or_install_cli() + if cli is None: + return None + + print( + f"dcode: 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) + 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) + 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``. @@ -530,37 +680,16 @@ def run_shell(path: str, *, insiders: bool, shell_override: str | None) -> int: workspace_folder = get_workspace_folder(devcontainer_path, main_repo) lookup = find_container(str(main_repo), str(devcontainer_path)) - if lookup.state in ("running", "stopped") and not ( - sys.stdin.isatty() and sys.stdout.isatty() - ): - if lookup.state == "stopped": - print( - f"dcode: devcontainer for {main_repo} exists but is stopped — " - "run interactively to be prompted to start it, or run " - f"`dcode {path}` first", - file=sys.stderr, - ) - else: - print( - "dcode: dcode shell requires an interactive terminal", - file=sys.stderr, - ) - return 1 - if lookup.state == "stopped": - container_id = lookup.id - if container_id is None: # pragma: no cover - defensive - print("dcode: container lookup returned no id", file=sys.stderr) - return 1 - if not _prompt_start_stopped(container_id, main_repo): - return 1 - if lookup.state == "missing": + if lookup.state == "docker_unavailable": + detail = lookup.detail or "unknown error" print( - f"dcode: no devcontainer found running for {main_repo} — " - f"open in VS Code first (`dcode {path}`)", + f"dcode: docker CLI not available — is Docker Desktop running? " + f"({detail})", file=sys.stderr, ) return 1 + if lookup.state == "ambiguous": ids = ", ".join(lookup.ids) print( @@ -569,21 +698,63 @@ def run_shell(path: str, *, insiders: bool, shell_override: str | None) -> int: file=sys.stderr, ) return 1 - if lookup.state == "docker_unavailable": - detail = lookup.detail or "unknown error" + + 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 — " + "run interactively to be prompted to start it, or run " + f"`dcode {path}` first", + file=sys.stderr, + ) + else: + print( + f"dcode: no devcontainer is running for {main_repo} — " + "run interactively to be prompted to build it, or run " + f"`dcode {path}` first", + file=sys.stderr, + ) + return 1 + + if not is_interactive: + # state == "running" but no TTY for an interactive shell. print( - f"dcode: docker CLI not available — is Docker Desktop running? " - f"({detail})", + "dcode: dcode shell requires an interactive terminal", file=sys.stderr, ) return 1 - container_id = lookup.id - if container_id is None: # pragma: no cover - defensive - print("dcode: container lookup returned no id", file=sys.stderr) - return 1 + 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 + if not _prompt_start_stopped(stopped_id, main_repo): + return 1 + container_id: str = stopped_id + elif lookup.state == "missing": + if not _prompt_yes_no( + f"dcode: no devcontainer is running for {main_repo}. " + f"Build & start it now?", + default_yes=True, + ): + return 1 + built = _build_missing_container(main_repo, devcontainer_path) + if built is None: + return 1 + 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 + container_id = running_id - exec_user = _resolve_exec_user(devcontainer_cfg) + metadata_entries = _inspect_container_metadata(container_id) + exec_user = _resolve_exec_user(devcontainer_cfg, metadata_entries) if "remoteEnv" in devcontainer_cfg: print( diff --git a/tests/test_devcontainer_cli.py b/tests/test_devcontainer_cli.py new file mode 100644 index 0000000..48ba444 --- /dev/null +++ b/tests/test_devcontainer_cli.py @@ -0,0 +1,381 @@ +"""Tests for dcode.devcontainer_cli.""" + +from __future__ import annotations + +import json +import stat +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from dcode import devcontainer_cli +from dcode._progress import StreamedResult + + +def _completed(rc: int = 0, stdout: str = "", stderr: str = "") -> SimpleNamespace: + return SimpleNamespace(returncode=rc, stdout=stdout, stderr=stderr) + + +def _streamed( + rc: int = 0, + stdout: str = "", + stderr: str = "", + error: str | None = None, +) -> StreamedResult: + return StreamedResult(returncode=rc, stdout=stdout, stderr=stderr, error=error) + + +def _make_executable(path: Path) -> None: + path.write_text("#!/bin/sh\necho devcontainer 0.86.0\n") + mode = path.stat().st_mode + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def _ok_download_patch(payload: bytes = b"#!/bin/sh\necho ok\n"): + """Patch urlopen to return an in-memory file-like context manager.""" + import io + + fake = io.BytesIO(payload) + + class _Ctx: + def __enter__(self_inner): + return fake + + def __exit__(self_inner, *args): + return False + + return patch( + "dcode.devcontainer_cli.urllib.request.urlopen", + return_value=_Ctx(), + ) + + +# --------------------------------------------------------------------------- +# find_cli +# --------------------------------------------------------------------------- + + +class TestFindCli: + def test_returns_path_when_on_path(self): + with patch( + "dcode.devcontainer_cli.shutil.which", + return_value="/usr/local/bin/devcontainer", + ): + assert devcontainer_cli.find_cli() == Path("/usr/local/bin/devcontainer") + + def test_falls_back_to_default_install_dir(self, tmp_path, monkeypatch): + fake_home_bin = tmp_path / "bin" + fake_home_bin.mkdir() + binary = fake_home_bin / "devcontainer" + _make_executable(binary) + monkeypatch.setattr( + "dcode.devcontainer_cli.DEFAULT_INSTALL_PREFIX", tmp_path + ) + with patch("dcode.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 + ) + with patch("dcode.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): + # File exists but is not executable: do not consider it a valid CLI. + fake_home_bin = tmp_path / "bin" + fake_home_bin.mkdir() + binary = fake_home_bin / "devcontainer" + binary.write_text("not really an executable") + binary.chmod(0o644) + monkeypatch.setattr( + "dcode.devcontainer_cli.DEFAULT_INSTALL_PREFIX", tmp_path + ) + with patch("dcode.devcontainer_cli.shutil.which", return_value=None): + assert devcontainer_cli.find_cli() is None + + +# --------------------------------------------------------------------------- +# cli_version +# --------------------------------------------------------------------------- + + +class TestCliVersion: + def test_returns_version_string(self): + with patch( + "dcode.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", + 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", + side_effect=OSError("nope"), + ): + assert devcontainer_cli.cli_version(Path("/x/devcontainer")) is None + + +# --------------------------------------------------------------------------- +# install_cli +# --------------------------------------------------------------------------- + + +class TestInstallCli: + def test_success_returns_binary_path(self, tmp_path): + prefix = tmp_path / "dc" + bin_path = prefix / "bin" / "devcontainer" + + def fake_run(argv, **kwargs): + # Simulate the installer creating the binary. + bin_path.parent.mkdir(parents=True, exist_ok=True) + _make_executable(bin_path) + return _streamed(0, "", "") + + console = MagicMock() + with ( + _ok_download_patch(), + patch( + "dcode.devcontainer_cli._progress.run_streaming", + side_effect=fake_run, + ) as m, + ): + result = devcontainer_cli.install_cli(prefix=prefix, console=console) + assert result == bin_path + # Verify the install script was run with --prefix: + argv = m.call_args.args[0] + assert argv[0] == "sh" + assert "--prefix" in argv + assert argv[argv.index("--prefix") + 1] == str(prefix) + # The streaming helper was given a label and our console: + assert m.call_args.kwargs["label"].startswith("Installing Dev Containers CLI") + assert m.call_args.kwargs["console"] is console + + def test_download_failure_returns_none(self, tmp_path): + console = MagicMock() + import urllib.error + with patch( + "dcode.devcontainer_cli.urllib.request.urlopen", + side_effect=urllib.error.URLError("dns"), + ): + assert ( + devcontainer_cli.install_cli(prefix=tmp_path / "dc", console=console) + is None + ) + + def test_install_script_failure_returns_none(self, tmp_path): + console = MagicMock() + with ( + _ok_download_patch(), + patch( + "dcode.devcontainer_cli._progress.run_streaming", + return_value=_streamed(2, "", "no permission"), + ), + ): + assert ( + devcontainer_cli.install_cli(prefix=tmp_path / "dc", console=console) + is None + ) + + def test_install_script_succeeds_but_binary_missing(self, tmp_path): + # Pathological: installer exits 0 but leaves no binary. + console = MagicMock() + with ( + _ok_download_patch(), + patch( + "dcode.devcontainer_cli._progress.run_streaming", + return_value=_streamed(0, "", ""), + ), + ): + assert ( + devcontainer_cli.install_cli(prefix=tmp_path / "dc", console=console) + is None + ) + + def test_install_script_oserror_returns_none(self, tmp_path): + # run_streaming reports launcher OSError via the .error field. + console = MagicMock() + with ( + _ok_download_patch(), + patch( + "dcode.devcontainer_cli._progress.run_streaming", + return_value=_streamed(-1, "", "", error="missing /bin/sh"), + ), + ): + assert ( + devcontainer_cli.install_cli(prefix=tmp_path / "dc", console=console) + is None + ) + + +# --------------------------------------------------------------------------- +# up +# --------------------------------------------------------------------------- + + +class TestUp: + def test_success_returns_container_id(self, tmp_path): + success = json.dumps( + { + "outcome": "success", + "containerId": "abcdef0123456789", + "remoteUser": "node", + "remoteWorkspaceFolder": "/workspaces/proj", + } + ) + console = MagicMock() + with patch( + "dcode.devcontainer_cli._progress.run_streaming", + return_value=_streamed(0, success + "\n", "log noise"), + ) as m: + cid, err = devcontainer_cli.up( + Path("/x/devcontainer"), + tmp_path / "proj", + tmp_path / "proj/.devcontainer/devcontainer.json", + console=console, + ) + assert cid == "abcdef0123456789" + assert err == "" + # Verify the invocation shape: + argv = m.call_args.args[0] + assert argv[0] == "/x/devcontainer" + assert argv[1] == "up" + assert "--workspace-folder" in argv + assert argv[argv.index("--workspace-folder") + 1] == str(tmp_path / "proj") + assert "--config" in argv + # Streamed via the progress helper with our console: + assert m.call_args.kwargs["label"].startswith("Building devcontainer") + assert m.call_args.kwargs["console"] is console + + def test_success_with_log_lines_before_json(self, tmp_path): + # devcontainers/cli normally writes only JSON to stdout, but cope + # defensively with prefix log lines (e.g. progress chatter). + success = json.dumps({"outcome": "success", "containerId": "cid_abc"}) + stdout = "preparing build...\nfetching layers...\n" + success + "\n" + console = MagicMock() + with patch( + "dcode.devcontainer_cli._progress.run_streaming", + return_value=_streamed(0, stdout, ""), + ): + cid, err = devcontainer_cli.up( + Path("/x/devcontainer"), + tmp_path / "proj", + tmp_path / "proj/dc.json", + console=console, + ) + assert cid == "cid_abc" + assert err == "" + + def test_error_outcome_returns_description(self, tmp_path): + err_payload = json.dumps( + { + "outcome": "error", + "message": "Build failed", + "description": "Dockerfile RUN apt-get install foo failed", + } + ) + console = MagicMock() + with patch( + "dcode.devcontainer_cli._progress.run_streaming", + return_value=_streamed(1, err_payload + "\n", "stderr noise"), + ): + cid, err = devcontainer_cli.up( + Path("/x/devcontainer"), + tmp_path / "proj", + tmp_path / "proj/dc.json", + console=console, + ) + assert cid is None + assert "Dockerfile RUN apt-get install foo failed" in err + # Stderr was streamed live by run_streaming, so we don't repeat it + # in the returned summary. + assert "stderr noise" not in err + + def test_non_zero_with_no_json_returns_exit_summary(self, tmp_path): + # When the CLI bails before writing its JSON envelope we don't have + # a structured description, just an exit code. The full stderr was + # already streamed live, so the summary just points back at it. + console = MagicMock() + with patch( + "dcode.devcontainer_cli._progress.run_streaming", + return_value=_streamed(2, "", "Cannot connect to docker daemon\n"), + ): + cid, err = devcontainer_cli.up( + Path("/x/devcontainer"), + tmp_path / "proj", + tmp_path / "proj/dc.json", + console=console, + ) + assert cid is None + assert "exited with code 2" in err + assert "see output above" in err + + 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", + return_value=_streamed(-1, "", "", error="not found"), + ): + cid, err = devcontainer_cli.up( + Path("/missing/devcontainer"), + tmp_path / "proj", + tmp_path / "proj/dc.json", + console=console, + ) + assert cid is None + assert "failed to launch" in err + assert "not found" in err + + def test_zero_exit_with_no_container_id_treated_as_failure(self, tmp_path): + # Defensive: if outcome=success but no containerId, we shouldn't + # silently return success. + weird = json.dumps({"outcome": "success"}) + console = MagicMock() + with patch( + "dcode.devcontainer_cli._progress.run_streaming", + return_value=_streamed(0, weird + "\n", ""), + ): + cid, err = devcontainer_cli.up( + Path("/x/devcontainer"), + tmp_path / "proj", + tmp_path / "proj/dc.json", + console=console, + ) + assert cid is None + assert err # some explanation + + def test_zero_exit_with_unparseable_output_treated_as_failure(self, tmp_path): + console = MagicMock() + with patch( + "dcode.devcontainer_cli._progress.run_streaming", + return_value=_streamed(0, "totally not json", "logs"), + ): + cid, err = devcontainer_cli.up( + Path("/x/devcontainer"), + tmp_path / "proj", + tmp_path / "proj/dc.json", + console=console, + ) + assert cid is None + assert "exited with code 0" in err + + +# --------------------------------------------------------------------------- +# install_hint +# --------------------------------------------------------------------------- + + +class TestInstallHint: + def test_includes_curl_and_npm_paths(self): + hint = devcontainer_cli.install_hint() + assert "curl" in hint + assert "install.sh" in hint + assert "npm install -g @devcontainers/cli" in hint diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 7e43418..8e806ae 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -173,6 +173,51 @@ def test_missing_warns(self): assert hint +# --------------------------------------------------------------------------- +# check_devcontainer_cli +# --------------------------------------------------------------------------- + + +class TestCheckDevcontainerCli: + def test_present_with_version(self): + with ( + patch( + "dcode.doctor.devcontainer_cli.find_cli", + return_value=Path("/u/local/bin/devcontainer"), + ), + patch( + "dcode.doctor.devcontainer_cli.cli_version", + return_value="0.86.0", + ), + ): + status, msg, hint = doctor.check_devcontainer_cli() + assert status == "ok" + assert "/u/local/bin/devcontainer" in msg + assert "0.86.0" in msg + assert hint is None + + def test_present_without_version_still_ok(self): + with ( + patch( + "dcode.doctor.devcontainer_cli.find_cli", + return_value=Path("/x/devcontainer"), + ), + patch("dcode.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): + status, msg, hint = doctor.check_devcontainer_cli() + assert status == "warn" + assert "not on PATH" in msg + assert hint is not None + assert "curl" in hint + assert "install.sh" in hint + + # --------------------------------------------------------------------------- # check_wsl + sub-checks # --------------------------------------------------------------------------- @@ -571,6 +616,7 @@ def test_no_failures_exits_0(self, tmp_path, capsys): check_editor=lambda: ("ok", "e", None), check_extension=lambda: ("ok", "x", None), check_docker=lambda: ("ok", "d", None), + check_devcontainer_cli=lambda: ("ok", "dc-cli", None), check_git=lambda: ("ok", "g", None), check_devcontainer=lambda *a: ("warn", "w", "h"), check_devcontainer_parses=lambda *a: ("skip", "s", None), @@ -594,6 +640,7 @@ def test_with_failure_exits_1(self, tmp_path, capsys): check_editor=lambda: ("ok", "e", None), check_extension=lambda: ("ok", "x", None), check_docker=lambda: ("fail", "d", "h"), + check_devcontainer_cli=lambda: ("ok", "dc-cli", None), check_git=lambda: ("ok", "g", None), check_devcontainer=lambda *a: ("ok", "dc", None), check_devcontainer_parses=lambda *a: ("ok", "p", None), @@ -617,6 +664,7 @@ def test_summary_line_format(self, tmp_path, capsys): check_editor=lambda: ("ok", "e", None), check_extension=lambda: ("ok", "x", None), check_docker=lambda: ("warn", "d", "h"), + check_devcontainer_cli=lambda: ("ok", "dc-cli", None), check_git=lambda: ("warn", "g", "h"), check_devcontainer=lambda *a: ("ok", "dc", None), check_devcontainer_parses=lambda *a: ("ok", "p", None), @@ -627,7 +675,7 @@ def test_summary_line_format(self, tmp_path, capsys): ): doctor.run_doctor(tmp_path) err = capsys.readouterr().err - assert "dcode doctor: 8 ok, 2 warn, 0 fail" in err + assert "dcode doctor: 9 ok, 2 warn, 0 fail" in err def test_plan_failure_does_not_change_exit_code(self, tmp_path, capsys): with ( @@ -638,6 +686,7 @@ def test_plan_failure_does_not_change_exit_code(self, tmp_path, capsys): check_editor=lambda: ("ok", "e", None), check_extension=lambda: ("ok", "x", None), check_docker=lambda: ("ok", "d", None), + check_devcontainer_cli=lambda: ("ok", "dc-cli", None), check_git=lambda: ("ok", "g", None), check_devcontainer=lambda *a: ("ok", "dc", None), check_devcontainer_parses=lambda *a: ("ok", "p", None), @@ -667,6 +716,7 @@ def cap(p): check_editor=lambda: ("ok", "e", None), check_extension=lambda: ("ok", "x", None), check_docker=lambda: ("ok", "d", None), + check_devcontainer_cli=lambda: ("ok", "dc-cli", None), check_git=lambda: ("ok", "g", None), check_devcontainer=cap, check_devcontainer_parses=lambda *a: ("skip", "p", None), @@ -700,6 +750,7 @@ def test_run_doctor_no_color_emits_no_ansi(tmp_path, capsys, monkeypatch): check_editor=lambda: ("ok", "e", None), check_extension=lambda: ("ok", "x", None), check_docker=lambda: ("warn", "d", "h"), + check_devcontainer_cli=lambda: ("ok", "dc-cli", None), check_git=lambda: ("ok", "g", None), check_devcontainer=lambda *a: ("ok", "dc", None), check_devcontainer_parses=lambda *a: ("ok", "p", None), diff --git a/tests/test_shell.py b/tests/test_shell.py index 89eeb2e..c0eeef6 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -13,7 +13,12 @@ from dcode.shell import ( ContainerLookup, ResolvedShell, + _build_missing_container, + _inspect_container_metadata, _load_jsonc, + _obtain_or_install_cli, + _prompt_yes_no, + _resolve_exec_user, detect_login_shell, find_container, find_ssh_socket, @@ -454,6 +459,144 @@ def test_exec_user_none_invokes_id_un_first(self): assert argv2[-3:] == ["getent", "passwd", "vscode"] +# --------------------------------------------------------------------------- +# _inspect_container_metadata +# --------------------------------------------------------------------------- + + +class TestInspectContainerMetadata: + def test_returns_list_for_array_label(self): + label = json.dumps([{"id": "feat"}, {"remoteUser": "node"}]) + with patch( + "dcode.shell.subprocess.run", + return_value=_completed(0, label + "\n", ""), + ) as m: + result = _inspect_container_metadata("cid") + assert result == [{"id": "feat"}, {"remoteUser": "node"}] + # Verify the docker invocation shape: + argv = m.call_args.args[0] + assert argv[:3] == ["docker", "inspect", "cid"] + assert "--format" in argv + fmt = argv[argv.index("--format") + 1] + assert "devcontainer.metadata" in fmt + + 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", + return_value=_completed(0, label + "\n", ""), + ): + assert _inspect_container_metadata("cid") == [{"remoteUser": "vscode"}] + + def test_non_dict_array_entries_filtered(self): + label = json.dumps([{"a": 1}, "junk", 42, None, {"b": 2}]) + with patch( + "dcode.shell.subprocess.run", + return_value=_completed(0, label + "\n", ""), + ): + assert _inspect_container_metadata("cid") == [{"a": 1}, {"b": 2}] + + def test_missing_label_returns_empty(self): + # Docker's --format prints the empty string when the label is absent + # (or "" depending on Docker version). + for stdout in ("", "\n", "\n"): + with patch( + "dcode.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", + 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", + return_value=_completed(0, '"justastring"\n', ""), + ): + assert _inspect_container_metadata("cid") == [] + + def test_docker_nonzero_returns_empty(self): + with patch( + "dcode.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", + side_effect=FileNotFoundError("docker"), + ): + assert _inspect_container_metadata("cid") == [] + + +# --------------------------------------------------------------------------- +# _resolve_exec_user +# --------------------------------------------------------------------------- + + +class TestResolveExecUser: + def test_devcontainer_json_remote_user(self): + assert _resolve_exec_user({"remoteUser": "vscode"}) == "vscode" + + def test_devcontainer_json_container_user_when_no_remote(self): + assert _resolve_exec_user({"containerUser": "vscode"}) == "vscode" + + def test_remote_user_preferred_over_container_user(self): + cfg = {"remoteUser": "node", "containerUser": "root"} + assert _resolve_exec_user(cfg) == "node" + + def test_metadata_remote_user_used_when_json_empty(self): + assert ( + _resolve_exec_user({}, [{"remoteUser": "node"}]) == "node" + ) + + def test_devcontainer_json_overrides_metadata(self): + # Local devcontainer.json is the highest-precedence layer. + cfg = {"remoteUser": "vscode"} + meta = [{"remoteUser": "node"}] + assert _resolve_exec_user(cfg, meta) == "vscode" + + def test_last_metadata_layer_wins(self): + # Mirrors devcontainers/cli mergeConfiguration: reversed().find(...) + # — the last entry in the metadata array wins per key. + meta = [ + {"remoteUser": "base"}, + {"remoteUser": "feature"}, + {"remoteUser": "final"}, + ] + assert _resolve_exec_user({}, meta) == "final" + + def test_remote_user_in_earlier_layer_beats_container_user_in_later(self): + # Per-key independent reverse walk + remoteUser-over-containerUser + # precedence: an older remoteUser still wins over a newer + # containerUser, matching devcontainers/cli semantics. + meta = [ + {"remoteUser": "node"}, + {"containerUser": "root"}, + ] + assert _resolve_exec_user({}, meta) == "node" + + def test_blank_or_non_string_values_ignored(self): + meta = [ + {"remoteUser": " "}, + {"remoteUser": None}, + {"remoteUser": 123}, + {"remoteUser": "vscode"}, + ] + assert _resolve_exec_user({}, meta) == "vscode" + + def test_returns_none_when_nothing_set(self): + assert _resolve_exec_user({}, []) is None + assert _resolve_exec_user({}) is None + + # --------------------------------------------------------------------------- # find_ssh_socket # --------------------------------------------------------------------------- @@ -561,6 +704,7 @@ def __init__( login_shell: str = "/bin/bash", isatty: bool = True, execvp_side_effect=None, + metadata_entries: list | None = None, ): self.container_id = container_id self.ssh_sock = ssh_sock @@ -569,6 +713,7 @@ def __init__( self.login_shell = login_shell self.isatty = isatty self.execvp = MagicMock(side_effect=execvp_side_effect) + self.metadata_entries = metadata_entries if metadata_entries is not None else [] def __enter__(self): self._patches = [ @@ -576,6 +721,10 @@ def __enter__(self): "dcode.shell.find_container", return_value=ContainerLookup(state="running", id=self.container_id), ), + patch( + "dcode.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), @@ -629,6 +778,24 @@ def test_no_user_when_neither_present(self, tmp_path): argv = h.execvp.call_args.args[1] assert "-u" not in argv + def test_remote_user_from_image_metadata_label(self, tmp_path): + # devcontainer.json doesn't set remoteUser; it comes from the base + # image (e.g. mcr.microsoft.com/devcontainers/javascript-node). + proj = _make_project(tmp_path, "{}") + with _RunShellHarness(metadata_entries=[{"remoteUser": "node"}]) as h: + run_shell(str(proj), insiders=False, shell_override=None) + argv = h.execvp.call_args.args[1] + i = argv.index("-u") + assert argv[i + 1] == "node" + + def test_devcontainer_json_overrides_image_metadata(self, tmp_path): + proj = _make_project(tmp_path, '{"remoteUser": "vscode"}') + with _RunShellHarness(metadata_entries=[{"remoteUser": "node"}]) as h: + run_shell(str(proj), insiders=False, shell_override=None) + argv = h.execvp.call_args.args[1] + i = argv.index("-u") + assert argv[i + 1] == "vscode" + def test_workdir_present(self, tmp_path): proj = _make_project(tmp_path) with _RunShellHarness(workdir="/workspaces/proj") as h: @@ -794,6 +961,7 @@ def _run_stopped( ), ), 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), @@ -890,6 +1058,277 @@ def test_prompt_is_written_to_stderr_not_stdout( assert result.stdout.getvalue() == "" +# --------------------------------------------------------------------------- +# _prompt_yes_no +# --------------------------------------------------------------------------- + + +class TestPromptYesNo: + def _run(self, answer: str, *, default_yes: bool, capsys): + with patch("sys.stdin", _TTYStringIO(answer)): + return _prompt_yes_no("Q?", default_yes=default_yes) + + def test_y_accepts(self, capsys): + assert self._run("y\n", default_yes=True, capsys=capsys) is True + + def test_yes_word_accepts_case_insensitive(self, capsys): + assert self._run("YeS\n", default_yes=False, capsys=capsys) is True + + def test_empty_default_yes_accepts(self, capsys): + assert self._run("\n", default_yes=True, capsys=capsys) is True + + def test_empty_default_no_declines(self, capsys): + assert self._run("\n", default_yes=False, capsys=capsys) is False + assert "aborted" in capsys.readouterr().err + + def test_n_declines(self, capsys): + assert self._run("n\n", default_yes=True, capsys=capsys) is False + assert "aborted" in capsys.readouterr().err + + def test_garbage_declines(self, capsys): + assert self._run("maybe\n", default_yes=True, capsys=capsys) is False + assert "aborted" in capsys.readouterr().err + + def test_question_and_suffix_on_stderr(self, capsys): + with patch("sys.stdin", _TTYStringIO("y\n")): + _prompt_yes_no("Build it?", default_yes=True) + err = capsys.readouterr().err + assert "Build it?" in err + assert "[Y/n]" in err + + def test_default_no_renders_lower_y(self, capsys): + with patch("sys.stdin", _TTYStringIO("y\n")): + _prompt_yes_no("Install?", default_yes=False) + assert "[y/N]" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# _obtain_or_install_cli +# --------------------------------------------------------------------------- + + +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("sys.stdin", _TTYStringIO("")), + ): + assert _obtain_or_install_cli() == Path("/x/dc") + install.assert_not_called() + # Nothing should have been prompted. + assert "Install" not in capsys.readouterr().err + + 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("sys.stdin", _TTYStringIO("n\n")), + ): + assert _obtain_or_install_cli() is None + install.assert_not_called() + err = capsys.readouterr().err + assert "install" in err.lower() + assert "VS Code" in err # alternative hint + + def test_accepts_install_returns_installed_path(self, capsys): + with ( + patch("dcode.shell.devcontainer_cli.find_cli", return_value=None), + patch( + "dcode.shell.devcontainer_cli.install_cli", + return_value=Path("/home/u/.devcontainers/bin/devcontainer"), + ) as install, + patch("sys.stdin", _TTYStringIO("y\n")), + ): + assert _obtain_or_install_cli() == Path( + "/home/u/.devcontainers/bin/devcontainer" + ) + install.assert_called_once() + + 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("sys.stdin", _TTYStringIO("y\n")), + ): + assert _obtain_or_install_cli() is None + assert "install" in capsys.readouterr().err.lower() + + +# --------------------------------------------------------------------------- +# _build_missing_container +# --------------------------------------------------------------------------- + + +class TestBuildMissingContainer: + def test_returns_container_id_on_success(self, tmp_path, capsys): + proj = tmp_path / "proj" + cfg = tmp_path / "proj/.devcontainer/devcontainer.json" + with ( + patch( + "dcode.shell._obtain_or_install_cli", + return_value=Path("/x/devcontainer"), + ), + patch( + "dcode.shell.devcontainer_cli.up", + return_value=("abc123def456", ""), + ) as up, + ): + cid = _build_missing_container(proj, cfg) + assert cid == "abc123def456" + up.assert_called_once_with(Path("/x/devcontainer"), proj, cfg) + err = capsys.readouterr().err + 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): + 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", + return_value=Path("/x/devcontainer"), + ), + patch( + "dcode.shell.devcontainer_cli.up", + return_value=(None, "Dockerfile RUN failed: package foo not found"), + ), + ): + cid = _build_missing_container(tmp_path, tmp_path / "x.json") + assert cid is None + err = capsys.readouterr().err + assert "build failed" in err + assert "package foo not found" in err + + +# --------------------------------------------------------------------------- +# run_shell — missing → build flow +# --------------------------------------------------------------------------- + + +class TestRunShellMissingBuild: + def _patches( + self, + *, + answer: str, + built_container_id: str | None = "newcid", + metadata_entries=None, + ): + """Patches simulating find_container='missing' + a build outcome.""" + proj_metadata = list(metadata_entries) if metadata_entries else [] + return [ + patch( + "dcode.shell.find_container", + return_value=ContainerLookup(state="missing"), + ), + patch( + "dcode.shell._build_missing_container", + return_value=built_container_id, + ), + patch( + "dcode.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("sys.stdin", _TTYStringIO(answer)), + ] + + def _enter_all(self, stack, patches): + return [stack.enter_context(p) for p in patches] + + def test_accept_build_then_exec(self, tmp_path, monkeypatch, capsys): + from contextlib import ExitStack + + proj = _make_project(tmp_path) + monkeypatch.setattr("sys.stdout", _TTYStringIO()) + + execvp = MagicMock() + with ExitStack() as stack: + stack.enter_context(patch("dcode.shell.os.execvp", execvp)) + self._enter_all( + stack, + self._patches(answer="y\n", built_container_id="newcid"), + ) + rc = run_shell(str(proj), insiders=False, shell_override=None) + + assert rc == 0 + execvp.assert_called_once() + argv = execvp.call_args.args[1] + assert "newcid" in argv + err = capsys.readouterr().err + assert "Build & start it now?" in err + + def test_decline_build_returns_nonzero_no_exec(self, tmp_path, monkeypatch, capsys): + proj = _make_project(tmp_path) + monkeypatch.setattr("sys.stdout", _TTYStringIO()) + + execvp = MagicMock() + build = MagicMock() + with ( + patch("dcode.shell.os.execvp", execvp), + patch( + "dcode.shell.find_container", + return_value=ContainerLookup(state="missing"), + ), + patch("dcode.shell._build_missing_container", build), + patch("sys.stdin", _TTYStringIO("n\n")), + ): + rc = run_shell(str(proj), insiders=False, shell_override=None) + + assert rc != 0 + execvp.assert_not_called() + build.assert_not_called() + err = capsys.readouterr().err + assert "aborted" in err + + def test_accept_but_build_fails_returns_nonzero(self, tmp_path, monkeypatch): + from contextlib import ExitStack + + proj = _make_project(tmp_path) + monkeypatch.setattr("sys.stdout", _TTYStringIO()) + + execvp = MagicMock() + with ExitStack() as stack: + stack.enter_context(patch("dcode.shell.os.execvp", execvp)) + self._enter_all( + stack, + self._patches(answer="y\n", built_container_id=None), + ) + rc = run_shell(str(proj), insiders=False, shell_override=None) + + assert rc != 0 + execvp.assert_not_called() + + def test_built_container_metadata_used_for_remote_user( + self, tmp_path, monkeypatch + ): + from contextlib import ExitStack + + proj = _make_project(tmp_path) # devcontainer.json has no remoteUser + monkeypatch.setattr("sys.stdout", _TTYStringIO()) + + execvp = MagicMock() + with ExitStack() as stack: + stack.enter_context(patch("dcode.shell.os.execvp", execvp)) + self._enter_all( + stack, + self._patches( + answer="y\n", + built_container_id="newcid", + metadata_entries=[{"remoteUser": "node"}], + ), + ) + run_shell(str(proj), insiders=False, shell_override=None) + + argv = execvp.call_args.args[1] + i = argv.index("-u") + assert argv[i + 1] == "node" + + # --------------------------------------------------------------------------- # run_shell — error paths # --------------------------------------------------------------------------- @@ -913,13 +1352,17 @@ def test_missing_devcontainer(self, tmp_path, capsys): assert rc != 0 assert "dcode doctor" in capsys.readouterr().err - def test_state_missing_message(self, tmp_path, capsys): + def test_state_missing_non_tty_message(self, tmp_path, capsys, monkeypatch): proj = _make_project(tmp_path) + monkeypatch.setattr("sys.stdin", _TTYStringIO(isatty=False)) + monkeypatch.setattr("sys.stdout", _TTYStringIO(isatty=False)) with self._run_with("missing"): rc = run_shell(str(proj), insiders=False, shell_override=None) assert rc != 0 err = capsys.readouterr().err - assert "no devcontainer found running" in 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 def test_state_stopped_non_tty_message(self, tmp_path, capsys, monkeypatch): proj = _make_project(tmp_path)