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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .cursor/rules/shadcn-ui.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
description: Deckhand UI must use shadcn/ui Maia + Tailwind only
globs: src/**/*.{tsx,ts,css}
alwaysApply: false
---

# shadcn Maia UI only

Deckhand’s React UI uses **shadcn/ui** with the **Maia** preset (`base-maia`, preset `bbVJxYW`) and **Tailwind CSS v4**.

Icons use **[Lucide React](https://lucide.dev/guide/react/)** (`lucide-react`) via `lucideProps()` / `Icon` in `src/components/Icon.tsx`.

## Required

- Primitives from `@/components/ui/*` (generated by shadcn CLI)
- Layout/tokens via Tailwind utility classes and CSS variables from `src/index.css`
- Root: `AppProvider` (theme + `TooltipProvider`), Sonner toasts via `@/lib/toast`
- Button events: standard `onClick` on shadcn/Radix components
- `cn()` from `@/lib/utils` for conditional classes

## Do not add

- Adobe React Spectrum (`@react-spectrum/s2`, `@adobe/react-spectrum`), Spectrum Charts, or `style()` macros
- Parallel component kits (MUI, Chakra, Ant Design) alongside shadcn
- Hand-rolled Radix wrappers that duplicate `@/components/ui`

## App helpers

Prefer shadcn primitives and small app helpers (`Tip`, `StatusBadge`, `RowMenu`, `Field`, `MoreActionsMenu`, `SegmentedControl`) under `src/components/` — do not reintroduce Spectrum providers or macros.

## Custom widgets

When shadcn has no component (e.g. xterm, custom charts), style chrome with Tailwind tokens. Do not reintroduce a second design system.
33 changes: 0 additions & 33 deletions .cursor/rules/spectrum-ui.mdc

This file was deleted.

5 changes: 5 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Mechanical reformats — skip in blame.
# Enable with: git config blame.ignoreRevsFile .git-blame-ignore-revs

# Apply Biome formatting across the TypeScript sources
ea6d2d05947e562f45cf62c954035165d1f84ba1
29 changes: 25 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,33 @@ jobs:
with:
go-version-file: sidecar/go.mod
cache-dependency-path: sidecar/go.sum
- name: Build sidecar
- name: Format check
working-directory: sidecar
run: |
# gofmt -l exits 0 even when it lists files, so check explicitly.
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "Not gofmt-formatted:"
echo "$unformatted"
echo "Fix with: gofmt -w sidecar/"
exit 1
fi
- name: Build all packages
working-directory: sidecar
run: go build ./...
- name: Vet
working-directory: sidecar
run: go vet ./...
- name: Test
working-directory: sidecar
run: go test ./...
- name: Build sidecar binary
run: bash scripts/build-sidecar.sh
- name: Upload sidecar artifact
uses: actions/upload-artifact@v7
with:
name: deckhand-sidecar-${{ matrix.os }}
path: tauri/src-tauri/binaries/deckhand-sidecar*
path: src-tauri/binaries/deckhand-sidecar*

ui:
runs-on: ubuntu-latest
Expand All @@ -34,8 +54,9 @@ jobs:
bun-version: latest
- run: bun install
- run: bun run version:check
- run: bun run --filter @deckhand/app build
- run: bun run --filter @deckhand/tauri build
- name: Lint and format check
run: bunx biome ci .
- run: bun run build

# Installer packaging: .github/workflows/release.yml (on v* tags).
# Local: bun run tauri:build → .dmg/.app (macOS) or .deb/AppImage (Linux).
3 changes: 1 addition & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ jobs:

- uses: swatinem/rust-cache@v2
with:
workspaces: tauri/src-tauri -> target
workspaces: src-tauri -> target

- name: Install JS dependencies
run: bun install
Expand All @@ -101,7 +101,6 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: tauri
tagName: ${{ steps.tag.outputs.name }}
releaseName: Deckhand ${{ steps.tag.outputs.name }}
releaseBody: |
Expand Down
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ target/
.env
.env.*
sidecar/deckhand-sidecar
tauri/src-tauri/binaries/
tauri/src-tauri/target/
bin/
src-tauri/binaries/
src-tauri/target/
*.dmg
*.deb
*.AppImage
.idea/
.vscode/
coverage/
releases/
*.exe
144 changes: 144 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
bun install
bun run dev # build sidecar → Tauri dev (Vite on :1420)
bun run dev:ui # Vite UI only; pair with VITE_SIDECAR_URL
bun run dev:sidecar # go run the sidecar on 127.0.0.1:7420
bun run build # tsc --noEmit && vite build (the UI typecheck gate)
bun run build:sidecar # scripts/build-sidecar.sh → src-tauri/binaries/
bun run version 0.1.0-x # sync package.json + Cargo.toml
bun run version:check # fail if Cargo.toml ≠ root package.json version
bun run tauri:build # sidecar + installers (.dmg/.app, .deb/AppImage)

cd sidecar && go build ./... && go vet ./... # sidecar checks
```

There are **no tests yet** — 32 Go files, zero `*_test.go`, and no JS test runner. Verification
is the build: `bun run build` for the UI, `go build ./... && go vet ./...` for the sidecar. CI
(`.github/workflows/ci.yml`) runs exactly those plus `go test ./...` and `bun run version:check`.
CI also gates `gofmt` on `sidecar/` — fix a failure with `gofmt -w sidecar/`. There is no
formatter or linter configured for the TypeScript side; `tsc --noEmit` is the only gate there.

xterm.js is code-split behind `ExecTerminalLazy` (~85 KB gzipped, the largest single dependency).
Import that wrapper rather than `ExecTerminal` directly, or the terminal lands back in the initial
bundle.

The repo is a **single Bun package at the root** — no workspaces, one `package.json`, one
`vite.config.ts`, one `tsconfig.json`, one `index.html`. `sidecar/` is a separate Go module.
Do not reintroduce a second Vite app or a workspace member for the frontend.

Split-process dev (UI iteration without rebuilding Tauri) — stop any Tauri Vite on `:1420` first,
since `strictPort` is on:

```bash
./src-tauri/binaries/deckhand-sidecar --addr 127.0.0.1:7420
VITE_SIDECAR_URL=http://127.0.0.1:7420 bun run dev:ui
```

## Architecture

Three processes, one direction of travel:

```
React UI (src/) ──HTTP+WS localhost──► Go sidecar (sidecar/) ──► Docker Engine API
├──► kubeconfig (client-go)
├──► helm CLI
Tauri 2 (src-tauri/) spawns/kills ──────┴──► Firecracker (Linux + KVM)
```

The UI **never** talks to Docker or Kubernetes directly, and the sidecar has no UI knowledge —
every feature crosses both boundaries. Adding one typically means editing four places:

1. `sidecar/internal/<domain>/` — the capability (docker, k8s, helm, compose, domains, runtime, engine)
2. `sidecar/internal/server/server.go` `routes()` — one `mux.HandleFunc("VERB /api/...")` line
(Go 1.22 method+wildcard patterns; handler bodies live in `server.go` or `handlers_ext.go`)
3. `src/lib/api.ts` — a method on the single `api` object (the only place `fetch` base URL lives)
4. `src/routes/*.tsx` — a React Query `useQuery`/`useMutation`

Keep `docs/api.md` in step when routes change; it is the hand-maintained mirror of `routes()`.

### Sidecar boundaries

- `sidecar/` is a separate Go module, outside the JS package. It builds with Go and is copied
into `src-tauri/binaries/`, so Tauri runs a *stale* sidecar until `build:sidecar` reruns.
- Two binaries share the internals: `cmd/deckhand-sidecar` (HTTP daemon) and `cmd/deckhand` (CLI).
- Streaming is plain HTTP, not a framework: logs/build are text streams, image pull and container
stats are NDJSON, Docker events are SSE, TTYs are WebSockets (`tty_ws.go`).
- Docker, Kubernetes, and Firecracker report availability **independently** via `GET /api/status`;
partial connectivity is normal and the UI must degrade per-subsystem, not fail whole-app.
- Firecracker uses build-tag providers: `runtime/firecracker_linux.go` vs `runtime/nop.go`.
Anything platform-specific belongs behind `runtime.Provider`, not in a handler.

### Auth (do not regress this)

Every sidecar request must carry a per-launch token — `Authorization: Bearer <t>` or `?token=<t>`.
The query form is not laziness: `EventSource`, `WebSocket`, and `window.open` cannot set headers,
and the events, logs, exec, stats, and volume-export endpoints are driven through exactly those.
In `src/lib/api.ts`, `request()` uses the header and every `*Url()` helper goes through
`tokenized()`; a new streaming endpoint must use one or the other or it will 401.

CORS mirrors auth rather than an origin allowlist — the request's `Origin` is echoed only after the
token is accepted. That is deliberate: the Tauri webview's origin differs per platform
(`tauri://localhost` on macOS, `http://tauri.localhost` elsewhere), so an allowlist risks breaking
a platform that cannot be tested locally, while a caller holding the token is authorized anyway.
Preflight is answered before the token check because a preflight never carries the header.

This replaced `Access-Control-Allow-Origin: *` with no auth at all, under which any page the user
visited could `POST /api/docker/containers` with a bind mount of `/` and take the host.

### Sidecar-URL resolution (the fragile part)

Tauri spawns the sidecar, prefers `127.0.0.1:7420`, and falls back to an ephemeral port. It reuses a sidecar already holding that port **only** when `DECKHAND_SIDECAR_TOKEN` is set, since it never sees the stdout of one it did not spawn: without a token, attaching to a current sidecar 401s every request, and attaching to one predating auth (an older installed build, or any local process squatting the port) would run the app unauthenticated against a server it did not start. Otherwise it starts its own on an ephemeral port and logs why.

The child
prints `DECKHAND_SIDECAR_ADDR=host:port` on stdout, which Tauri exposes via the `sidecar_url`
command, alongside `sidecar_token`. `src/App.tsx` polls `invoke("sidecar_url")` (up to 40×250ms),
calls `setApiBaseUrl` + `setApiToken`, then polls `/health` before rendering — and **re-invokes**
both periodically while failing, so a restarted sidecar on a new port recovers. The token must be
refreshed with the URL: a restarted sidecar mints a new one, so refreshing only the URL reconnects
to an endpoint that then 401s everything. In a plain browser it uses `VITE_SIDECAR_URL` or
`:7420`. Never hardcode a base URL outside `src/lib/api.ts`.

### UI shell

`vite.config.ts` aliases `@` → `src/` and serves on `:1420` with `strictPort`, so a second Vite
fails loudly instead of drifting to another port; `tauri dev` starts it via `beforeDevCommand`.
Routing is code-defined in `src/router.tsx` (TanStack Router, flat routes under one
`RootLayout`), not file-based. Server state is React Query with `refetchInterval` polling
(~4–8s per list); client/persisted state is `src/stores/uiStore.ts` (Zustand + `persist`) —
including the three-way `mode` (`docker` | `kubernetes` | `microvms`) that drives the sidebar, and
user prefs like `confirmPrune`.

## Conventions

- **UI kit: shadcn/ui with the Maia preset (`base-maia`, `bbVJxYW`) + Tailwind v4, exclusively.**
Primitives in `src/components/ui/` (shadcn CLI-generated), tokens in `src/index.css`,
`cn()` from `@/lib/utils`. Do not add Adobe React Spectrum, Spectrum Charts, `style()` macros,
MUI/Chakra/Ant, or hand-rolled Radix wrappers duplicating `@/components/ui`. The repo was
migrated off Spectrum; reintroducing it undoes that. See `.cursor/rules/shadcn-ui.mdc`.
- Icons: `lucide-react` via `Icon` / `lucideProps()` in `src/components/Icon.tsx` (S/M/L size
tokens, stroke 1.75) — not raw `<LucideIcon size={…}>`.
- Page composition: `PageShell` (title/description/actions) wrapping `ListPane*` + `DetailPane`
parts, with the app helpers `Tip`, `StatusBadge`, `RowMenu`, `Field`, `MoreActionsMenu`,
`SegmentedControl`. Custom widgets with no shadcn equivalent (xterm, Vega charts) get Tailwind
token chrome rather than a second design system.
- Toasts go through `@/lib/toast` (Sonner), not `sonner` directly.
- Title-bar drag: use `useWindowDragProps()` and mark interactive children `data-no-drag`.
- Mutating sidecar operations log to the local JSONL audit log (`~/.deckhand/audit.jsonl`) via
`s.audit.Log(action, target, detail, err)` — follow that for new destructive/stateful handlers.
- Kubernetes Secret values are redacted server-side (`k8s/resources.go` `SecretSummary`); never
return raw secret data to the UI.
- Destructive actions (prune, remove) confirm in the UI by default, gated on `confirmPrune`.
- Versions: `package.json` is the source of truth; `src-tauri/Cargo.toml` must match, and
release tags `v<version>` must match it too. Use `bun run version <v>` rather than editing both.

## Platform notes

macOS and Linux only (Windows packaging deferred). Firecracker/MicroVMs are Linux + `/dev/kvm` +
`firecracker` on PATH; on macOS the `nop` provider reports unavailable and the sidebar entry hides.
macOS release builds are **ad-hoc signed, not notarized**.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Local-first Docker and Kubernetes desktop app for **macOS** and **Linux**.

Tauri 2 shell · Adobe Spectrum UI · Go sidecar for Docker, Compose, Kubernetes, Helm, and Linux Firecracker microVMs.
Tauri 2 shell · shadcn/ui (Maia) · Go sidecar for Docker, Compose, Kubernetes, Helm, and Linux Firecracker microVMs.

## Features

Expand Down Expand Up @@ -47,7 +47,7 @@ xattr -cr /Applications/Deckhand.app
## Architecture (sketch)

```
UI (Spectrum / React) → HTTP localhost → Go sidecar → Docker / kubeconfig / Helm / Firecracker
UI (shadcn / React) → HTTP localhost → Go sidecar → Docker / kubeconfig / Helm / Firecracker
↑
Tauri 2 (spawn + tray)
```
Expand Down
40 changes: 0 additions & 40 deletions app/package.json

This file was deleted.

Binary file removed app/public/favicon.png
Binary file not shown.
Loading