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"