skills/andromeda-stack/SKILL.md 24.1 K raw
1
---
2
name: Andromeda Stack
3
description: Scaffold a Go CRUD web app using net/http + html/template + modernc.org/sqlite + the shared andromeda pkg packages (auth, web, config, sqlite, darkmatter). Use when the user wants to build a new Go web server with CRUD operations in the andromeda monorepo.
4
---
5
6
# Go Andromeda Web App
7
8
## Overview
9
10
Scaffold and build Go CRUD web apps in the andromeda workspace using the
11
standard library `net/http` mux, `html/template`, `modernc.org/sqlite` (pure
12
Go, no cgo) and the shared `pkg/*` packages. Each app ships a single Go
13
binary with HTML pages, a JSON API, optional session or API key auth, embedded
14
templates + static assets via `embed.FS`, and Docker deployment.
15
16
Apps live under `apps/<name>/`. Each app is its own Go module. Shared packages
17
live under `pkg/` and are each their own module, wired in via local
18
`replace` directives.
19
20
Shared crates:
21
22
- `pkg/auth` — session `Store`, `RequireSession` / `RequireAPIKey` /
23
  `RequireBearerOrSession` middleware, `SecureEqual`, `VerifyPassword`
24
  (bcrypt or plaintext), `GenerateSessionToken`, `GenerateShortID`.
25
- `pkg/web` — `Render`, `WriteJSON`, `WriteError`, `DecodeJSON`,
26
  `EmbeddedHandler`, `RedirectWithError`, `RedirectWithSuccess`, `PathInt64`.
27
- `pkg/config` — `LoadDotEnv`, `Getenv`, `GetenvInt`, `GetenvBool`.
28
- `pkg/sqlite` — `Open(path, schema)` opens SQLite with the project
29
  defaults (`PRAGMA foreign_keys=ON`, `SetMaxOpenConns(1)`).
30
- `pkg/darkmatter` — embedded shared CSS + fonts plus `Mount(mux,
31
  "/assets")` to register routes on any `*http.ServeMux`.
