Docs - docs/ARCHITECTURE.md: how the pieces fit and why. The request path, the six enforced layers, the data model, the scanner, the job queue, direct-play playback, and the three protocol surfaces over one application. - docs/decisions/: seven ADRs with the alternatives that were rejected — SQLite-with-a-seam, direct-play-only, mark-missing-never-delete, the modular monolith, the one item-access chokepoint, the table-backed queue, and fixed-width timestamps. - README: the layout section was stale (it predated catalog, home, scanner, providers, engine, probe, playback, apitoken, arch and the dto packages) and now lists what exists, plus links to the new docs. Two real bugs, both found by verifying the docs instead of trusting them 1. The CLI could not run on a fresh install. `Config.Validate` created the data and config directories, but the CLI never called it, so `vellum user add` — the recovery path when you are locked out — failed with "unable to open database file" until the server had booted at least once. Split the directory creation into `Config.EnsureDirs`, call it from the CLI, and keep the media root check out of it: an admin command must not fail because a library is unmounted. The CLI tests now point DBPath inside DataDir, exactly as LoadConfig derives it, so they exercise the fresh-install path; removing EnsureDirs fails three of them. 2. A fresh clone could not build at all. `//go:embed all:dist` fails to compile when web/dist is absent, and web/.gitignore expected a `dist/.gitkeep` that was never created. Added it, so a clone builds and the server logs "web UI not embedded; run the frontend build to include it" — the intended behaviour for someone who only wants the API. Also: the httpapi tests depended on a built frontend and FAILED rather than skipping, so the suite was red on any fresh clone. findAsset and readAsset now skip with a message naming the missing step. They still RUN when a bundle is present (verified: zero skips here), so they have not been quietly disabled. Verified from a simulated clone of tracked files only: go build exit 0, 25 packages pass, the server boots and applies all 12 migrations, and the CLI creates a user and takes a backup. gofmt clean, vet exit 0, svelte-check 0/0.
159 lines
4.7 KiB
Go
159 lines
4.7 KiB
Go
// Package cli implements the vellum command-line subcommands that operate
|
|
// directly on the database: user administration and backups.
|
|
//
|
|
// cmd/vellum calls Run before booting the HTTP server. When Run reports
|
|
// handled == true the process exits without starting the server; when it
|
|
// reports handled == false the caller falls through to its default behaviour.
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
|
|
"github.com/mithral/vellum/internal/auth"
|
|
"github.com/mithral/vellum/internal/core"
|
|
"github.com/mithral/vellum/migrations"
|
|
)
|
|
|
|
// Run dispatches a CLI subcommand. args is os.Args[1:].
|
|
//
|
|
// handled is false when args names no known subcommand, so the caller can
|
|
// continue with its normal startup. When handled is true the database has been
|
|
// opened and migrated, and a non-nil error is fatal to the invocation.
|
|
func Run(args []string, cfg core.Config, log *slog.Logger) (handled bool, err error) {
|
|
return run(context.Background(), args, cfg, log, os.Stdout)
|
|
}
|
|
|
|
// run is the testable core of Run. It opens and migrates the database only
|
|
// once a subcommand has actually been matched, so an unrecognised argument
|
|
// leaves the database untouched.
|
|
func run(ctx context.Context, args []string, cfg core.Config, log *slog.Logger, out io.Writer) (bool, error) {
|
|
if len(args) == 0 {
|
|
return false, nil
|
|
}
|
|
switch args[0] {
|
|
case "user", "backup":
|
|
default:
|
|
return false, nil
|
|
}
|
|
|
|
// Create the data and config directories before opening the database.
|
|
//
|
|
// The server does this in Config.Validate, but the CLI is the way back in
|
|
// when someone is locked out, so it must work on a fresh install too —
|
|
// before the server has ever run and created them. Only the directories:
|
|
// an admin command must not fail because a media library is unmounted.
|
|
if err := cfg.EnsureDirs(); err != nil {
|
|
return true, err
|
|
}
|
|
|
|
db, err := core.OpenDB(cfg.DBPath)
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := core.Migrate(ctx, db, migrations.FS, log); err != nil {
|
|
return true, err
|
|
}
|
|
|
|
switch args[0] {
|
|
case "user":
|
|
return true, userCmd(ctx, db, args[1:], out)
|
|
case "backup":
|
|
return true, backupCmd(ctx, db, args[1:], out)
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// ── user ───────────────────────────────────────────────────────────────
|
|
|
|
func userCmd(ctx context.Context, db *sql.DB, args []string, out io.Writer) error {
|
|
if len(args) == 0 {
|
|
return errors.New("user: missing subcommand (add, passwd, list)")
|
|
}
|
|
switch args[0] {
|
|
case "add":
|
|
return userAdd(ctx, db, args[1:], out)
|
|
case "passwd":
|
|
return userPasswd(ctx, db, args[1:], out)
|
|
case "list":
|
|
return userList(ctx, db, out)
|
|
default:
|
|
return fmt.Errorf("user: unknown subcommand %q", args[0])
|
|
}
|
|
}
|
|
|
|
func userAdd(ctx context.Context, db *sql.DB, args []string, out io.Writer) error {
|
|
admin := false
|
|
var positional []string
|
|
for _, a := range args {
|
|
if a == "--admin" {
|
|
admin = true
|
|
continue
|
|
}
|
|
positional = append(positional, a)
|
|
}
|
|
if len(positional) != 2 {
|
|
return errors.New("usage: user add <username> <password> [--admin]")
|
|
}
|
|
|
|
u, err := auth.CreateUser(ctx, db, positional[0], "", positional[1], admin)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintf(out, "created user %d %s\n", u.ID, u.Username)
|
|
return err
|
|
}
|
|
|
|
func userPasswd(ctx context.Context, db *sql.DB, args []string, out io.Writer) error {
|
|
if len(args) != 2 {
|
|
return errors.New("usage: user passwd <username> <newpassword>")
|
|
}
|
|
username, password := args[0], args[1]
|
|
|
|
u, err := auth.GetUserByUsername(ctx, db, username)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("user %q not found", username)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := auth.SetPassword(ctx, db, u.ID, password); err != nil {
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintf(out, "updated password for user %d %s\n", u.ID, u.Username)
|
|
return err
|
|
}
|
|
|
|
func userList(ctx context.Context, db *sql.DB, out io.Writer) error {
|
|
users, err := auth.ListUsers(ctx, db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, u := range users {
|
|
if _, err := fmt.Fprintf(out, "%d\t%s\tadmin=%t\n", u.ID, u.Username, u.IsAdmin); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── backup ─────────────────────────────────────────────────────────────
|
|
|
|
func backupCmd(ctx context.Context, db *sql.DB, args []string, out io.Writer) error {
|
|
if len(args) != 1 {
|
|
return errors.New("usage: backup <path>")
|
|
}
|
|
path := args[0]
|
|
if _, err := db.ExecContext(ctx, "VACUUM INTO ?", path); err != nil {
|
|
return fmt.Errorf("backup: %w", err)
|
|
}
|
|
_, err := fmt.Fprintf(out, "backed up database to %s\n", path)
|
|
return err
|
|
}
|