Vellum/internal/cli/cli.go
mithral f680e1d9f6 Beta blockers: async scans, admin CLI, deploy docs, pagination, password UI
Five tasks, four delegated to agents on disjoint files and one on the critical
path. Board and verification log: ~/.opencode/plan/vellum-beta-tasks.md

T1 Async scans (coordinator)
- migration 0007 adds ref_id + result to jobs, indexed by (kind, ref_id, id),
  so a job can be found by what it operates on and can report back.
- jobs: EnqueueRef, JobID(ctx) so a handler can attach a result, SetResult.
- POST /libraries/{id}/scan now enqueues and returns 202; a real library takes
  longer than any request timeout, and a hanging request looks like a broken
  server. GET /libraries/{id}/scan/status reports the latest run.
- Libraries.svelte polls and shows "Scanning…" instead of freezing.

T2 Admin CLI (agent)
- internal/cli: `user add|passwd|list`, `backup <path>` via VACUUM INTO, with
  tests. Wired into main.go, which the coordinator kept.

T3 Deploy (agent)
- compose.yml now mounts all six media kinds read-only with :z and carries the
  metadata env keys. DEPLOY.md covers volumes, SELinux, admin bootstrap, TLS,
  backup and upgrade.

T4 Pagination (agent)
- GET /items takes offset and returns total; Browse loads 60 at a time with
  "Load more" and "Showing X of Y" instead of silently truncating at 300.

T5 Password change UI (agent)
- A Password section in Settings with validation, busy and success states.

Verified end to end: scan returns 202 in 0.7 ms and polls to done; the CLI
created a user, changed a password, and produced a complete 240 KB backup while
the server ran; pagination reports total 8 and pages correctly; compose parses
with every mount :z. go vet and 14 test packages pass; svelte-check 0/0.
2026-10-10 18:00:35 -04:00

149 lines
4.3 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
}
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
}