32
33
## Project Structure
34
35
```
36
apps/app-name/
37
├── main.go              # entry point; loads env, opens DB, builds App, starts server
38
├── app.go               # App struct + embed.FS + page-data structs
39
├── routes.go            # *http.ServeMux wiring + middleware
40
├── db.go                # schema, model, CRUD funcs (or thin wrapper around internal/store)
41
├── handlers_web.go      # HTML handlers (login, index, CRUD pages)
42
├── handlers_api.go      # JSON API handlers
43
├── templates/           # html/template files
44
│   ├── base.html        # layout with {{ block "title" . }} / {{ block "content" . }}
45
│   └── *.html           # pages that {{ template "base" . }} or define blocks
46
├── static/              # CSS, JS, images embedded via embed.FS
47
├── .env.example
48
├── Dockerfile           # multi-stage Go build, built from repo root
49
├── docker-compose.yml   # app-local dev compose
50
├── go.mod / go.sum
51
└── README.md
52
```
53
54
Apps with extra surface (e.g. `jotts` has `tui/` + `cmd_*.go` subcommands,
55
`feeds` has `subscriptions.go` + background poller) layer files on top of this
56
shape; do not invent new layers unless the app needs them.
57
58
## go.mod
59
60
Module path: `github.com/stevedylandev/andromeda/apps/<app-name>`. Pin
61
`go 1.25` (match other apps). Pull the shared crates by their canonical paths
62
and add `replace` directives for the local checkout:
63
64
```go
65
module github.com/stevedylandev/andromeda/apps/APP_NAME
66
67
go 1.25
68
69
require (
70
	github.com/stevedylandev/andromeda/pkg/auth v0.0.0
71
	github.com/stevedylandev/andromeda/pkg/config v0.0.0
72
	github.com/stevedylandev/andromeda/pkg/darkmatter v0.0.0
73
	github.com/stevedylandev/andromeda/pkg/sqlite v0.0.0
74
	github.com/stevedylandev/andromeda/pkg/web v0.0.0
75
)
76
77
replace (
78
	github.com/stevedylandev/andromeda/pkg/auth       => ../../pkg/auth
79
	github.com/stevedylandev/andromeda/pkg/config     => ../../pkg/config
80
	github.com/stevedylandev/andromeda/pkg/darkmatter => ../../pkg/darkmatter
81
	github.com/stevedylandev/andromeda/pkg/sqlite     => ../../pkg/sqlite
82
	github.com/stevedylandev/andromeda/pkg/web        => ../../pkg/web
83
)
84
```
85
86
Add `pkg/tui` only if the app ships a TUI. Add `golang.org/x/crypto`,
87
`yuin/goldmark`, etc. only when the specific app needs them. The Dockerfile
88
must copy `pkg/` into the build context for the replace directives.
89
90
Do NOT add ORMs, web frameworks (gin, echo, chi), connection pools, or HTTP
91
client libs unless explicitly requested. The stdlib `net/http` mux (with
92
method-prefixed patterns like `GET /notes/{short_id}`) is sufficient.
93
94
## main.go
95
96
Minimal — loads env, sets up logging + DB + sessions, starts the server.
97
98
```go
99
package main
100
101
import (
102
	"html/template"
103
	"log"
104
	"log/slog"
105
	"net/http"
106
	"os"
107
108
	"github.com/stevedylandev/andromeda/pkg/auth"
109
	"github.com/stevedylandev/andromeda/pkg/config"
110
)
111
112
func main() {
113
	config.LoadDotEnv(".env")
114
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
115
116
	dbPath := config.Getenv("APP_DB_PATH", "app.sqlite")
117
	db, err := openDB(dbPath)
118
	if err != nil {
119
		log.Fatal(err)
120
	}
121
	defer db.Close()
122
123
	sessions := &auth.Store{
124
		DB:           db,
125
		CookieName:   "session",
126
		CookieSecure: config.GetenvBool("COOKIE_SECURE", false),
127
	}
128
	if err := sessions.EnsureSchema(); err != nil {
129
		log.Fatal(err)
130
	}
131
	sessions.PruneExpired()
132
133
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
134
135
	password := os.Getenv("APP_PASSWORD")
136
	if password == "" {
137
		logger.Warn("APP_PASSWORD not set, using default 'changeme'")
138
		password = "changeme"
139
	}
140
	apiKey := os.Getenv("APP_API_KEY")
141
	if apiKey == "" {
142
		logger.Info("APP_API_KEY not set, /api/* will return 403")
143
	}
144
145
	app := &App{
146
		DB:           db,
147
		Log:          logger,
148
		Templates:    tmpl,
149
		Sessions:     sessions,
150
		Password:     password,
151
		APIKey:       apiKey,
152
		CookieSecure: sessions.CookieSecure,
153
	}
154
155
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
156
	logger.Info("APP_NAME server running", "addr", addr)
157
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
158
		log.Fatal(err)
159
	}
160
}
161
```
162
163
Apps that also expose CLI subcommands (e.g. `jotts`, `sipp`) keep `main.go`
164
as a dispatcher and move `runServer` into `cmd_server.go`.
165
166
## app.go — App struct + embed
167
168
Holds dependencies, embedded FS, and per-page data structs.
169
170
```go
171
package main
172
173
import (
174
	"database/sql"
175
	"embed"
176
	"html/template"
177
	"log/slog"
178
179
	"github.com/stevedylandev/andromeda/pkg/auth"
180
)
181
182
//go:embed templates/*.html static/*
183
var appFS embed.FS
184
185
type App struct {
186
	DB           *sql.DB
187
	Log          *slog.Logger
188
	Templates    *template.Template
189
	Sessions     *auth.Store
190
	Password     string
191
	APIKey       string
192
	CookieSecure bool
193
}
194
195
type indexPageData struct {
196
	Items []Item
197
}
198
199
type loginPageData struct {
200
	Error string
201
}
202
```
203
204
Page-data structs go here too. Keep names like `<page>PageData`.
205
206
## routes.go
207
208
Wire routes on a `*http.ServeMux` using method-prefixed patterns. Mount
209
darkmatter assets and static files. Wrap protected routes with
210
`Sessions.RequireSession` / `auth.RequireAPIKey`.
211
212
```go
213
package main
214
215
import (
216
	"net/http"
217
218
	"github.com/stevedylandev/andromeda/pkg/auth"
219
	"github.com/stevedylandev/andromeda/pkg/darkmatter"
220
	"github.com/stevedylandev/andromeda/pkg/web"
221
)
222
223
func (a *App) routes() *http.ServeMux {
224
	mux := http.NewServeMux()
225
226
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
227
	darkmatter.Mount(mux, "/assets")
228
229
	requireSession := func(next http.HandlerFunc) http.HandlerFunc {
230
		return a.Sessions.RequireSession("/login", next)
231
	}
232
	requireAPIKey := func(next http.HandlerFunc) http.HandlerFunc {
233
		return auth.RequireAPIKey(a.APIKey, next)
234
	}
235
236
	mux.HandleFunc("GET /login", a.loginGetHandler)
237
	mux.HandleFunc("POST /login", a.loginPostHandler)
238
	mux.HandleFunc("GET /logout", a.logoutHandler)
239
240
	mux.HandleFunc("GET /{$}", requireSession(a.indexHandler))
241
	mux.HandleFunc("GET /items/new", requireSession(a.newItemGetHandler))
242
	mux.HandleFunc("POST /items", requireSession(a.createItemHandler))
243
	mux.HandleFunc("GET /items/{short_id}", requireSession(a.viewItemHandler))
244
	mux.HandleFunc("GET /items/{short_id}/edit", requireSession(a.editItemGetHandler))
245
	mux.HandleFunc("POST /items/{short_id}", requireSession(a.updateItemHandler))
246
	mux.HandleFunc("POST /items/{short_id}/delete", requireSession(a.deleteItemHandler))
247
248
	mux.HandleFunc("GET /api/items", requireAPIKey(a.apiListItems))
249
	mux.HandleFunc("POST /api/items", requireAPIKey(a.apiCreateItem))
250
	mux.HandleFunc("GET /api/items/{short_id}", requireAPIKey(a.apiGetItem))
251
	mux.HandleFunc("PUT /api/items/{short_id}", requireAPIKey(a.apiUpdateItem))
252
	mux.HandleFunc("DELETE /api/items/{short_id}", requireAPIKey(a.apiDeleteItem))
253
254
	return mux
255
}
256
```
257
258
Notes:
259
- `GET /{$}` matches exactly `/` (no prefix matching).
260
- Path params extracted with `r.PathValue("short_id")`.
261
- For HTML forms (no JS), delete via `POST /items/{short_id}/delete`. The
262
  `/api/*` routes use real `DELETE`/`PUT`.
