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
+39 -52
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,27 +70,32 @@ 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
@@ -109,13 +107,6 @@ Examples:
`) `)
} }
// 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 {
if len(args) < 1 { if len(args) < 1 {
usage() usage()
@@ -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++
} }
rest := fs.Args() 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])
}
}
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++
} }
rest := fs.Args() default:
rest = append(rest, args[i])
}
}
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
} }