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