263
264
## db.go (or internal/store)
265
266
Single-file module: schema, model, CRUD. `pkg/sqlite.Open` handles the
267
driver, pragmas, and schema bootstrap.
268
269
```go
270
package main
271
272
import (
273
	"database/sql"
274
	"errors"
275
276
	"github.com/stevedylandev/andromeda/pkg/auth"
277
	"github.com/stevedylandev/andromeda/pkg/sqlite"
278
)
279
280
type Item struct {
281
	ID        int64  `json:"id"`
282
	ShortID   string `json:"short_id"`
283
	Title     string `json:"title"`
284
	Content   string `json:"content"`
285
	CreatedAt string `json:"created_at"`
286
	UpdatedAt string `json:"updated_at"`
287
}
288
289
type ItemInput struct {
290
	Title   string `json:"title"`
291
	Content string `json:"content"`
292
}
293
294
const itemColumns = `id, short_id, title, content, created_at, updated_at`
295
296
const schema = `
297
CREATE TABLE IF NOT EXISTS items (
298
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
299
    short_id   TEXT NOT NULL UNIQUE,
300
    title      TEXT NOT NULL,
301
    content    TEXT NOT NULL,
302
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
303
    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
304
);
305
`
306
307
func openDB(path string) (*sql.DB, error) { return sqlite.Open(path, schema) }
308
309
func scanItem(s interface{ Scan(...any) error }) (*Item, error) {
310
	var it Item
311
	err := s.Scan(&it.ID, &it.ShortID, &it.Title, &it.Content, &it.CreatedAt, &it.UpdatedAt)
312
	if errors.Is(err, sql.ErrNoRows) {
313
		return nil, nil
314
	}
315
	if err != nil {
316
		return nil, err
317
	}
318
	return &it, nil
319
}
320
321
func createItem(db *sql.DB, title, content string) (*Item, error) {
322
	shortID, err := auth.GenerateShortID(10)
323
	if err != nil {
324
		return nil, err
325
	}
326
	res, err := db.Exec(`INSERT INTO items (short_id, title, content) VALUES (?, ?, ?)`, shortID, title, content)
327
	if err != nil {
328
		return nil, err
329
	}
330
	id, err := res.LastInsertId()
331
	if err != nil {
332
		return nil, err
333
	}
334
	return scanItem(db.QueryRow(`SELECT `+itemColumns+` FROM items WHERE id = ?`, id))
335
}
336
337
func getItemByShortID(db *sql.DB, shortID string) (*Item, error) {
338
	return scanItem(db.QueryRow(`SELECT `+itemColumns+` FROM items WHERE short_id = ?`, shortID))
339
}
340
341
func listItems(db *sql.DB) ([]Item, error) {
342
	rows, err := db.Query(`SELECT ` + itemColumns + ` FROM items ORDER BY id DESC`)
343
	if err != nil {
344
		return nil, err
345
	}
346
	defer rows.Close()
347
	var out []Item
348
	for rows.Next() {
349
		var it Item
350
		if err := rows.Scan(&it.ID, &it.ShortID, &it.Title, &it.Content, &it.CreatedAt, &it.UpdatedAt); err != nil {
351
			return nil, err
352
		}
353
		out = append(out, it)
354
	}
355
	return out, rows.Err()
356
}
357
358
func updateItemByShortID(db *sql.DB, shortID, title, content string) (*Item, error) {
359
	res, err := db.Exec(`UPDATE items SET title=?, content=?, updated_at=datetime('now') WHERE short_id=?`, title, content, shortID)
360
	if err != nil {
361
		return nil, err
362
	}
363
	if n, _ := res.RowsAffected(); n == 0 {
364
		return nil, nil
365
	}
366
	return getItemByShortID(db, shortID)
367
}
368
369
func deleteItemByShortID(db *sql.DB, shortID string) (bool, error) {
370
	res, err := db.Exec(`DELETE FROM items WHERE short_id = ?`, shortID)
371
	if err != nil {
372
		return false, err
373
	}
374
	n, _ := res.RowsAffected()
375
	return n > 0, nil
376
}
377
```
378
379
Conventions:
380
- Short IDs via `auth.GenerateShortID(10)`.
381
- Model fields use `json:"..."` tags so the same struct serializes for the API.
382
- "Not found" returns `(nil, nil)`, not an error — handlers check `if x == nil`.
383
- DB path comes from `<APP>_DB_PATH` env (e.g. `JOTTS_DB_PATH`).
384
- For more complex apps, move this into `internal/store/` and keep `db.go` as
385
  thin pass-through wrappers (see `apps/jotts`).
