From 66504acc94332a767f71714c83dffe04788ea88c Mon Sep 17 00:00:00 2001 From: Brian Morton Date: Fri, 30 Oct 2026 07:42:40 +0000 Subject: [PATCH] Add utilities module with bootstrap TUI, style guide and agent guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit utilities/ is a Go module of in-container terminal tools built into the image and installed to /usr/local/bin. The first is `bootstrap`, a Bubble Tea v2 app that checks and walks through the setup an image cannot bake in: mise tool health, git identity (in-TUI huh form), GitHub CLI login and git credential helper (flags stale helper paths from the old image), Claude Code login, and optional Copilot, opencode, pi, Namespace and Azure logins. Interactive fixes run the tool's own login command via tea.ExecProcess and are re-checked. `bootstrap --check` (used automatically off-TTY and in postCreateCommand) prints the same report and exits 1 while required steps are outstanding. Patterns for future utilities: thin cmd/ → internal/, shared internal/ui/theme (single home for the neon cyberpunk palette, glyphs, huh theme), internal/sh runner with a FakeRunner for tests, headless model tests. utilities/STYLEGUIDE.md and utilities/AGENTS.md (+CLAUDE.md) document the contract; a root AGENTS.md captures the repo-level rules learned so far. Build wiring: devcontainer.json build.context is now the repo root (with a .dockerignore allowlist) so the Dockerfile can COPY utilities/ and compile it with the mise-managed Go as node; binaries are installed system-wide. CI gains a Go job (gofmt, vet, test, build), Dependabot gets gomod for /utilities, and the verify script checks `bootstrap --version` via the shims and login shells. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AihTX41wWUjfBk3c2A9h2Z --- .devcontainer/Dockerfile | 27 +- .devcontainer/devcontainer.json | 8 +- .dockerignore | 8 + .github/dependabot.yml | 7 + .github/workflows/build-containers.yml | 32 +- AGENTS.md | 48 +++ CLAUDE.md | 1 + README.md | 18 +- scripts/verify-devcontainer.sh | 2 +- utilities/.gitignore | 1 + utilities/AGENTS.md | 77 ++++ utilities/CLAUDE.md | 1 + utilities/STYLEGUIDE.md | 96 +++++ utilities/cmd/bootstrap/main.go | 48 +++ utilities/go.mod | 35 ++ utilities/go.sum | 68 +++ utilities/internal/bootstrap/model.go | 479 +++++++++++++++++++++ utilities/internal/bootstrap/model_test.go | 54 +++ utilities/internal/bootstrap/plain.go | 70 +++ utilities/internal/bootstrap/run.go | 47 ++ utilities/internal/bootstrap/step.go | 103 +++++ utilities/internal/bootstrap/steps.go | 371 ++++++++++++++++ utilities/internal/bootstrap/steps_test.go | 187 ++++++++ utilities/internal/sh/sh.go | 84 ++++ utilities/internal/ui/theme/huh.go | 32 ++ utilities/internal/ui/theme/theme.go | 153 +++++++ utilities/mise.toml | 21 + 27 files changed, 2068 insertions(+), 10 deletions(-) create mode 100644 .dockerignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 utilities/.gitignore create mode 100644 utilities/AGENTS.md create mode 100644 utilities/CLAUDE.md create mode 100644 utilities/STYLEGUIDE.md create mode 100644 utilities/cmd/bootstrap/main.go create mode 100644 utilities/go.mod create mode 100644 utilities/go.sum create mode 100644 utilities/internal/bootstrap/model.go create mode 100644 utilities/internal/bootstrap/model_test.go create mode 100644 utilities/internal/bootstrap/plain.go create mode 100644 utilities/internal/bootstrap/run.go create mode 100644 utilities/internal/bootstrap/step.go create mode 100644 utilities/internal/bootstrap/steps.go create mode 100644 utilities/internal/bootstrap/steps_test.go create mode 100644 utilities/internal/sh/sh.go create mode 100644 utilities/internal/ui/theme/huh.go create mode 100644 utilities/internal/ui/theme/theme.go create mode 100644 utilities/mise.toml diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 994b987..b1101c1 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -9,6 +9,10 @@ # Everything is therefore installed from plain RUN layers here. Tool versions # live in mise.toml (installed system-wide with mise); only the few things mise # cannot manage (apt packages, nsc, Playwright) are installed directly. +# +# The build context is the REPOSITORY ROOT (devcontainer.json: build.context +# "..", CI: `docker build -f .devcontainer/Dockerfile .`) so the in-container +# utilities in utilities/ can be compiled here. COPY paths are repo-relative. FROM mcr.microsoft.com/devcontainers/typescript-node:26-trixie USER root @@ -47,8 +51,8 @@ RUN mkdir -p /workspaces && chown node:node /workspaces # --------------------------------------------------------------------------- # tmux configuration for both `node` (default) and `root` (used by `coder ssh`). # --------------------------------------------------------------------------- -COPY tmux.conf /home/node/.tmux.conf -COPY tmux.conf /root/.tmux.conf +COPY .devcontainer/tmux.conf /home/node/.tmux.conf +COPY .devcontainer/tmux.conf /root/.tmux.conf RUN chown node:node /home/node/.tmux.conf && chown root:root /root/.tmux.conf # --------------------------------------------------------------------------- @@ -88,7 +92,7 @@ ENV MISE_DATA_DIR=/usr/local/share/mise \ RUSTUP_HOME=/usr/local/rustup \ CARGO_HOME=/usr/local/cargo ENV PATH=${MISE_DATA_DIR}/shims:${GOPATH}/bin:${CARGO_HOME}/bin:${PATH} -COPY mise.toml /etc/mise/config.toml +COPY .devcontainer/mise.toml /etc/mise/config.toml RUN mkdir -p "${MISE_DATA_DIR}" "${GOPATH}" "${RUSTUP_HOME}" "${CARGO_HOME}" \ && chown -R node:node "${MISE_DATA_DIR}" "${GOPATH}" "${RUSTUP_HOME}" "${CARGO_HOME}" @@ -99,7 +103,7 @@ RUN mkdir -p "${MISE_DATA_DIR}" "${GOPATH}" "${RUSTUP_HOME}" "${CARGO_HOME}" \ # (CI's init script is non-login and never noticed). Export the tool dirs and # mise variables from a profile.d snippet and source it from the interactive rc # files too, exactly as the devcontainer features used to do. -COPY 10-devcontainer-tools.sh /etc/profile.d/10-devcontainer-tools.sh +COPY .devcontainer/10-devcontainer-tools.sh /etc/profile.d/10-devcontainer-tools.sh RUN chmod 0644 /etc/profile.d/10-devcontainer-tools.sh \ && printf '\n. /etc/profile.d/10-devcontainer-tools.sh\n' >> /etc/bash.bashrc \ && printf '\n. /etc/profile.d/10-devcontainer-tools.sh\n' >> /etc/zsh/zshrc @@ -114,6 +118,21 @@ RUN MISE_YES=1 mise install --jobs 8 \ && if [ -n "$missing" ]; then echo "mise tools missing after install:" "$missing" >&2; exit 1; fi \ && rm -rf /home/node/.cache/mise +# --------------------------------------------------------------------------- +# In-container utilities (utilities/): Go TUIs such as `bootstrap`, which walks +# through logins and identity on a fresh container. Built with the mise-managed +# Go as `node` so the module cache lands in node-owned /go; the binaries are +# then installed system-wide. See utilities/AGENTS.md for the pattern. +# --------------------------------------------------------------------------- +COPY --chown=node:node utilities /usr/local/src/devcontainer-utilities +RUN cd /usr/local/src/devcontainer-utilities \ + && go build -trimpath -ldflags="-s -w" -o /tmp/devcontainer-utilities-bin/ ./cmd/... \ + && go clean -cache \ + && rm -rf /home/node/.cache/go-build +USER root +RUN install -m 0755 /tmp/devcontainer-utilities-bin/* /usr/local/bin/ \ + && rm -rf /tmp/devcontainer-utilities-bin + # Stay on the non-root `node` user. envbuilder (used by Coder) picks its target # user from the last `USER` directive when `containerUser`/`remoteUser` are not # applied from image metadata, so the intended user must be explicit here to diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c968596..5303655 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,8 +6,10 @@ // image. See the Dockerfile header and README for details. { "name": "bmorton/devcontainer", + // Build context is the repo root so the Dockerfile can compile utilities/. "build": { - "dockerfile": "Dockerfile" + "dockerfile": "Dockerfile", + "context": ".." }, // Connect as the `node` user. Set this explicitly rather than relying on the @@ -29,7 +31,9 @@ // Use 'postCreateCommand' to run commands after the container is created. // The sentinel file lets CI verify that the lifecycle actually ran to // completion (both the devcontainer CLI and envbuilder honor this hook). - "postCreateCommand": "kubectl version --client 2>/dev/null || true; touch \"$HOME/.devcontainer-postcreate-done\"", + // `bootstrap --check` leaves a status report (logins etc.) in the creation + // log; it never fails the lifecycle. + "postCreateCommand": "kubectl version --client 2>/dev/null || true; bootstrap --check || true; touch \"$HOME/.devcontainer-postcreate-done\"", // Start sshd (port 2222) on every container start; this replaces the entrypoint // the sshd devcontainer feature used to install. Never fails the start. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a7dd1a1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# The Docker build context is the repository root (devcontainer.json sets +# build.context to ".."), so the Dockerfile can COPY both .devcontainer/ and +# utilities/. Exclude everything else to keep the context small and cache keys +# stable. +* +!.devcontainer/ +!utilities/ +utilities/bin/ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1fcea87..c7253ff 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,3 +15,10 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 5 + + # Go dependencies of the in-container utilities + - package-ecosystem: "gomod" + directory: "/utilities" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index bd83c51..4614e4c 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -8,6 +8,36 @@ permissions: contents: read jobs: + utilities: + name: Test utilities (Go) + runs-on: namespace-profile-devcontainer + timeout-minutes: 15 + defaults: + run: + working-directory: utilities + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: utilities/go.mod + cache-dependency-path: utilities/go.sum + + - name: gofmt + run: test -z "$(gofmt -l .)" || { echo "gofmt needed:"; gofmt -l .; exit 1; } + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... + + - name: go build + run: go build -trimpath -o bin/ ./cmd/... + build-dockerfile: name: Build Dockerfile runs-on: namespace-profile-devcontainer @@ -23,7 +53,7 @@ jobs: - name: Build Dockerfile run: | echo "Building Dockerfile..." - docker build -t devcontainer-dockerfile .devcontainer/ + docker build -f .devcontainer/Dockerfile -t devcontainer-dockerfile . echo "Dockerfile build completed successfully" build-envbuilder: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..03da18f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# bmorton/devcontainer — guidance for coding agents + +Personal devcontainer image used from VS Code and, mainly, Coder (built with +envbuilder). The README explains the "why"; this file is the operating manual. + +## Non-negotiables + +- **No devcontainer `features:`.** envbuilder's cache probe cannot reproduce + feature layers, so features force a full rebuild on every workspace start. + Everything is installed from `.devcontainer/Dockerfile`. +- **Versioned tools go in `.devcontainer/mise.toml`,** pinned exactly. Check + `mise registry ` first (note: the registry's `nsc` is NATS, not + Namespace). Only apt packages, nsc and Playwright are installed directly. +- **Nothing important lives under `/home/node` in the image.** Coder may persist + the home directory, which would shadow it. Tools go to `/usr/local/share/mise`, + `GOPATH=/go`, `CARGO_HOME`/`RUSTUP_HOME` under `/usr/local`, all node-owned. +- **PATH additions must be made twice:** `ENV PATH` for non-login processes + and `.devcontainer/10-devcontainer-tools.sh` (profile.d, sourced from + bash.bashrc and zsh/zshrc) for login shells. Debian's `/etc/profile` resets + PATH and the base image only restores its own. +- **Keep the tmux key bindings.** Everything else in `.devcontainer/tmux.conf` + may be modernised; test on both the current tmux and the mise-installed one. + +## Validating changes + +There is no Docker inside the container. The PR workflow `Build Containers` +is the test: plain `docker build`, `devcontainer up` + exec, and an envbuilder +build that runs `scripts/verify-devcontainer.sh` as the init script. Extend +that script whenever you add something the image must guarantee (a binary, a +PATH entry, a config file). On merge, `Cache Devcontainer` republishes the +prebuilt image; Coder workspaces pick it up on next start. + +## Repo map + +- `.devcontainer/` — Dockerfile, devcontainer.json (build context is the repo + root), mise.toml, tmux.conf, profile.d snippet. +- `utilities/` — Go module of in-container TUIs (`bootstrap`); has its own + AGENTS.md and STYLEGUIDE.md. +- `scripts/` — verify script (runs inside the built container), local + envbuilder reproduction, cache publishing. +- `.github/workflows/` — PR builds, weekly `mise upgrade --bump` PR, cache publish. + +## Habits + +- Dependency bumps: mise.toml via the weekly workflow (or `mise upgrade --bump` + in `.devcontainer/`); base image and actions via Dependabot. +- Prefer small PRs with the CI run linked; write down the lesson in the + README or this file when a Coder-only failure is found. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index dfd302f..b2265a4 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,19 @@ a version for that project; `mise use` / `mise install` work as usual. are system-wide and owned by `node`, so `go install` and `cargo install` work without sudo and survive a persisted home directory. +## In-container utilities (`utilities/`) + +A Go module of small terminal tools that are compiled into the image and +installed to `/usr/local/bin`. They share one look (bright cyberpunk accents, +see [`utilities/STYLEGUIDE.md`](utilities/STYLEGUIDE.md)) and one code pattern +(`cmd/` → `internal/`, see [`utilities/AGENTS.md`](utilities/AGENTS.md)). + +| Command | What it does | +| --- | --- | +| `bootstrap` | Steps through the setup an image can't bake in: git identity, GitHub CLI login and credential helper, Claude Code login, and optionally Copilot, opencode, pi, Namespace and Azure. `enter` runs the fix for the selected item (interactive logins run in your terminal, then get re-checked). `bootstrap --check` prints the same report non-interactively and exits 1 while required items are outstanding; `postCreateCommand` runs it so the report is in the creation log. | + +Run `bootstrap` first thing in a new workspace. + ## VS Code extensions - eamodio.gitlens @@ -180,6 +193,7 @@ When you bump the envbuilder version, update it in both `scripts/verify-devcontainer.sh` runs inside the built container (CI uses it as envbuilder's init script) and asserts: user is `node`, tmux starts with the -shipped config, the lifecycle completed, `/workspaces` is writable, and every -mise-pinned tool is installed and runnable from a plain `PATH` lookup. +shipped config, the lifecycle completed, `/workspaces` is writable, every +mise-pinned tool and utility is runnable from a plain `PATH` lookup, and the +same holds in bash and zsh login shells. `scripts/test-envbuilder.sh` reproduces the Coder build locally with Docker. diff --git a/scripts/verify-devcontainer.sh b/scripts/verify-devcontainer.sh index e1e1e5c..8979873 100755 --- a/scripts/verify-devcontainer.sh +++ b/scripts/verify-devcontainer.sh @@ -108,7 +108,7 @@ if command -v mise >/dev/null 2>&1; then for entry in "go:version" "ruby:--version" "cargo:--version" "kubectl:version --client" \ "helm:version" "gh:--version" "az:version" "claude:--version" \ "opencode:--version" "copilot:--version" "pi:--version" "witr:--version" \ - "tmux:-V" "gopls:version" "golangci-lint:--version"; do + "tmux:-V" "gopls:version" "golangci-lint:--version" "bootstrap:--version"; do tool="${entry%%:*}" args="${entry#*:}" # shellcheck disable=SC2086 # intentional word splitting of $args diff --git a/utilities/.gitignore b/utilities/.gitignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/utilities/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/utilities/AGENTS.md b/utilities/AGENTS.md new file mode 100644 index 0000000..d272fcf --- /dev/null +++ b/utilities/AGENTS.md @@ -0,0 +1,77 @@ +# utilities/ — guidance for coding agents + +Go module `github.com/bmorton/devcontainer/utilities`: small terminal tools that +ship inside the devcontainer image (built by `.devcontainer/Dockerfile`, +installed to `/usr/local/bin`). The first is `bootstrap`. Read +`STYLEGUIDE.md` before touching any UI. + +## Layout and the pattern to copy + +``` +cmd//main.go thin: flags → internal/.Run(Options) → os.Exit +internal// all logic; exported Run(Options) int, RunCheck(...) +internal//model.go Bubble Tea model (Init/Update/View) only +internal/ui/theme/ shared palette, styles, glyphs, huh theme (the only place hex colors live) +internal/sh/ command runner interface + FakeRunner for tests +``` + +Adding a utility: + +1. `cmd//main.go` with `--check`/`--version` flags, mirroring `cmd/bootstrap`. +2. `internal//` with `run.go` (TTY detection, program start), `model.go`, + and the domain logic in plain functions that take `context.Context` and an + injectable `Env`/runner so they are unit-testable without a shell. +3. Register the binary nowhere: the Dockerfile builds `./cmd/...` and installs + every binary. Add it to the shim check list in `scripts/verify-devcontainer.sh` + and to the README table. + +## Libraries + +- Bubble Tea **v2**: `charm.land/bubbletea/v2`. `View()` returns `tea.View` + (`tea.NewView(s)`, set `AltScreen`, `WindowTitle`), keys arrive as + `tea.KeyPressMsg` (match on `msg.String()` such as `"ctrl+c"`, `"enter"`), + interactive subprocesses go through `tea.ExecProcess`. +- Lip Gloss v2 `charm.land/lipgloss/v2`, Bubbles v2 `charm.land/bubbles/v2/...` + (spinner, progress), Huh v2 `charm.land/huh/v2` with `theme.Huh()`. +- Do not import `github.com/charmbracelet/...` v1 paths; they are a different API. +- Stdlib first. Avoid new dependencies unless they remove meaningful code. + +## Conventions + +- `gofmt`, `go vet`, tests green: `mise run lint && mise run test` (or the raw + `go` commands). CI runs the same on every PR. +- Checks: concurrent, each with its own timeout, never writing to the terminal. + Return a `Result{Status, Summary, Detail}`; summaries are short lowercase + fragments, details may hold commands. Prefer parsing `--json` output over + scraping text; when text is unavoidable, keep the regex next to the step and + cover it with a test using `sh.FakeRunner`. +- Fixes: run the tool's own interactive command (`gh auth login`) rather than + re-implementing a flow. Re-check afterwards; if that is impossible, set + `Unverifiable` and say so. +- Never read or print secrets. Checking that a credential exists is fine; + echoing tokens, keys or auth files is not. +- Keep `cmd/` free of logic so `internal/` stays testable and reusable. +- Exit codes: 0 ready, 1 required work outstanding, 2 usage error. + +## Testing + +- Unit tests for every step/check with `sh.FakeRunner` (see `steps_test.go`). +- Headless model tests drive `Update` with `tea.WindowSizeMsg`, result + messages and `tea.KeyPressMsg{Code: 'j', Text: "j"}`, then assert on + `View().Content` (see `model_test.go`). No real terminal is needed. +- For a visual check, run the binary inside a detached tmux session and + `tmux capture-pane -p`; that is how screenshots in PRs are produced. + +## Build and run + +```sh +cd utilities +mise run build # ./bin/ +mise run test +mise run lint +go run ./cmd/bootstrap # TUI +go run ./cmd/bootstrap --check +``` + +The image builds with the mise-managed Go version pinned in +`.devcontainer/mise.toml`; keep `go.mod`'s `go` directive at or below it. diff --git a/utilities/CLAUDE.md b/utilities/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/utilities/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/utilities/STYLEGUIDE.md b/utilities/STYLEGUIDE.md new file mode 100644 index 0000000..eeacef5 --- /dev/null +++ b/utilities/STYLEGUIDE.md @@ -0,0 +1,96 @@ +# Utilities style guide + +Every TUI in `utilities/` should feel like one product: bright cyberpunk accents +on a near-black terminal, instant feedback, and a layout you can read at a +glance. This document is the contract; `internal/ui/theme` is its +implementation. If you need something that isn't here, add it to both. + +## Palette + +Dark terminals are the target. Never set a full-screen background color; let +the terminal's own black show through and paint only chips, badges and panels. + +| Token | Hex | Use for | +| --------- | --------- | ---------------------------------------------------------- | +| `Cyan` | `#00F0FF` | Primary accent: titles, selection cursor, key chips, links | +| `Magenta` | `#FF2BD6` | Secondary accent: emphasis, badges, gradient end | +| `Violet` | `#9D4DFF` | Borders, dividers, quiet structure | +| `Acid` | `#39FF14` | Success, ready, done | +| `Amber` | `#FFD400` | Warning, unknown, optional-but-missing, notes | +| `Crimson` | `#FF3864` | Error, action required | +| `Snow` | `#EAF6FF` | Primary text | +| `Steel` | `#9FA3C7` | Secondary text, summaries, descriptions | +| `Slate` | `#5C5F82` | Muted hints, separators, placeholders | +| `Night` | `#1A0B2E` | Raised backgrounds: selection row, blurred buttons, chips | +| `Ink` | `#0B0014` | Text on bright chips; the "background" we assume | + +Rules: + +- **Color never carries meaning alone.** Pair every status color with a glyph + or word (`✓ ready`, `✗ needed`). Lip Gloss degrades to 256/16 colors and + `NO_COLOR` automatically; the UI must still make sense in monochrome. +- One gradient per screen: the title. `theme.Gradient` defaults to + Cyan→Magenta; use Acid→Cyan only for a completion banner. +- No new hex values in feature code. Add a token to `theme.go` and a row here. + +## Typography and glyphs + +- Headings are uppercase, letter-spaced and gradient: `theme.Title("bootstrap")` + renders `B O O T S T R A P`. Use `theme.Header(title, subtitle)` for a + masthead; the subtitle is one sentence in `Dim`. +- Body text is sentence case. Avoid exclamation marks and emoji; use the glyph set: + + | Glyph | Meaning | + | ----- | ----------------------------- | + | `✓` | ready / done (Acid) | + | `✗` | required action (Crimson) | + | `○` | optional action (Amber) | + | `–` | skipped (Slate) | + | `?` | unknown (Amber) | + | `!` | error in the tool itself | + | `▌` | selection cursor (Magenta) | + | `⟫` | masthead chevron, "go" affordance | + | `◆` | panel heading bullet | + | `→` | "pressing enter does…" hints | + +- Spinners: `spinner.MiniDot` in `theme.Accent`. Progress bars: Cyan→Magenta + blend, no percentage text (the label beside it says `3/5 required ready`). + +## Layout + +- Page padding `1, 2`. Sections separated by one blank line in this order: + masthead → progress/summary → primary list → details panel → footer → status. +- Lists: `cursor glyph title [optional] summary`. The selected row uses + `theme.Selected` on the title only, never the whole line. +- Details live in `theme.Panel` (rounded Violet border, `0 2` padding). Panel + heading is `◆ Title` in `Emph`. +- Footer is key chips: `theme.KeyHint("enter", "fix")`. Order: navigation, + primary action, secondary actions, `q quit`. Show a chip only when its action + is valid for the current selection. +- Clamp widths (`clamp(width-2, 40, 110)`); never assume 80 columns. Use + `AltScreen` for interactive programs so quitting restores the shell cleanly. + +## Behaviour + +- **Checks run concurrently and never block the UI.** Each check gets a context + with a timeout (30s) and reports a `Result` with a one-line summary. +- **Offer the fix next to the finding.** Anything that needs a real terminal + (OAuth device flows, editors) runs through `tea.ExecProcess` and the UI + re-checks afterwards. Mark fixes you cannot re-verify `Unverifiable` and say + so in the summary. +- **Forms use huh with `theme.Huh()`.** Prefill from the current state and + validate inline; `esc` cancels back to the list without side effects. +- Keys: `j/k ↑/↓` move, `enter`/`space` act, `r` refresh, `s` skip (optional + only), `q`/`esc`/`ctrl+c` quit. Add bindings; don't change these. +- Every utility has a non-interactive mode (`--check` or similar) that prints + the same information with the same glyphs and sets a meaningful exit code, + and is used automatically when stdout is not a TTY. +- Status line (last row) is for transient feedback; clear it on the next key. + +## Copy + +- Summaries are lowercase fragments: `logged in to github.com as bmorton`, + `missing user.email`. Details may be full sentences and may include commands + in backticks. +- Say what will happen before it happens: `→ enter runs gh auth login`. +- Completion banner: `ALL SYSTEMS ONLINE`. Use it once, at the top. diff --git a/utilities/cmd/bootstrap/main.go b/utilities/cmd/bootstrap/main.go new file mode 100644 index 0000000..65950b1 --- /dev/null +++ b/utilities/cmd/bootstrap/main.go @@ -0,0 +1,48 @@ +// Command bootstrap steps you through the per-container setup the image can't +// bake in: git identity, GitHub/Claude/Copilot/opencode/pi/Namespace/Azure +// logins and mise tool health. With --check (or when not attached to a TTY) +// it prints a report instead of opening the TUI. +package main + +import ( + "flag" + "fmt" + "os" + "runtime/debug" + + "github.com/bmorton/devcontainer/utilities/internal/bootstrap" +) + +// version is overridden at build time with -ldflags "-X main.version=...". +var version = "dev" + +func main() { + check := flag.Bool("check", false, "print a status report and exit 1 if required steps are incomplete") + showVersion := flag.Bool("version", false, "print version and exit") + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "usage: bootstrap [--check] [--version]\n\n") + fmt.Fprintf(os.Stderr, "Interactive by default; use --check for a non-interactive report.\n\n") + flag.PrintDefaults() + } + flag.Parse() + + if *showVersion { + fmt.Println("bootstrap", buildVersion()) + return + } + os.Exit(bootstrap.Run(bootstrap.Options{Check: *check})) +} + +func buildVersion() string { + if version != "dev" { + return version + } + if bi, ok := debug.ReadBuildInfo(); ok { + for _, s := range bi.Settings { + if s.Key == "vcs.revision" && len(s.Value) >= 7 { + return "dev-" + s.Value[:7] + } + } + } + return version +} diff --git a/utilities/go.mod b/utilities/go.mod new file mode 100644 index 0000000..8099b25 --- /dev/null +++ b/utilities/go.mod @@ -0,0 +1,35 @@ +module github.com/bmorton/devcontainer/utilities + +go 1.27.3 + +require ( + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.1.1 + charm.land/huh/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.6 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect + github.com/charmbracelet/x/ansi v0.11.9 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-runewidth v0.0.30 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v1.0.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/utilities/go.sum b/utilities/go.sum new file mode 100644 index 0000000..3c91077 --- /dev/null +++ b/utilities/go.sum @@ -0,0 +1,68 @@ +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.1.1 h1:S1S9l37pCZGrj1JexBcr9x4Nr3MVeM42InqCYM/tDpU= +charm.land/bubbletea/v2 v2.1.1/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw= +github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio= +github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM= +github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= +github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= +github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/utilities/internal/bootstrap/model.go b/utilities/internal/bootstrap/model.go new file mode 100644 index 0000000..db64681 --- /dev/null +++ b/utilities/internal/bootstrap/model.go @@ -0,0 +1,479 @@ +package bootstrap + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" + + "charm.land/bubbles/v2/progress" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + + "github.com/bmorton/devcontainer/utilities/internal/ui/theme" +) + +const checkTimeout = 30 * time.Second + +type mode int + +const ( + modeList mode = iota + modeForm +) + +type checkDoneMsg struct { + idx int + res Result +} + +type fixDoneMsg struct { + idx int + err error +} + +// Model is the Bubble Tea model for the interactive bootstrap. +type Model struct { + env Env + steps []*Step + results []Result + + cursor int + width, height int + spinner spinner.Model + progress progress.Model + + mode mode + form *huh.Form + formIdx int + identName string + identEmail string + + status string + quitting bool +} + +// NewModel builds the TUI model; all checks start running in Init. +func NewModel(env Env, steps []*Step) Model { + results := make([]Result, len(steps)) + for i := range results { + results[i] = Result{Status: StatusChecking} + } + return Model{ + env: env, + steps: steps, + results: results, + spinner: spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(theme.Accent)), + progress: progress.New(progress.WithColors(theme.Cyan, theme.Magenta), progress.WithoutPercentage(), progress.WithWidth(40)), + } +} + +// Init implements tea.Model. +func (m Model) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, m.checkAll()) +} + +func (m Model) checkAll() tea.Cmd { + cmds := make([]tea.Cmd, 0, len(m.steps)) + for i := range m.steps { + cmds = append(cmds, m.check(i)) + } + return tea.Batch(cmds...) +} + +func (m Model) check(idx int) tea.Cmd { + step, env := m.steps[idx], m.env + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), checkTimeout) + defer cancel() + return checkDoneMsg{idx: idx, res: step.Check(ctx, env)} + } +} + +// requiredProgress returns ready/total for required steps. +func (m Model) requiredProgress() (done, total int) { + for i, s := range m.steps { + if s.Optional { + continue + } + total++ + if m.results[i].Status == StatusReady { + done++ + } + } + return done, total +} + +func (m Model) percent() float64 { + done, total := m.requiredProgress() + if total == 0 { + return 1 + } + return float64(done) / float64(total) +} + +func (m Model) allRequiredReady() bool { + done, total := m.requiredProgress() + return done == total +} + +// Update implements tea.Model. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Messages handled regardless of mode. + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.progress.SetWidth(clamp(m.width-6, 20, 60)) + if m.form != nil { + m.form = m.form.WithWidth(clamp(m.width-8, 30, 80)) + } + return m, nil + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case progress.FrameMsg: + pm, cmd := m.progress.Update(msg) + m.progress = pm + return m, cmd + case checkDoneMsg: + m.results[msg.idx] = msg.res + return m, m.progress.SetPercent(m.percent()) + case fixDoneMsg: + return m.afterFix(msg) + } + + if m.mode == modeForm { + return m.updateForm(msg) + } + + if key, ok := msg.(tea.KeyPressMsg); ok { + return m.handleKey(key) + } + return m, nil +} + +func (m Model) handleKey(key tea.KeyPressMsg) (tea.Model, tea.Cmd) { + m.status = "" + switch key.String() { + case "ctrl+c", "q", "esc": + m.quitting = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.steps)-1 { + m.cursor++ + } + case "g", "home": + m.cursor = 0 + case "G", "end": + m.cursor = len(m.steps) - 1 + case "enter", " ": + return m.runFix(m.cursor) + case "r": + for i := range m.results { + m.results[i] = Result{Status: StatusChecking} + } + m.status = theme.Dim.Render("re-checking everything…") + return m, m.checkAll() + case "s": + step := m.steps[m.cursor] + if !step.Optional { + m.status = theme.Warning.Render("required steps can't be skipped") + return m, nil + } + if m.results[m.cursor].Status == StatusSkipped { + m.results[m.cursor] = Result{Status: StatusChecking} + return m, m.check(m.cursor) + } + m.results[m.cursor] = Result{Status: StatusSkipped, Summary: "skipped"} + } + return m, nil +} + +func (m Model) runFix(idx int) (tea.Model, tea.Cmd) { + step, res := m.steps[idx], m.results[idx] + switch res.Status { + case StatusChecking: + m.status = theme.Dim.Render("still checking " + step.Title + "…") + return m, nil + case StatusReady: + m.status = theme.Success.Render(step.Title + " is already ready") + return m, nil + } + switch step.Fix.Kind { + case FixCommand: + m.status = theme.Dim.Render("running " + step.Fix.Label + "…") + cmd := exec.Command(step.Fix.Command[0], step.Fix.Command[1:]...) + return m, tea.ExecProcess(cmd, func(err error) tea.Msg { return fixDoneMsg{idx: idx, err: err} }) + case FixGitIdentity: + return m.startIdentityForm(idx) + default: + m.status = theme.Warning.Render("no automatic fix; see the details panel") + return m, nil + } +} + +func (m Model) afterFix(msg fixDoneMsg) (tea.Model, tea.Cmd) { + step := m.steps[msg.idx] + if msg.err != nil { + m.status = theme.Danger.Render(fmt.Sprintf("%s exited with an error: %v", step.Fix.Label, msg.err)) + m.results[msg.idx] = Result{Status: StatusChecking} + return m, m.check(msg.idx) + } + if step.Fix.Unverifiable { + m.results[msg.idx] = Ready("completed (not verifiable from here)") + m.status = theme.Success.Render(step.Title + " done") + return m, m.progress.SetPercent(m.percent()) + } + m.status = theme.Dim.Render("re-checking " + step.Title + "…") + m.results[msg.idx] = Result{Status: StatusChecking} + return m, m.check(msg.idx) +} + +// --- git identity form ----------------------------------------------------------- + +func (m Model) startIdentityForm(idx int) (tea.Model, tea.Cmd) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + m.identName, m.identEmail = identity(ctx, m.env) + m.formIdx = idx + m.form = huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Name"). + Description("Shown as the author on your commits."). + Placeholder("Ada Lovelace"). + Value(&m.identName). + Validate(nonEmpty("name")), + huh.NewInput(). + Title("Email"). + Description("Use the address attached to your GitHub account."). + Placeholder("ada@example.com"). + Value(&m.identEmail). + Validate(func(s string) error { + if err := nonEmpty("email")(s); err != nil { + return err + } + if !strings.Contains(s, "@") { + return fmt.Errorf("that doesn't look like an email address") + } + return nil + }), + ), + ).WithTheme(theme.Huh()).WithShowHelp(true).WithWidth(clamp(m.width-8, 30, 80)) + m.mode = modeForm + return m, m.form.Init() +} + +func nonEmpty(what string) func(string) error { + return func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("%s is required", what) + } + return nil + } +} + +func (m Model) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) { + fm, cmd := m.form.Update(msg) + if f, ok := fm.(*huh.Form); ok { + m.form = f + } + switch m.form.State { + case huh.StateCompleted: + m.mode = modeList + m.status = theme.Dim.Render("saving git identity…") + m.results[m.formIdx] = Result{Status: StatusChecking} + return m, m.applyIdentity(m.formIdx, m.identName, m.identEmail) + case huh.StateAborted: + m.mode = modeList + m.status = theme.Warning.Render("cancelled") + return m, nil + } + return m, cmd +} + +func (m Model) applyIdentity(idx int, name, email string) tea.Cmd { + env := m.env + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for _, kv := range [][2]string{{"user.name", name}, {"user.email", email}} { + if r := env.Runner.Run(ctx, "git", "config", "--global", kv[0], kv[1]); !r.OK() { + err := r.Err + if err == nil { + err = fmt.Errorf("git config %s: %s", kv[0], r.Combined()) + } + return fixDoneMsg{idx: idx, err: err} + } + } + return fixDoneMsg{idx: idx} + } +} + +// --- view ---------------------------------------------------------------------------- + +// View implements tea.Model. +func (m Model) View() tea.View { + if m.quitting { + return tea.NewView("") + } + width := m.width + if width <= 0 { + width = 80 + } + inner := clamp(width-2, 40, 110) + + var sections []string + sections = append(sections, theme.Header("bootstrap", "Finish wiring this devcontainer: identity, logins and tooling.")) + sections = append(sections, m.viewProgress()) + + if m.mode == modeForm { + sections = append(sections, theme.Panel.Width(inner).Render( + theme.Emph.Render(theme.GlyphBullet+" Git identity")+"\n\n"+m.form.View())) + sections = append(sections, theme.Footer(theme.KeyHint("tab", "next field"), theme.KeyHint("enter", "confirm"), theme.KeyHint("esc", "cancel"))) + } else { + sections = append(sections, m.viewSteps(inner)) + sections = append(sections, m.viewDetails(inner)) + sections = append(sections, m.viewFooter()) + } + if m.status != "" { + sections = append(sections, m.status) + } + + content := lipgloss.NewStyle().Padding(1, 2).Render(lipgloss.JoinVertical(lipgloss.Left, interleave(sections, "")...)) + v := tea.NewView(content) + v.AltScreen = true + v.WindowTitle = "bootstrap" + return v +} + +func (m Model) viewProgress() string { + done, total := m.requiredProgress() + label := theme.Dim.Render(fmt.Sprintf("%d/%d required ready", done, total)) + if m.allRequiredReady() && m.noneChecking() { + label = theme.Gradient("ALL SYSTEMS ONLINE", theme.Acid, theme.Cyan) + theme.Dim.Render(" optional steps below if you want them") + } + return lipgloss.JoinVertical(lipgloss.Left, m.progress.ViewAs(m.percent()), label) +} + +func (m Model) noneChecking() bool { + for _, r := range m.results { + if r.Status == StatusChecking { + return false + } + } + return true +} + +func (m Model) viewSteps(width int) string { + rows := make([]string, 0, len(m.steps)+1) + for i, step := range m.steps { + res := m.results[i] + cursor := " " + title := theme.Text.Render(step.Title) + if i == m.cursor { + cursor = theme.Emph.Render(theme.GlyphCursor) + " " + title = theme.Selected.Render(step.Title) + } + tag := "" + if step.Optional { + tag = " " + theme.Muted.Render("optional") + } + summary := "" + if res.Summary != "" { + summary = " " + theme.Dim.Render(res.Summary) + } + line := cursor + m.glyph(i) + " " + title + tag + summary + rows = append(rows, lipgloss.NewStyle().MaxWidth(width).Render(line)) + } + return strings.Join(rows, "\n") +} + +func (m Model) glyph(i int) string { + switch m.results[i].Status { + case StatusChecking: + return m.spinner.View() + case StatusReady: + return theme.StatusGlyph("ready") + case StatusNeeded: + if m.steps[i].Optional { + return theme.StatusGlyph("optional") + } + return theme.StatusGlyph("needed") + case StatusSkipped: + return theme.StatusGlyph("skipped") + case StatusError: + return theme.StatusGlyph("error") + default: + return theme.StatusGlyph("unknown") + } +} + +func (m Model) viewDetails(width int) string { + step, res := m.steps[m.cursor], m.results[m.cursor] + var lines []string + lines = append(lines, theme.Emph.Render(theme.GlyphBullet+" "+step.Title)) + lines = append(lines, theme.Dim.Render(step.Description)) + if res.Detail != "" { + lines = append(lines, "", theme.Text.Render(res.Detail)) + } + switch { + case res.Status == StatusReady: + lines = append(lines, "", theme.Success.Render(theme.GlyphReady+" nothing to do")) + case res.Status == StatusChecking: + lines = append(lines, "", theme.Dim.Render("checking…")) + case step.Fix.Kind == FixCommand: + lines = append(lines, "", theme.Accent.Render(theme.GlyphArrow+" enter runs ")+theme.Code.Render(step.Fix.Label)) + if step.Fix.Note != "" { + lines = append(lines, theme.Warning.Render(step.Fix.Note)) + } + case step.Fix.Kind == FixGitIdentity: + lines = append(lines, "", theme.Accent.Render(theme.GlyphArrow+" enter opens a short form")) + } + return theme.Panel.Width(width).Render(strings.Join(lines, "\n")) +} + +func (m Model) viewFooter() string { + hints := []string{ + theme.KeyHint("↑↓", "move"), + theme.KeyHint("enter", "fix"), + theme.KeyHint("r", "re-check"), + } + if m.steps[m.cursor].Optional { + hints = append(hints, theme.KeyHint("s", "skip")) + } + hints = append(hints, theme.KeyHint("q", "quit")) + return theme.Footer(hints...) +} + +// --- helpers ------------------------------------------------------------------------ + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// interleave puts sep between every element of parts. +func interleave(parts []string, sep string) []string { + out := make([]string, 0, len(parts)*2) + for i, p := range parts { + if i > 0 { + out = append(out, sep) + } + out = append(out, p) + } + return out +} diff --git a/utilities/internal/bootstrap/model_test.go b/utilities/internal/bootstrap/model_test.go new file mode 100644 index 0000000..a6559fb --- /dev/null +++ b/utilities/internal/bootstrap/model_test.go @@ -0,0 +1,54 @@ +package bootstrap + +import ( + "regexp" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// plain strips SGR color sequences so assertions can match visible text. +func plain(s string) string { return ansiRe.ReplaceAllString(s, "") } + +// TestModelRendersWithoutTerminal drives the model headlessly through a +// resize, a batch of results and a cursor move, and checks the view holds +// together. It guards against panics in View and keeps the layout contract +// (title, progress label, footer) honest. +func TestModelRendersWithoutTerminal(t *testing.T) { + steps := Steps() + var m tea.Model = NewModel(testEnv(nil, nil, nil), steps) + + m, _ = m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + for i := range steps { + res := Ready("ok") + if steps[i].Optional { + res = Needed("not logged in", "") + } + m, _ = m.Update(checkDoneMsg{idx: i, res: res}) + } + m, _ = m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) + + v := plain(m.View().Content) + for _, want := range []string{"B O O T S T R A P", "ALL SYSTEMS ONLINE", "optional", "re-check"} { + if !strings.Contains(v, want) { + t.Errorf("view missing %q", want) + } + } + if strings.Contains(v, "0/") { + t.Errorf("progress label did not update: %s", v) + } +} + +func TestModelQuits(t *testing.T) { + var m tea.Model = NewModel(testEnv(nil, nil, nil), Steps()) + _, cmd := m.Update(tea.KeyPressMsg{Code: 'q', Text: "q"}) + if cmd == nil { + t.Fatal("expected quit command") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("expected tea.QuitMsg, got %T", cmd()) + } +} diff --git a/utilities/internal/bootstrap/plain.go b/utilities/internal/bootstrap/plain.go new file mode 100644 index 0000000..666efea --- /dev/null +++ b/utilities/internal/bootstrap/plain.go @@ -0,0 +1,70 @@ +package bootstrap + +import ( + "context" + "fmt" + "io" + "strings" + "sync" + + "github.com/bmorton/devcontainer/utilities/internal/ui/theme" +) + +// RunCheck runs every check concurrently and prints a non-interactive report. +// It returns 1 when any required step is not ready, otherwise 0. +func RunCheck(ctx context.Context, env Env, steps []*Step, w io.Writer) int { + results := make([]Result, len(steps)) + var wg sync.WaitGroup + for i, s := range steps { + wg.Add(1) + go func(i int, s *Step) { + defer wg.Done() + cctx, cancel := context.WithTimeout(ctx, checkTimeout) + defer cancel() + results[i] = s.Check(cctx, env) + }(i, s) + } + wg.Wait() + + fmt.Fprintln(w, theme.Header("bootstrap", "status report")) + fmt.Fprintln(w) + exit := 0 + for i, s := range steps { + r := results[i] + kind := "unknown" + switch r.Status { + case StatusReady: + kind = "ready" + case StatusNeeded: + kind = "needed" + if s.Optional { + kind = "optional" + } else { + exit = 1 + } + case StatusError: + kind = "error" + if !s.Optional { + exit = 1 + } + } + title := theme.Text.Render(fmt.Sprintf("%-22s", s.Title)) + tag := "" + if s.Optional { + tag = theme.Muted.Render(" optional") + } + fmt.Fprintf(w, " %s %s %s%s\n", theme.StatusGlyph(kind), title, theme.Dim.Render(r.Summary), tag) + if r.Detail != "" && r.Status != StatusReady { + for _, line := range strings.Split(strings.TrimSpace(r.Detail), "\n") { + fmt.Fprintf(w, " %s\n", theme.Muted.Render(line)) + } + } + } + fmt.Fprintln(w) + if exit == 0 { + fmt.Fprintln(w, " "+theme.Gradient("ALL SYSTEMS ONLINE", theme.Acid, theme.Cyan)) + } else { + fmt.Fprintln(w, " "+theme.Danger.Render("required steps need attention")+theme.Dim.Render(" run `bootstrap` to step through them")) + } + return exit +} diff --git a/utilities/internal/bootstrap/run.go b/utilities/internal/bootstrap/run.go new file mode 100644 index 0000000..533b45d --- /dev/null +++ b/utilities/internal/bootstrap/run.go @@ -0,0 +1,47 @@ +package bootstrap + +import ( + "context" + "fmt" + "io" + "os" + + tea "charm.land/bubbletea/v2" +) + +// Options controls Run. +type Options struct { + // Check forces the non-interactive report even on a TTY. + Check bool + // Out receives the report in check mode (defaults to stdout). + Out io.Writer +} + +// Run is the program entrypoint shared by the cmd package. It returns the +// process exit code. +func Run(opts Options) int { + if opts.Out == nil { + opts.Out = os.Stdout + } + env := DefaultEnv() + steps := Steps() + + if opts.Check || !isTerminal(os.Stdout) || !isTerminal(os.Stdin) { + return RunCheck(context.Background(), env, steps, opts.Out) + } + + p := tea.NewProgram(NewModel(env, steps)) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, "bootstrap:", err) + return 1 + } + return 0 +} + +func isTerminal(f *os.File) bool { + fi, err := f.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} diff --git a/utilities/internal/bootstrap/step.go b/utilities/internal/bootstrap/step.go new file mode 100644 index 0000000..9fa57f4 --- /dev/null +++ b/utilities/internal/bootstrap/step.go @@ -0,0 +1,103 @@ +// Package bootstrap checks and completes the per-container setup that an image +// cannot bake in: logins, identity and tool health. It exposes a Bubble Tea +// TUI (Run) and a non-interactive report (RunCheck). +package bootstrap + +import ( + "context" + "os" + + "github.com/bmorton/devcontainer/utilities/internal/sh" +) + +// Status is the outcome of a Step's check. +type Status int + +const ( + StatusUnknown Status = iota // not checked yet + StatusChecking // check in flight + StatusReady // nothing to do + StatusNeeded // action required + StatusSkipped // optional step the user chose to skip + StatusError // the check itself failed (tool missing, timeout) +) + +// Result is what a check reports back. +type Result struct { + Status Status + Summary string // one line, shown next to the step title + Detail string // optional multi-line detail shown in the panel +} + +// Ready builds a StatusReady result. +func Ready(summary string) Result { return Result{Status: StatusReady, Summary: summary} } + +// Needed builds a StatusNeeded result. +func Needed(summary, detail string) Result { + return Result{Status: StatusNeeded, Summary: summary, Detail: detail} +} + +// Failed builds a StatusError result from an error. +func Failed(err error) Result { + return Result{Status: StatusError, Summary: "check failed", Detail: err.Error()} +} + +// FixKind says how a step is completed. +type FixKind int + +const ( + FixNone FixKind = iota // manual; Detail explains what to do + FixCommand // run Command interactively in the terminal + FixGitIdentity // in-TUI form that writes git config +) + +// Fix describes the action offered for a StatusNeeded step. +type Fix struct { + Kind FixKind + Label string // what we tell the user we're about to run + Command []string // for FixCommand + Note string // guidance shown before running (e.g. "use /login inside pi") + // Unverifiable marks fixes whose success cannot be re-checked afterwards; + // a clean exit is recorded as ready instead of re-running the check. + Unverifiable bool +} + +// Env is everything a check may touch, injected so tests can fake it. +type Env struct { + Runner sh.Runner + Getenv func(string) string + Home string + ReadFile func(string) ([]byte, error) + FileOK func(string) bool // exists and is readable + // Installed reports whether a command resolves on PATH. + Installed func(string) bool +} + +// DefaultEnv wires Env to the real process environment. +func DefaultEnv() Env { + home, _ := os.UserHomeDir() + return Env{ + Runner: sh.ExecRunner{}, + Getenv: os.Getenv, + Home: home, + ReadFile: os.ReadFile, + FileOK: func(p string) bool { + _, err := os.Stat(p) + return err == nil + }, + Installed: sh.Installed, + } +} + +// Step is one unit of setup. +type Step struct { + ID string + Title string + Description string + Optional bool + Check func(ctx context.Context, env Env) Result + Fix Fix +} + +// Required is the inverse of Optional, for readability at call sites. +func (s *Step) Required() bool { return !s.Optional } diff --git a/utilities/internal/bootstrap/steps.go b/utilities/internal/bootstrap/steps.go new file mode 100644 index 0000000..2997719 --- /dev/null +++ b/utilities/internal/bootstrap/steps.go @@ -0,0 +1,371 @@ +package bootstrap + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Steps returns the ordered setup checklist. Required steps come first. +func Steps() []*Step { + return []*Step{ + stepMise(), + stepGitIdentity(), + stepGitHubCLI(), + stepGitCredentialHelper(), + stepClaude(), + stepCopilot(), + stepOpenCode(), + stepPi(), + stepNamespace(), + stepAzure(), + } +} + +func notInstalled(name string) Result { + return Result{Status: StatusError, Summary: name + " is not installed", Detail: "Expected it on PATH via the mise shims. Run `mise install` and retry."} +} + +// --- tooling ---------------------------------------------------------------- + +func stepMise() *Step { + return &Step{ + ID: "mise", + Title: "Toolchain", + Description: "Every tool pinned in /etc/mise/config.toml is installed.", + Check: func(ctx context.Context, env Env) Result { + if !env.Installed("mise") { + return notInstalled("mise") + } + r := env.Runner.Run(ctx, "mise", "ls", "--missing") + if !r.OK() { + return Failed(fmt.Errorf("mise ls --missing: %s", r.Combined())) + } + if strings.TrimSpace(r.Stdout) == "" { + return Ready("all pinned tools installed") + } + return Needed("some pinned tools are missing", strings.TrimSpace(r.Stdout)) + }, + Fix: Fix{Kind: FixCommand, Label: "mise install", Command: []string{"mise", "install"}}, + } +} + +// --- git --------------------------------------------------------------------- + +func gitConfig(ctx context.Context, env Env, key string) string { + r := env.Runner.Run(ctx, "git", "config", "--global", "--get", key) + if !r.OK() { + return "" + } + return strings.TrimSpace(r.Stdout) +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +// identity returns the effective git author name and email. +func identity(ctx context.Context, env Env) (name, email string) { + name = firstNonEmpty(env.Getenv("GIT_AUTHOR_NAME"), gitConfig(ctx, env, "user.name")) + email = firstNonEmpty(env.Getenv("GIT_AUTHOR_EMAIL"), gitConfig(ctx, env, "user.email")) + return name, email +} + +func stepGitIdentity() *Step { + return &Step{ + ID: "git-identity", + Title: "Git identity", + Description: "user.name and user.email so commits are attributed to you (Coder may inject these via GIT_AUTHOR_* variables).", + Check: func(ctx context.Context, env Env) Result { + name, email := identity(ctx, env) + if name != "" && email != "" { + return Ready(fmt.Sprintf("%s <%s>", name, email)) + } + missing := []string{} + if name == "" { + missing = append(missing, "user.name") + } + if email == "" { + missing = append(missing, "user.email") + } + return Needed("missing "+strings.Join(missing, " and "), + "Press enter to fill in a short form, or run:\n git config --global user.name \"Your Name\"\n git config --global user.email you@example.com") + }, + Fix: Fix{Kind: FixGitIdentity, Label: "set git identity"}, + } +} + +var ghAccountRe = regexp.MustCompile(`Logged in to (\S+) account (\S+)`) + +func stepGitHubCLI() *Step { + return &Step{ + ID: "gh", + Title: "GitHub CLI", + Description: "gh is authenticated; it also feeds credentials to git and the Copilot CLI.", + Check: func(ctx context.Context, env Env) Result { + if !env.Installed("gh") { + return notInstalled("gh") + } + r := env.Runner.Run(ctx, "gh", "auth", "status") + out := r.Combined() + if r.Err != nil { + return Failed(r.Err) + } + if r.ExitCode != 0 { + return Needed("not logged in", out) + } + if m := ghAccountRe.FindStringSubmatch(out); m != nil { + return Ready(fmt.Sprintf("logged in to %s as %s", m[1], m[2])) + } + return Ready("logged in") + }, + Fix: Fix{Kind: FixCommand, Label: "gh auth login", Command: []string{"gh", "auth", "login"}}, + } +} + +func stepGitCredentialHelper() *Step { + return &Step{ + ID: "gh-git-credential", + Title: "Git credential helper", + Description: "git uses gh for https://github.com so clone/push work without prompts. A helper pointing at a gh binary that no longer exists (e.g. /usr/bin/gh from the old image) is flagged as stale.", + Check: func(ctx context.Context, env Env) Result { + r := env.Runner.Run(ctx, "git", "config", "--global", "--get-all", "credential.https://github.com.helper") + if r.Err != nil { + return Failed(r.Err) + } + var helper string + for _, line := range strings.Split(r.Stdout, "\n") { + if strings.Contains(line, "gh auth git-credential") { + helper = strings.TrimSpace(line) + } + } + if helper == "" { + return Needed("gh is not configured as git's credential helper", "Run gh auth setup-git (after gh auth login).") + } + // "!/usr/bin/gh auth git-credential" -> /usr/bin/gh + if strings.HasPrefix(helper, "!") { + fields := strings.Fields(helper[1:]) + if len(fields) > 0 && filepath.IsAbs(fields[0]) && !env.FileOK(fields[0]) { + return Needed("stale helper path "+fields[0], "The configured gh binary no longer exists. Re-run gh auth setup-git to point at the current gh.") + } + } + return Ready("gh handles github.com credentials") + }, + Fix: Fix{Kind: FixCommand, Label: "gh auth setup-git", Command: []string{"gh", "auth", "setup-git"}}, + } +} + +// --- coding agents ------------------------------------------------------------ + +type claudeAuthStatus struct { + LoggedIn bool `json:"loggedIn"` + AuthMethod string `json:"authMethod"` + Email string `json:"email"` +} + +func stepClaude() *Step { + return &Step{ + ID: "claude", + Title: "Claude Code", + Description: "Signed in to your Anthropic account.", + Check: func(ctx context.Context, env Env) Result { + if !env.Installed("claude") { + return notInstalled("claude") + } + r := env.Runner.Run(ctx, "claude", "auth", "status", "--json") + if r.Err != nil { + return Failed(r.Err) + } + var st claudeAuthStatus + if err := json.Unmarshal([]byte(strings.TrimSpace(r.Stdout)), &st); err != nil { + if r.ExitCode != 0 { + return Needed("not logged in", r.Combined()) + } + return Failed(fmt.Errorf("parse claude auth status: %w", err)) + } + if !st.LoggedIn { + return Needed("not logged in", "") + } + via := st.AuthMethod + if st.Email != "" { + via = st.Email + " via " + via + } + return Ready("logged in (" + via + ")") + }, + Fix: Fix{Kind: FixCommand, Label: "claude auth login", Command: []string{"claude", "auth", "login"}}, + } +} + +var copilotTokenVars = []string{"COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"} + +func stepCopilot() *Step { + return &Step{ + ID: "copilot", + Title: "GitHub Copilot CLI", + Optional: true, + Description: "copilot authenticates from COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN or a stored OAuth login. A stored login cannot be verified without launching copilot, so this step only proves the environment-variable path.", + Check: func(ctx context.Context, env Env) Result { + if !env.Installed("copilot") { + return notInstalled("copilot") + } + for _, v := range copilotTokenVars { + if env.Getenv(v) != "" { + return Ready("token from $" + v) + } + } + return Needed("no token in environment; stored login unverified", + "Press enter to run copilot login (device code), or export GH_TOKEN=\"$(gh auth token)\".") + }, + Fix: Fix{Kind: FixCommand, Label: "copilot login --device-code", Command: []string{"copilot", "login", "--device-code"}, Unverifiable: true}, + } +} + +func stepOpenCode() *Step { + return &Step{ + ID: "opencode", + Title: "opencode", + Optional: true, + Description: "At least one model provider is authenticated.", + Check: func(ctx context.Context, env Env) Result { + if !env.Installed("opencode") { + return notInstalled("opencode") + } + r := env.Runner.Run(ctx, "opencode", "auth", "list") + if r.Err != nil { + return Failed(r.Err) + } + out := strings.TrimSpace(r.Combined()) + if r.ExitCode != 0 { + return Failed(fmt.Errorf("opencode auth list: %s", out)) + } + if out == "" || strings.Contains(out, "No authenticated integrations") { + return Needed("no providers authenticated", "") + } + n := 0 + for _, l := range strings.Split(out, "\n") { + if strings.TrimSpace(l) != "" { + n++ + } + } + return Ready(fmt.Sprintf("%d integration(s)", n)) + }, + Fix: Fix{Kind: FixCommand, Label: "opencode auth login", Command: []string{"opencode", "auth", "login"}}, + } +} + +type piAuthCheck struct { + Status string `json:"status"` + Provider string `json:"provider"` + Reason string `json:"reason"` +} + +func stepPi() *Step { + return &Step{ + ID: "pi", + Title: "pi", + Optional: true, + Description: "pi has credentials for at least one model provider (~/.pi/agent/auth.json). Login happens inside pi with /login.", + Check: func(ctx context.Context, env Env) Result { + if !env.Installed("pi") { + return notInstalled("pi") + } + raw, err := env.ReadFile(filepath.Join(env.Home, ".pi", "agent", "auth.json")) + if err != nil { + return Needed("no provider credentials yet", "") + } + var providers map[string]json.RawMessage + if err := json.Unmarshal(raw, &providers); err != nil || len(providers) == 0 { + return Needed("no provider credentials yet", "") + } + names := make([]string, 0, len(providers)) + for p := range providers { + names = append(names, p) + } + sort.Strings(names) + var ready, notReady []string + for _, p := range names { + r := env.Runner.Run(ctx, "pi", "auth", "check", "--provider", p, "--json") + var st piAuthCheck + if json.Unmarshal([]byte(strings.TrimSpace(r.Stdout)), &st) == nil && st.Status == "ready" { + ready = append(ready, p) + } else { + notReady = append(notReady, p) + } + } + if len(ready) > 0 { + return Ready("providers ready: " + strings.Join(ready, ", ")) + } + return Needed("configured providers are not ready: "+strings.Join(notReady, ", "), "") + }, + Fix: Fix{Kind: FixCommand, Label: "pi", Command: []string{"pi"}, + Note: "pi will open. Run /login to add a provider, then quit pi to return here."}, + } +} + +// --- platform CLIs -------------------------------------------------------------- + +func stepNamespace() *Step { + return &Step{ + ID: "nsc", + Title: "Namespace (nsc)", + Optional: true, + Description: "Logged in to Namespace for remote builders and instances.", + Check: func(ctx context.Context, env Env) Result { + if !env.Installed("nsc") { + return notInstalled("nsc") + } + r := env.Runner.Run(ctx, "nsc", "auth", "check-login") + if r.Err != nil { + return Failed(r.Err) + } + if r.ExitCode != 0 { + return Needed("not logged in", "") + } + return Ready("authenticated") + }, + Fix: Fix{Kind: FixCommand, Label: "nsc login", Command: []string{"nsc", "login"}}, + } +} + +type azAccount struct { + Name string `json:"name"` + User struct { + Name string `json:"name"` + } `json:"user"` +} + +func stepAzure() *Step { + return &Step{ + ID: "az", + Title: "Azure CLI", + Optional: true, + Description: "az has an active subscription.", + Check: func(ctx context.Context, env Env) Result { + if !env.Installed("az") { + return notInstalled("az") + } + r := env.Runner.Run(ctx, "az", "account", "show", "-o", "json") + if r.Err != nil { + return Failed(r.Err) + } + if r.ExitCode != 0 { + return Needed("not logged in", "") + } + var acct azAccount + if json.Unmarshal([]byte(r.Stdout), &acct) == nil && acct.User.Name != "" { + return Ready(fmt.Sprintf("%s (%s)", acct.User.Name, acct.Name)) + } + return Ready("logged in") + }, + Fix: Fix{Kind: FixCommand, Label: "az login --use-device-code", Command: []string{"az", "login", "--use-device-code"}}, + } +} diff --git a/utilities/internal/bootstrap/steps_test.go b/utilities/internal/bootstrap/steps_test.go new file mode 100644 index 0000000..771f408 --- /dev/null +++ b/utilities/internal/bootstrap/steps_test.go @@ -0,0 +1,187 @@ +package bootstrap + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + "github.com/bmorton/devcontainer/utilities/internal/sh" +) + +func testEnv(results map[string]sh.Result, env map[string]string, files map[string]string) Env { + return Env{ + Runner: &sh.FakeRunner{Results: results}, + Getenv: func(k string) string { return env[k] }, + Home: "/home/test", + ReadFile: func(p string) ([]byte, error) { + if c, ok := files[p]; ok { + return []byte(c), nil + } + return nil, os.ErrNotExist + }, + FileOK: func(p string) bool { _, ok := files[p]; return ok }, + Installed: func(string) bool { return true }, + } +} + +func find(t *testing.T, id string) *Step { + t.Helper() + for _, s := range Steps() { + if s.ID == id { + return s + } + } + t.Fatalf("no step %q", id) + return nil +} + +func TestGitIdentity(t *testing.T) { + ctx := context.Background() + step := find(t, "git-identity") + + t.Run("from env", func(t *testing.T) { + env := testEnv(nil, map[string]string{"GIT_AUTHOR_NAME": "Ada", "GIT_AUTHOR_EMAIL": "ada@example.com"}, nil) + r := step.Check(ctx, env) + if r.Status != StatusReady || !strings.Contains(r.Summary, "Ada ") { + t.Fatalf("got %+v", r) + } + }) + t.Run("from git config", func(t *testing.T) { + env := testEnv(map[string]sh.Result{ + "git config --global --get user.name": {Stdout: "Ada\n"}, + "git config --global --get user.email": {Stdout: "ada@example.com\n"}, + }, nil, nil) + if r := step.Check(ctx, env); r.Status != StatusReady { + t.Fatalf("got %+v", r) + } + }) + t.Run("missing email", func(t *testing.T) { + env := testEnv(map[string]sh.Result{ + "git config --global --get user.name": {Stdout: "Ada\n"}, + "git config --global --get user.email": {ExitCode: 1}, + }, nil, nil) + r := step.Check(ctx, env) + if r.Status != StatusNeeded || !strings.Contains(r.Summary, "user.email") || strings.Contains(r.Summary, "user.name") { + t.Fatalf("got %+v", r) + } + }) +} + +func TestGitHubCLI(t *testing.T) { + ctx := context.Background() + step := find(t, "gh") + out := "github.com\n ✓ Logged in to github.com account bmorton (/home/x/.config/gh/hosts.yml)\n" + env := testEnv(map[string]sh.Result{"gh auth status": {Stdout: out}}, nil, nil) + r := step.Check(ctx, env) + if r.Status != StatusReady || r.Summary != "logged in to github.com as bmorton" { + t.Fatalf("got %+v", r) + } + env = testEnv(map[string]sh.Result{"gh auth status": {ExitCode: 1, Stderr: "You are not logged into any GitHub hosts."}}, nil, nil) + if r := step.Check(ctx, env); r.Status != StatusNeeded { + t.Fatalf("got %+v", r) + } +} + +func TestCredentialHelper(t *testing.T) { + ctx := context.Background() + step := find(t, "gh-git-credential") + key := "git config --global --get-all credential.https://github.com.helper" + + env := testEnv(map[string]sh.Result{key: {Stdout: "\n!/usr/bin/gh auth git-credential\n"}}, nil, map[string]string{"/usr/bin/gh": ""}) + if r := step.Check(ctx, env); r.Status != StatusReady { + t.Fatalf("existing helper: got %+v", r) + } + env = testEnv(map[string]sh.Result{key: {Stdout: "!/usr/bin/gh auth git-credential\n"}}, nil, nil) + r := step.Check(ctx, env) + if r.Status != StatusNeeded || !strings.Contains(r.Summary, "stale") { + t.Fatalf("stale helper: got %+v", r) + } + env = testEnv(map[string]sh.Result{key: {ExitCode: 1}}, nil, nil) + if r := step.Check(ctx, env); r.Status != StatusNeeded { + t.Fatalf("no helper: got %+v", r) + } +} + +func TestClaude(t *testing.T) { + ctx := context.Background() + step := find(t, "claude") + in := `{"loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty"}` + env := testEnv(map[string]sh.Result{"claude auth status --json": {Stdout: in}}, nil, nil) + r := step.Check(ctx, env) + if r.Status != StatusReady || !strings.Contains(r.Summary, "claude.ai") { + t.Fatalf("got %+v", r) + } + env = testEnv(map[string]sh.Result{"claude auth status --json": {Stdout: `{"loggedIn": false}`}}, nil, nil) + if r := step.Check(ctx, env); r.Status != StatusNeeded { + t.Fatalf("got %+v", r) + } +} + +func TestOpenCode(t *testing.T) { + ctx := context.Background() + step := find(t, "opencode") + env := testEnv(map[string]sh.Result{"opencode auth list": {Stdout: "No authenticated integrations\n"}}, nil, nil) + if r := step.Check(ctx, env); r.Status != StatusNeeded { + t.Fatalf("got %+v", r) + } + env = testEnv(map[string]sh.Result{"opencode auth list": {Stdout: "anthropic oauth\nopenai api\n"}}, nil, nil) + r := step.Check(ctx, env) + if r.Status != StatusReady || r.Summary != "2 integration(s)" { + t.Fatalf("got %+v", r) + } +} + +func TestPi(t *testing.T) { + ctx := context.Background() + step := find(t, "pi") + auth := "/home/test/.pi/agent/auth.json" + + env := testEnv(nil, nil, nil) + if r := step.Check(ctx, env); r.Status != StatusNeeded { + t.Fatalf("no auth file: got %+v", r) + } + env = testEnv(map[string]sh.Result{ + "pi auth check --provider anthropic --json": {Stdout: `{"status":"not_ready","provider":"anthropic","reason":"credentials_not_configured"}`, ExitCode: 1}, + "pi auth check --provider synthetic --json": {Stdout: `{"status":"ready","provider":"synthetic"}`}, + }, nil, map[string]string{auth: `{"anthropic":{},"synthetic":{"type":"api_key"}}`}) + r := step.Check(ctx, env) + if r.Status != StatusReady || r.Summary != "providers ready: synthetic" { + t.Fatalf("got %+v", r) + } +} + +func TestMise(t *testing.T) { + ctx := context.Background() + step := find(t, "mise") + env := testEnv(map[string]sh.Result{"mise ls --missing": {Stdout: ""}}, nil, nil) + if r := step.Check(ctx, env); r.Status != StatusReady { + t.Fatalf("got %+v", r) + } + env = testEnv(map[string]sh.Result{"mise ls --missing": {Stdout: "go 1.27.3 (missing)\n"}}, nil, nil) + r := step.Check(ctx, env) + if r.Status != StatusNeeded || !strings.Contains(r.Detail, "go") { + t.Fatalf("got %+v", r) + } +} + +func TestRunnerErrorIsFailure(t *testing.T) { + ctx := context.Background() + step := find(t, "nsc") + env := testEnv(map[string]sh.Result{"nsc auth check-login": {ExitCode: -1, Err: errors.New("boom")}}, nil, nil) + if r := step.Check(ctx, env); r.Status != StatusError { + t.Fatalf("got %+v", r) + } +} + +func TestStepsOrderRequiredFirst(t *testing.T) { + seenOptional := false + for _, s := range Steps() { + if s.Optional { + seenOptional = true + } else if seenOptional { + t.Fatalf("required step %q listed after an optional one", s.ID) + } + } +} diff --git a/utilities/internal/sh/sh.go b/utilities/internal/sh/sh.go new file mode 100644 index 0000000..d64ad7b --- /dev/null +++ b/utilities/internal/sh/sh.go @@ -0,0 +1,84 @@ +// Package sh runs external commands behind a small interface so callers can be +// unit-tested with a fake runner instead of a real shell. +package sh + +import ( + "bytes" + "context" + "errors" + "os/exec" + "strings" +) + +// Result is the outcome of running a command to completion. +type Result struct { + Stdout string + Stderr string + ExitCode int + // Err is set only when the command could not be started (not found, + // permission denied, context cancelled). A non-zero exit is not an Err. + Err error +} + +// OK reports whether the command started and exited 0. +func (r Result) OK() bool { return r.Err == nil && r.ExitCode == 0 } + +// Combined returns stdout followed by stderr, trimmed. +func (r Result) Combined() string { + return strings.TrimSpace(r.Stdout + "\n" + r.Stderr) +} + +// Runner executes a command non-interactively (no stdin) and captures output. +type Runner interface { + Run(ctx context.Context, name string, args ...string) Result +} + +// ExecRunner is the real Runner backed by os/exec. +type ExecRunner struct{} + +// Run implements Runner. +func (ExecRunner) Run(ctx context.Context, name string, args ...string) Result { + path, err := exec.LookPath(name) + if err != nil { + return Result{ExitCode: 127, Err: err} + } + cmd := exec.CommandContext(ctx, path, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + res := Result{Stdout: stdout.String(), Stderr: stderr.String()} + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + res.ExitCode = exitErr.ExitCode() + } else { + res.ExitCode = -1 + res.Err = err + } + } + return res +} + +// Installed reports whether name resolves on PATH. +func Installed(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +// FakeRunner is a Runner for tests. Results are keyed by the full command +// line ("gh auth status"); unmatched commands return exit 127. +type FakeRunner struct { + Results map[string]Result + Calls []string +} + +// Run implements Runner. +func (f *FakeRunner) Run(_ context.Context, name string, args ...string) Result { + key := strings.TrimSpace(name + " " + strings.Join(args, " ")) + f.Calls = append(f.Calls, key) + if r, ok := f.Results[key]; ok { + return r + } + return Result{ExitCode: 127, Err: errors.New("fake: no result for " + key)} +} diff --git a/utilities/internal/ui/theme/huh.go b/utilities/internal/ui/theme/huh.go new file mode 100644 index 0000000..b0f4ac3 --- /dev/null +++ b/utilities/internal/ui/theme/huh.go @@ -0,0 +1,32 @@ +package theme + +import "charm.land/huh/v2" + +// Huh returns the huh form theme for this suite. Always pass it to +// huh.NewForm(...).WithTheme(theme.Huh()) so prompts match the rest of the UI. +func Huh() huh.Theme { + return huh.ThemeFunc(func(isDark bool) *huh.Styles { + s := huh.ThemeBase(isDark) + + s.Focused.Base = s.Focused.Base.BorderForeground(Cyan) + s.Focused.Title = s.Focused.Title.Foreground(Cyan).Bold(true) + s.Focused.Description = s.Focused.Description.Foreground(Steel) + s.Focused.ErrorIndicator = s.Focused.ErrorIndicator.Foreground(Crimson) + s.Focused.ErrorMessage = s.Focused.ErrorMessage.Foreground(Crimson) + s.Focused.SelectSelector = s.Focused.SelectSelector.Foreground(Magenta) + s.Focused.SelectedOption = s.Focused.SelectedOption.Foreground(Cyan) + s.Focused.FocusedButton = s.Focused.FocusedButton.Foreground(Ink).Background(Magenta).Bold(true) + s.Focused.BlurredButton = s.Focused.BlurredButton.Foreground(Snow).Background(Night) + s.Focused.TextInput.Cursor = s.Focused.TextInput.Cursor.Foreground(Magenta) + s.Focused.TextInput.Prompt = s.Focused.TextInput.Prompt.Foreground(Cyan) + s.Focused.TextInput.Placeholder = s.Focused.TextInput.Placeholder.Foreground(Slate) + s.Focused.TextInput.Text = s.Focused.TextInput.Text.Foreground(Snow) + + s.Blurred = s.Focused + s.Blurred.Base = s.Blurred.Base.BorderForeground(Slate) + s.Blurred.Title = s.Blurred.Title.Foreground(Steel) + s.Blurred.TextInput.Prompt = s.Blurred.TextInput.Prompt.Foreground(Slate) + + return s + }) +} diff --git a/utilities/internal/ui/theme/theme.go b/utilities/internal/ui/theme/theme.go new file mode 100644 index 0000000..53562f2 --- /dev/null +++ b/utilities/internal/ui/theme/theme.go @@ -0,0 +1,153 @@ +// Package theme is the single source of truth for colors, glyphs and shared +// Lip Gloss styles used by every utility in this module. Utilities must not +// define their own hex colors; add to this package instead and document the +// addition in STYLEGUIDE.md. +package theme + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" +) + +// Palette: neon cyberpunk on a near-black background. Keep in sync with +// STYLEGUIDE.md, which explains when to use each color. +var ( + Cyan = lipgloss.Color("#00F0FF") // primary accent: titles, selection, keys + Magenta = lipgloss.Color("#FF2BD6") // secondary accent: emphasis, badges, gradient end + Violet = lipgloss.Color("#9D4DFF") // tertiary accent: borders, dividers + Acid = lipgloss.Color("#39FF14") // success / ready + Amber = lipgloss.Color("#FFD400") // warning / unknown / attention + Crimson = lipgloss.Color("#FF3864") // error / action required + Snow = lipgloss.Color("#EAF6FF") // primary text + Steel = lipgloss.Color("#9FA3C7") // secondary text + Slate = lipgloss.Color("#5C5F82") // muted text, hints, separators + Night = lipgloss.Color("#1A0B2E") // raised panel / selection background + Ink = lipgloss.Color("#0B0014") // deepest background, text on bright chips +) + +// Glyphs: single-cell symbols with consistent meaning across utilities. Color +// never carries meaning on its own; a glyph (or word) always accompanies it. +const ( + GlyphReady = "✓" + GlyphNeeded = "✗" + GlyphOptional = "○" + GlyphSkipped = "–" + GlyphUnknown = "?" + GlyphError = "!" + GlyphCursor = "▌" + GlyphChevron = "⟫" + GlyphBullet = "◆" + GlyphDot = "·" + GlyphArrow = "→" +) + +// Shared styles. Compose from these rather than calling lipgloss.NewStyle in +// feature code so the whole suite shifts together when the palette changes. +var ( + Text = lipgloss.NewStyle().Foreground(Snow) + Dim = lipgloss.NewStyle().Foreground(Steel) + Muted = lipgloss.NewStyle().Foreground(Slate) + Accent = lipgloss.NewStyle().Foreground(Cyan) + Emph = lipgloss.NewStyle().Foreground(Magenta).Bold(true) + Success = lipgloss.NewStyle().Foreground(Acid).Bold(true) + Warning = lipgloss.NewStyle().Foreground(Amber).Bold(true) + Danger = lipgloss.NewStyle().Foreground(Crimson).Bold(true) + Key = lipgloss.NewStyle().Foreground(Ink).Background(Cyan).Bold(true).Padding(0, 1) + KeyLabel = lipgloss.NewStyle().Foreground(Steel) + Badge = lipgloss.NewStyle().Foreground(Ink).Background(Magenta).Bold(true).Padding(0, 1) + Chip = lipgloss.NewStyle().Foreground(Steel).Background(Night).Padding(0, 1) + Selected = lipgloss.NewStyle().Foreground(Snow).Background(Night).Bold(true) + Panel = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(Violet).Padding(0, 2) + Code = lipgloss.NewStyle().Foreground(Amber) +) + +// Gradient colors s rune-by-rune across the given color stops (default +// Cyan→Magenta). Spaces are left uncolored so tracked-out titles stay crisp. +func Gradient(s string, stops ...color.Color) string { + if len(stops) == 0 { + stops = []color.Color{Cyan, Magenta} + } + runes := []rune(s) + if len(runes) == 0 { + return s + } + colors := lipgloss.Blend1D(len(runes), stops...) + var b strings.Builder + for i, r := range runes { + if r == ' ' { + b.WriteRune(r) + continue + } + c := stops[len(stops)-1] + if i < len(colors) { + c = colors[i] + } + b.WriteString(lipgloss.NewStyle().Foreground(c).Bold(true).Render(string(r))) + } + return b.String() +} + +// Tracked spaces letters out ("B O O T S T R A P") and widens word gaps so +// uppercase headings read as display type rather than shouting. +func Tracked(s string) string { + words := strings.Fields(s) + for i, w := range words { + letters := strings.Split(w, "") + words[i] = strings.Join(letters, " ") + } + return strings.Join(words, " ") +} + +// Title renders the canonical heading treatment: uppercase, tracked, gradient. +func Title(s string) string { + return Gradient(Tracked(strings.ToUpper(s))) +} + +// Header renders a utility's masthead: chevron + title, optional subtitle. +func Header(title, subtitle string) string { + lines := []string{Accent.Render(GlyphChevron) + " " + Title(title)} + if subtitle != "" { + lines = append(lines, Dim.Render(subtitle)) + } + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +// Divider renders a muted horizontal rule of the given width. +func Divider(width int) string { + if width < 0 { + width = 0 + } + return Muted.Render(strings.Repeat("─", width)) +} + +// KeyHint renders a footer key binding, e.g. [enter] fix. +func KeyHint(key, label string) string { + return Key.Render(key) + " " + KeyLabel.Render(label) +} + +// Footer joins key hints with consistent spacing. +func Footer(hints ...string) string { + return strings.Join(hints, " ") +} + +// StatusGlyph returns the colored glyph for a generic tri-state plus extras. +// Utilities with richer state machines should map onto these so the suite +// reads the same everywhere. +func StatusGlyph(kind string) string { + switch kind { + case "ready": + return Success.Render(GlyphReady) + case "needed": + return Danger.Render(GlyphNeeded) + case "optional": + return Warning.Render(GlyphOptional) + case "skipped": + return Muted.Render(GlyphSkipped) + case "error": + return Danger.Render(GlyphError) + default: + return Warning.Render(GlyphUnknown) + } +} diff --git a/utilities/mise.toml b/utilities/mise.toml new file mode 100644 index 0000000..4a51a35 --- /dev/null +++ b/utilities/mise.toml @@ -0,0 +1,21 @@ +# Developer tasks for the utilities module. Tools (Go etc.) come from the +# system config baked into the devcontainer; this file only adds tasks. + +[tasks.build] +description = "Build every utility into ./bin" +run = "go build -trimpath -o bin/ ./cmd/..." + +[tasks.test] +description = "Run unit tests" +run = "go test ./..." + +[tasks.lint] +description = "gofmt + go vet" +run = [ + 'test -z "$(gofmt -l .)" || { echo "gofmt needed:"; gofmt -l .; exit 1; }', + "go vet ./...", +] + +[tasks.run] +description = "Run the bootstrap TUI from source" +run = "go run ./cmd/bootstrap"