Skip to content

Latest commit

 

History

History
101 lines (73 loc) · 7.8 KB

File metadata and controls

101 lines (73 loc) · 7.8 KB

CLAUDE.md

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

What this is

dox is a self-hosted single-user / small-team todo app: a Go server binary that holds all state, and a thin TypeScript CLI/TUI client that talks to it over HTTP/JSON (no local cache). Whoever registers first becomes the owner; everyone else gets in via invite.

Commands

All routine work goes through justfile recipes.

just                    # list all recipes
just gen                # proto → Go + TS + OpenAPI, sqlc → Go DB bindings
just check              # full lint/format/typecheck pipeline (see scripts/check.sh)
just test               # Go (cd apps/server && go test ./...) + Bun (bun test)
just serve              # run server with version/commit injected via -ldflags
just cli -- <args>      # run CLI against local server, e.g. `just cli -- list`
just server-build       # build apps/server/bin/dox-server
just docker-build       # local image build, tags `dox:local` (mirrors CI)
just release vX.Y.Z     # tag main + push → triggers Docker Hub release

Single tests:

cd apps/server && go test ./internal/handler -run TestCreateTodo
bun test apps/cli/src/tui/App.test.tsx

Always run just gen after editing anything under proto/ or apps/server/internal/db/{migrations,queries}/*.sql. Generated output lives in apps/server/gen/ and packages/proto-gen/src/ and is gitignored — never hand-edit it.

Architecture

Top-level layout

proto/dox/v1/                    contract: auth, user, project, invite, todo, event
apps/server/                     Go binary (cmd/dox-server, internal/*)
apps/cli/                        TS thin client — CLI (commander) + TUI (Ink/React)
packages/core/                   shared TS: http client, config, output, per-resource SDKs
packages/proto-gen/              generated TS message types (buf out)
docker/, .github/workflows/      release.yml → Docker Hub + draft GH release
docs/onboarding.md               canonical reference for the auth/onboarding state machine

Server (apps/server/internal/)

Composition root is app.Run (apps/server/internal/app/app.go):

  1. db.Open(cfg.DBPath) opens SQLite and runs goose migrations from internal/db/migrations.
  2. queries.New(db) produces the sqlc-generated query handle.
  3. Handlers are constructed and registered onto a single runtime.NewServeMux() (grpc-gateway). The mux is wrapped in the authn middleware. There is no native gRPC listener — only HTTP/JSON via grpc-gateway. Clients hit /v1/... with fetch.
  4. A background event-retention sweeper runs alongside the HTTP server, bounded to Run's context so shutdown waits for in-flight DELETEs before closing the DB.

Package responsibilities (do not invent new ones — see dox-server-layout memory):

  • caller/ — Caller{UserID, UserName, Role} carried through context.Context. Middleware writes, handlers read via MustFrom.
  • authn/ — JWT verification + middleware. publicPaths is the only allowlist: /v1/auth/server-info, /v1/auth/register, /v1/auth/login. Everything else requires a bearer token.
  • authz/ — three project-scoped predicates: CanReadProject, CanWriteProjectTodos, CanAdminProject. Status-code policy is explicit: reads on invisible objects return NotFound (no existence leak); writes by visible-but-unprivileged callers return PermissionDenied.
  • handler/ — RPC handlers. One file per service. Mutations that need to publish bus messages atomically wrap the write + bus.Publish in runInTx (handler/events.go) so a subscriber failure rolls the mutation back.
  • bus/ — in-process synchronous pub/sub. Mutation handlers publish typed Msg values; the default ActivityRecorder subscriber persists them as events rows. To add a new activity verb: declare a struct + msg() method in bus/bus.go, add a recorder case, publish from the handler. Adding a new subscriber (webhook/audit) means passing it to bus.New(...) in app.go — handler code is untouched.
  • app/ — wiring + the retention sweep loop in cleanup.go.
  • config/ — env-driven (DOX_DB_PATH, DOX_LISTEN_ADDR, DOX_LOG_LEVEL, DOX_EVENT_RETENTION).
  • version/ — version, commit, date, builtBy are injected via -ldflags (see justfile / release.yml).

Database

SQLite, via sqlc, with goose-style migrations.

  • Schema: internal/db/migrations/*.sql
  • Hand-written queries: internal/db/queries/*.sql
  • Generated Go: internal/db/queries/*.sql.go + models.go (regenerated by just gen)
  • Column overrides live in apps/server/sqlc.yaml (e.g. todos.done and projects.archived map to Go bool since SQLite has no native bool)

IDs are ULIDs (oklog/ulid/v2). Timestamps are int64 unix milliseconds. Inbox = project_id IS NULL.

Client (apps/cli/, packages/core/)

apps/cli/src/index.ts is the entry: if invoked on a TTY with no subcommand, launches the Ink TUI; otherwise dispatches to commander subcommands. CLI commands and TUI components both call into packages/core, which holds the typed HTTP client, the ~/.config/dox/config.toml reader/writer, and resource SDKs (auth/, todo/, project/, invite/, event/, user/).

Onboarding is non-obvious: read docs/onboarding.md before touching anything under apps/cli/src/tui/components/Onboarding.tsx or the auth handlers. The state machine, why the branch picker is always shown, and the reauth path are all documented there.

Constraints worth remembering

  • RPC is grpc-gateway + HTTP/JSON. Do not propose Connect-RPC (see dox-rpc-decision memory).
  • Generated code is gitignored. If a apps/server/gen/... or packages/proto-gen/src/dox/... import looks missing, run just gen — don't try to commit the generated files or edit them.
  • Thin client. The CLI/TUI does not cache server state; every action hits the server. Don't add a local cache layer.
  • Auth public-path allowlist is the security boundary. Adding a new public endpoint means editing publicPaths in authn/middleware.go deliberately — anything not in that map requires a JWT.
  • Status codes carry meaning (authz/authz.go docstring): NotFound vs PermissionDenied is a deliberate existence-leak control, not interchangeable.
  • Bus messages and DB mutations must be tx-bound — call bus.Publish from inside runInTx with the tx-bound *queries.Queries, never from outside the transaction.
  • emit_empty_slices: true is set in sqlc.yaml — sqlc list results are non-nil empty slices, not nil. Handlers and tests rely on this.
  • Releases ship server and CLI from a single tag. just release vX.Y.Z pushes the tag and CI does the rest in parallel jobs: (1) Linux amd64/arm64 server binaries → tarballs on a draft GH Release, (2) multi-arch Docker image → Docker Hub, (3) apps/cli bundled via bun build --target=node → npm publish as @l1nsn0w/dox (see scripts/build-cli.ts and the publish-npm job in release.yml). The CLI version is injected at compile time via --define from apps/cli/src/version.ts, so dox --version reports the tag.

Notes for changes

  • Adding a new RPC: edit the relevant proto/dox/v1/*.proto, run just gen, implement the handler in apps/server/internal/handler/, decide if it's public (add to publicPaths) or auth-required, then wire the client SDK in packages/core/src/<resource>/.
  • Adding a new schema change: write a new migration in internal/db/migrations/, add queries in internal/db/queries/*.sql, run just gen. Migrations run automatically on db.Open.
  • Adding a new activity verb: see the bus/ description above — three small edits, no handler changes elsewhere.
  • Before opening a PR, run just check && just test. just check runs proto-lint, gofmt, go vet, golangci-lint, prettier, eslint, and tsc in one pass and prints a summary table.