386
387
## handlers_web.go
388
389
HTML handlers. Use `web.Render` for templates and `web.RedirectWithError` for
390
flash-style errors via query params.
391
392
```go
393
package main
394
395
import (
396
	"net/http"
397
	"strings"
398
399
	"github.com/stevedylandev/andromeda/pkg/auth"
400
	"github.com/stevedylandev/andromeda/pkg/web"
401
)
402
403
func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) {
404
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
405
}
406
407
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) {
408
	if err := r.ParseForm(); err != nil {
409
		web.RedirectWithError(w, r, "/login", "Invalid request")
410
		return
411
	}
412
	if !auth.SecureEqual(r.FormValue("password"), a.Password) {
413
		web.RedirectWithError(w, r, "/login", "Invalid password")
414
		return
415
	}
416
	token, err := a.Sessions.Create()
417
	if err != nil {
418
		a.Log.Error("create session failed", "err", err)
419
		web.RedirectWithError(w, r, "/login", "Server error")
420
		return
421
	}
422
	http.SetCookie(w, a.Sessions.SessionCookie(token))
423
	http.Redirect(w, r, "/", http.StatusSeeOther)
424
}
425
426
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
427
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
428
		a.Sessions.Delete(c.Value)
429
	}
430
	http.SetCookie(w, a.Sessions.ClearCookie())
431
	http.Redirect(w, r, "/login", http.StatusSeeOther)
432
}
433
434
func (a *App) createItemHandler(w http.ResponseWriter, r *http.Request) {
435
	if err := r.ParseForm(); err != nil {
436
		web.RedirectWithError(w, r, "/items/new", "Invalid request")
437
		return
438
	}
439
	title := strings.TrimSpace(r.FormValue("title"))
440
	if title == "" {
441
		web.RedirectWithError(w, r, "/items/new", "Title is required")
442
		return
443
	}
444
	it, err := createItem(a.DB, title, r.FormValue("content"))
445
	if err != nil {
446
		a.Log.Error("create item failed", "err", err)
447
		web.RedirectWithError(w, r, "/items/new", "Failed to create item")
448
		return
449
	}
450
	http.Redirect(w, r, "/items/"+it.ShortID, http.StatusSeeOther)
451
}
452
```
453
454
Patterns:
455
- Always `auth.SecureEqual` for password compare (constant-time).
456
- Use `http.StatusSeeOther` (303) for POST-redirect-GET.
457
- Pre-rendered HTML (e.g. markdown) goes through `template.HTML` in the
458
  page-data struct, not via `|safe`-style filters.
