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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 63 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` (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: `<workspaceFolder>/<worktree-relative-path>`
for worktrees, otherwise `<workspaceFolder>`. The path is probed with `test -d`;
Expand All @@ -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 <path>` 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 <path>` 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`.

Expand All @@ -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 <path>` 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
Expand Down
153 changes: 153 additions & 0 deletions src/dcode/_progress.py
Original file line number Diff line number Diff line change
@@ -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"]
41 changes: 35 additions & 6 deletions src/dcode/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


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