diff --git a/.air.toml b/.air.toml index cd937f5..8ce5786 100644 --- a/.air.toml +++ b/.air.toml @@ -1,16 +1,37 @@ # Air config for the Go backend. # -# Pattern from charm.land/hyper: only watch Go source. The web side has its -# own HMR loop via `bun run dev` (Vite); CSS/JS/templates over there are -# handled by SvelteKit and never need a Go rebuild. +# This file lives at the repo root so paths line up regardless of where +# the user invokes air from. The `task watch:server` entry points cd +# nowhere — air is run from the repo root and watches server/ directly. +# +# We only watch Go source. The web side has its own HMR loop via Vite; +# CSS/JS/templates over there never need a Go rebuild. -root = "server" -tmp_dir = "tmp" +root = "." +tmp_dir = "server/tmp" +testdata_dir = "testdata" [build] -cmd = "go build -o ./tmp/potluck ./cmd/server" -full_bin = "CI=1 CLICOLOR_FORCE=1 ./tmp/potluck" +cmd = "go build -C server -o tmp/potluck ./cmd/server" +bin = "server/tmp/potluck" +full_bin = "CI=1 CLICOLOR_FORCE=1 server/tmp/potluck" args_bin = ["--auto-migrate"] -include_ext = ["go", "mod", "sum"] -exclude_dir = ["tmp", "dist", "node_modules"] + +# Watch every Go file under server/, plus the migration .sql files (since +# they're embedded into the binary). +include_dir = ["server"] +include_ext = ["go", "mod", "sum", "sql"] +exclude_dir = ["server/tmp", "server/dist", "node_modules", "web", "data", ".git", "design"] +exclude_regex = ["_test\\.go$"] stop_on_error = true +send_interrupt = true +kill_delay = "500ms" +delay = 200 + +[log] +time = true + +[color] +main = "magenta" +build = "yellow" +runner = "cyan" diff --git a/AGENTS.md b/AGENTS.md index 9f3d996..8cc5d5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,6 +181,13 @@ in parallel with `task watch`. Configuration is documented in [`CONFIG.md`](./CONFIG.md), generated from `server/internal/config/config.go` via `task generate`. +> **Agents: don't start the server yourself.** The human running this repo +> keeps `task watch` running in another pane. Spawning a second copy fights +> for `:8080` and corrupts the dev SQLite WAL. `task test`, `task build`, +> `task generate`, and codegen tasks are fine; anything that calls +> `ListenAndServe` is not. Confirm a change works by reading logs from the +> running instance, not by booting your own. + ```bash # Dev — runs backend (air) and web (vite) in parallel task watch # alias: task dev diff --git a/Taskfile.yml b/Taskfile.yml index 0297ba7..a26b33b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -54,9 +54,8 @@ tasks: watch:server: desc: Watch the Go backend with air - dir: server cmds: - - air -c ../.air.toml + - air -c .air.toml watch:web: desc: Watch the SvelteKit web app diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 2d29a22..9a3b0a7 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -10,7 +10,6 @@ import ( "database/sql" "errors" "flag" - "log/slog" "net/http" "os" "os/signal" @@ -18,6 +17,7 @@ import ( "syscall" "time" + "charm.land/log/v2" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "github.com/google/uuid" @@ -37,33 +37,53 @@ import ( _ "modernc.org/sqlite" ) +// Information set at build time via -ldflags. +var ( + Version = "dev" + CommitSHA = "" + CommitDate = "" +) + func main() { autoMigrate := flag.Bool("auto-migrate", false, "run pending migrations on boot") flag.Parse() - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) - slog.SetDefault(logger) - cfg := config.MustGet() + switch { + case cfg.IsProduction(): + log.SetLevel(log.InfoLevel) + log.SetFormatter(log.JSONFormatter) + case cfg.IsLocal(): + log.SetReportCaller(true) + log.SetLevel(log.DebugLevel) + default: + log.SetLevel(log.InfoLevel) + } + + log.Info("Potluck", + "version", Version, + "commit", CommitSHA, + "commit_date", CommitDate, + "env", cfg.Environment, + ) + if dir := filepath.Dir(cfg.DatabaseURL); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { - logger.Error("mkdir db dir", "err", err) - os.Exit(2) + log.Fatal("create db directory", "err", err) } } db, err := sql.Open("sqlite", cfg.DatabaseURL+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)") if err != nil { - logger.Error("open db", "err", err) - os.Exit(2) + log.Fatal("open database", "err", err) } defer db.Close() if *autoMigrate { + log.Info("Running migrations") if err := migrations.Run(db); err != nil { - logger.Error("migrate", "err", err) - os.Exit(2) + log.Fatal("migrations failed", "err", err) } } @@ -76,10 +96,14 @@ func main() { hub := stream.NewHub(q) pioneer := provider.New(cfg.Pioneer.BaseURL, cfg.Pioneer.APIKey) + if !cfg.Pioneer.Valid() { + log.Warn("pioneer.ai not configured — /v1/* will refuse upstream calls") + } + r := chi.NewRouter() r.Use(chimw.RequestID) r.Use(chimw.RealIP) - r.Use(slogRequest(logger)) + r.Use(requestLogger) r.Use(chimw.Recoverer) r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { @@ -89,40 +113,8 @@ func main() { // Local-only login: trade an email for a session cookie. Real auth // lives behind a real provider — see design/security.md. if cfg.IsLocal() { - r.Post("/api/dev/login", func(w http.ResponseWriter, r *http.Request) { - email := r.URL.Query().Get("email") - if email == "" { - http.Error(w, "missing email", 400) - return - } - u, err := q.GetUserByEmail(r.Context(), email) - if errors.Is(err, sql.ErrNoRows) { - u, err = q.CreateUser(r.Context(), store.CreateUserParams{ - ID: uuid.NewString(), - Email: email, - DisplayName: email, - CreatedAt: time.Now().Unix(), - }) - } - if err != nil { - http.Error(w, err.Error(), 500) - return - } - tok, err := authSvc.IssueSession(r.Context(), u.ID) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - http.SetCookie(w, &http.Cookie{ - Name: auth.CookieName, - Value: tok, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(time.Duration(cfg.SessionTTL) * time.Second), - }) - _, _ = w.Write([]byte("ok")) - }) + log.Debug("Mounting dev login endpoint", "path", "/api/dev/login") + r.Post("/api/dev/login", devLoginHandler(q, authSvc, time.Duration(cfg.SessionTTL)*time.Second)) } apiSrv := &web.Server{ @@ -159,38 +151,104 @@ func main() { ReadHeaderTimeout: 10 * time.Second, } + errc := make(chan error, 1) go func() { - logger.Info("listening", "addr", cfg.HTTPListen, "env", cfg.Environment) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - logger.Error("server", "err", err) - os.Exit(1) - } + log.Info("Starting HTTP server", "addr", cfg.HTTPListen) + errc <- srv.ListenAndServe() }() stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) - <-stop - logger.Info("shutting down") + + select { + case sig := <-stop: + log.Info("Received signal, shutting down", "signal", sig) + case err := <-errc: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal("HTTP server crashed", "err", err) + } + } + + log.Info("Shutting down HTTP server") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - _ = srv.Shutdown(ctx) + if err := srv.Shutdown(ctx); err != nil { + log.Error("graceful shutdown failed", "err", err) + } + log.Info("Bye") +} + +// requestLogger logs each request with method, path, status, and duration. +// +// Lines are intentionally short — the HTTP middleware is the noisiest log +// source in the system, so noise control matters. Health checks log at +// Debug, everything else at Info, errors at Warn (4xx) or Error (5xx). +func requestLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(ww, r) + + fields := []any{ + "method", r.Method, + "path", r.URL.Path, + "status", ww.Status(), + "bytes", ww.BytesWritten(), + "dur", time.Since(start).Round(time.Microsecond), + "req_id", chimw.GetReqID(r.Context()), + } + switch { + case r.URL.Path == "/healthz": + log.Debug("HTTP", fields...) + case ww.Status() >= 500: + log.Error("HTTP", fields...) + case ww.Status() >= 400: + log.Warn("HTTP", fields...) + default: + log.Info("HTTP", fields...) + } + }) } -// slogRequest logs each request with method, path, status, and duration. -func slogRequest(l *slog.Logger) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor) - next.ServeHTTP(ww, r) - l.Info("http", - "method", r.Method, - "path", r.URL.Path, - "status", ww.Status(), - "bytes", ww.BytesWritten(), - "dur_ms", time.Since(start).Milliseconds(), - "req_id", chimw.GetReqID(r.Context()), - ) +// devLoginHandler is split out only because the inlined version drowned +// the boot path in noise. Local-only — see security.md. +func devLoginHandler(q *store.Queries, authSvc *auth.Service, ttl time.Duration) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + email := r.URL.Query().Get("email") + if email == "" { + http.Error(w, "missing email", http.StatusBadRequest) + return + } + u, err := q.GetUserByEmail(r.Context(), email) + if errors.Is(err, sql.ErrNoRows) { + log.Debug("Creating dev user", "email", email) + u, err = q.CreateUser(r.Context(), store.CreateUserParams{ + ID: uuid.NewString(), + Email: email, + DisplayName: email, + CreatedAt: time.Now().Unix(), + }) + } + if err != nil { + log.Error("dev login: lookup/create user", "err", err, "email", email) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + tok, err := authSvc.IssueSession(r.Context(), u.ID) + if err != nil { + log.Error("dev login: issue session", "err", err, "user_id", u.ID) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: auth.CookieName, + Value: tok, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(ttl), }) + log.Debug("Dev login OK", "email", email, "user_id", u.ID) + _, _ = w.Write([]byte("ok")) } } diff --git a/server/go.mod b/server/go.mod index 3f007e4..79a5e12 100644 --- a/server/go.mod +++ b/server/go.mod @@ -13,13 +13,30 @@ require ( ) require ( + charm.land/lipgloss/v2 v2.0.1 // indirect + charm.land/log/v2 v2.0.0 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // 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/go-logfmt/logfmt v0.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect modernc.org/libc v1.72.3 // indirect diff --git a/server/go.sum b/server/go.sum index 7908000..98ce56d 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,11 +1,33 @@ +charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= +charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= +charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +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/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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -14,10 +36,16 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -26,12 +54,18 @@ github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5s github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 21f4e9c..fd35a27 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -10,11 +10,39 @@ package config import ( - "github.com/caarlos0/env/v11" + "os" + "path/filepath" - _ "github.com/joho/godotenv/autoload" // load .env at package init + "github.com/caarlos0/env/v11" + "github.com/joho/godotenv" ) +// init walks up from the current directory looking for a .env file and +// loads it. Plain godotenv/autoload only checks cwd, which breaks when +// the binary is run from server/ during dev. Walking up means `task` from +// the repo root, `air` from server/, and a built binary launched from +// anywhere all pick up the same file. +// +// Existing env vars win over .env values — POSIX behaviour. +func init() { + dir, err := os.Getwd() + if err != nil { + return + } + for { + path := filepath.Join(dir, ".env") + if _, err := os.Stat(path); err == nil { + _ = godotenv.Load(path) + return + } + parent := filepath.Dir(dir) + if parent == dir { + return // filesystem root; no .env anywhere + } + dir = parent + } +} + // Config wraps every server-level setting with sensible defaults. // //go:generate go run github.com/g4s8/envdoc@latest -output ../../../CONFIG.md diff --git a/server/internal/migrations/migrations.go b/server/internal/migrations/migrations.go index f98948a..8503c66 100644 --- a/server/internal/migrations/migrations.go +++ b/server/internal/migrations/migrations.go @@ -10,16 +10,20 @@ import ( "database/sql" "embed" "fmt" + "strings" + "charm.land/log/v2" "github.com/pressly/goose/v3" ) //go:embed all:files var FS embed.FS -// Run applies all pending up-migrations. +// Run applies all pending up-migrations. Goose's chatty Printf output is +// routed through our structured logger so boot logs stay tidy. func Run(db *sql.DB) error { goose.SetBaseFS(FS) + goose.SetLogger(gooseLogger{}) if err := goose.SetDialect("sqlite3"); err != nil { return fmt.Errorf("goose dialect: %w", err) } @@ -28,3 +32,19 @@ func Run(db *sql.DB) error { } return nil } + +// gooseLogger adapts goose's Logger interface onto charm log so migration +// progress lines look like every other log line in the system. +type gooseLogger struct{} + +func (gooseLogger) Printf(format string, v ...any) { + msg := strings.TrimRight(fmt.Sprintf(format, v...), "\n") + if msg == "" { + return + } + log.Debug(msg) +} + +func (gooseLogger) Fatalf(format string, v ...any) { + log.Fatal(strings.TrimRight(fmt.Sprintf(format, v...), "\n")) +}