459
460
## handlers_api.go
461
462
JSON API handlers. Use `web.WriteJSON`, `web.WriteError`, `web.DecodeJSON`.
463
464
```go
465
package main
466
467
import (
468
	"net/http"
469
470
	"github.com/stevedylandev/andromeda/pkg/web"
471
)
472
473
func (a *App) apiListItems(w http.ResponseWriter, r *http.Request) {
474
	items, err := listItems(a.DB)
475
	if err != nil {
476
		a.Log.Error("list items failed", "err", err)
477
		web.WriteError(w, http.StatusInternalServerError, "internal error")
478
		return
479
	}
480
	web.WriteJSON(w, http.StatusOK, items)
481
}
482
483
func (a *App) apiCreateItem(w http.ResponseWriter, r *http.Request) {
484
	var in ItemInput
485
	if !web.DecodeJSON(w, r, &in) {
486
		return
487
	}
488
	if in.Title == "" {
489
		web.WriteError(w, http.StatusBadRequest, "title is required")
490
		return
491
	}
492
	it, err := createItem(a.DB, in.Title, in.Content)
493
	if err != nil {
494
		a.Log.Error("create item failed", "err", err)
495
		web.WriteError(w, http.StatusInternalServerError, "internal error")
496
		return
497
	}
498
	web.WriteJSON(w, http.StatusCreated, it)
499
}
500
501
func (a *App) apiGetItem(w http.ResponseWriter, r *http.Request) {
502
	it, err := getItemByShortID(a.DB, r.PathValue("short_id"))
503
	if err != nil {
504
		web.WriteError(w, http.StatusInternalServerError, "internal error")
505
		return
506
	}
507
	if it == nil {
508
		web.WriteError(w, http.StatusNotFound, "not found")
509
		return
510
	}
511
	web.WriteJSON(w, http.StatusOK, it)
512
}
513
```
514
515
## Authentication
516
517
`pkg/auth` provides three middleware patterns. Pick based on app shape:
518
519
### Session/cookie auth (web-facing apps)
520
521
Use `*auth.Store` for password-login web apps (jotts, feeds, bookmarks, etc).
522
Sessions stored in the same SQLite DB via `EnsureSchema()`. Routes guarded
523
with `store.RequireSession("/login", handler)`.
524
525
Key API:
526
- `store.Create() (token, err)` — issue new session row.
527
- `store.SessionCookie(token) *http.Cookie` — `HttpOnly`, `SameSite=Strict`,
528
  7-day MaxAge.
