refactor: clean up nidusctl CLI

This commit is contained in:
2026-08-30 19:54:34 +02:00
parent 9b9cbe2f6f
commit 2e43e32a9f
3 changed files with 106 additions and 115 deletions
+44 -57
View File
@@ -8,7 +8,6 @@
package main package main
import ( import (
"flag"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -23,18 +22,12 @@ func main() {
} }
func run(args []string) int { func run(args []string) int {
fs := flag.NewFlagSet("nidusctl", flag.ContinueOnError) if len(args) < 1 {
cfgPath := fs.String("config", "config.yaml", "path to configuration file")
if err := fs.Parse(args); err != nil {
return 2
}
rest := fs.Args()
if len(rest) < 1 {
usage() usage()
return 2 return 2
} }
cfg, err := config.Load(*cfgPath) cfg, err := config.Load()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
return 1 return 1
@@ -54,20 +47,20 @@ func run(args []string) int {
return 1 return 1
} }
switch rest[0] { switch args[0] {
case "user": case "user":
return runUser(dbase, rest[1:]) return runUser(dbase, args[1:])
case "calendar", "cal": case "calendar", "cal":
return runCalendar(dbase, st, rest[1:]) return runCalendar(dbase, st, args[1:])
case "addressbook", "card": case "addressbook", "card":
return runAddressBook(dbase, st, rest[1:]) return runAddressBook(dbase, st, args[1:])
case "migrate": case "migrate":
return runMigrate(st, rest[1:]) return runMigrate(st, args[1:])
case "help", "-h", "--help": case "help", "-h", "--help":
usage() usage()
return 0 return 0
default: default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", rest[0]) fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
usage() usage()
return 2 return 2
} }
@@ -77,43 +70,41 @@ func usage() {
fmt.Fprint(os.Stderr, `nidusctl - manage nidus DAV server users, resources, and sharing grants fmt.Fprint(os.Stderr, `nidusctl - manage nidus DAV server users, resources, and sharing grants
Usage: Usage:
nidusctl [-config config.yaml] user create <username> [--display-name NAME] [--email EMAIL] [--password PW] nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PW]
nidusctl [-config config.yaml] user delete <username> nidusctl user delete <username>
nidusctl [-config config.yaml] user list nidusctl user list
nidusctl [-config config.yaml] user passwd <username> [--password PW] nidusctl user passwd <username> [--password PW]
nidusctl [-config config.yaml] calendar create <owner> <calendar> [--color '#RRGGBB'] nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']
nidusctl [-config config.yaml] calendar color <owner> <calendar> <hex-color> nidusctl calendar color <owner> <calendar> <hex-color>
nidusctl [-config config.yaml] calendar delete <owner> <calendar> nidusctl calendar delete <owner> <calendar>
nidusctl [-config config.yaml] calendar list <owner> nidusctl calendar list <owner>
nidusctl [-config config.yaml] calendar share <owner> <calendar> <user> <read|write> nidusctl calendar share <owner> <calendar> <user> <read|write>
nidusctl [-config config.yaml] calendar unshare <owner> <calendar> <user> nidusctl calendar unshare <owner> <calendar> <user>
nidusctl [-config config.yaml] calendar shares <owner> <calendar> nidusctl calendar shares <owner> <calendar>
nidusctl [-config config.yaml] addressbook create <owner> <book> nidusctl addressbook create <owner> <book>
nidusctl [-config config.yaml] addressbook delete <owner> <book> nidusctl addressbook delete <owner> <book>
nidusctl [-config config.yaml] addressbook list <owner> nidusctl addressbook list <owner>
nidusctl [-config config.yaml] addressbook share <owner> <book> <user> <read|write> nidusctl addressbook share <owner> <book> <user> <read|write>
nidusctl [-config config.yaml] addressbook unshare <owner> <book> <user> nidusctl addressbook unshare <owner> <book> <user>
nidusctl [-config config.yaml] addressbook shares <owner> <book> nidusctl addressbook shares <owner> <book>
nidusctl [-config config.yaml] migrate [--verbose] nidusctl migrate [--verbose]
nidusctl help
Configuration is done via environment variables:
NIDUS_DATA_DIR - data directory (default: ./data)
Examples: Examples:
nidusctl user create alice --display-name "Alice Smith" --email alice@example.com nidusctl user create alice --display-name "Alice Smith" --email alice@example.com
nidusctl calendar create alice work nidusctl calendar create alice work
nidusctl calendar share alice work bob write nidusctl calendar share alice work bob write
nidusctl calendar shares alice work nidusctl calendar shares alice work
nidusctl calendar unshare alice work bob nidusctl calendar unshare alice work bob
nidusctl migrate nidusctl migrate
`) `)
}
// newFlagSet creates a flag.FlagSet configured for subcommand parsing
// (flags may appear before or after positional args, since callers parse
// flags first with fs.Parse then read fs.Args() for the rest).
func newFlagSet(name string) *flag.FlagSet {
return flag.NewFlagSet(name, flag.ContinueOnError)
} }
func runCalendar(dbase *db.DB, st *store.Store, args []string) int { func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
@@ -123,17 +114,17 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
} }
switch args[0] { switch args[0] {
case "create": case "create":
fs := newFlagSet("calendar create") color := ""
color := fs.String("color", "", "hex color like #3b82f6 (optional)") if len(args) > 2 && args[1] == "--color" {
if err := fs.Parse(args[1:]); err != nil { color = args[2]
return 2 args = args[:1]
} }
if fs.NArg() != 2 { if len(args) != 3 {
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']") fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']")
return 2 return 2
} }
owner, calName := fs.Arg(0), fs.Arg(1) owner, calName := args[1], args[2]
if err := dbase.CreateCalendarWithColor(owner, calName, *color); err != nil { if err := dbase.CreateCalendarWithColor(owner, calName, color); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }
@@ -365,11 +356,7 @@ func runAddressBook(dbase *db.DB, st *store.Store, args []string) int {
} }
func runMigrate(st *store.Store, args []string) int { func runMigrate(st *store.Store, args []string) int {
fs := newFlagSet("migrate") // Ignore args for now (could add --verbose flag in future if needed)
if err := fs.Parse(args); err != nil {
return 2
}
if err := st.Migrate(); err != nil { if err := st.Migrate(); err != nil {
fmt.Fprintf(os.Stderr, "migration failed: %v\n", err) fmt.Fprintf(os.Stderr, "migration failed: %v\n", err)
return 1 return 1
+18 -38
View File
@@ -12,25 +12,6 @@ import (
"git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/db"
) )
// writeTestConfig creates a minimal config.yaml in dir and returns its path.
func writeTestConfig(t *testing.T, dir string) string {
t.Helper()
cfgPath := filepath.Join(dir, "config.yaml")
dataDir := filepath.Join(dir, "data")
content := "storage:\n data_dir: " + dataDir + "\n" +
"users:\n" +
" alice:\n" +
" password: \"$2a$10$9.WEs0uz5TaNJLQbSTLrX.Te.BIe8XTTykVRzZbSHQMEkyFb8Sq/O\"\n" +
" bob:\n" +
" password: \"$2a$10$9.WEs0uz5TaNJLQbSTLrX.Te.BIe8XTTykVRzZbSHQMEkyFb8Sq/O\"\n"
if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil {
t.Fatalf("writing test config: %v", err)
}
return cfgPath
}
// runCLI runs the CLI's run() function, capturing stdout/stderr, and
// returns (exit code, combined stdout+stderr).
func runCLI(t *testing.T, args ...string) (int, string) { func runCLI(t *testing.T, args ...string) (int, string) {
t.Helper() t.Helper()
@@ -56,9 +37,9 @@ func runCLI(t *testing.T, args ...string) (int, string) {
func TestCalendarShareUnshareLifecycle(t *testing.T) { func TestCalendarShareUnshareLifecycle(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "bob", "write") code, out := runCLI(t, "calendar", "share", "alice", "work", "bob", "write")
if code != 0 { if code != 0 {
t.Fatalf("share exit code = %d, output: %s", code, out) t.Fatalf("share exit code = %d, output: %s", code, out)
} }
@@ -66,7 +47,7 @@ func TestCalendarShareUnshareLifecycle(t *testing.T) {
t.Errorf("output = %q, want to contain 'shared'", out) t.Errorf("output = %q, want to contain 'shared'", out)
} }
code, out = runCLI(t, "-config", cfgPath, "calendar", "shares", "alice", "work") code, out = runCLI(t, "calendar", "shares", "alice", "work")
if code != 0 { if code != 0 {
t.Fatalf("shares exit code = %d, output: %s", code, out) t.Fatalf("shares exit code = %d, output: %s", code, out)
} }
@@ -74,12 +55,12 @@ func TestCalendarShareUnshareLifecycle(t *testing.T) {
t.Errorf("output = %q, want to contain bob/write", out) t.Errorf("output = %q, want to contain bob/write", out)
} }
code, out = runCLI(t, "-config", cfgPath, "calendar", "unshare", "alice", "work", "bob") code, out = runCLI(t, "calendar", "unshare", "alice", "work", "bob")
if code != 0 { if code != 0 {
t.Fatalf("unshare exit code = %d, output: %s", code, out) t.Fatalf("unshare exit code = %d, output: %s", code, out)
} }
code, out = runCLI(t, "-config", cfgPath, "calendar", "shares", "alice", "work") code, out = runCLI(t, "calendar", "shares", "alice", "work")
if code != 0 { if code != 0 {
t.Fatalf("shares (after unshare) exit code = %d, output: %s", code, out) t.Fatalf("shares (after unshare) exit code = %d, output: %s", code, out)
} }
@@ -90,9 +71,9 @@ func TestCalendarShareUnshareLifecycle(t *testing.T) {
func TestCalendarShareInvalidPermission(t *testing.T) { func TestCalendarShareInvalidPermission(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "bob", "admin") code, out := runCLI(t, "calendar", "share", "alice", "work", "bob", "admin")
if code != 2 { if code != 2 {
t.Errorf("exit code = %d, want 2; output: %s", code, out) t.Errorf("exit code = %d, want 2; output: %s", code, out)
} }
@@ -103,9 +84,9 @@ func TestCalendarShareInvalidPermission(t *testing.T) {
func TestCalendarUnshareNotFound(t *testing.T) { func TestCalendarUnshareNotFound(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "unshare", "alice", "ghost", "bob") code, out := runCLI(t, "calendar", "unshare", "alice", "ghost", "bob")
if code != 1 { if code != 1 {
t.Errorf("exit code = %d, want 1; output: %s", code, out) t.Errorf("exit code = %d, want 1; output: %s", code, out)
} }
@@ -116,14 +97,14 @@ func TestCalendarUnshareNotFound(t *testing.T) {
func TestAddressBookShareUnshareLifecycle(t *testing.T) { func TestAddressBookShareUnshareLifecycle(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "addressbook", "share", "alice", "contacts", "bob", "read") code, out := runCLI(t, "addressbook", "share", "alice", "contacts", "bob", "read")
if code != 0 { if code != 0 {
t.Fatalf("share exit code = %d, output: %s", code, out) t.Fatalf("share exit code = %d, output: %s", code, out)
} }
code, out = runCLI(t, "-config", cfgPath, "addressbook", "shares", "alice", "contacts") code, out = runCLI(t, "addressbook", "shares", "alice", "contacts")
if code != 0 { if code != 0 {
t.Fatalf("shares exit code = %d, output: %s", code, out) t.Fatalf("shares exit code = %d, output: %s", code, out)
} }
@@ -131,7 +112,7 @@ func TestAddressBookShareUnshareLifecycle(t *testing.T) {
t.Errorf("output = %q, want to contain bob/read", out) t.Errorf("output = %q, want to contain bob/read", out)
} }
code, out = runCLI(t, "-config", cfgPath, "addressbook", "unshare", "alice", "contacts", "bob") code, out = runCLI(t, "addressbook", "unshare", "alice", "contacts", "bob")
if code != 0 { if code != 0 {
t.Fatalf("unshare exit code = %d, output: %s", code, out) t.Fatalf("unshare exit code = %d, output: %s", code, out)
} }
@@ -139,9 +120,9 @@ func TestAddressBookShareUnshareLifecycle(t *testing.T) {
func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) { func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "carol", "read") code, out := runCLI(t, "calendar", "share", "alice", "work", "carol", "read")
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, output: %s", code, out) t.Fatalf("exit code = %d, output: %s", code, out)
} }
@@ -154,10 +135,9 @@ func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) {
} }
func TestNoArgsShowsUsage(t *testing.T) { func TestNoArgsShowsUsage(t *testing.T) {
dir := t.TempDir()
// No config needed since usage() is printed before config.Load for // No config needed since usage() is printed before config.Load for
// missing subcommands. // missing subcommands.
code, out := runCLI(t, "-config", filepath.Join(dir, "missing.yaml")) code, out := runCLI(t)
if code != 2 { if code != 2 {
t.Errorf("exit code = %d, want 2", code) t.Errorf("exit code = %d, want 2", code)
} }
@@ -168,9 +148,9 @@ func TestNoArgsShowsUsage(t *testing.T) {
func TestUnknownCommand(t *testing.T) { func TestUnknownCommand(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := writeTestConfig(t, dir) t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "bogus") code, out := runCLI(t, "bogus")
if code != 2 { if code != 2 {
t.Errorf("exit code = %d, want 2", code) t.Errorf("exit code = %d, want 2", code)
} }
+44 -20
View File
@@ -31,31 +31,47 @@ func runUser(dbase *db.DB, args []string) int {
} }
func userCreate(dbase *db.DB, args []string) int { func userCreate(dbase *db.DB, args []string) int {
fs := newFlagSet("nidusctl user create") var displayName, email, password string
displayName := fs.String("display-name", "", "display name shown in DAV clients") var rest []string
email := fs.String("email", "", "email address")
password := fs.String("password", "", "password (omit to be prompted, recommended)") for i := 0; i < len(args); i++ {
if err := fs.Parse(args); err != nil { switch args[i] {
return 2 case "--display-name":
if i+1 < len(args) {
displayName = args[i+1]
i++
}
case "--email":
if i+1 < len(args) {
email = args[i+1]
i++
}
case "--password":
if i+1 < len(args) {
password = args[i+1]
i++
}
default:
rest = append(rest, args[i])
}
} }
rest := fs.Args()
if len(rest) != 1 { if len(rest) != 1 {
fmt.Fprintln(os.Stderr, "usage: nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PASSWORD]") fmt.Fprintln(os.Stderr, "usage: nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PASSWORD]")
return 2 return 2
} }
username := rest[0] username := rest[0]
pw := *password if password == "" {
if pw == "" {
var err error var err error
pw, err = promptPassword(username) password, err = promptPassword(username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error reading password: %v\n", err) fmt.Fprintf(os.Stderr, "error reading password: %v\n", err)
return 1 return 1
} }
} }
if err := dbase.CreateUser(username, pw, *displayName, *email); err != nil { if err := dbase.CreateUser(username, password, displayName, email); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }
@@ -98,29 +114,37 @@ func userList(dbase *db.DB, args []string) int {
} }
func userPasswd(dbase *db.DB, args []string) int { func userPasswd(dbase *db.DB, args []string) int {
fs := newFlagSet("nidusctl user passwd") var password string
password := fs.String("password", "", "new password (omit to be prompted, recommended)") var rest []string
if err := fs.Parse(args); err != nil {
return 2 for i := 0; i < len(args); i++ {
switch args[i] {
case "--password":
if i+1 < len(args) {
password = args[i+1]
i++
}
default:
rest = append(rest, args[i])
}
} }
rest := fs.Args()
if len(rest) != 1 { if len(rest) != 1 {
fmt.Fprintln(os.Stderr, "usage: nidusctl user passwd <username> [--password PASSWORD]") fmt.Fprintln(os.Stderr, "usage: nidusctl user passwd <username> [--password PASSWORD]")
return 2 return 2
} }
username := rest[0] username := rest[0]
pw := *password if password == "" {
if pw == "" {
var err error var err error
pw, err = promptPassword(username) password, err = promptPassword(username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error reading password: %v\n", err) fmt.Fprintf(os.Stderr, "error reading password: %v\n", err)
return 1 return 1
} }
} }
if err := dbase.SetPassword(username, pw); err != nil { if err := dbase.SetPassword(username, password); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }