Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
d1803eb
feat: add agents configuration
dvrd Mar 27, 2026
4eb161d
fix(ssh): validate access tokens in keyboard-interactive auth (#800)
dvrd Mar 27, 2026
6eaa578
Merge pull request #1 from dvrd/fix/ss-800
dvrd Mar 27, 2026
a4d3f65
fix(ssh): accept split key args in user create -k (#750)
dvrd Mar 27, 2026
faaa55b
feat(backend): set gitweb.owner on repo create (#753)
dvrd Mar 27, 2026
4a53343
feat(ui): add 'c' shortcut to copy clone command in repo view (#769)
dvrd Mar 27, 2026
e3ce7b0
Merge pull request #2 from dvrd/fix/ss-750
dvrd Mar 27, 2026
797ea51
Merge pull request #3 from dvrd/fix/ss-753
dvrd Mar 27, 2026
c2b638d
Merge pull request #4 from dvrd/feat/ss-769
dvrd Mar 27, 2026
1ec1950
fix(ssh): merge trailing args into key unconditionally
dvrd Mar 27, 2026
2753b71
fix(ui): prevent double-copy and empty clipboard on 'c' key
dvrd Mar 27, 2026
8cdd0a7
fix(ssh): improve token auth logging and remove redundant DB lookup
dvrd Mar 27, 2026
6562765
fix(ssh): reject extra args when -k is not provided
dvrd Mar 27, 2026
e1c7666
fix(ui): rename copy variable to avoid shadowing builtin
dvrd Mar 27, 2026
0a34735
fix(ui): allow token-auth users in TUI when AllowKeyless is false
dvrd Mar 27, 2026
eb6e99e
refactor(ssh): remove dead code and unused parameters
dvrd Mar 27, 2026
f98d1cc
fix(ssh): persist permissions in initializePermissions
dvrd Mar 28, 2026
f02d5fc
fix(ssh): store user ID instead of username in token auth extensions
dvrd Mar 28, 2026
c836512
test(ssh): tighten assertions and add extra-args rejection test
dvrd Mar 28, 2026
c44e2cb
Merge pull request #5 from dvrd/fix/ss-800
dvrd Mar 28, 2026
7322872
Merge pull request #6 from dvrd/fix/ss-750
dvrd Mar 28, 2026
4d09f44
Merge pull request #7 from dvrd/feat/ss-769
dvrd Mar 28, 2026
c9e2c5d
fix(web): return io.ErrShortWrite and handle partial Read in ReadFrom
dvrd Mar 28, 2026
5c430e6
fix(server): pre-bind listeners so port permission errors surface imm…
dvrd Mar 28, 2026
fdc7f1e
fix(ui): use path instead of filepath for git tree paths on Windows
dvrd Mar 28, 2026
d439099
feat(config): add anon_access and allow_keyless config fields
dvrd Mar 28, 2026
6c56b32
Merge pull request #11 from dvrd/feat/ss-758
dvrd Mar 28, 2026
a71174c
Merge pull request #8 from dvrd/fix/ss-616
dvrd Mar 28, 2026
d4795ea
Merge pull request #10 from dvrd/fix/ss-681
dvrd Mar 28, 2026
4f03544
Merge pull request #9 from dvrd/fix/ss-645
dvrd Mar 28, 2026
f8b73a1
fix(ui): stop loading spinner when readme tab opens on empty repo
dvrd Mar 28, 2026
843b6da
fix(ui): pressing copy key no longer duplicates items in filtered list
dvrd Mar 28, 2026
bdced3a
fix(git): treat signal-killed git processes as client disconnects
dvrd Mar 28, 2026
3275323
fix(test): use single-word repo name in txtar test
dvrd Mar 28, 2026
85d0b64
fix(backend): find README in docs/, .github/, .gitlab/ subdirectories
dvrd Mar 28, 2026
954ff50
fix(ssh): use errors.Is for ErrReferenceNotExist sentinel comparison
dvrd Mar 28, 2026
8d328b3
fix(git): only suppress signal-killed errors when context is cancelled
dvrd Mar 28, 2026
6df9001
fix(backend): return nil error from Readme when no README found
dvrd Mar 28, 2026
bcdf335
fix(ui): persist copied feedback for 1.5s using timer reset message
dvrd Mar 28, 2026
130eab5
fix(git): narrow signal-kill suppression to context.Canceled only
dvrd Mar 28, 2026
61c9e10
fix(ui): use generation counter to prevent stale copied-indicator reset
dvrd Mar 28, 2026
c536801
fix(ui): store GlobalIndex in copiedIdx so Render highlights correct …
dvrd Mar 28, 2026
3a7f8a2
fix(ui): track copied item by identity not index to fix pagination
dvrd Mar 28, 2026
fd90b7e
fix(ui): restore generation counter to prevent premature copied-state…
dvrd Mar 28, 2026
2b23308
fix(git): restore error format order — wrap ExitError, append stderr …
dvrd Mar 28, 2026
6809665
fix(backend): handle ErrReferenceNotExist in README search loop
dvrd Mar 28, 2026
02d28da
fix(git): log suppressed signal-kill at debug level instead of silent…
dvrd Mar 28, 2026
d67403c
fix(ui): remove stale SetItem call — render now uses item identity no…
dvrd Mar 28, 2026
0b5f3b7
fix(backend): tighten README glob to avoid matching READMEFILE and si…
dvrd Mar 28, 2026
37193a6
fix(git): verify and document WaitDelay/ExitError interaction on canc…
dvrd Mar 28, 2026
dbfe3ad
fix(git): narrow WaitDelay fallback to exec.ErrWaitDelay specifically
dvrd Mar 28, 2026
831ae91
fix(git): use forward slashes in LatestFile path matching for Windows…
dvrd Mar 28, 2026
79cbb01
fix(git): use path.Dir instead of filepath.Dir in LatestFile for Wind…
dvrd Mar 28, 2026
f3eb83f
fix(git): check erro not err in stderr copy goroutine
dvrd Mar 28, 2026
4736251
fix(ui): propagate backend.Readme error instead of silently discarding
dvrd Mar 28, 2026
5d8d087
fix(git): make context-cancel suppression portable across Windows/Unix
dvrd Mar 28, 2026
c99245e
chore(git): remove dead os.ErrNotExist branch after cmd.Wait()
dvrd Mar 28, 2026
7ee638c
Merge pull request #12 from dvrd/fix/ss-522
dvrd Mar 28, 2026
ad03df5
Merge pull request #13 from dvrd/fix/ss-510
dvrd Mar 28, 2026
8fe4da1
Merge pull request #14 from dvrd/fix/ss-560
dvrd Mar 28, 2026
51d6d84
Merge pull request #15 from dvrd/fix/ss-634
dvrd Mar 28, 2026
35defc7
fix(ssh): user create -k correctly handles space-separated public key…
dvrd Mar 28, 2026
f37f6e8
fix(git): prevent zombie processes on context-cancelled git commands …
dvrd Mar 28, 2026
03abe8e
fix(ssh): create host key directory before generating key (#18)
dvrd Mar 28, 2026
c3c6c32
fix(server): pre-bind listeners; idempotent Shutdown; TLS guard (#645…
dvrd Mar 28, 2026
8b876c1
feat(web): add GET /{repo}/raw/{ref}/{filepath} raw blob endpoint (#4…
dvrd Mar 28, 2026
011b073
fix(mirror): disable gc.auto and no-timeout to prevent CPU peg on lar…
dvrd Mar 28, 2026
186e193
fix(ssh): validate access tokens in keyboard-interactive auth (#22)
dvrd Mar 28, 2026
0a6948c
fix(lfs): encode expires_in as seconds, not nanoseconds (#23)
dvrd Mar 28, 2026
fb91ef4
fix(web): return io.ErrShortWrite and handle EOF-with-data in ReadFro…
dvrd Mar 28, 2026
cb463f8
fix(cmd): user create -k accepts multi-word key from unquoted SSH arg…
dvrd Mar 28, 2026
6c584fd
fix(ssh): downgrade spurious signal-killed log to debug on client dis…
dvrd Mar 28, 2026
409092c
chore: merge dev into main
dvrd Mar 28, 2026
2fa63f4
Merge pull request #29 from dvrd/merge-dev-to-main
dvrd Mar 28, 2026
1099bbb
fix(lfs): fix open file handle blocking rename and concurrent upload …
dvrd Mar 28, 2026
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
235 changes: 235 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
# AGENTS.md — Soft Serve

> AI agent guide for [Soft Serve](https://github.com/charmbracelet/soft-serve), a self-hosted Git server with SSH, HTTP, and native git protocol support, plus a built-in TUI.

## Project Identity

- **Module:** `github.com/charmbracelet/soft-serve`
- **Language:** Go 1.25+
- **Binary:** `soft` (entry point: `cmd/soft/main.go`)
- **Database:** SQLite (default) or PostgreSQL, via `jmoiron/sqlx`
- **CLI framework:** `spf13/cobra` (used for both the main CLI and SSH subcommands)
- **SSH framework:** `charm.land/wish/v2`
- **TUI framework:** `charm.land/bubbletea/v2` + `lipgloss/v2` + `glamour/v2`
- **HTTP router:** `gorilla/mux`
- **Config:** YAML file (`$SOFT_SERVE_DATA_PATH/config.yaml`) + env vars (`SOFT_SERVE_*` prefix)

## Architecture Overview

Soft Serve runs **four concurrent servers** via `errgroup`:

| Server | Package | Protocol | Purpose |
|--------|---------|----------|---------|
| SSH | `pkg/ssh/` | SSH | Git push/pull, interactive TUI, admin CLI commands |
| HTTP | `pkg/web/` | HTTP/HTTPS | Git smart HTTP, LFS API, go-get meta, health check |
| Git Daemon | `pkg/daemon/` | Native git (TCP) | Read-only anonymous git access |
| Stats | `pkg/stats/` | HTTP | Prometheus `/metrics` endpoint |

### Layered Design

```
cmd/soft/ → CLI entry points (serve, browse, admin, hook)
pkg/ssh/ → SSH server + middleware + command dispatch
pkg/web/ → HTTP server + middleware + routes
pkg/daemon/ → Git daemon (raw TCP, pktline)
pkg/backend/ → Central business logic orchestrator
pkg/store/ → Data store interface (7 sub-interfaces)
pkg/store/database/ → SQL implementation of Store
pkg/db/ → Database layer (open, migrate, models)
pkg/proto/ → Core domain interfaces (User, Repository)
pkg/access/ → Access level enum (NoAccess → Admin)
pkg/git/ → Git service handlers (upload-pack, receive-pack)
pkg/config/ → Configuration parsing (YAML + env)
pkg/ui/ → TUI components (Bubble Tea models)
```

### Canonical Data Flow

```
Transport layer (SSH/HTTP/daemon)
→ Middleware (context injection, auth, logging)
→ Command/Route dispatch
→ Backend (business logic)
→ Store (data access)
→ Database (SQLite/PostgreSQL)
```

## Directory Structure

```
cmd/
soft/main.go — Binary entry point
soft/serve/ — `soft serve` command (starts all servers)
soft/admin/ — `soft admin` SSH admin commands
soft/browse/ — `soft browse` TUI browser
soft/hook/ — `soft hook` git hook handler
cmd.go — Shared InitBackendContext / CloseDBContext helpers
git/ — Low-level git operations (repo, commit, tree, tag, refs)
pkg/
access/ — AccessLevel enum (NoAccess, ReadOnly, ReadWrite, Admin)
backend/ — Central Backend struct: users, repos, auth, LFS, webhooks, cache
config/ — Config struct (YAML + env, SOFT_SERVE_* prefix)
cron/ — Cron scheduler wrapper (robfig/cron)
daemon/ — Git daemon server (TCP, native git protocol)
db/ — Database abstraction (open, migrations, models)
migrate/ — SQL migration files (SQLite + PostgreSQL variants)
models/ — DB model structs
git/ — Git service handlers (upload-pack, receive-pack, LFS transfer)
hooks/ — Git hook generation and interface
jobs/ — Cron job definitions (mirror pull)
jwk/ — JSON Web Key support
lfs/ — Git LFS protocol (client, pointers, scanner, transfers)
log/ — Logger setup
proto/ — Core interfaces (Repository, User, AccessToken, errors)
ssh/ — SSH server, middleware, session handler
cmd/ — 27 SSH subcommands (git, repo, user, token, webhook, settings)
sshutils/ — SSH utility functions
ssrf/ — SSRF protection for outbound requests
stats/ — Prometheus metrics server
storage/ — Storage abstraction (file storage for LFS)
store/ — Store interface (composite of 7 sub-interfaces)
database/ — SQL-backed Store implementation
sync/ — Sync utilities
task/ — Async task manager
ui/ — Bubble Tea TUI components
common/ — Shared TUI state/styles
components/ — Reusable widgets (code, footer, header, selector, statusbar, tabs, viewport)
pages/ — TUI pages (repo list, repo detail)
styles/ — TUI styling
utils/ — General utilities (SanitizeRepo, ValidateRepo)
version/ — Version info
web/ — HTTP server (git smart HTTP, LFS API, auth, health)
webhook/ — Webhook event system (push, branch/tag, repo, collaborator)
testscript/ — Integration tests (testscript framework)
migrations/ — Goose SQL migrations
```

## Key Patterns

### Context-Based Dependency Injection

All major dependencies are threaded through `context.Context` with typed keys:

```go
// Injecting
ctx = config.WithContext(ctx, cfg)
ctx = backend.WithContext(ctx, be)

// Extracting
cfg := config.FromContext(ctx)
be := backend.FromContext(ctx)
```

Shared bootstrap in `cmd/cmd.go:InitBackendContext` (used as Cobra `PersistentPreRunE`).

### Backend as Central Orchestrator

`pkg/backend/backend.go:Backend` is the single entry point for all business logic. It wraps the Store and provides methods for repos, users, auth, LFS, webhooks, settings, and caching. Handlers and commands call Backend methods — never the Store directly.

### Store Interface (Composite)

`pkg/store/store.go:Store` aggregates 7 sub-interfaces:

- `RepositoryStore` — repo CRUD
- `UserStore` — user CRUD
- `CollaboratorStore` — repo collaborators
- `SettingStore` — server settings
- `LFSStore` — LFS objects/locks
- `AccessTokenStore` — API tokens
- `WebhookStore` — webhook management

Single SQL implementation in `pkg/store/database/`.

### Authentication

**SSH:** Public key verification → `be.UserByPublicKey()`. Keyboard-interactive as keyless fallback.

**HTTP:** Three schemes via `Authorization` header:
- `Basic` — username/password (bcrypt) or username/access-token
- `Token` — access token directly (`ss_` prefixed, SHA256 hashed)
- `Bearer` — JWT (Ed25519 signed, scoped to repo)

### Authorization

Four levels in `pkg/access/access.go`:
- `NoAccess` (0) — denied
- `ReadOnlyAccess` (1) — clone/fetch
- `ReadWriteAccess` (2) — push, LFS locks
- `AdminAccess` (3) — server management

### SSH Command Dispatch

Non-PTY SSH sessions go through `CommandMiddleware` which builds a Cobra command tree from `pkg/ssh/cmd/`. The same `cobra.Command` pattern used for the main CLI is reused for SSH commands.

PTY sessions launch the Bubble Tea TUI via `SessionHandler`.

### Git Operations

Git push/pull is handled by shelling out to the `git` binary (not pure Go) via `pkg/git/service.go:gitServiceHandler`. Environment variables pass context (repo name, username, public key) to git hooks.

### Webhook System

Events defined in `pkg/webhook/`: push, branch create/delete, tag create/delete, repository create/delete, collaborator add/remove. Delivery via HTTP POST with HMAC-SHA256 signatures. SSRF protection in `pkg/ssrf/`.

## Build & Test

```bash
# Build
go build ./cmd/soft

# Run all tests
go test ./...

# Run integration tests
go test ./testscript/...

# Run specific test
go test -run TestName ./pkg/backend/...
```

### Test Framework

- Unit tests: standard Go `testing` + `matryer/is` for assertions
- Integration tests: `rogpeppe/go-internal/testscript` (script-driven, in `testscript/`)
- Test helpers in `pkg/test/`

## Database

- **Drivers:** SQLite (`modernc.org/sqlite`, pure Go) and PostgreSQL (`lib/pq`)
- **Access:** `jmoiron/sqlx` with raw SQL queries, `$1`/`$2` placeholders
- **Migrations:** Go-embedded SQL files in `pkg/db/migrate/`, separate `.up.sql`/`.down.sql` per driver
- **Models:** `pkg/db/models/`
- **Transactions:** `db.TransactionContext` helper

## Configuration

Loaded from `$SOFT_SERVE_DATA_PATH/config.yaml` with env var overrides (`SOFT_SERVE_*` prefix). Parsed via `caarlos0/env`. Key config sections:

- `SSH` — listen addr, max timeout, key path
- `HTTP` — listen addr, TLS cert/key, public URL
- `Git` — listen addr, max connections, max timeout
- `Stats` — listen addr
- `LFS` — enabled flag
- `DB` — driver (sqlite/postgres), data source

## Metrics

Prometheus metrics throughout the codebase via `promauto`. Exposed at Stats server `/metrics`. Covers SSH connections, HTTP requests, git operations, TUI sessions, LFS transfers.

## Known TODOs

- `pkg/backend/backend.go` — proper caching interface (currently basic in-memory)
- `pkg/backend/lfs.go` — S3 storage support for LFS
- `pkg/lfs/ssh_client.go` — Git LFS SSH client (placeholder)
- `pkg/backend/hooks.go` — async hook execution
- `pkg/backend/user.go` — user repository ownership

## Common Pitfalls

1. **Never edit `*_templ.go` files** — they are generated. Edit `.templ` sources only.
2. **Context is king** — all dependencies flow through `context.Context`. Use `FromContext` accessors.
3. **Backend, not Store** — handlers call `Backend` methods, never `Store` directly.
4. **Git binary, not go-git** — push/pull shells out to `git`. `go-git` is used for read operations (log, tree, diff).
5. **Dual DB support** — SQL must work on both SQLite and PostgreSQL. Test with both if modifying queries.
6. **SSH commands are Cobra commands** — same pattern as CLI, dispatched in `CommandMiddleware`.
7. **Access levels gate everything** — check `AccessLevel` before any repo operation.
22 changes: 22 additions & 0 deletions cmd/soft/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import (
"syscall"
"time"

"charm.land/log/v2"
"github.com/charmbracelet/soft-serve/cmd"
"github.com/charmbracelet/soft-serve/pkg/access"
"github.com/charmbracelet/soft-serve/pkg/backend"
"github.com/charmbracelet/soft-serve/pkg/config"
"github.com/charmbracelet/soft-serve/pkg/db"
Expand Down Expand Up @@ -70,6 +72,26 @@ var (
return fmt.Errorf("migration error: %w", err)
}

// Apply config-driven settings so operators can automate
// server setup without a post-start SSH session.
{
cfgCtx := config.FromContext(ctx)
be := backend.FromContext(ctx)
logger := log.FromContext(ctx).WithPrefix("server")
if cfgCtx.AnonAccess != "" {
if err := be.SetAnonAccess(ctx, access.ParseAccessLevel(cfgCtx.AnonAccess)); err != nil {
return fmt.Errorf("set anon_access: %w", err)
}
logger.Debug("applied anon_access from config", "level", cfgCtx.AnonAccess)
}
if cfgCtx.AllowKeyless != nil {
if err := be.SetAllowKeyless(ctx, *cfgCtx.AllowKeyless); err != nil {
return fmt.Errorf("set allow_keyless: %w", err)
}
logger.Debug("applied allow_keyless from config", "value", *cfgCtx.AllowKeyless)
}
}

s, err := NewServer(ctx)
if err != nil {
return fmt.Errorf("start server: %w", err)
Expand Down
Loading