529
- `store.ClearCookie()` — `MaxAge=-1` cookie for logout.
530
- `store.HasValid(r)` — check the incoming request's cookie.
531
- `store.PruneExpired()` — call at boot to GC stale rows.
532
533
### API key auth (header-based)
534
535
`auth.RequireAPIKey(expectedKey, next)`. Reads `X-API-Key` header,
536
`SecureEqual` compare. Returns 403 if `expectedKey` is empty (auth disabled by
537
config), 401 on mismatch. Use this for `/api/*` routes when there's also a
538
session-cookie web UI.
539
540
### Bearer-or-Session (mixed surfaces)
541
542
`auth.RequireBearerOrSession(store, expectedBearer, next)` — accepts either
543
`Authorization: Bearer <token>` or a valid session cookie. Used by apps that
544
expose the same endpoint to a CLI client AND a logged-in browser user (e.g.
545
`sipp`, `jotts upload`).
546
547
### Env vars
548
549
| Variable | Purpose | Default |
550
|----------|---------|---------|
551
| `HOST` | bind address | `127.0.0.1` (set `0.0.0.0` in Docker) |
552
| `PORT` | listen port | `3000` |
553
| `<APP>_DB_PATH` | SQLite path | `<app>.sqlite` |
554
| `<APP>_PASSWORD` | session login password | none → "changeme" warning |
555
| `<APP>_API_KEY` | API key for `/api/*` | none → 403 |
556
| `COOKIE_SECURE` | HTTPS-only cookie flag | `false` |
557
558
App-specific env vars are prefixed with the uppercase app name
559
(`JOTTS_PASSWORD`, `FEEDS_API_KEY`). `HOST`, `PORT`, `COOKIE_SECURE` are not
560
prefixed.
561
562
## Templates (html/template)
563
564
Templates live in `templates/`, parsed once at startup via
565
`template.ParseFS(appFS, "templates/*.html")` and rendered with
566
`web.Render(t, w, "name.html", data, log)`.
567
568
Use `{{ define "..." }}` / `{{ template "..." . }}` for layout composition.
569
The base file defines block names; pages define the content blocks.
570
571
**templates/base.html:**
572
573
```html
574
{{ define "base" }}
575
<!DOCTYPE html>
576
<html lang="en">
577
<head>
578
  <meta charset="UTF-8">
579
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
580
  <title>{{ template "title" . }}</title>
581
  <meta name="theme-color" content="#121113" />
582
  <link rel="stylesheet" href="/assets/darkmatter.css">
583
  <link rel="stylesheet" href="/static/styles.css">
584
</head>
585
<body>
586
  <div class="container">
587
    {{ template "content" . }}
588
  </div>
589
</body>
590
</html>
591
{{ end }}
592
```
593
594
**templates/index.html:**
595
596
```html
597
{{ define "title" }}Items{{ end }}
598
{{ define "content" }}
599
  {{ range .Items }}
600
    <a href="/items/{{ .ShortID }}">{{ .Title }}</a>
601
  {{ end }}
602
{{ end }}
603
{{ template "base" . }}
604
```
605
606
Notes:
607
- Link CSS via `/static/styles.css` (app-local) and `/assets/darkmatter.css`
608
  (shared theme served by `darkmatter.Mount`).
