diff --git a/.github/workflows/cd-deploy-whygraph.yml b/.github/workflows/cd-deploy-whygraph.yml index 7155310..ff2c70c 100644 --- a/.github/workflows/cd-deploy-whygraph.yml +++ b/.github/workflows/cd-deploy-whygraph.yml @@ -1,10 +1,12 @@ name: Deploy WhyGraph Image # Builds and pushes ghcr.io/mtrdesign/whygraph — the self-contained -# scanning-service image the `whygraph` host shim (scripts/install.sh) -# runs. Publishing is tied to releases: cutting a GitHub release (tagged -# vX.Y.Z) builds the image and tags it to match that version, so the image -# tag follows the release version 1:1. +# scanning-service image. It is the single install channel: +# `docker run --rm ghcr.io/mtrdesign/whygraph install | sh` emits the host +# shims (via the in-image `whygraph install` command). Publishing is tied to +# releases: cutting a GitHub release (tagged vX.Y.Z) builds the image, tags it +# to match that version, and bakes WHYGRAPH_VERSION so the shims pin that +# release. The image tag follows the release version 1:1. on: release: @@ -60,6 +62,7 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | - CODEGRAPH_VERSION=latest + CODEGRAPH_VERSION=1.4.1 + WHYGRAPH_VERSION=${{ steps.meta.outputs.version }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/CLAUDE.md b/CLAUDE.md index b861ef9..30882a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,15 +67,15 @@ There is no Claude Code marketplace install; `whygraph init --agent claude` is t ## Docker delivery (the default install) -WhyGraph ships as a self-contained image so a developer needs **only Docker** on the host — no Python / Node / gh / codegraph install. The whole UX is three steps: +WhyGraph ships as a self-contained image so a developer needs **only Docker** on the host — no Python / Node / gh / codegraph install. The **image is the single install channel** — there is no GitHub-hosted install script. The whole UX is three steps: ```bash -curl -fsSL https://raw.githubusercontent.com/mtrdesign/whygraph/main/scripts/install.sh | sh +docker run --rm ghcr.io/mtrdesign/whygraph install | sh # tag selects version; :latest is default whygraph init # in a repo whygraph scan ``` -`scripts/install.sh` drops a ~10-line `whygraph` (and `whygraph-mcp`) **shim** on PATH that wraps `docker run --rm -v "$PWD:/workspace" -w /workspace whygraph "$@"` and pulls `ghcr.io/mtrdesign/whygraph`. The container is **ephemeral per command** — there is no compose, no `docker exec`, no long-running container. The image is built from `docker/whygraph/Dockerfile` (base `python:3.12-slim` + git + gh + Node 22 + pinned CodeGraph CLI + WhyGraph) and published by `.github/workflows/publish-whygraph-image.yml`. +The in-image **`whygraph install` command** (`cli/commands/install.py`) prints a POSIX `sh` installer to stdout; piping it to `sh` writes the `whygraph` (and `whygraph-mcp`) **shims** onto PATH, each wrapping `docker run --rm -v "$PWD:/workspace" -w /workspace whygraph "$@"`. `docker run … install` is reached via a tiny **image-only launcher** (`/usr/local/bin/install` → `exec whygraph install`); there is deliberately **no `ENTRYPOINT`** so `docker run codegraph …` and ` whygraph-mcp` still resolve. The shims bake `ghcr.io/mtrdesign/whygraph:` from the image's `WHYGRAPH_VERSION` (baked at build), so an install **pins the concrete release** even via `:latest`; each shim still honours a `WHYGRAPH_IMAGE` override at run time. The container is **ephemeral per command** — no compose, no `docker exec`, no long-running container. The image is built from `docker/whygraph/Dockerfile` (base `python:3.12-slim` + git + gh + Node 22 + pinned CodeGraph CLI + WhyGraph) and published by `.github/workflows/cd-deploy-whygraph.yml` (which passes `WHYGRAPH_VERSION` + a pinned `CODEGRAPH_VERSION` as build-args). Invariants that keep the shim correct — preserve them: diff --git a/README.md b/README.md index d2c917f..13da8f7 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ whygraph init --agent claude # wire the MCP server into your editor whygraph-mcp # sanity-check the server (Ctrl-C to exit) ``` -The only-Docker install needs nothing but Docker on the host: +The only-Docker install needs nothing but Docker on the host — one command pulls the image and +installs the shims from inside it (pin a version with the image tag): ```bash -curl -fsSL https://raw.githubusercontent.com/mtrdesign/whygraph/main/scripts/install.sh | sh +docker run --rm ghcr.io/mtrdesign/whygraph install | sh ``` See the [Getting Started guide](https://mtrdesign.github.io/whygraph/getting-started/) for every install path and the [Quickstart](https://mtrdesign.github.io/whygraph/getting-started/quickstart/) for the walkthrough. diff --git a/docker/whygraph/Dockerfile b/docker/whygraph/Dockerfile index 4785431..16b7e1b 100644 --- a/docker/whygraph/Dockerfile +++ b/docker/whygraph/Dockerfile @@ -51,6 +51,14 @@ RUN set -eux; \ apt-get purge -y --auto-remove gnupg; \ rm -rf /var/lib/apt/lists/* +# WhyGraph's own version, baked so `whygraph install` can pin the generated +# shims to a concrete release even when the image is pulled via `:latest`. The +# publish workflow passes the release tag; `latest` is the local-build default. +# Declared here (not with the top ARGs) so bumping it never busts the +# apt/node/codegraph layers above. +ARG WHYGRAPH_VERSION=latest +ENV WHYGRAPH_VERSION=${WHYGRAPH_VERSION} + # Install WhyGraph itself so the `whygraph` and `whygraph-mcp` console # scripts land on PATH. Copy the build inputs hatchling needs and install. WORKDIR /opt/whygraph @@ -58,6 +66,16 @@ COPY pyproject.toml ./ COPY src ./src RUN pip install --no-cache-dir . +# Image-only shortcut so `docker run IMAGE install | sh` works without an +# ENTRYPOINT — an entrypoint of `whygraph` would break `docker run IMAGE +# codegraph …` (services/codegraph/bootstrap.py) and the whygraph-mcp shim. +# Deliberately NOT a pyproject [project.scripts] entry, so a native +# `uv tool install` never drops a bare `install` on a user's PATH (it would +# shadow coreutils `install`). Added last so no earlier build step depends on +# coreutils `install`. +RUN printf '#!/bin/sh\nexec whygraph install "$@"\n' > /usr/local/bin/install \ + && chmod +x /usr/local/bin/install + # The host shim bind-mounts the current repo here and sets the command # (e.g. `whygraph scan`). A bare `docker run IMAGE` prints CLI help. WORKDIR /workspace diff --git a/docs/deploy/docker.md b/docs/deploy/docker.md index e8e2ea6..214044e 100644 --- a/docs/deploy/docker.md +++ b/docs/deploy/docker.md @@ -1,21 +1,24 @@ # Run with Docker Don't want Python, Node, `gh`, and CodeGraph on your machine? WhyGraph ships as a self-contained -image. Your host needs **only Docker**. Install a tiny shim, then it's the same `init` and `scan` as a -native install. +image. Your host needs **only Docker**. One command installs the shims from inside the image, then +it's the same `init` and `scan` as a native install. ```bash -curl -fsSL https://raw.githubusercontent.com/mtrdesign/whygraph/main/scripts/install.sh | sh +docker run --rm ghcr.io/mtrdesign/whygraph install | sh cd your-repo whygraph init # bootstrap the WhyGraph DB + write config whygraph scan # crawl history + refresh CodeGraph + LLM descriptions ``` +Pick a version with the image tag - `docker run --rm ghcr.io/mtrdesign/whygraph:1.2.3 install | sh` +pins that release; `:latest` (the default) installs the newest. + ## How the shim works -`install.sh` drops `whygraph` and `whygraph-mcp` shims on your `PATH`. Each one runs the published -image against the current directory: +`docker run … install` prints a short installer to stdout; piping it to `sh` drops `whygraph` and +`whygraph-mcp` shims on your `PATH`. Each one runs the published image against the current directory: ```bash docker run --rm -v "$PWD:/workspace" -w /workspace ghcr.io/mtrdesign/whygraph whygraph "$@" @@ -43,7 +46,7 @@ The shim passes your environment through. A GitHub token goes in `[scan].token` ## Wire your editor, still only Docker -The MCP server is containerized too. `install.sh` drops a `whygraph-mcp` shim alongside `whygraph`, so +The MCP server is containerized too. The installer drops a `whygraph-mcp` shim alongside `whygraph`, so there's nothing extra to install. Wire your editor from inside the repo: ```bash diff --git a/docs/deploy/service.md b/docs/deploy/service.md index cae7107..9d82e20 100644 --- a/docs/deploy/service.md +++ b/docs/deploy/service.md @@ -62,8 +62,8 @@ docker run --rm -i \ ``` The `-i` flag keeps stdin open for the MCP stdio transport. Your app spawns this command and talks -JSON-RPC to it, exactly as an editor would. The [`install.sh` shim](docker.md) wraps the same call as -a bare `whygraph-mcp` on `PATH`. +JSON-RPC to it, exactly as an editor would. The [`whygraph-mcp` shim](docker.md) — dropped by +`docker run … install | sh` — wraps the same call as a bare `whygraph-mcp` on `PATH`. ## Credentials diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 47e9741..a7315cc 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -8,14 +8,16 @@ Pick the path that fits where you are. === "Docker (recommended)" - The host needs **only Docker** - no Python, Node, `gh`, or CodeGraph. A tiny shim runs everything - inside one published image. + The host needs **only Docker** - no Python, Node, `gh`, or CodeGraph. One command pulls the + published image and installs the shims from inside it: ```bash - curl -fsSL https://raw.githubusercontent.com/mtrdesign/whygraph/main/scripts/install.sh | sh + docker run --rm ghcr.io/mtrdesign/whygraph install | sh ``` - This drops `whygraph` and `whygraph-mcp` shims on your `PATH`. Each wraps a + Pin a specific version with the image tag - `docker run --rm ghcr.io/mtrdesign/whygraph:1.2.3 + install | sh`; `:latest` (the default) installs the newest release. This drops `whygraph` and + `whygraph-mcp` shims on your `PATH`. Each wraps a `docker run --rm -v "$PWD:/workspace" … ghcr.io/mtrdesign/whygraph` against the current repo. The container is ephemeral per command. See [Run with Docker](../deploy/docker.md) for the full story. diff --git a/scripts/install.sh b/scripts/install.sh deleted file mode 100755 index 08aed87..0000000 --- a/scripts/install.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env sh -# WhyGraph installer — pure-Docker, no host Python/Node/gh needed. -# -# Drops a tiny `whygraph` (and `whygraph-mcp`) shim on your PATH that runs -# the WhyGraph container against the current directory, then pulls the -# image. After this, the whole workflow is just: -# -# whygraph init # in a repo: bootstrap DB + config (+ optional --agent wiring) -# whygraph scan # crawl history + build/refresh CodeGraph index + LLM descriptions -# -# Usage: -# curl -fsSL https://raw.githubusercontent.com/mtrdesign/whygraph/main/scripts/install.sh | sh -# -# Overrides (env vars): -# WHYGRAPH_IMAGE image to run (default: ghcr.io/mtrdesign/whygraph:latest) -# WHYGRAPH_BIN_DIR where to install (default: $HOME/.local/bin) - -set -eu - -IMAGE="${WHYGRAPH_IMAGE:-ghcr.io/mtrdesign/whygraph:latest}" -BIN_DIR="${WHYGRAPH_BIN_DIR:-$HOME/.local/bin}" - -if ! command -v docker >/dev/null 2>&1; then - echo "error: Docker is required but not found on PATH." >&2 - echo " Install Docker first: https://docs.docker.com/get-docker/" >&2 - exit 1 -fi - -mkdir -p "$BIN_DIR" - -# Write a shim for one console script ($1 = whygraph | whygraph-mcp). -# The shim resolves the image, host uid/gid, and a TTY at *run* time, so -# the values below that reference $IMAGE are interpolated now while the -# \$(...) / \$VAR forms are deliberately escaped to evaluate at run time. -write_shim() { - name="$1" - target="$BIN_DIR/$name" - cat > "$target" <&2 - echo " Build it locally and set WHYGRAPH_IMAGE, e.g.:" >&2 - echo " docker build -f docker/whygraph/Dockerfile -t whygraph:latest ." >&2 - echo " WHYGRAPH_IMAGE=whygraph:latest whygraph version" >&2 -fi - -case ":$PATH:" in - *":$BIN_DIR:"*) ;; - *) echo "note: add $BIN_DIR to your PATH, e.g. 'export PATH=\"$BIN_DIR:\$PATH\"'" ;; -esac - -echo "done. Try: cd && whygraph init && whygraph scan" diff --git a/src/whygraph/cli/__init__.py b/src/whygraph/cli/__init__.py index 295a226..bbfdb10 100644 --- a/src/whygraph/cli/__init__.py +++ b/src/whygraph/cli/__init__.py @@ -16,6 +16,7 @@ from .commands.analyze import analyze_cmd from .commands.hooks import hooks_cmd from .commands.init import init_cmd +from .commands.install import install_cmd from .commands.scan import scan_cmd from .commands.version import version_cmd @@ -32,3 +33,4 @@ def main() -> None: main.add_command(scan_cmd) main.add_command(analyze_cmd) main.add_command(hooks_cmd) +main.add_command(install_cmd) diff --git a/src/whygraph/cli/commands/install.py b/src/whygraph/cli/commands/install.py new file mode 100644 index 0000000..41f9c10 --- /dev/null +++ b/src/whygraph/cli/commands/install.py @@ -0,0 +1,129 @@ +"""The ``whygraph install`` subcommand — emit the host shim installer. + +WhyGraph ships as a single Docker image. This command prints a POSIX ``sh`` +installer to **stdout** so the whole install is one Docker-native line:: + + docker run --rm ghcr.io/mtrdesign/whygraph install | sh + +The image auto-pulls on first use, this command emits the installer, and the +host ``sh`` writes two shims — ``whygraph`` and ``whygraph-mcp`` — onto +``PATH``. Each shim runs the image ephemerally against the current directory +(``docker run --rm -v "$PWD:/workspace" …``), so every command is a fresh +process reading that repo's own ``whygraph.toml`` / ``.whygraph`` / ``.codegraph``. + +The image reference baked into the shims is ``ghcr.io/mtrdesign/whygraph`` at +the version baked into the image at build time (``WHYGRAPH_VERSION`` env, set by +the publish workflow), so an install freezes to a **concrete release** even when +pulled via ``:latest``. Each shim still honours a ``WHYGRAPH_IMAGE`` override at +run time for mirrors / local builds. + +Notes +----- +Only the installer script is written to stdout (via :func:`click.echo`); all +logging goes to stderr. That keeps the ``… | sh`` pipe clean — the container's +stderr surfaces on the terminal but never corrupts the piped script. +""" + +from __future__ import annotations + +import os + +import click + +IMAGE_REPO = "ghcr.io/mtrdesign/whygraph" +"""Canonical published image; the baked default for the generated shims.""" + +_SHIM_TEMPLATE = """\ +#!/usr/bin/env sh +# WhyGraph shim — runs '__NAME__' inside the WhyGraph container against the +# current directory. Generated by `whygraph install`; safe to re-generate. +set -eu +IMAGE="${WHYGRAPH_IMAGE:-__IMAGE__}" + +# Allocate a TTY only when attached to one, so progress bars render +# interactively but pipes / CI / MCP-over-stdio still work. +tty="" +[ -t 0 ] && [ -t 1 ] && tty="-t" + +# --user + HOME=/tmp so files written into the repo (.whygraph/, .codegraph/) +# are owned by the host user and git sees matching ownership (no "dubious +# ownership"). Tokens / LLM keys pass through from the host env when set. +exec docker run --rm -i $tty \\ + --user "$(id -u):$(id -g)" -e HOME=/tmp \\ + -v "$PWD:/workspace" -w /workspace \\ + -e GH_TOKEN -e GITHUB_TOKEN \\ + -e ANTHROPIC_API_KEY -e OPENAI_API_KEY -e DEEPSEEK_API_KEY \\ + "$IMAGE" __NAME__ "$@" +""" + +_INSTALLER_TEMPLATE = """\ +#!/usr/bin/env sh +# WhyGraph installer — emitted by `whygraph install` from inside the image. +# Writes the `whygraph` and `whygraph-mcp` shims onto your PATH; each runs this +# image ephemerally against the current directory. Re-run any time to refresh. +set -eu + +BIN_DIR="${WHYGRAPH_BIN_DIR:-$HOME/.local/bin}" +mkdir -p "$BIN_DIR" + +cat > "$BIN_DIR/whygraph" <<'WHYGRAPH_SHIM' +__WHYGRAPH_SHIM__ +WHYGRAPH_SHIM +chmod +x "$BIN_DIR/whygraph" +echo "installed $BIN_DIR/whygraph" + +cat > "$BIN_DIR/whygraph-mcp" <<'WHYGRAPH_MCP_SHIM' +__WHYGRAPH_MCP_SHIM__ +WHYGRAPH_MCP_SHIM +chmod +x "$BIN_DIR/whygraph-mcp" +echo "installed $BIN_DIR/whygraph-mcp" + +case ":$PATH:" in + *":$BIN_DIR:"*) ;; + *) echo "note: add $BIN_DIR to your PATH, e.g. 'export PATH=\\"$BIN_DIR:\\$PATH\\"'" ;; +esac + +echo "done. Try: cd && whygraph init && whygraph scan" +""" + + +def _shim(name: str, image: str) -> str: + """Render one shim's body for ``name``, baking ``image`` as its default.""" + return _SHIM_TEMPLATE.replace("__NAME__", name).replace("__IMAGE__", image) + + +def render_installer(image: str) -> str: + """Render the full installer script that writes both shims for ``image``. + + Parameters + ---------- + image : str + The image reference baked as each shim's ``WHYGRAPH_IMAGE`` default, + e.g. ``ghcr.io/mtrdesign/whygraph:1.2.3``. + + Returns + ------- + str + A self-contained POSIX ``sh`` script, newline-terminated. + """ + return _INSTALLER_TEMPLATE.replace( + "__WHYGRAPH_MCP_SHIM__", _shim("whygraph-mcp", image).rstrip("\n") + ).replace("__WHYGRAPH_SHIM__", _shim("whygraph", image).rstrip("\n")) + + +@click.command(name="install") +def install_cmd() -> None: + """Emit the host shim installer (`docker run … install | sh`). + + Prints a POSIX ``sh`` script that installs the ``whygraph`` and + ``whygraph-mcp`` shims onto ``PATH``. The shims are pinned to + ``ghcr.io/mtrdesign/whygraph`` at this image's baked ``WHYGRAPH_VERSION`` + (``latest`` for un-versioned local builds), overridable per command via the + ``WHYGRAPH_IMAGE`` environment variable. + """ + version = os.environ.get("WHYGRAPH_VERSION", "latest") + image = f"{IMAGE_REPO}:{version}" + click.echo(render_installer(image), nl=False) + + +__all__ = ["install_cmd", "render_installer", "IMAGE_REPO"] diff --git a/tests/test_install_cmd.py b/tests/test_install_cmd.py new file mode 100644 index 0000000..b4b5622 --- /dev/null +++ b/tests/test_install_cmd.py @@ -0,0 +1,70 @@ +"""Tests for the ``whygraph install`` subcommand. + +The command prints a POSIX ``sh`` installer to stdout (the ``docker run … +install | sh`` bootstrap). These assert the emitted script is clean (no log +leakage on stdout), pins the baked version, and — end to end — writes two +executable, syntactically-valid shims that carry the ephemeral ``docker run`` +invocation verbatim. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from click.testing import CliRunner + +from whygraph.cli import main +from whygraph.cli.commands.install import IMAGE_REPO, render_installer + + +def test_stdout_is_a_clean_script_pinned_to_the_baked_version() -> None: + # The `… install | sh` pipe requires stdout to be *only* the script; + # logging must stay on stderr. + result = CliRunner().invoke(main, ["install"], env={"WHYGRAPH_VERSION": "9.9.9"}) + assert result.exit_code == 0, result.output + assert result.output.startswith("#!/usr/bin/env sh") + assert f"{IMAGE_REPO}:9.9.9" in result.output + + +def test_defaults_to_latest_without_the_env(monkeypatch) -> None: + monkeypatch.delenv("WHYGRAPH_VERSION", raising=False) + result = CliRunner().invoke(main, ["install"]) + assert result.exit_code == 0, result.output + assert f"{IMAGE_REPO}:latest" in result.output + + +def test_render_installer_carries_both_shims_and_the_docker_run_line() -> None: + script = render_installer(f"{IMAGE_REPO}:1.2.3") + # Writes both shims onto PATH... + assert 'cat > "$BIN_DIR/whygraph"' in script + assert 'cat > "$BIN_DIR/whygraph-mcp"' in script + # ...each baking the ref as an overridable default... + assert script.count(f'IMAGE="${{WHYGRAPH_IMAGE:-{IMAGE_REPO}:1.2.3}}"') == 2 + # ...and running the image ephemerally against the cwd with token passthrough. + assert '-v "$PWD:/workspace" -w /workspace' in script + assert "-e GH_TOKEN -e GITHUB_TOKEN" in script + assert '"$IMAGE" whygraph "$@"' in script + assert '"$IMAGE" whygraph-mcp "$@"' in script + + +def test_emitted_installer_writes_two_valid_executable_shims() -> None: + script = render_installer(f"{IMAGE_REPO}:1.2.3") + runner = CliRunner() + with runner.isolated_filesystem(): + Path("installer.sh").write_text(script) + # `sh -n` parses the outer installer without executing it. + assert subprocess.run(["sh", "-n", "installer.sh"]).returncode == 0 + + # Run it into an isolated bin dir (never touches the real PATH). + env = {**os.environ, "WHYGRAPH_BIN_DIR": str(Path("bin").resolve())} + assert subprocess.run(["sh", "installer.sh"], env=env).returncode == 0 + + for name in ("whygraph", "whygraph-mcp"): + shim = Path("bin") / name + assert shim.exists(), name + assert os.access(shim, os.X_OK), name + # Each shim parses and pins the baked image default. + assert subprocess.run(["sh", "-n", str(shim)]).returncode == 0, name + assert f"{IMAGE_REPO}:1.2.3" in shim.read_text() diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 4abd423..e3d706b 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -15,6 +15,12 @@ def test_imports() -> None: from whygraph.mcp import server # noqa: F401 +def test_cli_registers_install_command() -> None: + from whygraph.cli import main + + assert "install" in main.commands + + def test_mcp_server_name() -> None: from whygraph.mcp.server import mcp