609
- Forms POST to web routes, not `/api/*`.
610
- Pass pre-rendered HTML as `template.HTML(...)`; `html/template` auto-escapes
611
  everything else.
612
- Flash errors via `?error=...` query param + `web.RedirectWithError` /
613
  `RedirectWithSuccess`.
614
615
## Static assets
616
617
Embedded via `//go:embed templates/*.html static/*` on a single `appFS`.
618
Serve under `/static/` via `web.EmbeddedHandler(appFS, "static")`. Shared
619
theme files (CSS + fonts) come from `darkmatter.Mount(mux, "/assets")`.
620
621
## Logging (slog)
622
623
Standard library `log/slog`. Build once in `main`:
624
625
```go
626
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
627
```
628
629
Pass it into `App` and pass it to `web.Render` (it logs template errors).
630
Use `logger.Error("msg", "err", err)`, `logger.Warn(...)`, `logger.Info(...)`.
631
632
## Dockerfile
633
634
Multi-stage, built from the repo root so `pkg/` is available for the
635
`replace` directives. Pure Go (`CGO_ENABLED=0`) because the SQLite driver is
636
`modernc.org/sqlite`.
637
638
```dockerfile
639
# Build from repo root: docker build -t APP_NAME -f apps/APP_NAME/Dockerfile .
640
FROM golang:1.25-bookworm AS builder
641
WORKDIR /app
642
COPY pkg/ ./pkg/
643
COPY apps/APP_NAME/go.mod apps/APP_NAME/go.sum ./apps/APP_NAME/
644
WORKDIR /app/apps/APP_NAME
645
RUN go mod download
646
COPY apps/APP_NAME/ ./
647
RUN CGO_ENABLED=0 go build -o /APP_NAME .
648
649
FROM debian:bookworm-slim
650
COPY --from=builder /APP_NAME /usr/local/bin/APP_NAME
651
WORKDIR /data
652
ENV HOST=0.0.0.0
653
ENV PORT=3000
654
EXPOSE 3000
655
CMD ["APP_NAME"]
656
```
657
658
If the app makes outbound HTTPS requests, add to the final stage:
659
```dockerfile
660
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
661
```
662
663
If the app has CLI subcommands (jotts), set `CMD ["APP_NAME", "server"]`.
664
665
## App-local docker-compose.yml
666
667
```yaml
668
services:
669
  app:
670
    build:
671
      context: ../..
672
      dockerfile: apps/APP_NAME/Dockerfile
673
    ports:
674
      - "${PORT:-3000}:${PORT:-3000}"
675
    environment:
676
      - APP_NAME_PASSWORD=${APP_NAME_PASSWORD:-changeme}
677
      - APP_NAME_DB_PATH=/data/APP_NAME.sqlite
678
      - COOKIE_SECURE=false
679
      - HOST=0.0.0.0
680
      - PORT=${PORT:-3000}
681
    volumes:
682
      - APP_NAME-data:/data
683
    restart: unless-stopped
684
685
volumes:
686
  APP_NAME-data:
687
```
688
689
## .env.example
690
691
Always ship one listing every var the app reads, with short comments.
692
693
## Wiring up a new app
694
695
A new app is not done when `go run .` works. Several workspace-level files
696
list every app explicitly and must be updated, or CI / Docker images / deploy
697
break silently.
698
699
### 1. Root `docker-compose.yml` (production)
700
701
Pulls images from GHCR. Add a service + named volume:
702
703
```yaml
704
services:
705
  APP_NAME:
706
    image: ghcr.io/stevedylandev/andromeda/APP_NAME:latest
707
    restart: unless-stopped
708
    ports:
709
      - "PORT:3000"
710
    volumes:
711
      - APP_NAME_data:/data
712
    env_file: apps/APP_NAME/.env
713
```
714
715
Pick a unique host port (existing: 3000 sipp, 3030 jotts, 3456 og, 3535 posts,
716
3565 shrink, 4555 feeds, 4646 library, 6754 cellar). Drop the `volumes` /
717
`env_file` lines if stateless (see `shrink`, `og`).
718
719
If the app holds data worth backing up, mount its volume read-only into the
720
`backup` service.
721
722
### 2. `.github/workflows/docker.yml`
723
724
Two spots — both list every app:
725
726
```yaml
727
ALL='["backup","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp","APP_NAME"]'
728
```
729
730
```yaml
731
for app in backup bookmarks cellar easel feeds jotts library og posts shrink sipp APP_NAME; do
732
```
733
734
### 3. `.github/workflows/docker-test.yml`
735
736
Same two lists — keep in lockstep with `docker.yml`.
737
738
### 4. App-local `docker-compose.yml`
739
740
Per-app dev compose (see above).
741
742
## Useful commands
743
744
Per-app (from `apps/<app>/`):
745
746
```bash
747
go mod tidy
748
go build ./...
749
go vet ./...
750
go test ./...
751
go run .                # or: go run . server  for apps with subcommands
752
```
753
754
Repo-wide (from root):
755
756
```bash
757
make go-check                       # fmt + test + vet across all Go modules
758
make go-test
759
make go-vet
760
make go-fmt
761
make go-app-test APP=APP_NAME
762
make go-app-vet  APP=APP_NAME
763
make go-app-fmt  APP=APP_NAME
764
```
765
766
The shared crates (`pkg/web`, `pkg/auth`, `pkg/config`,
767
`pkg/sqlite`, `pkg/darkmatter`) are each their own module — run
768
`go build ./...` inside any to check in isolation.
769
770
## Checklist
771
772
When scaffolding a new app:
773
774
1. `mkdir apps/APP_NAME && cd apps/APP_NAME && go mod init github.com/stevedylandev/andromeda/apps/APP_NAME`
775
2. Add shared-crate `require` + `replace` directives to `go.mod` (auth, config, sqlite, web, darkmatter).
776
3. `main.go` — env load, DB open, sessions bootstrap, `App` build, `ListenAndServe`.
777
4. `app.go` — `App` struct, `//go:embed`, page-data structs.
778
5. `routes.go` — mux wiring, middleware, `/static/` + `darkmatter.Mount`.
779
6. `db.go` — schema, model, CRUD via `pkg/sqlite.Open`.
780
7. `handlers_web.go` — login/logout + HTML CRUD pages.
781
8. `handlers_api.go` — JSON CRUD endpoints behind `RequireAPIKey`.
782
9. `templates/base.html` + per-page templates using `{{ define }}` blocks.
783
10. `static/styles.css` + any app-specific assets.
784
11. `.env.example` listing every env var.
785
12. `Dockerfile` (multi-stage, `CGO_ENABLED=0`) + app-local `docker-compose.yml`.
786
13. Add service + volume to root `docker-compose.yml` (+ `backup` mount if stateful).
787
14. Add app name to both `ALL` array + `for app in ...` loops in
788
    `.github/workflows/docker.yml` AND `.github/workflows/docker-test.yml`.
789
15. `go mod tidy`, then `make go-app-vet APP=APP_NAME` and `make go-app-test APP=APP_NAME`.
790
16. `docker build -f apps/APP_NAME/Dockerfile .` from repo root to verify image.
791
792
## What NOT to include
793
794
- No web frameworks (gin, echo, chi, fiber) — stdlib `net/http` mux is enough.
795
- No ORMs — raw `database/sql` + `?` placeholders.
796
- No connection pools — `sqlite.Open` sets `MaxOpenConns(1)` on purpose.
797
- No cgo SQLite drivers — use `modernc.org/sqlite` via `pkg/sqlite`.
798
- No external CSS frameworks unless specified — use `pkg/darkmatter`.
799
- No JS frontend / build step — `html/template` rendered server-side.
800
- No CLI / TUI deps (cobra, bubbletea) unless the app actually needs them.
801
- No `replace` directives for anything outside `pkg/`.